초기 프로젝트 설정
- React + Vite + Tailwind 프론트엔드 - Express + Sequelize + MariaDB 백엔드 - 넥슨 OAuth 2.0 인증 (캐릭터 목록 조회) - 주간 보스 결정석 수익 계산기 UI (리스트형) - Docker Compose + Caddy 리버스 프록시 설정 - 보스/난이도 이미지 에셋 포함 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
32
.env
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# DB (기존 MariaDB 활용)
|
||||||
|
DB_HOST=mariadb
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=maplestory
|
||||||
|
DB_PASSWORD=xSMK3sG9DG9Vn2dQ
|
||||||
|
DB_NAME=maplestory
|
||||||
|
|
||||||
|
# Redis (세션 저장)
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# RustFS (S3 호환 스토리지)
|
||||||
|
S3_ENDPOINT=http://rustfs:9000
|
||||||
|
S3_PUBLIC_URL=https://s3.caadiq.co.kr
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
S3_BUCKET=maplestory
|
||||||
|
|
||||||
|
# 넥슨 OAuth
|
||||||
|
NEXON_CLIENT_ID=
|
||||||
|
NEXON_CLIENT_SECRET=
|
||||||
|
NEXON_REDIRECT_URI=https://maple.caadiq.co.kr/api/auth/callback
|
||||||
|
|
||||||
|
# 넥슨 API (캐릭터 상세 조회용)
|
||||||
|
NEXON_API_KEY=
|
||||||
|
|
||||||
|
# 세션
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# 앱
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
509
PLAN.md
Normal file
|
|
@ -0,0 +1,509 @@
|
||||||
|
# 메이플스토리 도우미 (maple.caadiq.co.kr) - 최종 계획서
|
||||||
|
|
||||||
|
> 첫 번째 기능: 주간 보스 수익 계산기. 추후 다른 기능 확장 가능.
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
|
넥슨 OAuth 로그인으로 내 캐릭터 목록을 자동으로 불러온 뒤, 각 캐릭터별로 클리어 가능한 주간 보스를 선택하면 **주간 결정석 수익(메소)**을 자동 계산해주는 웹 애플리케이션.
|
||||||
|
|
||||||
|
## 2. 기술 스택
|
||||||
|
|
||||||
|
| 구분 | 기술 | 선택 이유 |
|
||||||
|
|---|---|---|
|
||||||
|
| Frontend | React + Vite + Tailwind CSS | 기존 레포 프로젝트들과 동일 스택 |
|
||||||
|
| Backend | Express (Node.js) | OAuth 토큰 관리, API 프록시 |
|
||||||
|
| DB | MariaDB | 기존 인프라 활용 (`db` 네트워크) |
|
||||||
|
| ORM | Sequelize | 기존 mailbox 프로젝트와 동일 |
|
||||||
|
| 세션 | express-session + Redis | 토큰 서버사이드 관리 |
|
||||||
|
| 배포 | Docker Compose + Caddy | 기존 인프라와 일관성 |
|
||||||
|
|
||||||
|
## 3. 넥슨 OAuth 2.0 인증 흐름
|
||||||
|
|
||||||
|
### 3.1 사전 준비
|
||||||
|
|
||||||
|
1. [openapi.nexon.com](https://openapi.nexon.com) 에서 Friends Application 등록
|
||||||
|
2. 플랫폼: Web, Redirect URI 설정 (예: `https://maple.caadiq.co.kr/api/auth/callback`)
|
||||||
|
3. 활용 데이터 항목: `maplestory.characterlist` 선택
|
||||||
|
4. Client ID, Client Secret 발급
|
||||||
|
|
||||||
|
### 3.2 인증 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[프론트엔드] [백엔드] [넥슨]
|
||||||
|
│ │ │
|
||||||
|
│ 1. 로그인 버튼 클릭 │ │
|
||||||
|
│ ──────────────────────────> │ │
|
||||||
|
│ │ 2. state 생성 + 세션 저장 │
|
||||||
|
│ 3. 넥슨 로그인 페이지 리다이렉트 │
|
||||||
|
│ ─────────────────────────────────────────────────────> │
|
||||||
|
│ │ │
|
||||||
|
│ │ 4. redirect_uri?code=XXX │
|
||||||
|
│ │ <───────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │ 5. POST /oauth2/token │
|
||||||
|
│ │ (code + client_secret) │
|
||||||
|
│ │ ────────────────────────> │
|
||||||
|
│ │ │
|
||||||
|
│ │ 6. access_token 응답 │
|
||||||
|
│ │ <───────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │ 7. 토큰을 세션에 저장 │
|
||||||
|
│ 8. 로그인 완료 리다이렉트 │ │
|
||||||
|
│ <────────────────────────── │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 OAuth 엔드포인트 정리
|
||||||
|
|
||||||
|
| 용도 | Method | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| 인가 코드 발급 | GET | `https://openid.nexon.com/oauth2/authorize` |
|
||||||
|
| 토큰 발급 | POST | `https://openid.nexon.com/oauth2/token` |
|
||||||
|
| 토큰 갱신 | POST | `https://openid.nexon.com/oauth2/token` (grant_type=refresh_token) |
|
||||||
|
| 사용자 정보 | GET | `https://openid.nexon.com/api/v1/user/info` |
|
||||||
|
|
||||||
|
### 3.4 인가 코드 요청 파라미터
|
||||||
|
|
||||||
|
| 파라미터 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| response_type | `code` |
|
||||||
|
| client_id | 발급받은 Client ID |
|
||||||
|
| redirect_uri | `https://{도메인}/api/auth/callback` |
|
||||||
|
| scope | `maplestory.characterlist` |
|
||||||
|
| state | CSRF 방지용 랜덤 문자열 |
|
||||||
|
|
||||||
|
### 3.5 토큰 정보
|
||||||
|
|
||||||
|
| 토큰 | 유효기간 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| access_token | 30분 | API 호출 시 `Authorization: Bearer {token}` |
|
||||||
|
| refresh_token | 14일 | access_token 만료 시 갱신 |
|
||||||
|
|
||||||
|
### 3.6 보안 요구사항
|
||||||
|
|
||||||
|
- `client_secret`은 백엔드에서만 관리, 프론트엔드 노출 금지
|
||||||
|
- `access_token`은 서버 세션(Redis)에 저장, 클라이언트에 노출 금지
|
||||||
|
- `state` 파라미터로 CSRF 공격 방지
|
||||||
|
- 모든 통신 HTTPS 필수
|
||||||
|
|
||||||
|
## 4. 넥슨 API 활용
|
||||||
|
|
||||||
|
### 4.1 캐릭터 목록 조회 (OAuth 필요)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://open.api.nexon.com/maplestory/v1/character/list
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 캐릭터 상세 조회 (API 키 사용)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://open.api.nexon.com/maplestory/v1/id?character_name={name}
|
||||||
|
→ ocid 획득
|
||||||
|
|
||||||
|
GET https://open.api.nexon.com/maplestory/v1/character/basic?ocid={ocid}
|
||||||
|
→ 레벨, 직업, 월드, 캐릭터 이미지
|
||||||
|
```
|
||||||
|
|
||||||
|
> 캐릭터 목록은 OAuth, 상세 정보는 API 키로 조회하는 하이브리드 방식.
|
||||||
|
|
||||||
|
## 5. DB 스키마
|
||||||
|
|
||||||
|
### `bosses` - 보스 기본 정보
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE bosses (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
image_url VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `boss_difficulties` - 보스 난이도별 정보
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE boss_difficulties (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
boss_id INT NOT NULL,
|
||||||
|
difficulty ENUM('easy','normal','hard','chaos','extreme') NOT NULL,
|
||||||
|
crystal_price BIGINT NOT NULL,
|
||||||
|
required_level INT DEFAULT 0,
|
||||||
|
default_party_size TINYINT DEFAULT 1,
|
||||||
|
FOREIGN KEY (boss_id) REFERENCES bosses(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_boss_diff (boss_id, difficulty)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `users` - 로그인 사용자
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nexon_uid VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `user_characters` - 사용자 캐릭터 목록 (캐시)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_characters (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
character_name VARCHAR(50) NOT NULL,
|
||||||
|
ocid VARCHAR(100),
|
||||||
|
world_name VARCHAR(20),
|
||||||
|
job_name VARCHAR(50),
|
||||||
|
character_level INT,
|
||||||
|
character_image VARCHAR(255),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_user_char (user_id, character_name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `user_boss_selections` - 캐릭터별 보스 선택
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_boss_selections (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
user_character_id INT NOT NULL,
|
||||||
|
boss_difficulty_id INT NOT NULL,
|
||||||
|
party_size TINYINT NOT NULL DEFAULT 1,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_character_id) REFERENCES user_characters(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (boss_difficulty_id) REFERENCES boss_difficulties(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_selection (user_character_id, boss_difficulty_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 수익 계산 로직
|
||||||
|
|
||||||
|
### 6.1 제한 조건
|
||||||
|
|
||||||
|
| 제한 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 캐릭터당 결정석 | 최대 **12개**/주 |
|
||||||
|
| 계정 전체 결정석 | 최대 **90개**/주 |
|
||||||
|
|
||||||
|
### 6.2 계산 알고리즘
|
||||||
|
|
||||||
|
```
|
||||||
|
입력: 각 캐릭터별 선택된 보스 목록 + 파티 인원
|
||||||
|
|
||||||
|
1. 각 보스의 실수익 계산: crystal_price ÷ party_size
|
||||||
|
2. 캐릭터별로 실수익 내림차순 정렬
|
||||||
|
3. 캐릭터별 상위 12개만 유효 (캐릭터 한도 적용)
|
||||||
|
4. 모든 캐릭터의 유효 결정석을 실수익 기준으로 병합 정렬
|
||||||
|
5. 상위 90개 선택 (계정 한도 적용)
|
||||||
|
6. 선택된 결정석의 실수익 합산 = 주간 총 수익
|
||||||
|
|
||||||
|
부가 정보:
|
||||||
|
- 캐릭터별 수익 소계
|
||||||
|
- 한도 초과로 제외된 결정석 표시
|
||||||
|
- 수익 극대화를 위한 추천 (낮은 수익 보스 → 제외 제안)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 예시
|
||||||
|
|
||||||
|
```
|
||||||
|
캐릭터A: 검마하드(500M÷6=83M), 스우하드(256M÷6=42M), ... → 12개 선택
|
||||||
|
캐릭터B: 루시드하드(175M÷6=29M), 윌하드(140M÷6=23M), ... → 10개 선택
|
||||||
|
캐릭터C: 노말자쿰(10M÷1=10M), ... → 8개 선택
|
||||||
|
|
||||||
|
합계: 30/90개, 총 수익: 약 2,400,000,000 메소
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. API 엔드포인트 설계
|
||||||
|
|
||||||
|
### 7.1 인증
|
||||||
|
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/auth/login` | 넥슨 OAuth 로그인 리다이렉트 |
|
||||||
|
| GET | `/api/auth/callback` | OAuth 콜백 (토큰 교환) |
|
||||||
|
| POST | `/api/auth/logout` | 로그아웃 (세션 삭제) |
|
||||||
|
| GET | `/api/auth/me` | 현재 로그인 상태 확인 |
|
||||||
|
|
||||||
|
### 7.2 캐릭터
|
||||||
|
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/characters` | 내 캐릭터 목록 (넥슨 API → DB 캐시) |
|
||||||
|
| POST | `/api/characters/refresh` | 캐릭터 목록 갱신 |
|
||||||
|
|
||||||
|
### 7.3 보스
|
||||||
|
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/bosses` | 보스 목록 + 난이도별 결정석 가격 |
|
||||||
|
|
||||||
|
### 7.4 보스 선택 & 계산
|
||||||
|
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/selections` | 내 캐릭터별 보스 선택 현황 |
|
||||||
|
| PUT | `/api/selections/:characterId` | 캐릭터별 보스 선택 저장 |
|
||||||
|
| GET | `/api/calculate` | 주간 수익 계산 결과 |
|
||||||
|
|
||||||
|
## 8. 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
maplestory/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env # DB, Redis, OAuth, S3 설정
|
||||||
|
├── frontend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ ├── tailwind.config.js
|
||||||
|
│ ├── index.html
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.jsx
|
||||||
|
│ ├── App.jsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Layout.jsx # 공통 레이아웃 (네비게이션 포함)
|
||||||
|
│ │ └── LoginButton.jsx # 넥슨 로그인 버튼
|
||||||
|
│ ├── features/
|
||||||
|
│ │ └── boss/ # 보스 수익 계산기 기능
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── CharacterList.jsx # 캐릭터 목록 (카드형)
|
||||||
|
│ │ │ ├── CharacterCard.jsx # 캐릭터 정보 카드
|
||||||
|
│ │ │ ├── BossSelector.jsx # 캐릭터별 보스 선택 체크리스트
|
||||||
|
│ │ │ ├── PartySize.jsx # 파티 인원 입력
|
||||||
|
│ │ │ └── RevenueSummary.jsx # 수익 요약 대시보드
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useBosses.js # 보스 데이터 조회
|
||||||
|
│ │ │ └── useCalculator.js # 수익 계산
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ │ └── calculator.js # 수익 계산 로직 (12개/90개 제한)
|
||||||
|
│ │ └── BossPage.jsx # 보스 계산기 페이지
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ ├── useAuth.js # 로그인 상태 관리
|
||||||
|
│ │ └── useCharacters.js # 캐릭터 목록 조회
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── client.js # API 클라이언트 (fetch wrapper)
|
||||||
|
│ └── pages/
|
||||||
|
│ └── Home.jsx # 홈 (기능 목록)
|
||||||
|
│
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── server.js # Express 엔트리포인트
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── db.js # Sequelize 연결
|
||||||
|
│ │ └── redis.js # Redis 연결
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.js # 세션 인증 미들웨어
|
||||||
|
│ │ └── session.js # express-session + Redis 설정
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.js # OAuth 로그인/콜백/로그아웃
|
||||||
|
│ │ ├── characters.js # 캐릭터 목록 조회/갱신
|
||||||
|
│ │ └── boss/ # 보스 관련 라우트
|
||||||
|
│ │ ├── bosses.js # 보스 데이터 조회
|
||||||
|
│ │ ├── selections.js # 보스 선택 저장/조회
|
||||||
|
│ │ └── calculate.js # 수익 계산
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── index.js # Sequelize 모델 연결
|
||||||
|
│ │ ├── User.js
|
||||||
|
│ │ ├── UserCharacter.js
|
||||||
|
│ │ └── boss/ # 보스 관련 모델
|
||||||
|
│ │ ├── Boss.js
|
||||||
|
│ │ ├── BossDifficulty.js
|
||||||
|
│ │ └── UserBossSelection.js
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── nexon.js # 넥슨 API 호출 서비스
|
||||||
|
│ │ └── boss/
|
||||||
|
│ │ └── calculator.js # 수익 계산 서비스
|
||||||
|
│ └── seeders/
|
||||||
|
│ └── boss-data.js # 초기 보스 + 결정석 데이터 시드
|
||||||
|
│
|
||||||
|
└── scripts/
|
||||||
|
└── upload-boss-images.js # 보스 이미지 RustFS 업로드 스크립트
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 보스 이미지
|
||||||
|
|
||||||
|
### 추출 경로 (WzComparerR2)
|
||||||
|
|
||||||
|
### 추출 경로 (WzComparerR2)
|
||||||
|
|
||||||
|
```
|
||||||
|
UI.wz > UIBoss.img
|
||||||
|
```
|
||||||
|
|
||||||
|
초상화+배경+텍스트가 하나로 합쳐진 통합 이미지 사용.
|
||||||
|
|
||||||
|
### 저장 위치
|
||||||
|
|
||||||
|
기존 RustFS(S3 호환 스토리지)에 저장:
|
||||||
|
|
||||||
|
```
|
||||||
|
버킷: maplestory
|
||||||
|
경로: boss/images/{boss-slug}.png
|
||||||
|
|
||||||
|
예시:
|
||||||
|
s3://maplestory/boss/images/black-mage.png
|
||||||
|
s3://maplestory/boss/images/seren.png
|
||||||
|
s3://maplestory/boss/images/lucid.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- 내부 접근: `http://rustfs:9000/maplestory/boss/images/...`
|
||||||
|
- 외부 URL: `https://s3.caadiq.co.kr/maplestory/boss/images/...`
|
||||||
|
- DB `bosses.image_url` 컬럼에 `boss/images/{slug}.png` 경로 저장
|
||||||
|
- 프론트엔드에서 `S3_PUBLIC_URL + image_url`로 이미지 표시
|
||||||
|
- `scripts/upload-boss-images.js`로 로컬 PNG → RustFS 일괄 업로드
|
||||||
|
|
||||||
|
## 10. UI 화면 구성
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 메이플스토리 주간 보스 수익 계산기 │
|
||||||
|
│ [넥슨 로그인] / [로그아웃] │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 내 캐릭터 [새로고침] │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ [캐릭IMG] │ │ [캐릭IMG] │ │ [캐릭IMG] │ │
|
||||||
|
│ │ Lv.285 │ │ Lv.275 │ │ Lv.260 │ │
|
||||||
|
│ │ 아크메이지│ │ 나이트로드│ │ 아델 │ │
|
||||||
|
│ │ 4/12개 │ │ 6/12개 │ │ 0/12개 │ │
|
||||||
|
│ │ [선택] │ │ [선택] │ │ [선택] │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 아크메이지 (리부트) - 보스 선택 4/12개 사용중 │
|
||||||
|
│ ┌───────────────────────────────────────────────┐ │
|
||||||
|
│ │ ☑ [IMG] 검은 마법사 하드 500,000,000 ÷[6] │ │
|
||||||
|
│ │ ☑ [IMG] 스우 하드 256,000,000 ÷[6] │ │
|
||||||
|
│ │ ☑ [IMG] 루시드 하드 175,000,000 ÷[6] │ │
|
||||||
|
│ │ ☑ [IMG] 윌 하드 140,000,000 ÷[6] │ │
|
||||||
|
│ │ ☐ [IMG] 가엔슬 노말 42,000,000 ÷[1] │ │
|
||||||
|
│ │ ☐ [IMG] 듄켈 하드 70,000,000 ÷[6] │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └───────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 주간 수익 요약 │
|
||||||
|
│ ┌───────────────────────────────────────────────┐ │
|
||||||
|
│ │ 캐릭터 결정석 수익 │ │
|
||||||
|
│ │ ───────────────────────────────────────────── │ │
|
||||||
|
│ │ 아크메이지 12/12 1,200,000,000 메소 │ │
|
||||||
|
│ │ 나이트로드 10/12 800,000,000 메소 │ │
|
||||||
|
│ │ 아델 8/12 400,000,000 메소 │ │
|
||||||
|
│ │ ───────────────────────────────────────────── │ │
|
||||||
|
│ │ 합계 30/90 2,400,000,000 메소 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ⚠ 수익 최적화 팁: │ │
|
||||||
|
│ │ 아델의 자쿰(10M)을 제외하면 다른 캐릭터에서 │ │
|
||||||
|
│ │ 더 높은 수익 보스를 추가할 수 있습니다. │ │
|
||||||
|
│ └───────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Docker Compose 구성
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
- db
|
||||||
|
- app
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
|
db:
|
||||||
|
external: true
|
||||||
|
app:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. 환경 변수 (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# DB (기존 MariaDB 활용)
|
||||||
|
DB_HOST=mariadb
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=maplestory
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_NAME=maplestory
|
||||||
|
|
||||||
|
# Redis (세션 저장)
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# RustFS (S3 호환 스토리지)
|
||||||
|
S3_ENDPOINT=http://rustfs:9000
|
||||||
|
S3_PUBLIC_URL=https://s3.caadiq.co.kr
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
S3_BUCKET=maplestory
|
||||||
|
|
||||||
|
# 넥슨 OAuth
|
||||||
|
NEXON_CLIENT_ID=
|
||||||
|
NEXON_CLIENT_SECRET=
|
||||||
|
NEXON_REDIRECT_URI=https://maple.caadiq.co.kr/api/auth/callback
|
||||||
|
|
||||||
|
# 넥슨 API (캐릭터 상세 조회용)
|
||||||
|
NEXON_API_KEY=
|
||||||
|
|
||||||
|
# 세션
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# 앱
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. 구현 단계
|
||||||
|
|
||||||
|
| 단계 | 내용 | 상세 |
|
||||||
|
|---|---|---|
|
||||||
|
| **1** | 프로젝트 초기 설정 | Vite + Express 스캐폴딩, Docker, MariaDB/Redis 연결 |
|
||||||
|
| **2** | DB 스키마 + 시드 | 테이블 생성, 전체 주간 보스 결정석 데이터 시딩 |
|
||||||
|
| **3** | 넥슨 OAuth 구현 | 로그인/콜백/로그아웃, 세션 관리 |
|
||||||
|
| **4** | 캐릭터 목록 연동 | OAuth 토큰으로 캐릭터 목록 조회 + ocid로 상세 조회 |
|
||||||
|
| **5** | 보스 목록 UI | 보스 데이터 API + 프론트엔드 보스 목록 표시 |
|
||||||
|
| **6** | 캐릭터별 보스 선택 | 체크박스 UI, 파티 인원 설정, 선택 저장 |
|
||||||
|
| **7** | 수익 계산 엔진 | 12개/90개 제한 적용, 최적 조합 계산, 요약 대시보드 |
|
||||||
|
| **8** | 보스 이미지 적용 | WZ 추출 이미지 적용 |
|
||||||
|
| **9** | Docker 배포 | Dockerfile, docker-compose, Caddy 리버스 프록시 |
|
||||||
|
|
||||||
|
## 14. 고려사항
|
||||||
|
|
||||||
|
- **Friends 앱 등록**: 넥슨 Open API에서 Friends Application 등록 및 검수 필요 (초기 테스트 단계에서는 5명까지 사용 가능)
|
||||||
|
- **결정석 가격 업데이트**: 패치 시 DB의 `boss_difficulties` 테이블만 업데이트
|
||||||
|
- **토큰 갱신**: access_token 만료(30분) 시 refresh_token으로 자동 갱신 미들웨어 구현
|
||||||
|
- **데이터 갱신 의무**: 넥슨 정책상 30일 내 데이터 갱신 필요
|
||||||
|
- **보스 이미지 저작권**: 넥슨 게임 리소스 사용 시 저작권 고려 필요
|
||||||
13
backend/lib/db.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
|
||||||
|
export const sequelize = new Sequelize(
|
||||||
|
process.env.DB_NAME || 'maplestory',
|
||||||
|
process.env.DB_USER || 'maplestory',
|
||||||
|
process.env.DB_PASSWORD || '',
|
||||||
|
{
|
||||||
|
host: process.env.DB_HOST || 'mariadb',
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306'),
|
||||||
|
dialect: 'mariadb',
|
||||||
|
logging: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
6
backend/lib/redis.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
export const redis = new Redis({
|
||||||
|
host: process.env.REDIS_HOST || 'redis',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||||
|
});
|
||||||
6
backend/middleware/auth.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export function requireAuth(req, res, next) {
|
||||||
|
if (!req.session?.userId) {
|
||||||
|
return res.status(401).json({ error: '로그인이 필요합니다' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
16
backend/middleware/session.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import session from 'express-session';
|
||||||
|
import { RedisStore } from 'connect-redis';
|
||||||
|
import { redis } from '../lib/redis.js';
|
||||||
|
|
||||||
|
export const sessionMiddleware = session({
|
||||||
|
store: new RedisStore({ client: redis, prefix: 'maple:sess:' }),
|
||||||
|
secret: process.env.SESSION_SECRET || 'dev-secret',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 14 * 24 * 60 * 60 * 1000, // 14일
|
||||||
|
sameSite: 'lax',
|
||||||
|
},
|
||||||
|
});
|
||||||
10
backend/models/User.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../lib/db.js';
|
||||||
|
|
||||||
|
export const User = sequelize.define('User', {
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
nexon_uid: { type: DataTypes.STRING(50), allowNull: false, unique: true },
|
||||||
|
}, {
|
||||||
|
tableName: 'users',
|
||||||
|
underscored: true,
|
||||||
|
});
|
||||||
19
backend/models/UserCharacter.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../lib/db.js';
|
||||||
|
|
||||||
|
export const UserCharacter = sequelize.define('UserCharacter', {
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
user_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
character_name: { type: DataTypes.STRING(50), allowNull: false },
|
||||||
|
ocid: { type: DataTypes.STRING(100) },
|
||||||
|
world_name: { type: DataTypes.STRING(20) },
|
||||||
|
job_name: { type: DataTypes.STRING(50) },
|
||||||
|
character_level: { type: DataTypes.INTEGER },
|
||||||
|
character_image: { type: DataTypes.STRING(255) },
|
||||||
|
}, {
|
||||||
|
tableName: 'user_characters',
|
||||||
|
underscored: true,
|
||||||
|
indexes: [
|
||||||
|
{ unique: true, fields: ['user_id', 'character_name'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
12
backend/models/boss/Boss.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../lib/db.js';
|
||||||
|
|
||||||
|
export const Boss = sequelize.define('Boss', {
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
name: { type: DataTypes.STRING(50), allowNull: false },
|
||||||
|
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
|
||||||
|
image_url: { type: DataTypes.STRING(255) },
|
||||||
|
}, {
|
||||||
|
tableName: 'bosses',
|
||||||
|
underscored: true,
|
||||||
|
});
|
||||||
18
backend/models/boss/BossDifficulty.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../lib/db.js';
|
||||||
|
|
||||||
|
export const BossDifficulty = sequelize.define('BossDifficulty', {
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
boss_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
difficulty: { type: DataTypes.ENUM('easy', 'normal', 'hard', 'chaos', 'extreme'), allowNull: false },
|
||||||
|
crystal_price: { type: DataTypes.BIGINT, allowNull: false },
|
||||||
|
required_level: { type: DataTypes.INTEGER, defaultValue: 0 },
|
||||||
|
max_party_size: { type: DataTypes.TINYINT, defaultValue: 1 },
|
||||||
|
}, {
|
||||||
|
tableName: 'boss_difficulties',
|
||||||
|
underscored: true,
|
||||||
|
timestamps: false,
|
||||||
|
indexes: [
|
||||||
|
{ unique: true, fields: ['boss_id', 'difficulty'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
17
backend/models/boss/UserBossSelection.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../lib/db.js';
|
||||||
|
|
||||||
|
export const UserBossSelection = sequelize.define('UserBossSelection', {
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
user_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
user_character_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
boss_difficulty_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
party_size: { type: DataTypes.TINYINT, allowNull: false, defaultValue: 1 },
|
||||||
|
}, {
|
||||||
|
tableName: 'user_boss_selections',
|
||||||
|
underscored: true,
|
||||||
|
timestamps: false,
|
||||||
|
indexes: [
|
||||||
|
{ unique: true, fields: ['user_character_id', 'boss_difficulty_id'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
21
backend/models/index.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { User } from './User.js';
|
||||||
|
import { UserCharacter } from './UserCharacter.js';
|
||||||
|
import { Boss } from './boss/Boss.js';
|
||||||
|
import { BossDifficulty } from './boss/BossDifficulty.js';
|
||||||
|
import { UserBossSelection } from './boss/UserBossSelection.js';
|
||||||
|
|
||||||
|
// User <-> UserCharacter
|
||||||
|
User.hasMany(UserCharacter, { foreignKey: 'user_id', as: 'characters' });
|
||||||
|
UserCharacter.belongsTo(User, { foreignKey: 'user_id' });
|
||||||
|
|
||||||
|
// Boss <-> BossDifficulty
|
||||||
|
Boss.hasMany(BossDifficulty, { foreignKey: 'boss_id', as: 'difficulties' });
|
||||||
|
BossDifficulty.belongsTo(Boss, { foreignKey: 'boss_id' });
|
||||||
|
|
||||||
|
// UserBossSelection 관계
|
||||||
|
UserBossSelection.belongsTo(User, { foreignKey: 'user_id' });
|
||||||
|
UserBossSelection.belongsTo(UserCharacter, { foreignKey: 'user_character_id', as: 'character' });
|
||||||
|
UserBossSelection.belongsTo(BossDifficulty, { foreignKey: 'boss_difficulty_id', as: 'difficulty' });
|
||||||
|
UserCharacter.hasMany(UserBossSelection, { foreignKey: 'user_character_id', as: 'selections' });
|
||||||
|
|
||||||
|
export { User, UserCharacter, Boss, BossDifficulty, UserBossSelection };
|
||||||
3169
backend/package-lock.json
generated
Normal file
23
backend/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "maplestory-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"sequelize": "^6.37.5",
|
||||||
|
"mysql2": "^3.14.1",
|
||||||
|
"mariadb": "^3.4.0",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"connect-redis": "^8.0.1",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
|
"@aws-sdk/client-s3": "^3.800.0",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"crypto": "^1.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
backend/routes/auth.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { exchangeToken, getUserInfo, refreshToken } from '../services/nexon.js';
|
||||||
|
import { User } from '../models/index.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/login', (req, res) => {
|
||||||
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
req.session.oauthState = state;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: process.env.NEXON_CLIENT_ID,
|
||||||
|
redirect_uri: process.env.NEXON_REDIRECT_URI,
|
||||||
|
scope: 'maplestory.characterlist',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(`https://openid.nexon.com/oauth2/authorize?${params}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/callback', async (req, res) => {
|
||||||
|
const { code, state } = req.query;
|
||||||
|
|
||||||
|
if (!code || state !== req.session.oauthState) {
|
||||||
|
return res.status(400).json({ error: '잘못된 요청입니다' });
|
||||||
|
}
|
||||||
|
delete req.session.oauthState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await exchangeToken(code);
|
||||||
|
const userInfo = await getUserInfo(tokens.access_token);
|
||||||
|
|
||||||
|
const [user] = await User.findOrCreate({
|
||||||
|
where: { nexon_uid: userInfo.uid },
|
||||||
|
});
|
||||||
|
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.accessToken = tokens.access_token;
|
||||||
|
req.session.refreshToken = tokens.refresh_token;
|
||||||
|
req.session.tokenExpiresAt = Date.now() + tokens.expires_in * 1000;
|
||||||
|
|
||||||
|
res.redirect('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('OAuth 콜백 오류:', err.message);
|
||||||
|
res.status(500).json({ error: '로그인 처리 중 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
req.session.destroy((err) => {
|
||||||
|
if (err) return res.status(500).json({ error: '로그아웃 실패' });
|
||||||
|
res.clearCookie('connect.sid');
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', (req, res) => {
|
||||||
|
if (!req.session?.userId) {
|
||||||
|
return res.json({ authenticated: false });
|
||||||
|
}
|
||||||
|
res.json({ authenticated: true, userId: req.session.userId });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
23
backend/routes/boss/bosses.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { Boss, BossDifficulty } from '../../models/index.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 보스 목록 + 난이도별 결정석 가격
|
||||||
|
router.get('/', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const bosses = await Boss.findAll({
|
||||||
|
include: [{ model: BossDifficulty, as: 'difficulties' }],
|
||||||
|
order: [
|
||||||
|
['sort_order', 'ASC'],
|
||||||
|
[{ model: BossDifficulty, as: 'difficulties' }, 'crystal_price', 'DESC'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
res.json(bosses);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('보스 목록 조회 오류:', err.message);
|
||||||
|
res.status(500).json({ error: '보스 목록 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
31
backend/routes/boss/calculate.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { UserCharacter, UserBossSelection, BossDifficulty, Boss } from '../../models/index.js';
|
||||||
|
import { calculateRevenue } from '../../services/boss/calculator.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const characters = await UserCharacter.findAll({
|
||||||
|
where: { user_id: req.session.userId },
|
||||||
|
include: [{
|
||||||
|
model: UserBossSelection,
|
||||||
|
as: 'selections',
|
||||||
|
include: [{
|
||||||
|
model: BossDifficulty,
|
||||||
|
as: 'difficulty',
|
||||||
|
include: [{ model: Boss }],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = calculateRevenue(characters);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('수익 계산 오류:', err.message);
|
||||||
|
res.status(500).json({ error: '수익 계산 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
58
backend/routes/boss/selections.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { UserBossSelection, BossDifficulty, Boss } from '../../models/index.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 내 캐릭터별 보스 선택 현황
|
||||||
|
router.get('/', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const selections = await UserBossSelection.findAll({
|
||||||
|
where: { user_id: req.session.userId },
|
||||||
|
include: [{
|
||||||
|
model: BossDifficulty,
|
||||||
|
as: 'difficulty',
|
||||||
|
include: [{ model: Boss }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
res.json(selections);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('선택 조회 오류:', err.message);
|
||||||
|
res.status(500).json({ error: '보스 선택 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 캐릭터별 보스 선택 저장
|
||||||
|
router.put('/:characterId', requireAuth, async (req, res) => {
|
||||||
|
const { characterId } = req.params;
|
||||||
|
const { selections } = req.body; // [{ boss_difficulty_id, party_size }]
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 기존 선택 삭제
|
||||||
|
await UserBossSelection.destroy({
|
||||||
|
where: {
|
||||||
|
user_id: req.session.userId,
|
||||||
|
user_character_id: characterId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 새 선택 생성
|
||||||
|
if (selections?.length) {
|
||||||
|
await UserBossSelection.bulkCreate(
|
||||||
|
selections.map((s) => ({
|
||||||
|
user_id: req.session.userId,
|
||||||
|
user_character_id: characterId,
|
||||||
|
boss_difficulty_id: s.boss_difficulty_id,
|
||||||
|
party_size: s.party_size || 1,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('선택 저장 오류:', err.message);
|
||||||
|
res.status(500).json({ error: '보스 선택 저장 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
63
backend/routes/characters.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { getCharacterList, getCharacterOcid, getCharacterBasic } from '../services/nexon.js';
|
||||||
|
import { UserCharacter } from '../models/index.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 내 캐릭터 목록 조회
|
||||||
|
router.get('/', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const characters = await UserCharacter.findAll({
|
||||||
|
where: { user_id: req.session.userId },
|
||||||
|
order: [['character_level', 'DESC']],
|
||||||
|
});
|
||||||
|
res.json(characters);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('캐릭터 목록 조회 오류:', err.message);
|
||||||
|
res.status(500).json({ error: '캐릭터 목록 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 캐릭터 목록 갱신 (넥슨 API에서 다시 가져오기)
|
||||||
|
router.post('/refresh', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const charList = await getCharacterList(req.session.accessToken);
|
||||||
|
|
||||||
|
if (!charList?.account_list) {
|
||||||
|
return res.status(400).json({ error: '캐릭터 목록을 가져올 수 없습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const account of charList.account_list) {
|
||||||
|
for (const char of account.character_list || []) {
|
||||||
|
try {
|
||||||
|
const ocid = await getCharacterOcid(char.character_name);
|
||||||
|
const basic = await getCharacterBasic(ocid);
|
||||||
|
|
||||||
|
const [userChar] = await UserCharacter.upsert({
|
||||||
|
user_id: req.session.userId,
|
||||||
|
character_name: char.character_name,
|
||||||
|
ocid,
|
||||||
|
world_name: basic.world_name,
|
||||||
|
job_name: basic.character_class,
|
||||||
|
character_level: basic.character_level,
|
||||||
|
character_image: basic.character_image,
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push(userChar);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`캐릭터 조회 실패: ${char.character_name}`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('캐릭터 갱신 오류:', err.message);
|
||||||
|
res.status(500).json({ error: '캐릭터 목록 갱신 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
49
backend/server.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { sessionMiddleware } from './middleware/session.js';
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import characterRoutes from './routes/characters.js';
|
||||||
|
import bossRoutes from './routes/boss/bosses.js';
|
||||||
|
import selectionRoutes from './routes/boss/selections.js';
|
||||||
|
import calculateRoutes from './routes/boss/calculate.js';
|
||||||
|
import { sequelize } from './lib/db.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.NODE_ENV === 'production'
|
||||||
|
? 'https://maple.caadiq.co.kr'
|
||||||
|
: 'http://localhost:5173',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/characters', characterRoutes);
|
||||||
|
app.use('/api/boss', bossRoutes);
|
||||||
|
app.use('/api/boss/selections', selectionRoutes);
|
||||||
|
app.use('/api/boss/calculate', calculateRoutes);
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('DB 연결 성공');
|
||||||
|
await sequelize.sync();
|
||||||
|
console.log('테이블 동기화 완료');
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`서버 시작: http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('서버 시작 실패:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
60
backend/services/boss/calculator.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
const MAX_CRYSTALS_PER_CHARACTER = 12;
|
||||||
|
const MAX_CRYSTALS_PER_ACCOUNT = 90;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주간 보스 수익 계산
|
||||||
|
* @param {Array} characterSelections - 캐릭터별 보스 선택 데이터
|
||||||
|
* @returns {Object} 계산 결과
|
||||||
|
*/
|
||||||
|
export function calculateRevenue(characterSelections) {
|
||||||
|
const characterResults = [];
|
||||||
|
const allCrystals = [];
|
||||||
|
|
||||||
|
for (const char of characterSelections) {
|
||||||
|
const crystals = char.selections
|
||||||
|
.map((s) => ({
|
||||||
|
characterId: char.id,
|
||||||
|
characterName: char.character_name,
|
||||||
|
bossName: s.difficulty.Boss?.name || '',
|
||||||
|
difficulty: s.difficulty.difficulty,
|
||||||
|
crystalPrice: Number(s.difficulty.crystal_price),
|
||||||
|
partySize: s.party_size,
|
||||||
|
revenue: Math.floor(Number(s.difficulty.crystal_price) / s.party_size),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.revenue - a.revenue)
|
||||||
|
.slice(0, MAX_CRYSTALS_PER_CHARACTER);
|
||||||
|
|
||||||
|
characterResults.push({
|
||||||
|
id: char.id,
|
||||||
|
characterName: char.character_name,
|
||||||
|
crystalCount: crystals.length,
|
||||||
|
maxCrystals: MAX_CRYSTALS_PER_CHARACTER,
|
||||||
|
crystals,
|
||||||
|
});
|
||||||
|
|
||||||
|
allCrystals.push(...crystals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계정 한도 적용: 전체에서 수익 높은 순으로 90개
|
||||||
|
allCrystals.sort((a, b) => b.revenue - a.revenue);
|
||||||
|
const activeCrystals = allCrystals.slice(0, MAX_CRYSTALS_PER_ACCOUNT);
|
||||||
|
const excludedCrystals = allCrystals.slice(MAX_CRYSTALS_PER_ACCOUNT);
|
||||||
|
|
||||||
|
const totalRevenue = activeCrystals.reduce((sum, c) => sum + c.revenue, 0);
|
||||||
|
|
||||||
|
// 캐릭터별 소계 재계산 (계정 한도 반영)
|
||||||
|
const activeSet = new Set(activeCrystals);
|
||||||
|
for (const charResult of characterResults) {
|
||||||
|
const active = charResult.crystals.filter((c) => activeSet.has(c));
|
||||||
|
charResult.activeCount = active.length;
|
||||||
|
charResult.revenue = active.reduce((sum, c) => sum + c.revenue, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters: characterResults,
|
||||||
|
totalCrystals: activeCrystals.length,
|
||||||
|
maxCrystals: MAX_CRYSTALS_PER_ACCOUNT,
|
||||||
|
totalRevenue,
|
||||||
|
excludedCrystals,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
backend/services/nexon.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const NEXON_API_BASE = 'https://open.api.nexon.com';
|
||||||
|
const NEXON_OPENID_BASE = 'https://openid.nexon.com';
|
||||||
|
|
||||||
|
export async function exchangeToken(code) {
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`${NEXON_OPENID_BASE}/oauth2/token`,
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: process.env.NEXON_CLIENT_ID,
|
||||||
|
client_secret: process.env.NEXON_CLIENT_SECRET,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken(refreshToken) {
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`${NEXON_OPENID_BASE}/oauth2/token`,
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: process.env.NEXON_CLIENT_ID,
|
||||||
|
client_secret: process.env.NEXON_CLIENT_SECRET,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserInfo(accessToken) {
|
||||||
|
const { data } = await axios.get(`${NEXON_OPENID_BASE}/api/v1/user/info`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCharacterList(accessToken) {
|
||||||
|
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/list`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCharacterOcid(characterName) {
|
||||||
|
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/id`, {
|
||||||
|
params: { character_name: characterName },
|
||||||
|
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
||||||
|
});
|
||||||
|
return data.ocid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCharacterBasic(ocid) {
|
||||||
|
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/basic`, {
|
||||||
|
params: { ocid },
|
||||||
|
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
49
docker-compose.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
container_name: maplestory-frontend
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- frontend_modules:/app/node_modules
|
||||||
|
command: sh -c "npm install && npm run dev"
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: maplestory-backend
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- backend_modules:/app/node_modules
|
||||||
|
command: sh -c "npm install && npm run dev"
|
||||||
|
env_file: .env
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
- db
|
||||||
|
- app
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
frontend_modules:
|
||||||
|
backend_modules:
|
||||||
|
redis-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
|
db:
|
||||||
|
external: true
|
||||||
|
app:
|
||||||
|
external: true
|
||||||
25
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
boss-images/
|
||||||
16
frontend/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
29
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>메이플스토리 도우미</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2995
frontend/package-lock.json
generated
Normal file
30
frontend/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
15
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Home from './pages/Home'
|
||||||
|
import BossPage from './features/boss/BossPage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
<Route path="/boss" element={<BossPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/src/api/client.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export async function api(url, options = {}) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||||
|
...options,
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
21
frontend/src/components/Layout.jsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Outlet, Link } from 'react-router-dom'
|
||||||
|
import LoginButton from './LoginButton'
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 text-white">
|
||||||
|
<header className="border-b border-gray-800 px-6 py-4">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between">
|
||||||
|
<Link to="/" className="text-xl font-bold">메이플스토리 도우미</Link>
|
||||||
|
<nav className="flex items-center gap-6">
|
||||||
|
<Link to="/boss" className="text-gray-400 hover:text-white transition">보스 계산기</Link>
|
||||||
|
<LoginButton />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
frontend/src/components/LoginButton.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
|
||||||
|
export default function LoginButton() {
|
||||||
|
const { authenticated, loading, logout } = useAuth()
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
|
||||||
|
if (authenticated) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="rounded bg-gray-700 px-4 py-2 text-sm hover:bg-gray-600 transition"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="/api/auth/login"
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 text-sm hover:bg-blue-500 transition"
|
||||||
|
>
|
||||||
|
넥슨 로그인
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
frontend/src/features/boss/BossPage.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const DIFF_KEYS = { '이지': 'easy', '노말': 'normal', '하드': 'hard', '카오스': 'chaos', '익스트림': 'extreme' }
|
||||||
|
|
||||||
|
const DUMMY_BOSSES = [
|
||||||
|
{
|
||||||
|
id: 1, name: '자쿰', imgId: 1,
|
||||||
|
difficulties: [
|
||||||
|
{ name: '이지', crystal: 6_612_500, defaultParty: 1 },
|
||||||
|
{ name: '노말', crystal: 16_200_000, defaultParty: 1 },
|
||||||
|
{ name: '카오스', crystal: 81_000_000, defaultParty: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, name: '힐라', imgId: 3,
|
||||||
|
difficulties: [
|
||||||
|
{ name: '노말', crystal: 6_612_500, defaultParty: 1 },
|
||||||
|
{ name: '하드', crystal: 56_250_000, defaultParty: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, name: '매그너스', imgId: 10,
|
||||||
|
difficulties: [
|
||||||
|
{ name: '이지', crystal: 7_200_000, defaultParty: 1 },
|
||||||
|
{ name: '노말', crystal: 19_012_500, defaultParty: 1 },
|
||||||
|
{ name: '하드', crystal: 95_062_500, defaultParty: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4, name: '파풀라투스', imgId: 22,
|
||||||
|
difficulties: [
|
||||||
|
{ name: '이지', crystal: 4_012_500, defaultParty: 1 },
|
||||||
|
{ name: '노말', crystal: 13_012_500, defaultParty: 1 },
|
||||||
|
{ name: '카오스', crystal: 79_012_500, defaultParty: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5, name: '듄켈', imgId: 27,
|
||||||
|
difficulties: [
|
||||||
|
{ name: '노말', crystal: 92_450_000, defaultParty: 1 },
|
||||||
|
{ name: '하드', crystal: 231_125_000, defaultParty: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6, name: '림보', imgId: 33,
|
||||||
|
difficulties: [
|
||||||
|
{ name: '노말', crystal: 140_000_000, defaultParty: 1 },
|
||||||
|
{ name: '하드', crystal: 350_000_000, defaultParty: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatMeso(n) {
|
||||||
|
if (n >= 100_000_000) {
|
||||||
|
const uk = Math.floor(n / 100_000_000)
|
||||||
|
const man = Math.floor((n % 100_000_000) / 10_000)
|
||||||
|
return man > 0 ? `${uk}억 ${man.toLocaleString()}만` : `${uk}억`
|
||||||
|
}
|
||||||
|
if (n >= 10_000) return `${Math.floor(n / 10_000).toLocaleString()}만`
|
||||||
|
return n.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function BossRowList({ boss, selections, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-800 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 bg-gray-900 px-3 py-2">
|
||||||
|
<img src={`/boss-images/icon/${boss.imgId}.png`} alt={boss.name} className="w-10 h-10 rounded object-cover" />
|
||||||
|
<span className="font-medium">{boss.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-800/50">
|
||||||
|
{boss.difficulties.map((diff, i) => {
|
||||||
|
const key = `${boss.id}-${i}`
|
||||||
|
const sel = selections[key] || { enabled: false, party: diff.defaultParty }
|
||||||
|
return (
|
||||||
|
<label key={i} className={`flex items-center gap-3 px-3 py-2 cursor-pointer transition ${sel.enabled ? '' : 'opacity-50'}`}>
|
||||||
|
<input type="checkbox" checked={sel.enabled} onChange={(e) => onChange(key, { ...sel, enabled: e.target.checked })} className="accent-emerald-500 w-4 h-4 shrink-0" />
|
||||||
|
<img src={`/boss-images/diff-badge/${DIFF_KEYS[diff.name]}.png`} alt={diff.name} className="h-5 shrink-0" />
|
||||||
|
<div className="flex-1 text-sm text-gray-400">{formatMeso(diff.crystal)}</div>
|
||||||
|
<select value={sel.party} onChange={(e) => { e.stopPropagation(); onChange(key, { ...sel, party: Number(e.target.value) }) }} className="bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 outline-none w-14 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((n) => <option key={n} value={n}>÷{n}인</option>)}
|
||||||
|
</select>
|
||||||
|
<div className={`text-sm font-medium w-20 text-right shrink-0 ${sel.enabled ? 'text-green-400' : 'text-gray-600'}`}>
|
||||||
|
{sel.enabled ? formatMeso(Math.floor(diff.crystal / sel.party)) : '-'}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BossPage() {
|
||||||
|
const [selections, setSelections] = useState({})
|
||||||
|
|
||||||
|
const handleChange = (key, sel) => setSelections((prev) => ({ ...prev, [key]: sel }))
|
||||||
|
|
||||||
|
const entries = Object.entries(selections).filter(([, s]) => s.enabled)
|
||||||
|
const totalCrystals = entries.length
|
||||||
|
const totalRevenue = entries.reduce((sum, [key, sel]) => {
|
||||||
|
const [bossId, diffIdx] = key.split('-').map(Number)
|
||||||
|
const boss = DUMMY_BOSSES.find((b) => b.id === bossId)
|
||||||
|
return sum + Math.floor(boss.difficulties[diffIdx].crystal / sel.party)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">주간 보스 수익 계산기</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-6 rounded-lg border border-gray-800 bg-gray-900/50 p-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">결정석</div>
|
||||||
|
<div className="text-lg font-bold">{totalCrystals}<span className="text-gray-500 text-sm">/12</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">예상 수익</div>
|
||||||
|
<div className="text-lg font-bold text-green-400">{formatMeso(totalRevenue)} <span className="text-sm text-gray-400">메소</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{DUMMY_BOSSES.map((boss) => (
|
||||||
|
<BossRowList key={boss.id} boss={boss} selections={selections} onChange={handleChange} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
frontend/src/hooks/useAuth.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [authenticated, setAuthenticated] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api('/api/auth/me')
|
||||||
|
.then((data) => setAuthenticated(data.authenticated))
|
||||||
|
.catch(() => setAuthenticated(false))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await api('/api/auth/logout', { method: 'POST' })
|
||||||
|
setAuthenticated(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticated, loading, logout }
|
||||||
|
}
|
||||||
35
frontend/src/hooks/useCharacters.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
export function useCharacters() {
|
||||||
|
const [characters, setCharacters] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchCharacters = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api('/api/characters')
|
||||||
|
setCharacters(data)
|
||||||
|
} catch {
|
||||||
|
setCharacters([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshCharacters = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api('/api/characters/refresh', { method: 'POST' })
|
||||||
|
setCharacters(data)
|
||||||
|
} catch {
|
||||||
|
// 무시
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchCharacters() }, [fetchCharacters])
|
||||||
|
|
||||||
|
return { characters, loading, refreshCharacters }
|
||||||
|
}
|
||||||
1
frontend/src/index.css
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "tailwindcss";
|
||||||
13
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
22
frontend/src/pages/Home.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-8 pt-16">
|
||||||
|
<h1 className="text-4xl font-bold">메이플스토리 도우미</h1>
|
||||||
|
<p className="text-gray-400">메이플스토리 유틸리티 모음</p>
|
||||||
|
<div className="grid gap-4 w-full max-w-md">
|
||||||
|
<Link
|
||||||
|
to="/boss"
|
||||||
|
className="flex items-center gap-4 rounded-lg border border-gray-800 p-6 hover:border-gray-600 transition"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">💎</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">주간 보스 수익 계산기</h2>
|
||||||
|
<p className="text-sm text-gray-400">캐릭터별 보스 결정석 수익을 계산합니다</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
allowedHosts: ['maple.caadiq.co.kr', 'maplestory-frontend'],
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
BIN
images/boss/가디언엔젤슬라임.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
images/boss/검은마법사.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/boss/더스크.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
images/boss/데미안.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
images/boss/듄켈.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
images/boss/루시드.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
images/boss/림보.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/boss/매그너스.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
images/boss/반반.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
images/boss/발드릭스.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
images/boss/벨룸.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
images/boss/블러디퀸.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
images/boss/세렌.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
images/boss/스우.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
images/boss/시그너스.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
images/boss/윌.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
images/boss/유피테르.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/boss/자쿰.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
images/boss/진힐라.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
images/boss/찬란한용성.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/boss/카링.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
images/boss/칼로스.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
images/boss/파풀라투스.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
images/boss/피에르.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
images/boss/핑크빈.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
images/boss/힐라.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
images/difficulty/chaos.png
Normal file
|
After Width: | Height: | Size: 1,009 B |
BIN
images/difficulty/easy.png
Normal file
|
After Width: | Height: | Size: 826 B |
BIN
images/difficulty/extreme.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
images/difficulty/hard.png
Normal file
|
After Width: | Height: | Size: 842 B |
BIN
images/difficulty/normal.png
Normal file
|
After Width: | Height: | Size: 986 B |