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:
parent
e6ed427b68
commit
bdffa90305
14
drizzle/0001_wakeful_silver_fox.sql
Normal file
14
drizzle/0001_wakeful_silver_fox.sql
Normal 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`);
|
||||||
72
drizzle/meta/0001_snapshot.json
Normal file
72
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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
21
pnpm-lock.yaml
generated
@ -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
2
src/app.d.ts
vendored
@ -14,7 +14,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
interface Locals {
|
interface Locals {
|
||||||
user?: {
|
user?: {
|
||||||
id: number;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
@ -176,10 +235,74 @@ export class CounterDurableObject {
|
|||||||
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');
|
||||||
|
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') {
|
} else if (data && data.type === 'sqlQuery') {
|
||||||
// SQL 조회 요청 처리
|
// SQL 조회 요청 처리
|
||||||
try {
|
try {
|
||||||
@ -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':
|
|
||||||
session.evenBet += amount;
|
|
||||||
break;
|
|
||||||
case 'big':
|
|
||||||
session.bigBet += amount;
|
|
||||||
break;
|
|
||||||
case 'small':
|
|
||||||
session.smallBet += amount;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
// 클라이언트 측에서 자본금 차감은 이미 되어있으므로 서버에서도 동기화
|
|
||||||
|
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;
|
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 {
|
||||||
|
// 기존 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);
|
this.sql.exec(DB_SCHEMA);
|
||||||
console.log('SQLite database initialized successfully');
|
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);
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,13 +333,25 @@
|
|||||||
<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>
|
||||||
|
<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
|
<button
|
||||||
onclick={logout}
|
onclick={logout}
|
||||||
class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition"
|
class="px-4 py-2 bg-white hover:bg-gray-100 text-black border-2 border-black rounded-lg transition"
|
||||||
>
|
>
|
||||||
로그아웃
|
로그아웃
|
||||||
</button>
|
</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">
|
||||||
<p class="text-gray-700">
|
<p class="text-gray-700">
|
||||||
@ -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>
|
|
||||||
|
|
||||||
<!-- 연결 상태, 접속자 수, 자본금 (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}
|
{/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>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- 합계 -->
|
<!-- 카운트다운 타이머 -->
|
||||||
<div class="text-center mb-4">
|
<div class="mb-6 p-4 bg-white rounded-xl">
|
||||||
<div class="text-4xl font-bold text-indigo-600 mb-2">
|
<div class="flex flex-col items-center justify-center gap-4">
|
||||||
합계: {dice1 + dice2 + dice3}
|
<div class="text-sm font-semibold text-gray-700">
|
||||||
|
{noMoreBet ? '🚫 No more bet' : '✅ Place your bet'}
|
||||||
</div>
|
</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'}">
|
<div class="text-green-700 font-bold" style="font-size:20px;">
|
||||||
{(dice1 + dice2 + dice3) % 2 === 1 ? '✓ 홀수' : '홀수'}
|
<NumberFlow value={remainingTime} />s
|
||||||
</span>
|
|
||||||
<span class="{(dice1 + dice2 + dice3) % 2 === 0 ? 'text-pink-600' : 'text-gray-400'}">
|
|
||||||
{(dice1 + dice2 + dice3) % 2 === 0 ? '✓ 짝수' : '짝수'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center gap-4 text-2xl font-semibold mt-2">
|
</div>
|
||||||
<span class="{(dice1 + dice2 + dice3) >= 10 ? 'text-orange-600' : 'text-gray-400'}">
|
</div> <!-- 접속자 수 & 자본금 (2 columns) -->
|
||||||
{(dice1 + dice2 + dice3) >= 10 ? '✓ 대' : '대'}
|
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||||
</span>
|
<div class="p-4 rounded-lg bg-green-50 border border-green-200 flex flex-col items-center justify-center">
|
||||||
<span class="{(dice1 + dice2 + dice3) <= 9 ? 'text-purple-600' : 'text-gray-400'}">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
{(dice1 + dice2 + dice3) <= 9 ? '✓ 소' : '소'}
|
<div class="w-2.5 h-2.5 rounded-full {isConnected ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}"></div>
|
||||||
</span>
|
<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>
|
</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-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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
63
src/routes/profile/+page.server.ts
Normal file
63
src/routes/profile/+page.server.ts
Normal 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;
|
||||||
74
src/routes/profile/+page.svelte
Normal file
74
src/routes/profile/+page.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user