초기 프로젝트 설정

- 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>
This commit is contained in:
caadiq 2026-04-07 18:55:06 +09:00
commit 4bbb496724
73 changed files with 7777 additions and 0 deletions

32
.env Normal file
View 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
View 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
View 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
View 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
View 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'),
});

View file

@ -0,0 +1,6 @@
export function requireAuth(req, res, next) {
if (!req.session?.userId) {
return res.status(401).json({ error: '로그인이 필요합니다' });
}
next();
}

View 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
View 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,
});

View 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'] },
],
});

View 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,
});

View 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'] },
],
});

View 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
View 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

File diff suppressed because it is too large Load diff

23
backend/package.json Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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();

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

30
frontend/package.json Normal file
View 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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

15
frontend/src/App.jsx Normal file
View 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>
)
}

View 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()
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 }
}

View 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
View file

@ -0,0 +1 @@
@import "tailwindcss";

13
frontend/src/main.jsx Normal file
View 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>,
)

View 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
View 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,
},
},
},
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
images/boss/더스크.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
images/boss/데미안.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
images/boss/듄켈.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
images/boss/루시드.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
images/boss/림보.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

BIN
images/boss/반반.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
images/boss/벨룸.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
images/boss/세렌.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
images/boss/스우.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
images/boss/윌.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
images/boss/자쿰.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
images/boss/진힐라.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
images/boss/카링.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

BIN
images/boss/칼로스.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
images/boss/피에르.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
images/boss/핑크빈.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
images/boss/힐라.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
images/difficulty/chaos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,009 B

BIN
images/difficulty/easy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
images/difficulty/hard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 B