diff --git a/.env b/.env index dc00469..d620991 100644 --- a/.env +++ b/.env @@ -5,10 +5,6 @@ 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 @@ -16,16 +12,8 @@ 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= +# 넥슨 API +NEXON_API_KEY=test_d32f00908105a5803bf0ce5cf717747c0f06152c00f907ea7f9bb68d3541d2b6efe8d04e6d233bd35cf2fabdeb93fb0d # 앱 NODE_ENV=development diff --git a/backend/lib/redis.js b/backend/lib/redis.js deleted file mode 100644 index b113099..0000000 --- a/backend/lib/redis.js +++ /dev/null @@ -1,6 +0,0 @@ -import Redis from 'ioredis'; - -export const redis = new Redis({ - host: process.env.REDIS_HOST || 'redis', - port: parseInt(process.env.REDIS_PORT || '6379'), -}); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js deleted file mode 100644 index ac81d9c..0000000 --- a/backend/middleware/auth.js +++ /dev/null @@ -1,6 +0,0 @@ -export function requireAuth(req, res, next) { - if (!req.session?.userId) { - return res.status(401).json({ error: '로그인이 필요합니다' }); - } - next(); -} diff --git a/backend/middleware/session.js b/backend/middleware/session.js deleted file mode 100644 index 46d07dc..0000000 --- a/backend/middleware/session.js +++ /dev/null @@ -1,16 +0,0 @@ -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', - }, -}); diff --git a/backend/models/User.js b/backend/models/User.js deleted file mode 100644 index 633b72e..0000000 --- a/backend/models/User.js +++ /dev/null @@ -1,10 +0,0 @@ -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, -}); diff --git a/backend/models/UserCharacter.js b/backend/models/UserCharacter.js deleted file mode 100644 index ab94701..0000000 --- a/backend/models/UserCharacter.js +++ /dev/null @@ -1,19 +0,0 @@ -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'] }, - ], -}); diff --git a/backend/models/boss/UserBossSelection.js b/backend/models/boss/UserBossSelection.js deleted file mode 100644 index 352b3c1..0000000 --- a/backend/models/boss/UserBossSelection.js +++ /dev/null @@ -1,17 +0,0 @@ -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'] }, - ], -}); diff --git a/backend/models/index.js b/backend/models/index.js index 7dd5b9e..055d298 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -1,21 +1,8 @@ -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 }; +export { Boss, BossDifficulty }; diff --git a/backend/package-lock.json b/backend/package-lock.json index a099360..43180cd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,12 +10,8 @@ "dependencies": { "@aws-sdk/client-s3": "^3.800.0", "axios": "^1.9.0", - "connect-redis": "^8.0.1", "cors": "^2.8.5", - "crypto": "^1.0.1", "express": "^5.1.0", - "express-session": "^1.18.1", - "ioredis": "^5.6.1", "mariadb": "^3.4.0", "mysql2": "^3.14.1", "sequelize": "^6.37.5" @@ -872,12 +868,6 @@ "node": ">=18.0.0" } }, - "node_modules/@ioredis/commands": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", - "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", - "license": "MIT" - }, "node_modules/@smithy/chunked-blob-reader": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", @@ -1740,15 +1730,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1761,18 +1742,6 @@ "node": ">= 0.8" } }, - "node_modules/connect-redis": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz", - "integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "express-session": ">=1" - } - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1830,13 +1799,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/crypto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", - "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", - "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", - "license": "ISC" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2020,50 +1982,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-session": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", - "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", - "license": "MIT", - "dependencies": { - "cookie": "~0.7.2", - "cookie-signature": "~1.0.7", - "debug": "~2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.1.0", - "parseurl": "~1.3.3", - "safe-buffer": "~5.2.1", - "uid-safe": "~2.1.5" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-session/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/express-session/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express-session/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/fast-xml-builder": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", @@ -2352,30 +2270,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ioredis": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", - "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.5.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2403,18 +2297,6 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2616,15 +2498,6 @@ "node": ">= 0.8" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2711,15 +2584,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2744,27 +2608,6 @@ "node": ">= 0.10" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/retry-as-promised": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", @@ -2787,26 +2630,6 @@ "node": ">= 18" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3034,12 +2857,6 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3096,18 +2913,6 @@ "node": ">= 0.6" } }, - "node_modules/uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "license": "MIT", - "dependencies": { - "random-bytes": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/backend/package.json b/backend/package.json index b578895..983a9a7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,11 +13,7 @@ "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" + "axios": "^1.9.0" } } diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..7d596c6 --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,28 @@ +import { Router } from 'express'; + +const router = Router(); + +// 관리자 인증 미들웨어 +function requireAdmin(req, res, next) { + const key = req.headers['x-admin-key']; + if (!key || key !== process.env.NEXON_API_KEY) { + return res.status(403).json({ error: '접근 권한이 없습니다' }); + } + next(); +} + +// 관리자 키 검증 +router.post('/verify', (req, res) => { + const { key } = req.body; + if (key === process.env.NEXON_API_KEY) { + return res.json({ verified: true }); + } + res.status(403).json({ error: '유효하지 않은 키입니다' }); +}); + +// 관리자 API에 미들웨어 적용 +router.use(requireAdmin); + +// TODO: 보스 관리 API 추가 예정 + +export default router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js deleted file mode 100644 index d842754..0000000 --- a/backend/routes/auth.js +++ /dev/null @@ -1,66 +0,0 @@ -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; diff --git a/backend/routes/characters.js b/backend/routes/characters.js index 3d3a646..4c311f3 100644 --- a/backend/routes/characters.js +++ b/backend/routes/characters.js @@ -1,62 +1,32 @@ import { Router } from 'express'; -import { requireAuth } from '../middleware/auth.js'; -import { getCharacterList, getCharacterOcid, getCharacterBasic } from '../services/nexon.js'; -import { UserCharacter } from '../models/index.js'; +import { getCharacterOcid, getCharacterBasic } from '../services/nexon.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: '캐릭터 목록 조회 실패' }); +// 캐릭터 닉네임으로 정보 조회 +router.get('/search', async (req, res) => { + const { name } = req.query; + if (!name) { + return res.status(400).json({ error: '캐릭터 닉네임을 입력해주세요' }); } -}); -// 캐릭터 목록 갱신 (넥슨 API에서 다시 가져오기) -router.post('/refresh', requireAuth, async (req, res) => { try { - const charList = await getCharacterList(req.session.accessToken); + const ocid = await getCharacterOcid(name); + const basic = await getCharacterBasic(ocid); - 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); + res.json({ + character_name: basic.character_name, + world_name: basic.world_name, + job_name: basic.character_class, + character_level: basic.character_level, + character_image: basic.character_image, + }); } catch (err) { - console.error('캐릭터 갱신 오류:', err.message); - res.status(500).json({ error: '캐릭터 목록 갱신 실패' }); + if (err.response?.status === 400) { + return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' }); + } + console.error('캐릭터 조회 오류:', err.message); + res.status(500).json({ error: '캐릭터 조회 실패' }); } }); diff --git a/backend/server.js b/backend/server.js index f9287b8..83ba3f9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,11 +1,8 @@ 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 adminRoutes from './routes/admin.js'; import { sequelize } from './lib/db.js'; const app = express(); @@ -18,13 +15,10 @@ app.use(cors({ 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.use('/api/admin', adminRoutes); app.get('/api/health', (_req, res) => { res.json({ status: 'ok' }); diff --git a/backend/services/nexon.js b/backend/services/nexon.js index 4d8b47b..0ae961d 100644 --- a/backend/services/nexon.js +++ b/backend/services/nexon.js @@ -1,49 +1,6 @@ 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`, { diff --git a/docker-compose.yml b/docker-compose.yml index 70d0130..84b87cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,17 +28,9 @@ services: - db - app - redis: - image: redis:7-alpine - volumes: - - redis-data:/data - networks: - - app - volumes: frontend_modules: backend_modules: - redis-data: networks: caddy: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 093529e..0a317e6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom' import Layout from './components/Layout' import Home from './pages/Home' import BossPage from './features/boss/BossPage' +import Admin from './pages/Admin' export default function App() { return ( @@ -9,6 +10,7 @@ export default function App() { }> } /> } /> + } /> ) diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 1ce87f3..f9b260d 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,5 +1,4 @@ import { Outlet, Link } from 'react-router-dom' -import LoginButton from './LoginButton' export default function Layout() { return ( @@ -9,7 +8,6 @@ export default function Layout() { 메이플스토리 도우미 diff --git a/frontend/src/components/LoginButton.jsx b/frontend/src/components/LoginButton.jsx deleted file mode 100644 index 15ddeae..0000000 --- a/frontend/src/components/LoginButton.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useAuth } from '../hooks/useAuth' - -export default function LoginButton() { - const { authenticated, loading, logout } = useAuth() - - if (loading) return null - - if (authenticated) { - return ( - - ) - } - - return ( - - 넥슨 로그인 - - ) -} diff --git a/frontend/src/features/boss/BossPage.jsx b/frontend/src/features/boss/BossPage.jsx index 6287140..69d71d2 100644 --- a/frontend/src/features/boss/BossPage.jsx +++ b/frontend/src/features/boss/BossPage.jsx @@ -1,51 +1,59 @@ import { useState } from 'react' +import { api } from '../../api/client' const DIFF_KEYS = { '이지': 'easy', '노말': 'normal', '하드': 'hard', '카오스': 'chaos', '익스트림': 'extreme' } +const DIFF_COLORS = { + '이지': 'text-green-400 border-green-400/30 bg-green-400/10', + '노말': 'text-gray-300 border-gray-500/30 bg-gray-500/10', + '하드': 'text-rose-400 border-rose-400/30 bg-rose-400/10', + '카오스': 'text-amber-400 border-amber-400/30 bg-amber-400/10', + '익스트림': 'text-red-500 border-red-500/30 bg-red-500/10', +} 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 }, + { name: '이지', crystal: 6_612_500, maxParty: 1 }, + { name: '노말', crystal: 16_200_000, maxParty: 1 }, + { name: '카오스', crystal: 81_000_000, maxParty: 1 }, ], }, { id: 2, name: '힐라', imgId: 3, difficulties: [ - { name: '노말', crystal: 6_612_500, defaultParty: 1 }, - { name: '하드', crystal: 56_250_000, defaultParty: 1 }, + { name: '노말', crystal: 6_612_500, maxParty: 1 }, + { name: '하드', crystal: 56_250_000, maxParty: 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 }, + { name: '이지', crystal: 7_200_000, maxParty: 1 }, + { name: '노말', crystal: 19_012_500, maxParty: 1 }, + { name: '하드', crystal: 95_062_500, maxParty: 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 }, + { name: '이지', crystal: 4_012_500, maxParty: 1 }, + { name: '노말', crystal: 13_012_500, maxParty: 1 }, + { name: '카오스', crystal: 79_012_500, maxParty: 1 }, ], }, { id: 5, name: '듄켈', imgId: 27, difficulties: [ - { name: '노말', crystal: 92_450_000, defaultParty: 1 }, - { name: '하드', crystal: 231_125_000, defaultParty: 6 }, + { name: '노말', crystal: 92_450_000, maxParty: 1 }, + { name: '하드', crystal: 231_125_000, maxParty: 6 }, ], }, { id: 6, name: '림보', imgId: 33, difficulties: [ - { name: '노말', crystal: 140_000_000, defaultParty: 1 }, - { name: '하드', crystal: 350_000_000, defaultParty: 6 }, + { name: '노말', crystal: 140_000_000, maxParty: 1 }, + { name: '하드', crystal: 350_000_000, maxParty: 6 }, ], }, ] @@ -60,69 +68,324 @@ function formatMeso(n) { return n.toLocaleString() } -function BossRowList({ boss, selections, onChange }) { - return ( -
-
- {boss.name} - {boss.name} -
-
- {boss.difficulties.map((diff, i) => { - const key = `${boss.id}-${i}` - const sel = selections[key] || { enabled: false, party: diff.defaultParty } - return ( - - ) - })} -
-
- ) -} +/* ── 좌측: 캐릭터 패널 ── */ +function CharacterPanel({ characters, selectedChar, onSelect, onAdd, onRemove }) { + const [name, setName] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') -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) + const handleSearch = async (e) => { + e.preventDefault() + if (!name.trim()) return + setLoading(true) + setError('') + try { + const data = await api(`/api/characters/search?name=${encodeURIComponent(name.trim())}`) + onAdd(data) + setName('') + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } return ( -
-

주간 보스 수익 계산기

+
+

1. 캐릭터 등록

+
+ setName(e.target.value)} + placeholder="닉네임 입력" + className="flex-1 min-w-0 rounded border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm outline-none focus:border-emerald-500 transition" + /> + +
+ {error &&

{error}

} -
-
-
결정석
-
{totalCrystals}/12
-
-
-
예상 수익
-
{formatMeso(totalRevenue)} 메소
-
-
- -
- {DUMMY_BOSSES.map((boss) => ( - +
+ {characters.map((char) => ( +
onSelect(char.character_name)} + className={`flex items-center gap-2 rounded-lg px-2 py-2 cursor-pointer transition group ${ + selectedChar === char.character_name + ? 'bg-emerald-500/10 border border-emerald-500/50' + : 'hover:bg-gray-800/50 border border-transparent' + }`} + > + {char.character_image && ( + + )} +
+
{char.character_name}
+
Lv.{char.character_level} {char.job_name}
+
+ { e.stopPropagation(); onRemove(char.character_name) }} + className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition cursor-pointer text-lg" + > + × + +
))}
) } + +/* ── 중앙: 보스 선택 패널 ── */ +function BossPanel({ selectedChar, selections, onChange }) { + if (!selectedChar) { + return ( +
+ 캐릭터를 선택해주세요 +
+ ) + } + + return ( +
+

2. 보스 선택

+ +
+ {/* 헤더 */} +
+
보스
+
난이도
+
파티원 수
+
수익
+
+ + {/* 보스 행 */} +
+ {DUMMY_BOSSES.map((boss) => { + // 현재 캐릭터에서 이 보스의 선택된 난이도 찾기 + const selectedDiffIdx = boss.difficulties.findIndex((_, i) => { + const key = `${boss.id}-${i}` + return selections[key]?.enabled + }) + const sel = selectedDiffIdx >= 0 ? selections[`${boss.id}-${selectedDiffIdx}`] : null + const diff = selectedDiffIdx >= 0 ? boss.difficulties[selectedDiffIdx] : null + const isSelected = !!sel?.enabled + + return ( +
+ {/* 보스 이름 + 아이콘 */} +
+ {boss.name} + {boss.name} +
+ + {/* 난이도 선택 */} +
+ {boss.difficulties.map((d, i) => { + const key = `${boss.id}-${i}` + const active = selections[key]?.enabled + return ( + + ) + })} +
+ + {/* 파티원 수 */} +
+ {isSelected && ( + + )} +
+ + {/* 수익 */} +
+ {isSelected ? formatMeso(Math.floor(diff.crystal / sel.party)) : '-'} +
+
+ ) + })} +
+
+
+ ) +} + +/* ── 우측: 결과 패널 ── */ +function ResultPanel({ characters, allSelections }) { + let totalCrystals = 0 + let totalRevenue = 0 + + const charResults = characters.map((char) => { + const charSel = allSelections[char.character_name] || {} + let crystals = 0 + let revenue = 0 + + Object.entries(charSel).forEach(([key, sel]) => { + if (!sel.enabled) return + const [bossId, diffIdx] = key.split('-').map(Number) + const boss = DUMMY_BOSSES.find((b) => b.id === bossId) + if (!boss) return + crystals++ + revenue += Math.floor(boss.difficulties[diffIdx].crystal / sel.party) + }) + + totalCrystals += crystals + totalRevenue += revenue + + return { name: char.character_name, crystals, revenue } + }) + + return ( +
+

3. 결과

+ +
+ {/* 합산 */} +
+
+ 보유 결정석 +
{totalCrystals}/90
+
+
+ 총 수익 +
{formatMeso(totalRevenue)}
+
메소
+
+
+ + {/* 결정석 게이지 */} +
+
+
+ + {/* 캐릭터별 소계 */} + {charResults.length > 0 && ( +
+
캐릭터별
+ {charResults.map((r) => ( +
+ {r.name} +
+ {r.crystals}/12 + 0 ? 'text-green-400' : 'text-gray-600'}>{r.revenue > 0 ? formatMeso(r.revenue) : '-'} +
+
+ ))} +
+ )} +
+
+ ) +} + +/* ── 메인 ── */ +export default function BossPage() { + const [characters, setCharacters] = useState(() => { + const saved = localStorage.getItem('maple-characters') + return saved ? JSON.parse(saved) : [] + }) + const [selectedChar, setSelectedChar] = useState(null) + const [allSelections, setAllSelections] = useState(() => { + const saved = localStorage.getItem('maple-boss-selections') + return saved ? JSON.parse(saved) : {} + }) + + const saveCharacters = (chars) => { + setCharacters(chars) + localStorage.setItem('maple-characters', JSON.stringify(chars)) + } + + const saveSelections = (sels) => { + setAllSelections(sels) + localStorage.setItem('maple-boss-selections', JSON.stringify(sels)) + } + + const handleAddCharacter = (charData) => { + if (characters.find((c) => c.character_name === charData.character_name)) return + saveCharacters([...characters, charData]) + setSelectedChar(charData.character_name) + } + + const handleRemoveCharacter = (name) => { + saveCharacters(characters.filter((c) => c.character_name !== name)) + if (selectedChar === name) setSelectedChar(null) + const newSelections = { ...allSelections } + delete newSelections[name] + saveSelections(newSelections) + } + + const handleBossChange = (charSelections) => { + if (!selectedChar) return + saveSelections({ ...allSelections, [selectedChar]: charSelections }) + } + + const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {} + + return ( +
+ {/* 좌측 */} +
+ +
+ + {/* 중앙 */} +
+ +
+ + {/* 우측 */} +
+ +
+
+ ) +} diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js deleted file mode 100644 index 187654e..0000000 --- a/frontend/src/hooks/useAuth.js +++ /dev/null @@ -1,21 +0,0 @@ -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 } -} diff --git a/frontend/src/hooks/useCharacters.js b/frontend/src/hooks/useCharacters.js deleted file mode 100644 index bef7c7b..0000000 --- a/frontend/src/hooks/useCharacters.js +++ /dev/null @@ -1,35 +0,0 @@ -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 } -} diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000..ccff0d8 --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react' +import { useSearchParams, Navigate } from 'react-router-dom' +import { api } from '../api/client' + +export default function Admin() { + const [searchParams] = useSearchParams() + const [verified, setVerified] = useState(null) // null=로딩, true=인증됨, false=실패 + + useEffect(() => { + const keyFromUrl = searchParams.get('key') + const keyFromStorage = localStorage.getItem('maple-admin-key') + const key = keyFromUrl || keyFromStorage + + if (!key) { + setVerified(false) + return + } + + api('/api/admin/verify', { method: 'POST', body: { key } }) + .then(() => { + localStorage.setItem('maple-admin-key', key) + setVerified(true) + }) + .catch(() => { + localStorage.removeItem('maple-admin-key') + setVerified(false) + }) + }, [searchParams]) + + if (verified === null) { + return
인증 중...
+ } + + if (!verified) { + return + } + + return ( +
+
+

관리자

+ +
+ +
+ 관리자 페이지 준비 중 +
+
+ ) +}