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,
"tag": "0000_omniscient_lady_mastermind",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1763601535973,
"tag": "0001_wakeful_silver_fox",
"breakpoints": true
}
]
}

View File

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

21
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@number-flow/svelte':
specifier: ^0.3.9
version: 0.3.9(svelte@5.43.8)
drizzle-orm:
specifier: ^0.44.7
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':
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':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@ -1487,6 +1495,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
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:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@ -2255,6 +2266,12 @@ snapshots:
'@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': {}
'@poppinss/colors@4.1.5':
@ -2849,6 +2866,10 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
number-flow@0.5.8:
dependencies:
esm-env: 1.2.2
once@1.4.0:
dependencies:
wrappy: 1.0.2

2
src/app.d.ts vendored
View File

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

View File

@ -1,7 +1,8 @@
import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-types';
import type { Session } from './types';
import { applyBetResults } from './game-results';
import { DB_SCHEMA, type UserRecord, type CurrentBetRecord } from './db-schema';
import { verifyToken } from './server/auth';
import type { BettingInfo, BetTypeKey } from './types';
// 게임 지속 시간 상수 (ms)
export const NO_MORE_BET_DURATION_MS = 15_000; // 15초
@ -11,11 +12,22 @@ export interface Env {
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 {
private ctx: DurableObjectState;
private env: Env;
private sessions: Map<WebSocket, Session>;
private userSessions: Map<string, WebSocket>; // userId -> WebSocket 매핑
private sql: any; // SqlStorage 타입
// 주사위 게임 상태
@ -32,6 +44,7 @@ export class CounterDurableObject {
this.ctx = ctx;
this.env = env;
this.sessions = new Map();
this.userSessions = new Map();
// SQLite 초기화
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 업그레이드 요청 처리
const upgradeHeader = request.headers.get('Upgrade');
if (!upgradeHeader || upgradeHeader !== 'websocket') {
@ -135,17 +206,10 @@ export class CounterDurableObject {
const session: Session = {
id: crypto.randomUUID(),
webSocket: server,
userId: undefined,
nickname: undefined,
capital: undefined,
oddBet: 0,
evenBet: 0,
bigBet: 0,
smallBet: 0,
oddResult: null,
evenResult: null,
bigResult: null,
smallResult: null,
lastWinAmount: 0
betInfo: [] // 배팅 정보 배열
};
// WebSocket Hibernation API 사용
@ -153,11 +217,6 @@ export class CounterDurableObject {
this.ctx.acceptWebSocket(server);
this.sessions.set(server, session);
// 첫 접속자가 들어왔을 때 게임 루프 시작
if (this.sessions.size === 1) {
this.startGameLoop();
}
// 현재 상태 전송
this.broadcast();
@ -173,14 +232,78 @@ export class CounterDurableObject {
const data = typeof message === 'string' ? JSON.parse(message) : null;
const session = this.sessions.get(ws);
if (!session) return;
if (!session) return;
if (data && data.type === 'setUser') {
// 사용자 정보 설정
session.nickname = data.nickname;
session.capital = data.capital;
this.broadcast();
} else if (data && data.type === 'sqlQuery') {
if (data && data.type === 'setUser') {
// JWT 토큰 검증
const token = data.token;
if (!token) {
console.error('Missing JWT token');
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 조회 요청 처리
try {
let result;
@ -209,27 +332,57 @@ export class CounterDurableObject {
ws.send(JSON.stringify({ type: 'sqlError', error: error.message }));
}
} else if (data && data.type === 'bet' && !this.noMoreBet) {
// 배팅 처리
// 배팅 처리 - betInfo 배열에 추가 및 current_bet 테이블에 저장
const amount = data.amount || 1000;
const betType = data.betType as BetTypeKey; // 'Odd', 'Even', 'Big', 'Small'
if (session.capital && session.capital >= amount) {
switch (data.betType) {
case 'odd':
session.oddBet += amount;
break;
case 'even':
session.evenBet += amount;
break;
case 'big':
session.bigBet += amount;
break;
case 'small':
session.smallBet += amount;
break;
}
// 클라이언트 측에서 자본금 차감은 이미 되어있으므로 서버에서도 동기화
if (!session.userId || session.capital === undefined) {
console.error('User not set in session');
return;
}
if (!this.gameId) {
console.error('No active game');
return;
}
if (session.capital >= amount) {
// 사용자 정보 조회 (userId로 직접 조회)
const user = this.getUser(session.userId);
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;
this.broadcast();
// DB에도 자본금 업데이트
this.updateUserCapital(user.id, session.capital);
// 해당 사용자에게만 업데이트 전송 (배팅 정보, 자본금)
this.sendToSession(ws);
}
}
} catch (error) {
@ -244,17 +397,76 @@ export class CounterDurableObject {
*/
private initializeDatabase() {
try {
this.sql.exec(DB_SCHEMA);
console.log('SQLite database initialized successfully');
// 기존 user 테이블에 nickname 컬럼이 있는지 확인
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) {
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 {
// 기존 사용자 확인
const existing = this.sql.exec(
@ -265,8 +477,7 @@ export class CounterDurableObject {
if (existing) {
// 업데이트
this.sql.exec(
'UPDATE user SET nickname = ?, email = ?, capital = COALESCE(?, capital) WHERE id = ?',
nickname,
'UPDATE user SET email = ?, capital = COALESCE(?, capital) WHERE id = ?',
email,
capital,
userId
@ -274,9 +485,8 @@ export class CounterDurableObject {
} else {
// 새로운 사용자 생성
this.sql.exec(
'INSERT INTO user (id, nickname, email, capital) VALUES (?, ?, ?, ?)',
'INSERT INTO user (id, email, capital) VALUES (?, ?, ?)',
userId,
nickname,
email,
capital || 10000
);
@ -285,9 +495,8 @@ export class CounterDurableObject {
// 사용자가 없으면 생성
try {
this.sql.exec(
'INSERT INTO user (id, nickname, email, capital) VALUES (?, ?, ?, ?)',
'INSERT INTO user (id, email, capital) VALUES (?, ?, ?)',
userId,
nickname,
email,
capital || 10000
);
@ -434,13 +643,127 @@ export class CounterDurableObject {
this.noMoreBetStartTime = Date.now();
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)
this.dice1 = Math.floor(Math.random() * 6) + 1;
this.dice2 = 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);
@ -472,17 +795,17 @@ export class CounterDurableObject {
this.dice2 = 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) => {
session.oddBet = 0;
session.evenBet = 0;
session.bigBet = 0;
session.smallBet = 0;
session.oddResult = null;
session.evenResult = null;
session.bigResult = null;
session.smallResult = null;
session.lastWinAmount = 0;
session.betInfo = [];
});
// 상태 저장
@ -501,19 +824,71 @@ export class CounterDurableObject {
}
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);
ws.close(code, 'Durable Object is closing WebSocket');
// 마지막 접속자가 나갔을 때 게임 루프 중지
if (this.sessions.size === 0) {
this.stopGameLoop();
}
// 남은 클라이언트들에게 업데이트 전송
// 남은 클라이언트들에게 업데이트 전송 (접속자 수 변경)
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() {
// WebSocket Hibernation API를 사용할 때는 getWebSockets()로 실제 연결 수를 확인
const connectedWebSockets = this.ctx.getWebSockets();
@ -522,7 +897,23 @@ export class CounterDurableObject {
// @ts-ignore - Cloudflare Workers types 불일치
connectedWebSockets.forEach((ws: WebSocket) => {
try {
// WebSocket이 OPEN 상태인지 확인
if (ws.readyState !== WebSocket.OPEN && ws.readyState !== 1) {
return;
}
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({
noMoreBet: this.noMoreBet,
@ -531,17 +922,14 @@ export class CounterDurableObject {
dice3: this.dice3,
noMoreBetStartTime: this.noMoreBetStartTime,
noMoreBetEndTime: this.noMoreBetEndTime,
online: connectedWebSockets.length,
online: this.sessions.size,
gameId: this.gameId,
// 세션별 사용자 정보 (DB 최신 값)
nickname: session.nickname,
capital: currentCapital,
// 세션별 배팅 정보
capital: session?.capital,
oddResult: session?.oddResult,
evenResult: session?.evenResult,
bigResult: session?.bigResult,
smallResult: session?.smallResult,
lastWinAmount: session?.lastWinAmount,
betInfo: session.betInfo || [],
});
ws.send(message);
} catch (error) {
console.error('Error broadcasting to client:', error);

View File

@ -4,7 +4,6 @@ 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
@ -31,7 +30,6 @@ export const DB_SCHEMA = `
// 타입 정의
export interface UserRecord {
id: string;
nickname: string;
email: string;
joinGameCount: number;
capital: number;
@ -42,9 +40,10 @@ export interface CurrentBetRecord {
gameId: string;
diceNum: number;
userId: string;
betType: string;
betType: string; // BetTypeKey를 문자열로 저장
amount: number;
isWin: number; // SQLite는 boolean이 없어서 0 또는 1
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;
}
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 })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
@ -27,10 +27,10 @@ export async function createToken(payload: { userId: number; email: string }): P
.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 {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as { userId: number; email: string };
return payload as { userId: string; email: string };
} catch {
return null;
}

View File

@ -17,7 +17,11 @@ export async function createUser(
try {
const drizzleDb = getDb(db);
// UUID 생성
const userId = crypto.randomUUID();
const newUser: NewUser = {
id: userId,
email,
passwordHash,
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 {
const drizzleDb = getDb(db);

View File

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

View File

@ -1,18 +1,9 @@
export interface Session {
id: string;
token?: string;
id: string | null;
token: string | null;
webSocket: WebSocket;
nickname?: string;
capital?: number;
oddBet: number;
evenBet: number;
bigBet: number;
smallBet: number;
oddResult: 'win' | 'lose' | null;
evenResult: 'win' | 'lose' | null;
bigResult: 'win' | 'lose' | null;
smallResult: 'win' | 'lose' | null;
lastWinAmount: number;
nickname: string | null;
capital: number;
betInfo?: BettingInfo[]; // 배팅 정보 (기존 호환성을 위해 optional)
}

View File

@ -1,8 +1,18 @@
import { redirect } from '@sveltejs/kit';
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 {
user: locals.user
user: locals.user,
authToken
};
};

View File

@ -1,4 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import NumberFlow from '@number-flow/svelte';
let { data } = $props();
let online = $state(0);
@ -8,12 +11,27 @@
// 사용자 정보
let nickname = $state('');
let capital = $state(10000); // 초기 자본금
let inputNickname = $state('');
let inputCapital = $state('10000');
let capital = $state(0);
// 게임 ID
let gameId = $state<string | null>(null);
// 베팅 정보
let betInfo = $state<any[]>([]);
// WebSocket 수동 종료 플래그 (의도적 종료 vs 에러 종료 구분)
let intentionalClose = $state(false);
// 중복 로그인 감지 상태
let isDuplicateLogin = $state(false);
// 로그아웃 함수
async function logout() {
intentionalClose = true;
if (ws) {
ws.close();
ws = null;
}
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
}
@ -51,9 +69,14 @@
let remainingTime = $state(0);
function connectWebSocket() {
// 이미 연결 중이거나 연결되어 있으면 중복 연결 방지
if (ws || isConnecting || isConnected) {
console.log('WebSocket already connecting or connected, skipping...');
return;
}
isConnecting = true;
nickname = inputNickname;
capital = parseInt(inputCapital);
console.log('Starting WebSocket connection...');
// WebSocket 프로토콜 결정 (https -> wss, http -> ws)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@ -64,14 +87,13 @@
ws.onopen = () => {
isConnected = true;
isConnecting = false;
console.log('WebSocket connected');
console.log('WebSocket connected successfully');
// 서버에 사용자 정보 전송
if (ws) {
// 서버에 JWT 토큰 전송
if (ws && data.authToken) {
ws.send(JSON.stringify({
type: 'setUser',
nickname: nickname,
capital: capital
token: data.authToken
}));
}
};
@ -79,6 +101,31 @@
ws.onmessage = (event) => {
try {
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;
dice1 = data.dice1;
dice2 = data.dice2;
@ -86,11 +133,18 @@
noMoreBetStartTime = data.noMoreBetStartTime;
noMoreBetEndTime = data.noMoreBetEndTime;
online = data.online;
gameId = data.gameId ?? null;
// 배팅 결과
// 사용자 정보 업데이트
if (data.nickname !== undefined) {
nickname = data.nickname;
}
if (data.capital !== undefined) {
capital = data.capital;
}
if (data.betInfo !== undefined) {
betInfo = data.betInfo;
}
if (data.oddResult !== undefined) oddResult = data.oddResult;
if (data.evenResult !== undefined) evenResult = data.evenResult;
if (data.bigResult !== undefined) bigResult = data.bigResult;
@ -123,10 +177,11 @@
isConnecting = false;
};
ws.onclose = () => {
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
isConnected = false;
isConnecting = false;
console.log('WebSocket disconnected');
ws = null;
};
}
@ -206,7 +261,23 @@
return patterns[number] || patterns[1];
}
// 카운트다운 타이머 업데이트
// 페이지 로드 시 자동으로 WebSocket 연결 (한 번만)
onMount(() => {
if (data.user) {
connectWebSocket();
}
// 컴포넌트 언마운트 시 WebSocket 정리
return () => {
intentionalClose = true;
if (ws) {
ws.close();
ws = null;
}
};
});
// 카운트다운 타이머
$effect(() => {
let interval: ReturnType<typeof setInterval> | null = null;
@ -225,17 +296,36 @@
if (interval) clearInterval(interval);
};
});
// 컴포넌트 언마운트 시 WebSocket 정리
$effect(() => {
return () => {
disconnectWebSocket();
};
});
</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="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}
<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="font-semibold text-gray-800">{data.user.nickname} ({data.user.email})</p>
</div>
<button
onclick={logout}
class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition"
>
로그아웃
</button>
<div class="flex gap-2 items-center">
<div class="px-4 py-2 bg-white text-black border-2 border-black rounded-lg font-semibold flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-black {isConnected ? 'animate-pulse' : 'opacity-50'}"></div>
<span>{online}명 접속</span>
</div>
<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>
{:else}
<div class="mb-4 bg-yellow-50 p-4 rounded-lg text-center">
@ -259,118 +361,20 @@
</div>
{/if}
<h1 class="text-4xl font-bold text-center mb-8 text-gray-800">
Durable Objects TEST
</h1>
<!-- 연결 상태, 접속자 수, 자본금 (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 gameId}
<div class="text-center mb-6 text-sm text-gray-600">현재 게임 ID: <span class="font-mono font-semibold">{gameId}</span></div>
{/if}
<!-- 주사위 디스플레이 -->
{#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">
<h2 class="text-2xl font-bold text-center mb-4 text-gray-800">
🎲 결과
</h2>
<div class="mb-6 p-4 bg-gradient-to-br from-yellow-50 to-orange-50 rounded-xl border border-yellow-200">
<!-- 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}
<div class="relative">
<div class="w-24 h-24 bg-white rounded-2xl shadow-2xl border-4 border-gray-800 p-3">
<div class="grid grid-cols-3 gap-1 h-full">
<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-0.5 h-full">
{#each getDiceDots(dice) as row}
{#each row as dot}
<div class="flex items-center justify-center">
@ -382,95 +386,51 @@
{/each}
</div>
</div>
<div class="absolute -top-2 -right-2 w-8 h-8 bg-red-500 text-white
rounded-full flex items-center justify-center text-sm font-bold shadow-lg">
<div class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white
rounded-full flex items-center justify-center text-xs font-bold shadow-lg">
{dice}
</div>
</div>
{/each}
</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>
{/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">
배팅 선택 (1000원씩 배팅)
</h2>
@ -486,7 +446,7 @@
>
홀수
{#if oddBet > 0}
<div class="text-sm mt-1">{oddBet.toLocaleString()}</div>
<div class="text-sm mt-1"><NumberFlow value={oddBet} /></div>
{/if}
</button>
<button
@ -498,31 +458,31 @@
>
짝수
{#if evenBet > 0}
<div class="text-sm mt-1">{evenBet.toLocaleString()}</div>
<div class="text-sm mt-1"><NumberFlow value={evenBet} /></div>
{/if}
</button>
<button
onclick={betBig}
disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-orange-500 text-white rounded-xl font-bold text-xl
hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed
class="px-4 py-4 bg-white text-black border-2 border-black rounded-xl font-bold text-xl
hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105"
>
대 (大)
{#if bigBet > 0}
<div class="text-sm mt-1">{bigBet.toLocaleString()}</div>
<div class="text-sm mt-1"><NumberFlow value={bigBet} /></div>
{/if}
</button>
<button
onclick={betSmall}
disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-purple-500 text-white rounded-xl font-bold text-xl
hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed
<button
onclick={betSmall}
disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-white text-black border-2 border-black rounded-xl font-bold text-xl
hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105"
>
소 (小)
{#if smallBet > 0}
<div class="text-sm mt-1">{smallBet.toLocaleString()}</div>
<div class="text-sm mt-1"><NumberFlow value={smallBet} /></div>
{/if}
</button>
</div>
@ -534,6 +494,33 @@
{/if}
</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>

View File

@ -4,7 +4,7 @@ import { verifyPassword, createToken } from '$lib/server/auth';
import { getUserByEmail } from '$lib/server/db';
export const actions = {
default: async ({ request, platform, cookies }) => {
default: async ({ request, platform, cookies, url }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
@ -46,7 +46,9 @@ export const actions = {
maxAge: 60 * 60 * 24 * 7 // 7 days
});
throw redirect(303, '/');
// redirectTo 쿼리 파라미터가 있으면 해당 페이지로, 없으면 메인 페이지로
const redirectTo = url.searchParams.get('redirectTo') || '/';
throw redirect(303, redirectTo);
}
} 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';
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>
<svelte:head>
@ -34,7 +49,8 @@
id="email"
name="email"
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"
placeholder="your@email.com"
/>
@ -52,13 +68,14 @@
id="nickname"
name="nickname"
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"
placeholder="닉네임을 입력하세요"
/>
{#if form?.weak && form?.field === 'nickname'}
<p class="mt-1 text-sm text-red-400">닉네임은 2자 이상이어야 합니다.</p>
{/if}
<p class="mt-1 text-xs text-gray-400">이메일 입력 시 자동으로 설정됩니다.</p>
</div>
<div>