diff --git a/.env b/.env index d620991..4381644 100644 --- a/.env +++ b/.env @@ -8,8 +8,8 @@ DB_NAME=maplestory # RustFS (S3 호환 스토리지) S3_ENDPOINT=http://rustfs:9000 S3_PUBLIC_URL=https://s3.caadiq.co.kr -S3_ACCESS_KEY= -S3_SECRET_KEY= +S3_ACCESS_KEY=IN7FjBydNQbnUybLLVfk +S3_SECRET_KEY=u1m508WWLGQsn5ueRXV4qPID8OVqiz0Pnm9QDVeI S3_BUCKET=maplestory # 넥슨 API diff --git a/backend/lib/s3.js b/backend/lib/s3.js new file mode 100644 index 0000000..0cefd22 --- /dev/null +++ b/backend/lib/s3.js @@ -0,0 +1,34 @@ +import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; + +export const s3 = new S3Client({ + endpoint: process.env.S3_ENDPOINT, + region: 'auto', + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY, + secretAccessKey: process.env.S3_SECRET_KEY, + }, + forcePathStyle: true, +}); + +export const S3_BUCKET = process.env.S3_BUCKET; +export const S3_PUBLIC_URL = process.env.S3_PUBLIC_URL; + +export async function uploadObject(key, body, contentType) { + await s3.send(new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + Body: body, + ContentType: contentType, + })); +} + +export async function deleteObject(key) { + await s3.send(new DeleteObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + })); +} + +export function getPublicUrl(key) { + return `${S3_PUBLIC_URL}/${S3_BUCKET}/${key}`; +} diff --git a/backend/models/Image.js b/backend/models/Image.js new file mode 100644 index 0000000..52033ad --- /dev/null +++ b/backend/models/Image.js @@ -0,0 +1,14 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../lib/db.js'; + +export const Image = sequelize.define('Image', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + name: { type: DataTypes.STRING(100), allowNull: false, unique: true }, + path: { type: DataTypes.STRING(255), allowNull: false }, // S3 키 (예: common/abc.webp) + width: { type: DataTypes.INTEGER }, + height: { type: DataTypes.INTEGER }, + size: { type: DataTypes.INTEGER }, // bytes +}, { + tableName: 'images', + underscored: true, +}); diff --git a/backend/models/boss/Boss.js b/backend/models/boss/Boss.js deleted file mode 100644 index 95f1338..0000000 --- a/backend/models/boss/Boss.js +++ /dev/null @@ -1,12 +0,0 @@ -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, -}); diff --git a/backend/models/boss/BossDifficulty.js b/backend/models/boss/BossDifficulty.js deleted file mode 100644 index 3556b2f..0000000 --- a/backend/models/boss/BossDifficulty.js +++ /dev/null @@ -1,18 +0,0 @@ -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'] }, - ], -}); diff --git a/backend/models/index.js b/backend/models/index.js index 055d298..f59e636 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -1,8 +1,3 @@ -import { Boss } from './boss/Boss.js'; -import { BossDifficulty } from './boss/BossDifficulty.js'; +import { Image } from './Image.js'; -// Boss <-> BossDifficulty -Boss.hasMany(BossDifficulty, { foreignKey: 'boss_id', as: 'difficulties' }); -BossDifficulty.belongsTo(Boss, { foreignKey: 'boss_id' }); - -export { Boss, BossDifficulty }; +export { Image }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 43180cd..8b398f0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,8 +13,10 @@ "cors": "^2.8.5", "express": "^5.1.0", "mariadb": "^3.4.0", + "multer": "^2.0.0", "mysql2": "^3.14.1", - "sequelize": "^6.37.5" + "sequelize": "^6.37.5", + "sharp": "^0.34.1" } }, "node_modules/@aws-crypto/crc32": { @@ -868,6 +870,481 @@ "node": ">=18.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "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", @@ -1636,6 +2113,12 @@ "node": ">= 0.6" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1692,6 +2175,23 @@ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1742,6 +2242,21 @@ "node": ">= 0.8" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1843,6 +2358,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dottie": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", @@ -2422,6 +2946,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mysql2": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", @@ -2608,6 +3194,20 @@ "node": ">= 0.10" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/retry-as-promised": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", @@ -2630,6 +3230,26 @@ "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", @@ -2770,6 +3390,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2866,6 +3530,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strnum": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", @@ -2913,6 +3594,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -2928,6 +3615,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/backend/package.json b/backend/package.json index 983a9a7..7872b0f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,8 @@ "mysql2": "^3.14.1", "mariadb": "^3.4.0", "@aws-sdk/client-s3": "^3.800.0", - "axios": "^1.9.0" + "axios": "^1.9.0", + "multer": "^2.0.0", + "sharp": "^0.34.1" } } diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 7d596c6..9ddbb07 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1,6 +1,14 @@ import { Router } from 'express'; +import multer from 'multer'; +import { Image } from '../models/index.js'; +import { convertAndUpload, deleteFromS3 } from '../services/image.js'; +import { getPublicUrl } from '../lib/s3.js'; const router = Router(); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB +}); // 관리자 인증 미들웨어 function requireAdmin(req, res, next) { @@ -11,7 +19,7 @@ function requireAdmin(req, res, next) { next(); } -// 관리자 키 검증 +// 키 검증 (인증 불필요) router.post('/verify', (req, res) => { const { key } = req.body; if (key === process.env.NEXON_API_KEY) { @@ -20,9 +28,116 @@ router.post('/verify', (req, res) => { res.status(403).json({ error: '유효하지 않은 키입니다' }); }); -// 관리자 API에 미들웨어 적용 +// 이하 모든 라우트는 인증 필요 router.use(requireAdmin); -// TODO: 보스 관리 API 추가 예정 +/* ── 이미지 관리 ── */ + +// 이미지 목록 +router.get('/images', async (_req, res) => { + try { + const images = await Image.findAll({ order: [['created_at', 'DESC']] }); + res.json(images.map((img) => ({ + id: img.id, + name: img.name, + url: getPublicUrl(img.path), + width: img.width, + height: img.height, + size: img.size, + created_at: img.created_at, + }))); + } catch (err) { + console.error('이미지 목록 조회 오류:', err.message); + res.status(500).json({ error: '이미지 목록 조회 실패' }); + } +}); + +// 이미지 업로드 (다중 지원) +router.post('/images', upload.array('files', 50), async (req, res) => { + if (!req.files?.length) return res.status(400).json({ error: '파일이 없습니다' }); + + const names = Array.isArray(req.body.names) ? req.body.names : [req.body.names]; + + const results = []; + const errors = []; + + // 입력 이름 정리 및 중복 사전 체크 + const requestedNames = req.files.map((file, i) => + (names[i] || file.originalname.replace(/\.[^.]+$/, '')).trim() + ); + + // 같은 요청 내 중복 + const seen = new Set(); + const dupInRequest = new Set(); + requestedNames.forEach((n) => { + if (seen.has(n)) dupInRequest.add(n); + seen.add(n); + }); + + // DB와 중복 + const existing = await Image.findAll({ + where: { name: requestedNames }, + attributes: ['name'], + }); + const dupInDb = new Set(existing.map((i) => i.name)); + + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + const name = requestedNames[i]; + + if (dupInRequest.has(name)) { + errors.push({ name, error: '같은 이름이 요청에 중복됩니다' }); + continue; + } + if (dupInDb.has(name)) { + errors.push({ name, error: '이미 같은 이름의 이미지가 존재합니다' }); + continue; + } + + try { + const { path, width, height, size } = await convertAndUpload(file.buffer); + const image = await Image.create({ name, path, width, height, size }); + results.push({ + id: image.id, + name: image.name, + url: getPublicUrl(image.path), + width: image.width, + height: image.height, + size: image.size, + }); + } catch (err) { + console.error(`이미지 업로드 오류 (${file.originalname}):`, err.message); + errors.push({ name, error: err.message }); + } + } + + res.json({ uploaded: results, errors }); +}); + +// 이미지 다중 삭제 +router.post('/images/delete', async (req, res) => { + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: '삭제할 이미지를 선택해주세요' }); + } + + try { + const images = await Image.findAll({ where: { id: ids } }); + + await Promise.all( + images.map((img) => + deleteFromS3(img.path).catch((err) => + console.warn(`S3 삭제 실패 (${img.path}):`, err.message) + ) + ) + ); + await Image.destroy({ where: { id: ids } }); + + res.json({ success: true, deleted: images.length }); + } catch (err) { + console.error('이미지 삭제 오류:', err.message); + res.status(500).json({ error: '이미지 삭제 실패' }); + } +}); export default router; diff --git a/backend/routes/boss/bosses.js b/backend/routes/boss/bosses.js deleted file mode 100644 index f3e440d..0000000 --- a/backend/routes/boss/bosses.js +++ /dev/null @@ -1,23 +0,0 @@ -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; diff --git a/backend/routes/boss/calculate.js b/backend/routes/boss/calculate.js deleted file mode 100644 index 72c5ffc..0000000 --- a/backend/routes/boss/calculate.js +++ /dev/null @@ -1,31 +0,0 @@ -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; diff --git a/backend/routes/boss/selections.js b/backend/routes/boss/selections.js deleted file mode 100644 index f44f049..0000000 --- a/backend/routes/boss/selections.js +++ /dev/null @@ -1,58 +0,0 @@ -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; diff --git a/backend/routes/characters.js b/backend/routes/characters.js deleted file mode 100644 index 4c311f3..0000000 --- a/backend/routes/characters.js +++ /dev/null @@ -1,33 +0,0 @@ -import { Router } from 'express'; -import { getCharacterOcid, getCharacterBasic } from '../services/nexon.js'; - -const router = Router(); - -// 캐릭터 닉네임으로 정보 조회 -router.get('/search', async (req, res) => { - const { name } = req.query; - if (!name) { - return res.status(400).json({ error: '캐릭터 닉네임을 입력해주세요' }); - } - - try { - const ocid = await getCharacterOcid(name); - const basic = await getCharacterBasic(ocid); - - 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) { - if (err.response?.status === 400) { - return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' }); - } - console.error('캐릭터 조회 오류:', err.message); - res.status(500).json({ error: '캐릭터 조회 실패' }); - } -}); - -export default router; diff --git a/backend/server.js b/backend/server.js index 83ba3f9..88e0693 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,9 +1,8 @@ import express from 'express'; import cors from 'cors'; -import characterRoutes from './routes/characters.js'; -import bossRoutes from './routes/boss/bosses.js'; import adminRoutes from './routes/admin.js'; import { sequelize } from './lib/db.js'; +import './models/index.js'; const app = express(); const PORT = process.env.PORT || 3000; @@ -16,8 +15,6 @@ app.use(cors({ })); app.use(express.json()); -app.use('/api/characters', characterRoutes); -app.use('/api/boss', bossRoutes); app.use('/api/admin', adminRoutes); app.get('/api/health', (_req, res) => { diff --git a/backend/services/boss/calculator.js b/backend/services/boss/calculator.js deleted file mode 100644 index edf9a1f..0000000 --- a/backend/services/boss/calculator.js +++ /dev/null @@ -1,60 +0,0 @@ -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, - }; -} diff --git a/backend/services/image.js b/backend/services/image.js new file mode 100644 index 0000000..1c3e966 --- /dev/null +++ b/backend/services/image.js @@ -0,0 +1,31 @@ +import sharp from 'sharp'; +import crypto from 'crypto'; +import { uploadObject, deleteObject } from '../lib/s3.js'; + +/** + * 이미지를 webp로 변환하고 RustFS에 업로드 + * @param {Buffer} buffer - 원본 이미지 버퍼 + * @returns {Promise<{path: string, width: number, height: number, size: number}>} + */ +export async function convertAndUpload(buffer) { + const webpBuffer = await sharp(buffer) + .webp({ quality: 90 }) + .toBuffer(); + + const metadata = await sharp(webpBuffer).metadata(); + const hash = crypto.createHash('sha256').update(webpBuffer).digest('hex').slice(0, 16); + const path = `common/${hash}.webp`; + + await uploadObject(path, webpBuffer, 'image/webp'); + + return { + path, + width: metadata.width, + height: metadata.height, + size: webpBuffer.length, + }; +} + +export async function deleteFromS3(path) { + await deleteObject(path); +} diff --git a/backend/services/nexon.js b/backend/services/nexon.js deleted file mode 100644 index 0ae961d..0000000 --- a/backend/services/nexon.js +++ /dev/null @@ -1,19 +0,0 @@ -import axios from 'axios'; - -const NEXON_API_BASE = 'https://open.api.nexon.com'; - -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; -} diff --git a/frontend/src/features/admin/AdminImages.jsx b/frontend/src/features/admin/AdminImages.jsx index cbc3b66..52e5761 100644 --- a/frontend/src/features/admin/AdminImages.jsx +++ b/frontend/src/features/admin/AdminImages.jsx @@ -1,159 +1,249 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect } from 'react' import { api } from '../../api/client' -function UploadModal({ open, onClose, onUpload, uploading }) { - const [file, setFile] = useState(null) - const [name, setName] = useState('') - const [preview, setPreview] = useState(null) - const fileInputRef = useRef(null) +/* ── 공용 모달 ── */ +function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) { + if (!open) return null + return ( +
{description}
+공용 이미지를 업로드하고 관리합니다