From b0f71692265c8cdb1d8350a846331d06fac20467 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 17 Jan 2026 13:01:35 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20API=20=EB=9D=BC=EC=9A=B0=ED=8A=B8?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/admin/* + /api/* 분리 구조를 /api/*로 통합 - GET 요청은 공개, POST/PUT/DELETE는 인증 필요로 변경 - albums 라우트를 기능별 파일로 분리 (index, photos, teasers) - 프론트엔드 API 호출 경로 업데이트 Co-Authored-By: Claude Opus 4.5 --- backend/package-lock.json | 273 +++++++ backend/package.json | 2 + backend/src/app.js | 54 +- backend/src/routes/admin/albums.js | 676 ------------------ backend/src/routes/albums/index.js | 410 +++++++++++ backend/src/routes/albums/photos.js | 252 +++++++ backend/src/routes/albums/teasers.js | 90 +++ backend/src/routes/{admin => }/auth.js | 60 +- backend/src/routes/{admin => }/index.js | 15 +- backend/src/routes/{admin => }/members.js | 67 +- backend/src/routes/public/albums.js | 179 ----- backend/src/routes/public/index.js | 13 - backend/src/routes/public/members.js | 38 - backend/src/routes/{admin => }/stats.js | 32 +- frontend/src/api/admin/albums.js | 20 +- frontend/src/api/admin/auth.js | 4 +- frontend/src/api/admin/members.js | 6 +- frontend/src/api/admin/stats.js | 2 +- frontend/src/hooks/useAdminAuth.js | 2 +- .../src/pages/pc/admin/AdminAlbumForm.jsx | 2 +- .../src/pages/pc/admin/AdminAlbumPhotos.jsx | 2 +- .../src/pages/pc/admin/AdminMemberEdit.jsx | 4 +- frontend/src/pages/pc/admin/AdminMembers.jsx | 2 +- 23 files changed, 1234 insertions(+), 971 deletions(-) delete mode 100644 backend/src/routes/admin/albums.js create mode 100644 backend/src/routes/albums/index.js create mode 100644 backend/src/routes/albums/photos.js create mode 100644 backend/src/routes/albums/teasers.js rename backend/src/routes/{admin => }/auth.js (50%) rename backend/src/routes/{admin => }/index.js (55%) rename backend/src/routes/{admin => }/members.js (78%) delete mode 100644 backend/src/routes/public/albums.js delete mode 100644 backend/src/routes/public/index.js delete mode 100644 backend/src/routes/public/members.js rename backend/src/routes/{admin => }/stats.js (59%) diff --git a/backend/package-lock.json b/backend/package-lock.json index 3a608bb..0d267c1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,8 @@ "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.3.0", "@fastify/static": "^8.0.0", + "@fastify/swagger": "^9.0.0", + "@scalar/fastify-api-reference": "^1.25.0", "bcrypt": "^6.0.0", "dayjs": "^1.11.13", "fastify": "^5.2.1", @@ -1172,6 +1174,29 @@ "glob": "^11.0.0" } }, + "node_modules/@fastify/swagger": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.6.1.tgz", + "integrity": "sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -1696,6 +1721,121 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@scalar/core": { + "version": "0.3.32", + "resolved": "https://registry.npmjs.org/@scalar/core/-/core-0.3.32.tgz", + "integrity": "sha512-5qUC2l1fhLyKw1iKAbuQCWb2TNZ4Y52MDc7MA7RA7QLdJ0QPBzk2UAJ0ZuYY2dIhZ3qTcpuqtF4EXEllXHoNOA==", + "license": "MIT", + "dependencies": { + "@scalar/types": "0.5.8" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/fastify-api-reference": { + "version": "1.43.8", + "resolved": "https://registry.npmjs.org/@scalar/fastify-api-reference/-/fastify-api-reference-1.43.8.tgz", + "integrity": "sha512-HNMoMIf9UtU1+tCNGEzdqbfe4v9eY6jD/h0C4G3LLE3fNT+13F67QVkgtjvQ0+CQkt5nUigXM9/8uHA70J0Wcg==", + "license": "MIT", + "dependencies": { + "@scalar/core": "0.3.32", + "@scalar/openapi-parser": "0.24.1", + "@scalar/openapi-types": "0.5.3", + "fastify-plugin": "^4.5.1", + "github-slugger": "^2.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/fastify-api-reference/node_modules/fastify-plugin": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "license": "MIT" + }, + "node_modules/@scalar/helpers": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.2.8.tgz", + "integrity": "sha512-aXXRF4sCaiGZIRpZ1MUcnl8y0Q9pPG1VXqQMWacVWDh6zQN9cuayTC/TbODzWeldp50sgJ1E8MpHvpeV7CEF9g==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/json-magic": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@scalar/json-magic/-/json-magic-0.9.1.tgz", + "integrity": "sha512-57CHpIAjS2+SFl5phlDKJNPj3eNQh8U0iu6MKknVaW+qIQ55tTnYy2qIjdm3joUoPIu41iHdjW5PupwXK6Zneg==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.2.8", + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/openapi-parser": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@scalar/openapi-parser/-/openapi-parser-0.24.1.tgz", + "integrity": "sha512-SyjqI5yhAhg8a6LHJvSjO57cOJQOkeoh8MvsaE0ccIa1MgQK48dVN4aNckCSKpALBXKnBuLCywEC9Sbi9nSG2g==", + "license": "MIT", + "dependencies": { + "@scalar/json-magic": "0.9.1", + "@scalar/openapi-types": "0.5.3", + "@scalar/openapi-upgrader": "0.1.7", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", + "jsonpointer": "^5.0.1", + "leven": "^4.0.0", + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.5.3.tgz", + "integrity": "sha512-m4n/Su3K01d15dmdWO1LlqecdSPKuNjuokrJLdiQ485kW/hRHbXW1QP6tJL75myhw/XhX5YhYAR+jrwnGjXiMw==", + "license": "MIT", + "dependencies": { + "zod": "^4.1.11" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/openapi-upgrader": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@scalar/openapi-upgrader/-/openapi-upgrader-0.1.7.tgz", + "integrity": "sha512-065froUtqvaHjyeJtyitf8tb+k7oh7nU0OinAHYbj1Bqgwb1s2+uKMqHYHEES5CNpp+2xtL4lxup6Aq29yW+sQ==", + "license": "MIT", + "dependencies": { + "@scalar/openapi-types": "0.5.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/types": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.5.8.tgz", + "integrity": "sha512-eL8zojDI9QB+kNRkuM80auTKHnzNrlOLC8ZLUJVnY0Jj5ZtoInKMDGodgQXK1wOSDTcfVfgLALOY1zb6cFFlCg==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.2.8", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", @@ -2450,6 +2590,20 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ajv-formats": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", @@ -2932,6 +3086,12 @@ "is-property": "^1.0.2" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -3085,12 +3245,50 @@ "dequal": "^2.0.3" } }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-4.1.0.tgz", + "integrity": "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -3259,6 +3457,24 @@ "node": ">=8.0.0" } }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/node-addon-api": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", @@ -3306,6 +3522,12 @@ "node": ">=14.0.0" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3799,6 +4021,18 @@ ], "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -3835,6 +4069,21 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-fest": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.1.tgz", + "integrity": "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -3958,6 +4207,30 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 0c32754..873dbd1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,8 @@ "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.3.0", "@fastify/static": "^8.0.0", + "@fastify/swagger": "^9.0.0", + "@scalar/fastify-api-reference": "^1.25.0", "bcrypt": "^6.0.0", "dayjs": "^1.11.13", "fastify": "^5.2.1", diff --git a/backend/src/app.js b/backend/src/app.js index 4bb7296..a8861cf 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -3,6 +3,8 @@ import path from 'path'; import { fileURLToPath } from 'url'; import Fastify from 'fastify'; import fastifyStatic from '@fastify/static'; +import fastifySwagger from '@fastify/swagger'; +import scalarApiReference from '@scalar/fastify-api-reference'; import multipart from '@fastify/multipart'; import config from './config/index.js'; @@ -15,8 +17,7 @@ import xBotPlugin from './services/x/index.js'; import schedulerPlugin from './plugins/scheduler.js'; // 라우트 -import adminRoutes from './routes/admin/index.js'; -import publicRoutes from './routes/public/index.js'; +import routes from './routes/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -47,9 +48,54 @@ export async function buildApp(opts = {}) { await fastify.register(xBotPlugin); await fastify.register(schedulerPlugin); + // Swagger (OpenAPI) 설정 + await fastify.register(fastifySwagger, { + openapi: { + info: { + title: 'fromis_9 API', + description: 'fromis_9 팬사이트 백엔드 API', + version: '2.0.0', + }, + servers: [ + { url: '/', description: 'Current server' }, + ], + tags: [ + { name: 'auth', description: '인증 API' }, + { name: 'members', description: '멤버 관리 API' }, + { name: 'albums', description: '앨범 관리 API' }, + { name: 'stats', description: '통계 API' }, + { name: 'public', description: '공개 API' }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + }, + }); + + // Scalar API Reference UI + await fastify.register(scalarApiReference, { + routePrefix: '/docs', + configuration: { + theme: 'purple', + spec: { + url: '/docs/json', + }, + }, + }); + + // OpenAPI JSON 엔드포인트 + fastify.get('/docs/json', { schema: { hide: true } }, async () => { + return fastify.swagger(); + }); + // 라우트 등록 - await fastify.register(adminRoutes, { prefix: '/api/admin' }); - await fastify.register(publicRoutes, { prefix: '/api' }); + await fastify.register(routes, { prefix: '/api' }); // 헬스 체크 엔드포인트 fastify.get('/api/health', async () => { diff --git a/backend/src/routes/admin/albums.js b/backend/src/routes/admin/albums.js deleted file mode 100644 index 6d04099..0000000 --- a/backend/src/routes/admin/albums.js +++ /dev/null @@ -1,676 +0,0 @@ -import { - uploadAlbumCover, - deleteAlbumCover, - uploadAlbumPhoto, - deleteAlbumPhoto, - uploadAlbumVideo, - deleteAlbumVideo, -} from '../../services/image.js'; - -/** - * 앨범 관리 라우트 - */ -export default async function albumsRoutes(fastify, opts) { - const { db } = fastify; - - // 모든 라우트에 인증 적용 - fastify.addHook('preHandler', fastify.authenticate); - - // ==================== 앨범 CRUD ==================== - - /** - * 앨범 목록 조회 (트랙 포함) - * GET /api/admin/albums - */ - fastify.get('/', async (request, reply) => { - const [albums] = await db.query(` - SELECT id, title, folder_name, album_type, album_type_short, release_date, - cover_original_url, cover_medium_url, cover_thumb_url, description - FROM albums - ORDER BY release_date DESC - `); - - // 각 앨범에 트랙 정보 추가 - for (const album of albums) { - const [tracks] = await db.query( - `SELECT id, track_number, title, is_title_track, duration - FROM tracks WHERE album_id = ? ORDER BY track_number`, - [album.id] - ); - album.tracks = tracks; - } - - return albums; - }); - - /** - * 앨범 상세 조회 - * GET /api/admin/albums/:id - */ - fastify.get('/:id', async (request, reply) => { - const { id } = request.params; - - const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]); - if (albums.length === 0) { - return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); - } - - const album = albums[0]; - - // 트랙 정보 조회 - const [tracks] = await db.query( - `SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number`, - [id] - ); - album.tracks = tracks; - - return album; - }); - - /** - * 앨범 생성 - * POST /api/admin/albums - */ - fastify.post('/', async (request, reply) => { - const parts = request.parts(); - let data = null; - let coverBuffer = null; - - for await (const part of parts) { - if (part.type === 'file' && part.fieldname === 'cover') { - coverBuffer = await part.toBuffer(); - } else if (part.fieldname === 'data') { - data = JSON.parse(part.value); - } - } - - if (!data) { - return reply.code(400).send({ error: '데이터가 필요합니다.' }); - } - - const { - title, - album_type, - album_type_short, - release_date, - folder_name, - description, - tracks, - } = data; - - // 필수 필드 검증 - if (!title || !album_type || !release_date || !folder_name) { - return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' }); - } - - const connection = await db.getConnection(); - - try { - await connection.beginTransaction(); - - let coverOriginalUrl = null; - let coverMediumUrl = null; - let coverThumbUrl = null; - - // 커버 이미지 업로드 - if (coverBuffer) { - const urls = await uploadAlbumCover(folder_name, coverBuffer); - coverOriginalUrl = urls.originalUrl; - coverMediumUrl = urls.mediumUrl; - coverThumbUrl = urls.thumbUrl; - } - - // 앨범 삽입 - const [albumResult] = await connection.query( - `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, - cover_original_url, cover_medium_url, cover_thumb_url, description) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - title, - album_type, - album_type_short || null, - release_date, - folder_name, - coverOriginalUrl, - coverMediumUrl, - coverThumbUrl, - description || null, - ] - ); - - const albumId = albumResult.insertId; - - // 트랙 삽입 - if (tracks && tracks.length > 0) { - for (const track of tracks) { - await connection.query( - `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, - lyricist, composer, arranger, lyrics, music_video_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - albumId, - track.track_number, - track.title, - track.duration || null, - track.is_title_track ? 1 : 0, - track.lyricist || null, - track.composer || null, - track.arranger || null, - track.lyrics || null, - track.music_video_url || null, - ] - ); - } - } - - await connection.commit(); - - return { message: '앨범이 생성되었습니다.', albumId }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }); - - /** - * 앨범 수정 - * PUT /api/admin/albums/:id - */ - fastify.put('/:id', async (request, reply) => { - const { id } = request.params; - const parts = request.parts(); - let data = null; - let coverBuffer = null; - - for await (const part of parts) { - if (part.type === 'file' && part.fieldname === 'cover') { - coverBuffer = await part.toBuffer(); - } else if (part.fieldname === 'data') { - data = JSON.parse(part.value); - } - } - - if (!data) { - return reply.code(400).send({ error: '데이터가 필요합니다.' }); - } - - const { - title, - album_type, - album_type_short, - release_date, - folder_name, - description, - tracks, - } = data; - - const connection = await db.getConnection(); - - try { - await connection.beginTransaction(); - - // 기존 앨범 조회 - const [existingAlbums] = await connection.query( - 'SELECT * FROM albums WHERE id = ?', - [id] - ); - if (existingAlbums.length === 0) { - return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); - } - - const existing = existingAlbums[0]; - let coverOriginalUrl = existing.cover_original_url; - let coverMediumUrl = existing.cover_medium_url; - let coverThumbUrl = existing.cover_thumb_url; - - // 새 커버 이미지 업로드 - if (coverBuffer) { - const urls = await uploadAlbumCover(folder_name, coverBuffer); - coverOriginalUrl = urls.originalUrl; - coverMediumUrl = urls.mediumUrl; - coverThumbUrl = urls.thumbUrl; - } - - // 앨범 업데이트 - await connection.query( - `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, - folder_name = ?, cover_original_url = ?, cover_medium_url = ?, - cover_thumb_url = ?, description = ? - WHERE id = ?`, - [ - title, - album_type, - album_type_short || null, - release_date, - folder_name, - coverOriginalUrl, - coverMediumUrl, - coverThumbUrl, - description || null, - id, - ] - ); - - // 기존 트랙 삭제 후 새 트랙 삽입 - await connection.query('DELETE FROM tracks WHERE album_id = ?', [id]); - - if (tracks && tracks.length > 0) { - for (const track of tracks) { - await connection.query( - `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, - lyricist, composer, arranger, lyrics, music_video_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - id, - track.track_number, - track.title, - track.duration || null, - track.is_title_track ? 1 : 0, - track.lyricist || null, - track.composer || null, - track.arranger || null, - track.lyrics || null, - track.music_video_url || null, - ] - ); - } - } - - await connection.commit(); - - return { message: '앨범이 수정되었습니다.' }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }); - - /** - * 앨범 삭제 - * DELETE /api/admin/albums/:id - */ - fastify.delete('/:id', async (request, reply) => { - const { id } = request.params; - - const connection = await db.getConnection(); - - try { - await connection.beginTransaction(); - - // 기존 앨범 조회 - const [existingAlbums] = await connection.query( - 'SELECT * FROM albums WHERE id = ?', - [id] - ); - if (existingAlbums.length === 0) { - return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); - } - - const album = existingAlbums[0]; - - // S3에서 커버 이미지 삭제 - if (album.cover_original_url && album.folder_name) { - await deleteAlbumCover(album.folder_name); - } - - // 트랙 삭제 - await connection.query('DELETE FROM tracks WHERE album_id = ?', [id]); - - // 앨범 삭제 - await connection.query('DELETE FROM albums WHERE id = ?', [id]); - - await connection.commit(); - - return { message: '앨범이 삭제되었습니다.' }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }); - - // ==================== 앨범 사진 관리 ==================== - - /** - * 앨범 사진 목록 조회 - * GET /api/admin/albums/:albumId/photos - */ - fastify.get('/:albumId/photos', async (request, reply) => { - const { albumId } = request.params; - - // 앨범 존재 확인 - const [albums] = await db.query( - 'SELECT folder_name FROM albums WHERE id = ?', - [albumId] - ); - if (albums.length === 0) { - return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); - } - - // 사진 조회 (멤버 정보 포함) - const [photos] = await db.query( - `SELECT - p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, - p.sort_order, p.width, p.height, p.file_size, - GROUP_CONCAT(pm.member_id) as member_ids - FROM album_photos p - LEFT JOIN album_photo_members pm ON p.id = pm.photo_id - WHERE p.album_id = ? - GROUP BY p.id - ORDER BY p.sort_order ASC`, - [albumId] - ); - - // 멤버 배열 파싱 - return photos.map((photo) => ({ - ...photo, - members: photo.member_ids ? photo.member_ids.split(',').map(Number) : [], - })); - }); - - /** - * 앨범 티저 목록 조회 - * GET /api/admin/albums/:albumId/teasers - */ - fastify.get('/:albumId/teasers', async (request, reply) => { - const { albumId } = request.params; - - // 앨범 존재 확인 - const [albums] = await db.query( - 'SELECT folder_name FROM albums WHERE id = ?', - [albumId] - ); - if (albums.length === 0) { - return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); - } - - // 티저 조회 - const [teasers] = await db.query( - `SELECT id, original_url, medium_url, thumb_url, video_url, sort_order, media_type - FROM album_teasers - WHERE album_id = ? - ORDER BY sort_order ASC`, - [albumId] - ); - - return teasers; - }); - - /** - * 앨범 사진 업로드 (SSE) - * POST /api/admin/albums/:albumId/photos - */ - fastify.post('/:albumId/photos', async (request, reply) => { - const { albumId } = request.params; - - // SSE 헤더 설정 - reply.raw.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }); - - const sendProgress = (current, total, message) => { - reply.raw.write(`data: ${JSON.stringify({ current, total, message })}\n\n`); - }; - - const connection = await db.getConnection(); - - try { - await connection.beginTransaction(); - - // 앨범 정보 조회 - const [albums] = await connection.query( - 'SELECT folder_name FROM albums WHERE id = ?', - [albumId] - ); - if (albums.length === 0) { - reply.raw.write(`data: ${JSON.stringify({ error: '앨범을 찾을 수 없습니다.' })}\n\n`); - reply.raw.end(); - return; - } - - const folderName = albums[0].folder_name; - const parts = request.parts(); - - let metadata = []; - let startNumber = null; - let photoType = 'concept'; - const files = []; - - for await (const part of parts) { - if (part.type === 'file' && part.fieldname === 'photos') { - const buffer = await part.toBuffer(); - files.push({ buffer, mimetype: part.mimetype }); - } else if (part.fieldname === 'metadata') { - metadata = JSON.parse(part.value); - } else if (part.fieldname === 'startNumber') { - startNumber = parseInt(part.value) || null; - } else if (part.fieldname === 'photoType') { - photoType = part.value; - } - } - - // 시작 번호 결정 - let nextOrder; - if (startNumber && startNumber > 0) { - nextOrder = startNumber; - } else { - const [existingPhotos] = await connection.query( - 'SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?', - [albumId] - ); - nextOrder = (existingPhotos[0].maxOrder || 0) + 1; - } - - const uploadedPhotos = []; - const totalFiles = files.length; - const subFolder = photoType === 'teaser' ? 'teaser' : 'photo'; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const meta = metadata[i] || {}; - const orderNum = String(nextOrder + i).padStart(2, '0'); - const isVideo = file.mimetype === 'video/mp4'; - const filename = `${orderNum}.${isVideo ? 'mp4' : 'webp'}`; - - sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); - - let originalUrl, mediumUrl, thumbUrl, videoUrl; - let photoMetadata = {}; - - if (isVideo) { - // 비디오 파일은 별도 처리 필요 (썸네일 생성 등) - // 현재는 간단히 업로드만 - videoUrl = await uploadAlbumVideo(folderName, filename, file.buffer); - // 썸네일 없이 일단 저장 - originalUrl = videoUrl; - mediumUrl = videoUrl; - thumbUrl = videoUrl; - } else { - // 이미지 파일 처리 - const result = await uploadAlbumPhoto(folderName, subFolder, filename, file.buffer); - originalUrl = result.originalUrl; - mediumUrl = result.mediumUrl; - thumbUrl = result.thumbUrl; - photoMetadata = result.metadata; - } - - let photoId; - - if (photoType === 'teaser') { - // 티저 → album_teasers 테이블 - const mediaType = isVideo ? 'video' : 'image'; - const [result] = await connection.query( - `INSERT INTO album_teasers - (album_id, original_url, medium_url, thumb_url, video_url, sort_order, media_type) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [albumId, originalUrl, mediumUrl, thumbUrl, videoUrl || null, nextOrder + i, mediaType] - ); - photoId = result.insertId; - } else { - // 컨셉 포토 → album_photos 테이블 - const [result] = await connection.query( - `INSERT INTO album_photos - (album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - albumId, - originalUrl, - mediumUrl, - thumbUrl, - meta.groupType || 'group', - meta.conceptName || null, - nextOrder + i, - photoMetadata.width || null, - photoMetadata.height || null, - photoMetadata.size || null, - ] - ); - photoId = result.insertId; - - // 멤버 태깅 저장 - if (meta.members && meta.members.length > 0) { - for (const memberId of meta.members) { - await connection.query( - 'INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)', - [photoId, memberId] - ); - } - } - } - - uploadedPhotos.push({ - id: photoId, - original_url: originalUrl, - medium_url: mediumUrl, - thumb_url: thumbUrl, - video_url: videoUrl || null, - filename, - media_type: isVideo ? 'video' : 'image', - }); - } - - await connection.commit(); - - // 완료 이벤트 - reply.raw.write(`data: ${JSON.stringify({ - done: true, - message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`, - photos: uploadedPhotos, - })}\n\n`); - reply.raw.end(); - } catch (error) { - await connection.rollback(); - console.error('사진 업로드 오류:', error); - reply.raw.write(`data: ${JSON.stringify({ error: '사진 업로드 중 오류가 발생했습니다.' })}\n\n`); - reply.raw.end(); - } finally { - connection.release(); - } - }); - - /** - * 앨범 사진 삭제 - * DELETE /api/admin/albums/:albumId/photos/:photoId - */ - fastify.delete('/:albumId/photos/:photoId', async (request, reply) => { - const { albumId, photoId } = request.params; - - const connection = await db.getConnection(); - - try { - await connection.beginTransaction(); - - // 사진 정보 조회 - const [photos] = await connection.query( - `SELECT p.*, a.folder_name - FROM album_photos p - JOIN albums a ON p.album_id = a.id - WHERE p.id = ? AND p.album_id = ?`, - [photoId, albumId] - ); - - if (photos.length === 0) { - return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' }); - } - - const photo = photos[0]; - const filename = photo.original_url.split('/').pop(); - - // S3에서 삭제 - await deleteAlbumPhoto(photo.folder_name, 'photo', filename); - - // 멤버 태깅 삭제 - await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]); - - // 사진 삭제 - await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]); - - await connection.commit(); - - return { message: '사진이 삭제되었습니다.' }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }); - - /** - * 티저 삭제 - * DELETE /api/admin/albums/:albumId/teasers/:teaserId - */ - fastify.delete('/:albumId/teasers/:teaserId', async (request, reply) => { - const { albumId, teaserId } = request.params; - - const connection = await db.getConnection(); - - try { - await connection.beginTransaction(); - - // 티저 정보 조회 - const [teasers] = await connection.query( - `SELECT t.*, a.folder_name - FROM album_teasers t - JOIN albums a ON t.album_id = a.id - WHERE t.id = ? AND t.album_id = ?`, - [teaserId, albumId] - ); - - if (teasers.length === 0) { - return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' }); - } - - const teaser = teasers[0]; - const filename = teaser.original_url.split('/').pop(); - - // S3에서 썸네일 삭제 - await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename); - - // 비디오 파일 삭제 - if (teaser.video_url) { - const videoFilename = teaser.video_url.split('/').pop(); - await deleteAlbumVideo(teaser.folder_name, videoFilename); - } - - // 티저 삭제 - await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]); - - await connection.commit(); - - return { message: '티저가 삭제되었습니다.' }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }); -} diff --git a/backend/src/routes/albums/index.js b/backend/src/routes/albums/index.js new file mode 100644 index 0000000..12203bd --- /dev/null +++ b/backend/src/routes/albums/index.js @@ -0,0 +1,410 @@ +import { + uploadAlbumCover, + deleteAlbumCover, +} from '../../services/image.js'; +import photosRoutes from './photos.js'; +import teasersRoutes from './teasers.js'; + +/** + * 앨범 라우트 + * GET: 공개, POST/PUT/DELETE: 인증 필요 + */ +export default async function albumsRoutes(fastify) { + const { db } = fastify; + + // 하위 라우트 등록 + fastify.register(photosRoutes); + fastify.register(teasersRoutes); + + /** + * 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함) + */ + async function getAlbumDetails(album) { + const [tracks] = await db.query( + 'SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number', + [album.id] + ); + album.tracks = tracks; + + const [teasers] = await db.query( + `SELECT original_url, medium_url, thumb_url, video_url, media_type + FROM album_teasers WHERE album_id = ? ORDER BY sort_order`, + [album.id] + ); + album.teasers = teasers; + + const [photos] = await db.query( + `SELECT + p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order, + p.width, p.height, + GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members + FROM album_photos p + LEFT JOIN album_photo_members pm ON p.id = pm.photo_id + LEFT JOIN members m ON pm.member_id = m.id + WHERE p.album_id = ? + GROUP BY p.id + ORDER BY p.sort_order`, + [album.id] + ); + + const conceptPhotos = {}; + for (const photo of photos) { + const concept = photo.concept_name || 'Default'; + if (!conceptPhotos[concept]) { + conceptPhotos[concept] = []; + } + conceptPhotos[concept].push({ + id: photo.id, + original_url: photo.original_url, + medium_url: photo.medium_url, + thumb_url: photo.thumb_url, + width: photo.width, + height: photo.height, + type: photo.photo_type, + members: photo.members, + sortOrder: photo.sort_order, + }); + } + album.conceptPhotos = conceptPhotos; + + return album; + } + + // ==================== GET (공개) ==================== + + /** + * GET /api/albums + */ + fastify.get('/', { + schema: { + tags: ['albums'], + summary: '전체 앨범 목록 조회', + }, + }, async () => { + const [albums] = await db.query(` + SELECT id, title, folder_name, album_type, album_type_short, release_date, + cover_original_url, cover_medium_url, cover_thumb_url, description + FROM albums + ORDER BY release_date DESC + `); + + for (const album of albums) { + const [tracks] = await db.query( + `SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger + FROM tracks WHERE album_id = ? ORDER BY track_number`, + [album.id] + ); + album.tracks = tracks; + } + + return albums; + }); + + /** + * GET /api/albums/by-name/:albumName/track/:trackTitle + */ + fastify.get('/by-name/:albumName/track/:trackTitle', { + schema: { + tags: ['albums'], + summary: '앨범명과 트랙명으로 트랙 조회', + }, + }, async (request, reply) => { + const albumName = decodeURIComponent(request.params.albumName); + const trackTitle = decodeURIComponent(request.params.trackTitle); + + const [albums] = await db.query( + 'SELECT * FROM albums WHERE folder_name = ? OR title = ?', + [albumName, albumName] + ); + + if (albums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + const album = albums[0]; + + const [tracks] = await db.query( + 'SELECT * FROM tracks WHERE album_id = ? AND title = ?', + [album.id, trackTitle] + ); + + if (tracks.length === 0) { + return reply.code(404).send({ error: '트랙을 찾을 수 없습니다.' }); + } + + const track = tracks[0]; + + const [otherTracks] = await db.query( + 'SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number', + [album.id] + ); + + return { + ...track, + album: { + id: album.id, + title: album.title, + folder_name: album.folder_name, + cover_thumb_url: album.cover_thumb_url, + cover_medium_url: album.cover_medium_url, + release_date: album.release_date, + album_type: album.album_type, + }, + otherTracks, + }; + }); + + /** + * GET /api/albums/by-name/:name + */ + fastify.get('/by-name/:name', { + schema: { + tags: ['albums'], + summary: '앨범명으로 앨범 조회', + }, + }, async (request, reply) => { + const name = decodeURIComponent(request.params.name); + + const [albums] = await db.query( + 'SELECT * FROM albums WHERE folder_name = ? OR title = ?', + [name, name] + ); + + if (albums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + return getAlbumDetails(albums[0]); + }); + + /** + * GET /api/albums/:id + */ + fastify.get('/:id', { + schema: { + tags: ['albums'], + summary: 'ID로 앨범 조회', + }, + }, async (request, reply) => { + const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [ + request.params.id, + ]); + + if (albums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + return getAlbumDetails(albums[0]); + }); + + // ==================== POST/PUT/DELETE (인증 필요) ==================== + + /** + * POST /api/albums + */ + fastify.post('/', { + schema: { + tags: ['albums'], + summary: '앨범 생성', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const parts = request.parts(); + let data = null; + let coverBuffer = null; + + for await (const part of parts) { + if (part.type === 'file' && part.fieldname === 'cover') { + coverBuffer = await part.toBuffer(); + } else if (part.fieldname === 'data') { + data = JSON.parse(part.value); + } + } + + if (!data) { + return reply.code(400).send({ error: '데이터가 필요합니다.' }); + } + + const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data; + + if (!title || !album_type || !release_date || !folder_name) { + return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' }); + } + + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + let coverOriginalUrl = null; + let coverMediumUrl = null; + let coverThumbUrl = null; + + if (coverBuffer) { + const urls = await uploadAlbumCover(folder_name, coverBuffer); + coverOriginalUrl = urls.originalUrl; + coverMediumUrl = urls.mediumUrl; + coverThumbUrl = urls.thumbUrl; + } + + const [albumResult] = await connection.query( + `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, + cover_original_url, cover_medium_url, cover_thumb_url, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [title, album_type, album_type_short || null, release_date, folder_name, + coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null] + ); + + const albumId = albumResult.insertId; + + if (tracks && tracks.length > 0) { + for (const track of tracks) { + await connection.query( + `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, + lyricist, composer, arranger, lyrics, music_video_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [albumId, track.track_number, track.title, track.duration || null, + track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null, + track.arranger || null, track.lyrics || null, track.music_video_url || null] + ); + } + } + + await connection.commit(); + return { message: '앨범이 생성되었습니다.', albumId }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }); + + /** + * PUT /api/albums/:id + */ + fastify.put('/:id', { + schema: { + tags: ['albums'], + summary: '앨범 수정', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const parts = request.parts(); + let data = null; + let coverBuffer = null; + + for await (const part of parts) { + if (part.type === 'file' && part.fieldname === 'cover') { + coverBuffer = await part.toBuffer(); + } else if (part.fieldname === 'data') { + data = JSON.parse(part.value); + } + } + + if (!data) { + return reply.code(400).send({ error: '데이터가 필요합니다.' }); + } + + const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data; + + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]); + if (existingAlbums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + const existing = existingAlbums[0]; + let coverOriginalUrl = existing.cover_original_url; + let coverMediumUrl = existing.cover_medium_url; + let coverThumbUrl = existing.cover_thumb_url; + + if (coverBuffer) { + const urls = await uploadAlbumCover(folder_name, coverBuffer); + coverOriginalUrl = urls.originalUrl; + coverMediumUrl = urls.mediumUrl; + coverThumbUrl = urls.thumbUrl; + } + + await connection.query( + `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, + folder_name = ?, cover_original_url = ?, cover_medium_url = ?, + cover_thumb_url = ?, description = ? + WHERE id = ?`, + [title, album_type, album_type_short || null, release_date, folder_name, + coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null, id] + ); + + await connection.query('DELETE FROM tracks WHERE album_id = ?', [id]); + + if (tracks && tracks.length > 0) { + for (const track of tracks) { + await connection.query( + `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, + lyricist, composer, arranger, lyrics, music_video_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, track.track_number, track.title, track.duration || null, + track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null, + track.arranger || null, track.lyrics || null, track.music_video_url || null] + ); + } + } + + await connection.commit(); + return { message: '앨범이 수정되었습니다.' }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }); + + /** + * DELETE /api/albums/:id + */ + fastify.delete('/:id', { + schema: { + tags: ['albums'], + summary: '앨범 삭제', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]); + if (existingAlbums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + const album = existingAlbums[0]; + + if (album.cover_original_url && album.folder_name) { + await deleteAlbumCover(album.folder_name); + } + + await connection.query('DELETE FROM tracks WHERE album_id = ?', [id]); + await connection.query('DELETE FROM albums WHERE id = ?', [id]); + + await connection.commit(); + return { message: '앨범이 삭제되었습니다.' }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }); +} diff --git a/backend/src/routes/albums/photos.js b/backend/src/routes/albums/photos.js new file mode 100644 index 0000000..7384b3b --- /dev/null +++ b/backend/src/routes/albums/photos.js @@ -0,0 +1,252 @@ +import { + uploadAlbumPhoto, + deleteAlbumPhoto, + uploadAlbumVideo, +} from '../../services/image.js'; + +/** + * 앨범 사진 라우트 + * GET: 공개, POST/DELETE: 인증 필요 + */ +export default async function photosRoutes(fastify) { + const { db } = fastify; + + /** + * GET /api/albums/:albumId/photos + */ + fastify.get('/:albumId/photos', { + schema: { + tags: ['albums'], + summary: '앨범 컨셉 포토 목록', + }, + }, async (request, reply) => { + const { albumId } = request.params; + + const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]); + if (albums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + const [photos] = await db.query( + `SELECT + p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, + p.sort_order, p.width, p.height, p.file_size, + GROUP_CONCAT(pm.member_id) as member_ids + FROM album_photos p + LEFT JOIN album_photo_members pm ON p.id = pm.photo_id + WHERE p.album_id = ? + GROUP BY p.id + ORDER BY p.sort_order ASC`, + [albumId] + ); + + return photos.map((photo) => ({ + ...photo, + members: photo.member_ids ? photo.member_ids.split(',').map(Number) : [], + })); + }); + + /** + * POST /api/albums/:albumId/photos (SSE) + */ + fastify.post('/:albumId/photos', { + schema: { + tags: ['albums'], + summary: '앨범 사진 업로드', + description: 'SSE로 진행률 반환', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { albumId } = request.params; + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + + const sendProgress = (current, total, message) => { + reply.raw.write(`data: ${JSON.stringify({ current, total, message })}\n\n`); + }; + + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const [albums] = await connection.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]); + if (albums.length === 0) { + reply.raw.write(`data: ${JSON.stringify({ error: '앨범을 찾을 수 없습니다.' })}\n\n`); + reply.raw.end(); + return; + } + + const folderName = albums[0].folder_name; + const parts = request.parts(); + + let metadata = []; + let startNumber = null; + let photoType = 'concept'; + const files = []; + + for await (const part of parts) { + if (part.type === 'file' && part.fieldname === 'photos') { + const buffer = await part.toBuffer(); + files.push({ buffer, mimetype: part.mimetype }); + } else if (part.fieldname === 'metadata') { + metadata = JSON.parse(part.value); + } else if (part.fieldname === 'startNumber') { + startNumber = parseInt(part.value) || null; + } else if (part.fieldname === 'photoType') { + photoType = part.value; + } + } + + let nextOrder; + if (startNumber && startNumber > 0) { + nextOrder = startNumber; + } else { + const [existingPhotos] = await connection.query( + 'SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?', + [albumId] + ); + nextOrder = (existingPhotos[0].maxOrder || 0) + 1; + } + + const uploadedPhotos = []; + const totalFiles = files.length; + const subFolder = photoType === 'teaser' ? 'teaser' : 'photo'; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const meta = metadata[i] || {}; + const orderNum = String(nextOrder + i).padStart(2, '0'); + const isVideo = file.mimetype === 'video/mp4'; + const filename = `${orderNum}.${isVideo ? 'mp4' : 'webp'}`; + + sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); + + let originalUrl, mediumUrl, thumbUrl, videoUrl; + let photoMetadata = {}; + + if (isVideo) { + videoUrl = await uploadAlbumVideo(folderName, filename, file.buffer); + originalUrl = videoUrl; + mediumUrl = videoUrl; + thumbUrl = videoUrl; + } else { + const result = await uploadAlbumPhoto(folderName, subFolder, filename, file.buffer); + originalUrl = result.originalUrl; + mediumUrl = result.mediumUrl; + thumbUrl = result.thumbUrl; + photoMetadata = result.metadata; + } + + let photoId; + + if (photoType === 'teaser') { + const mediaType = isVideo ? 'video' : 'image'; + const [result] = await connection.query( + `INSERT INTO album_teasers + (album_id, original_url, medium_url, thumb_url, video_url, sort_order, media_type) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [albumId, originalUrl, mediumUrl, thumbUrl, videoUrl || null, nextOrder + i, mediaType] + ); + photoId = result.insertId; + } else { + const [result] = await connection.query( + `INSERT INTO album_photos + (album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [albumId, originalUrl, mediumUrl, thumbUrl, meta.groupType || 'group', + meta.conceptName || null, nextOrder + i, photoMetadata.width || null, + photoMetadata.height || null, photoMetadata.size || null] + ); + photoId = result.insertId; + + if (meta.members && meta.members.length > 0) { + for (const memberId of meta.members) { + await connection.query( + 'INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)', + [photoId, memberId] + ); + } + } + } + + uploadedPhotos.push({ + id: photoId, + original_url: originalUrl, + medium_url: mediumUrl, + thumb_url: thumbUrl, + video_url: videoUrl || null, + filename, + media_type: isVideo ? 'video' : 'image', + }); + } + + await connection.commit(); + + reply.raw.write(`data: ${JSON.stringify({ + done: true, + message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`, + photos: uploadedPhotos, + })}\n\n`); + reply.raw.end(); + } catch (error) { + await connection.rollback(); + console.error('사진 업로드 오류:', error); + reply.raw.write(`data: ${JSON.stringify({ error: '사진 업로드 중 오류가 발생했습니다.' })}\n\n`); + reply.raw.end(); + } finally { + connection.release(); + } + }); + + /** + * DELETE /api/albums/:albumId/photos/:photoId + */ + fastify.delete('/:albumId/photos/:photoId', { + schema: { + tags: ['albums'], + summary: '컨셉 포토 삭제', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { albumId, photoId } = request.params; + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const [photos] = await connection.query( + `SELECT p.*, a.folder_name + FROM album_photos p + JOIN albums a ON p.album_id = a.id + WHERE p.id = ? AND p.album_id = ?`, + [photoId, albumId] + ); + + if (photos.length === 0) { + return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' }); + } + + const photo = photos[0]; + const filename = photo.original_url.split('/').pop(); + + await deleteAlbumPhoto(photo.folder_name, 'photo', filename); + await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]); + await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]); + + await connection.commit(); + return { message: '사진이 삭제되었습니다.' }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }); +} diff --git a/backend/src/routes/albums/teasers.js b/backend/src/routes/albums/teasers.js new file mode 100644 index 0000000..7ba256b --- /dev/null +++ b/backend/src/routes/albums/teasers.js @@ -0,0 +1,90 @@ +import { + deleteAlbumPhoto, + deleteAlbumVideo, +} from '../../services/image.js'; + +/** + * 앨범 티저 라우트 + * GET: 공개, DELETE: 인증 필요 + */ +export default async function teasersRoutes(fastify) { + const { db } = fastify; + + /** + * GET /api/albums/:albumId/teasers + */ + fastify.get('/:albumId/teasers', { + schema: { + tags: ['albums'], + summary: '앨범 티저 목록', + }, + }, async (request, reply) => { + const { albumId } = request.params; + + const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]); + if (albums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + const [teasers] = await db.query( + `SELECT id, original_url, medium_url, thumb_url, video_url, sort_order, media_type + FROM album_teasers + WHERE album_id = ? + ORDER BY sort_order ASC`, + [albumId] + ); + + return teasers; + }); + + /** + * DELETE /api/albums/:albumId/teasers/:teaserId + */ + fastify.delete('/:albumId/teasers/:teaserId', { + schema: { + tags: ['albums'], + summary: '티저 삭제', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { albumId, teaserId } = request.params; + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const [teasers] = await connection.query( + `SELECT t.*, a.folder_name + FROM album_teasers t + JOIN albums a ON t.album_id = a.id + WHERE t.id = ? AND t.album_id = ?`, + [teaserId, albumId] + ); + + if (teasers.length === 0) { + return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' }); + } + + const teaser = teasers[0]; + const filename = teaser.original_url.split('/').pop(); + + await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename); + + if (teaser.video_url) { + const videoFilename = teaser.video_url.split('/').pop(); + await deleteAlbumVideo(teaser.folder_name, videoFilename); + } + + await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]); + + await connection.commit(); + return { message: '티저가 삭제되었습니다.' }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }); +} diff --git a/backend/src/routes/admin/auth.js b/backend/src/routes/auth.js similarity index 50% rename from backend/src/routes/admin/auth.js rename to backend/src/routes/auth.js index 47ce3d0..a6bcb2c 100644 --- a/backend/src/routes/admin/auth.js +++ b/backend/src/routes/auth.js @@ -1,14 +1,44 @@ import bcrypt from 'bcrypt'; /** - * 어드민 인증 라우트 + * 인증 라우트 + * /api/auth/* */ -export default async function adminAuthRoutes(fastify, opts) { +export default async function authRoutes(fastify, opts) { /** - * POST /api/admin/login + * POST /api/auth/login * 관리자 로그인 */ - fastify.post('/login', async (request, reply) => { + fastify.post('/login', { + schema: { + tags: ['auth'], + summary: '관리자 로그인', + body: { + type: 'object', + required: ['username', 'password'], + properties: { + username: { type: 'string', description: '관리자 아이디' }, + password: { type: 'string', description: '비밀번호' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + message: { type: 'string' }, + token: { type: 'string' }, + user: { + type: 'object', + properties: { + id: { type: 'integer' }, + username: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, async (request, reply) => { const { username, password } = request.body || {}; if (!username || !password) { @@ -50,10 +80,30 @@ export default async function adminAuthRoutes(fastify, opts) { }); /** - * GET /api/admin/verify + * GET /api/auth/verify * 토큰 검증 */ fastify.get('/verify', { + schema: { + tags: ['auth'], + summary: '토큰 검증', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + valid: { type: 'boolean' }, + user: { + type: 'object', + properties: { + id: { type: 'integer' }, + username: { type: 'string' }, + }, + }, + }, + }, + }, + }, preHandler: [fastify.authenticate], }, async (request, reply) => { return { valid: true, user: request.user }; diff --git a/backend/src/routes/admin/index.js b/backend/src/routes/index.js similarity index 55% rename from backend/src/routes/admin/index.js rename to backend/src/routes/index.js index 79235a3..ec1f092 100644 --- a/backend/src/routes/admin/index.js +++ b/backend/src/routes/index.js @@ -1,19 +1,20 @@ import authRoutes from './auth.js'; import membersRoutes from './members.js'; -import albumsRoutes from './albums.js'; +import albumsRoutes from './albums/index.js'; import statsRoutes from './stats.js'; /** - * 어드민 라우트 통합 + * 라우트 통합 + * /api/* */ -export default async function adminRoutes(fastify, opts) { - // 인증 라우트 (prefix 없음) - fastify.register(authRoutes); +export default async function routes(fastify, opts) { + // 인증 라우트 + fastify.register(authRoutes, { prefix: '/auth' }); - // 멤버 관리 라우트 + // 멤버 라우트 fastify.register(membersRoutes, { prefix: '/members' }); - // 앨범 관리 라우트 + // 앨범 라우트 fastify.register(albumsRoutes, { prefix: '/albums' }); // 통계 라우트 diff --git a/backend/src/routes/admin/members.js b/backend/src/routes/members.js similarity index 78% rename from backend/src/routes/admin/members.js rename to backend/src/routes/members.js index e29103b..3cf6ad8 100644 --- a/backend/src/routes/admin/members.js +++ b/backend/src/routes/members.js @@ -1,18 +1,24 @@ -import { uploadMemberImage } from '../../services/image.js'; +import { uploadMemberImage } from '../services/image.js'; /** - * 어드민 멤버 관리 라우트 + * 멤버 라우트 + * GET: 공개, PUT: 인증 필요 */ -export default async function adminMembersRoutes(fastify, opts) { +export default async function membersRoutes(fastify, opts) { + const { db } = fastify; + /** - * GET /api/admin/members - * 멤버 목록 조회 + * GET /api/members + * 전체 멤버 목록 조회 (공개) */ fastify.get('/', { - preHandler: [fastify.authenticate], + schema: { + tags: ['members'], + summary: '전체 멤버 목록 조회', + }, }, async (request, reply) => { try { - const [members] = await fastify.db.query(` + const [members] = await db.query(` SELECT m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former, i.original_url as image_original, @@ -24,7 +30,7 @@ export default async function adminMembersRoutes(fastify, opts) { `); // 별명 조회 - const [nicknames] = await fastify.db.query( + const [nicknames] = await db.query( 'SELECT member_id, nickname FROM member_nicknames' ); @@ -52,16 +58,25 @@ export default async function adminMembersRoutes(fastify, opts) { }); /** - * GET /api/admin/members/:name - * 멤버 상세 조회 + * GET /api/members/:name + * 멤버 상세 조회 (공개) */ fastify.get('/:name', { - preHandler: [fastify.authenticate], + schema: { + tags: ['members'], + summary: '멤버 상세 조회', + params: { + type: 'object', + properties: { + name: { type: 'string', description: '멤버 이름' }, + }, + }, + }, }, async (request, reply) => { const { name } = request.params; try { - const [members] = await fastify.db.query(` + const [members] = await db.query(` SELECT m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former, i.original_url as image_original, @@ -79,7 +94,7 @@ export default async function adminMembersRoutes(fastify, opts) { const member = members[0]; // 별명 조회 - const [nicknames] = await fastify.db.query( + const [nicknames] = await db.query( 'SELECT nickname FROM member_nicknames WHERE member_id = ?', [member.id] ); @@ -96,10 +111,22 @@ export default async function adminMembersRoutes(fastify, opts) { }); /** - * PUT /api/admin/members/:name - * 멤버 수정 + * PUT /api/members/:name + * 멤버 수정 (인증 필요) */ fastify.put('/:name', { + schema: { + tags: ['members'], + summary: '멤버 수정', + description: 'multipart/form-data로 이미지와 정보를 함께 전송', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + properties: { + name: { type: 'string', description: '멤버 이름' }, + }, + }, + }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { name } = request.params; @@ -107,7 +134,7 @@ export default async function adminMembersRoutes(fastify, opts) { try { // 기존 멤버 조회 - const [existing] = await fastify.db.query( + const [existing] = await db.query( 'SELECT id, image_id FROM members WHERE name = ?', [decodedName] ); @@ -144,7 +171,7 @@ export default async function adminMembersRoutes(fastify, opts) { const { originalUrl, mediumUrl, thumbUrl } = await uploadMemberImage(newName, imageBuffer); // images 테이블에 저장 - const [result] = await fastify.db.query( + const [result] = await db.query( 'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', [originalUrl, mediumUrl, thumbUrl] ); @@ -152,7 +179,7 @@ export default async function adminMembersRoutes(fastify, opts) { } // 멤버 정보 업데이트 - await fastify.db.query(` + await db.query(` UPDATE members SET name = ?, name_en = ?, @@ -173,7 +200,7 @@ export default async function adminMembersRoutes(fastify, opts) { // 별명 업데이트 (기존 삭제 후 새로 추가) if (fields.nicknames) { - await fastify.db.query( + await db.query( 'DELETE FROM member_nicknames WHERE member_id = ?', [memberId] ); @@ -181,7 +208,7 @@ export default async function adminMembersRoutes(fastify, opts) { const nicknames = JSON.parse(fields.nicknames); if (nicknames.length > 0) { const values = nicknames.map(n => [memberId, n]); - await fastify.db.query( + await db.query( 'INSERT INTO member_nicknames (member_id, nickname) VALUES ?', [values] ); diff --git a/backend/src/routes/public/albums.js b/backend/src/routes/public/albums.js deleted file mode 100644 index b3579ca..0000000 --- a/backend/src/routes/public/albums.js +++ /dev/null @@ -1,179 +0,0 @@ -/** - * 공개 앨범 라우트 - */ -export default async function publicAlbumsRoutes(fastify, opts) { - const { db } = fastify; - - /** - * 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함) - */ - async function getAlbumDetails(album) { - // 트랙 정보 조회 - const [tracks] = await db.query( - 'SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number', - [album.id] - ); - album.tracks = tracks; - - // 티저 이미지/비디오 조회 - const [teasers] = await db.query( - `SELECT original_url, medium_url, thumb_url, video_url, media_type - FROM album_teasers WHERE album_id = ? ORDER BY sort_order`, - [album.id] - ); - album.teasers = teasers; - - // 컨셉 포토 조회 (멤버 정보 포함) - const [photos] = await db.query( - `SELECT - p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order, - p.width, p.height, - GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members - FROM album_photos p - LEFT JOIN album_photo_members pm ON p.id = pm.photo_id - LEFT JOIN members m ON pm.member_id = m.id - WHERE p.album_id = ? - GROUP BY p.id - ORDER BY p.sort_order`, - [album.id] - ); - - // 컨셉별로 그룹화 - const conceptPhotos = {}; - for (const photo of photos) { - const concept = photo.concept_name || 'Default'; - if (!conceptPhotos[concept]) { - conceptPhotos[concept] = []; - } - conceptPhotos[concept].push({ - id: photo.id, - original_url: photo.original_url, - medium_url: photo.medium_url, - thumb_url: photo.thumb_url, - width: photo.width, - height: photo.height, - type: photo.photo_type, - members: photo.members, - sortOrder: photo.sort_order, - }); - } - album.conceptPhotos = conceptPhotos; - - return album; - } - - /** - * 전체 앨범 조회 (트랙 포함) - * GET /api/albums - */ - fastify.get('/', async (request, reply) => { - const [albums] = await db.query(` - SELECT id, title, folder_name, album_type, album_type_short, release_date, - cover_original_url, cover_medium_url, cover_thumb_url - FROM albums - ORDER BY release_date DESC - `); - - // 각 앨범에 트랙 정보 추가 - for (const album of albums) { - const [tracks] = await db.query( - `SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger - FROM tracks WHERE album_id = ? ORDER BY track_number`, - [album.id] - ); - album.tracks = tracks; - } - - return albums; - }); - - /** - * 앨범명과 트랙명으로 트랙 상세 조회 - * GET /api/albums/by-name/:albumName/track/:trackTitle - */ - fastify.get('/by-name/:albumName/track/:trackTitle', async (request, reply) => { - const albumName = decodeURIComponent(request.params.albumName); - const trackTitle = decodeURIComponent(request.params.trackTitle); - - // 앨범 조회 - const [albums] = await db.query( - 'SELECT * FROM albums WHERE folder_name = ? OR title = ?', - [albumName, albumName] - ); - - if (albums.length === 0) { - return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); - } - - const album = albums[0]; - - // 해당 앨범의 트랙 조회 - const [tracks] = await db.query( - 'SELECT * FROM tracks WHERE album_id = ? AND title = ?', - [album.id, trackTitle] - ); - - if (tracks.length === 0) { - return reply.code(404).send({ error: '트랙을 찾을 수 없습니다.' }); - } - - const track = tracks[0]; - - // 앨범의 다른 트랙 목록 조회 - const [otherTracks] = await db.query( - 'SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number', - [album.id] - ); - - return { - ...track, - album: { - id: album.id, - title: album.title, - folder_name: album.folder_name, - cover_thumb_url: album.cover_thumb_url, - cover_medium_url: album.cover_medium_url, - release_date: album.release_date, - album_type: album.album_type, - }, - otherTracks, - }; - }); - - /** - * 앨범 folder_name 또는 title로 조회 - * GET /api/albums/by-name/:name - */ - fastify.get('/by-name/:name', async (request, reply) => { - const name = decodeURIComponent(request.params.name); - - const [albums] = await db.query( - 'SELECT * FROM albums WHERE folder_name = ? OR title = ?', - [name, name] - ); - - if (albums.length === 0) { - return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); - } - - const album = await getAlbumDetails(albums[0]); - return album; - }); - - /** - * ID로 앨범 조회 - * GET /api/albums/:id - */ - fastify.get('/:id', async (request, reply) => { - const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [ - request.params.id, - ]); - - if (albums.length === 0) { - return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); - } - - const album = await getAlbumDetails(albums[0]); - return album; - }); -} diff --git a/backend/src/routes/public/index.js b/backend/src/routes/public/index.js deleted file mode 100644 index cbbb4be..0000000 --- a/backend/src/routes/public/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import albumsRoutes from './albums.js'; -import membersRoutes from './members.js'; - -/** - * 공개 라우트 통합 - */ -export default async function publicRoutes(fastify, opts) { - // 앨범 라우트 - fastify.register(albumsRoutes, { prefix: '/albums' }); - - // 멤버 라우트 - fastify.register(membersRoutes, { prefix: '/members' }); -} diff --git a/backend/src/routes/public/members.js b/backend/src/routes/public/members.js deleted file mode 100644 index 269d70d..0000000 --- a/backend/src/routes/public/members.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * 공개 멤버 라우트 - */ -export default async function publicMembersRoutes(fastify, opts) { - const { db } = fastify; - - /** - * 전체 멤버 조회 - * GET /api/members - */ - fastify.get('/', async (request, reply) => { - const [members] = await db.query(` - SELECT id, name, name_en, birth_date, instagram, image_url, is_former - FROM members - ORDER BY id ASC - `); - return members; - }); - - /** - * 멤버 상세 조회 (이름으로) - * GET /api/members/:name - */ - fastify.get('/:name', async (request, reply) => { - const memberName = decodeURIComponent(request.params.name); - - const [members] = await db.query( - 'SELECT * FROM members WHERE name = ?', - [memberName] - ); - - if (members.length === 0) { - return reply.code(404).send({ error: '멤버를 찾을 수 없습니다.' }); - } - - return members[0]; - }); -} diff --git a/backend/src/routes/admin/stats.js b/backend/src/routes/stats.js similarity index 59% rename from backend/src/routes/admin/stats.js rename to backend/src/routes/stats.js index 1763ab0..599cdb2 100644 --- a/backend/src/routes/admin/stats.js +++ b/backend/src/routes/stats.js @@ -1,17 +1,35 @@ /** - * 어드민 통계 라우트 + * 통계 라우트 + * 인증 필요 */ export default async function statsRoutes(fastify, opts) { const { db } = fastify; - // 모든 라우트에 인증 적용 - fastify.addHook('preHandler', fastify.authenticate); - /** - * 대시보드 통계 조회 - * GET /api/admin/stats + * GET /api/stats + * 대시보드 통계 조회 (인증 필요) */ - fastify.get('/', async (request, reply) => { + fastify.get('/', { + schema: { + tags: ['stats'], + summary: '대시보드 통계 조회', + description: '멤버, 앨범, 사진, 일정, 트랙 수를 조회합니다.', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + members: { type: 'integer', description: '활동 중인 멤버 수' }, + albums: { type: 'integer', description: '앨범 수' }, + photos: { type: 'integer', description: '사진 수 (컨셉포토 + 티저)' }, + schedules: { type: 'integer', description: '전체 일정 수' }, + tracks: { type: 'integer', description: '트랙 수' }, + }, + }, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { try { // 멤버 수 (현재 활동 중인 멤버만) const [[{ memberCount }]] = await db.query( diff --git a/frontend/src/api/admin/albums.js b/frontend/src/api/admin/albums.js index d1913c4..6c203c5 100644 --- a/frontend/src/api/admin/albums.js +++ b/frontend/src/api/admin/albums.js @@ -5,38 +5,38 @@ import { fetchAdminApi, fetchAdminFormData } from "../index"; // 앨범 목록 조회 export async function getAlbums() { - return fetchAdminApi("/api/admin/albums"); + return fetchAdminApi("/api/albums"); } // 앨범 상세 조회 export async function getAlbum(id) { - return fetchAdminApi(`/api/admin/albums/${id}`); + return fetchAdminApi(`/api/albums/${id}`); } // 앨범 생성 export async function createAlbum(formData) { - return fetchAdminFormData("/api/admin/albums", formData, "POST"); + return fetchAdminFormData("/api/albums", formData, "POST"); } // 앨범 수정 export async function updateAlbum(id, formData) { - return fetchAdminFormData(`/api/admin/albums/${id}`, formData, "PUT"); + return fetchAdminFormData(`/api/albums/${id}`, formData, "PUT"); } // 앨범 삭제 export async function deleteAlbum(id) { - return fetchAdminApi(`/api/admin/albums/${id}`, { method: "DELETE" }); + return fetchAdminApi(`/api/albums/${id}`, { method: "DELETE" }); } // 앨범 사진 목록 조회 export async function getAlbumPhotos(albumId) { - return fetchAdminApi(`/api/admin/albums/${albumId}/photos`); + return fetchAdminApi(`/api/albums/${albumId}/photos`); } // 앨범 사진 업로드 export async function uploadAlbumPhotos(albumId, formData) { return fetchAdminFormData( - `/api/admin/albums/${albumId}/photos`, + `/api/albums/${albumId}/photos`, formData, "POST" ); @@ -44,19 +44,19 @@ export async function uploadAlbumPhotos(albumId, formData) { // 앨범 사진 삭제 export async function deleteAlbumPhoto(albumId, photoId) { - return fetchAdminApi(`/api/admin/albums/${albumId}/photos/${photoId}`, { + return fetchAdminApi(`/api/albums/${albumId}/photos/${photoId}`, { method: "DELETE", }); } // 앨범 티저 목록 조회 export async function getAlbumTeasers(albumId) { - return fetchAdminApi(`/api/admin/albums/${albumId}/teasers`); + return fetchAdminApi(`/api/albums/${albumId}/teasers`); } // 앨범 티저 삭제 export async function deleteAlbumTeaser(albumId, teaserId) { - return fetchAdminApi(`/api/admin/albums/${albumId}/teasers/${teaserId}`, { + return fetchAdminApi(`/api/albums/${albumId}/teasers/${teaserId}`, { method: "DELETE", }); } diff --git a/frontend/src/api/admin/auth.js b/frontend/src/api/admin/auth.js index a162fe7..968d2d2 100644 --- a/frontend/src/api/admin/auth.js +++ b/frontend/src/api/admin/auth.js @@ -5,12 +5,12 @@ import { fetchAdminApi } from "../index"; // 토큰 검증 export async function verifyToken() { - return fetchAdminApi("/api/admin/verify"); + return fetchAdminApi("/api/auth/verify"); } // 로그인 export async function login(username, password) { - const response = await fetch("/api/admin/login", { + const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), diff --git a/frontend/src/api/admin/members.js b/frontend/src/api/admin/members.js index cda7a06..065bef2 100644 --- a/frontend/src/api/admin/members.js +++ b/frontend/src/api/admin/members.js @@ -5,15 +5,15 @@ import { fetchAdminApi, fetchAdminFormData } from "../index"; // 멤버 목록 조회 export async function getMembers() { - return fetchAdminApi("/api/admin/members"); + return fetchAdminApi("/api/members"); } // 멤버 상세 조회 export async function getMember(id) { - return fetchAdminApi(`/api/admin/members/${id}`); + return fetchAdminApi(`/api/members/${id}`); } // 멤버 수정 export async function updateMember(id, formData) { - return fetchAdminFormData(`/api/admin/members/${id}`, formData, "PUT"); + return fetchAdminFormData(`/api/members/${id}`, formData, "PUT"); } diff --git a/frontend/src/api/admin/stats.js b/frontend/src/api/admin/stats.js index 6ff1ca3..5f7577e 100644 --- a/frontend/src/api/admin/stats.js +++ b/frontend/src/api/admin/stats.js @@ -5,5 +5,5 @@ import { fetchAdminApi } from "../index"; // 대시보드 통계 조회 export async function getStats() { - return fetchAdminApi("/api/admin/stats"); + return fetchAdminApi("/api/stats"); } diff --git a/frontend/src/hooks/useAdminAuth.js b/frontend/src/hooks/useAdminAuth.js index a6d0623..451b0a5 100644 --- a/frontend/src/hooks/useAdminAuth.js +++ b/frontend/src/hooks/useAdminAuth.js @@ -14,7 +14,7 @@ function useAdminAuth() { const { data, isLoading, isError } = useQuery({ queryKey: ['admin', 'auth'], - queryFn: () => fetchAdminApi('/api/admin/verify'), + queryFn: () => fetchAdminApi('/api/auth/verify'), enabled: !!token, retry: false, staleTime: 1000 * 60 * 5, // 5분간 캐시 diff --git a/frontend/src/pages/pc/admin/AdminAlbumForm.jsx b/frontend/src/pages/pc/admin/AdminAlbumForm.jsx index bb03232..0f5b0c9 100644 --- a/frontend/src/pages/pc/admin/AdminAlbumForm.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbumForm.jsx @@ -227,7 +227,7 @@ function AdminAlbumForm() { try { const token = localStorage.getItem('adminToken'); - const url = isEditMode ? `/api/admin/albums/${id}` : '/api/admin/albums'; + const url = isEditMode ? `/api/albums/${id}` : '/api/albums'; const method = isEditMode ? 'PUT' : 'POST'; const submitData = new FormData(); diff --git a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx index 9b8d104..70a0989 100644 --- a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx @@ -402,7 +402,7 @@ function AdminAlbumPhotos() { formData.append('photoType', photoType); // 업로드 진행률 + SSE로 서버 처리 진행률 - const response = await fetch(`/api/admin/albums/${albumId}/photos`, { + const response = await fetch(`/api/albums/${albumId}/photos`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, diff --git a/frontend/src/pages/pc/admin/AdminMemberEdit.jsx b/frontend/src/pages/pc/admin/AdminMemberEdit.jsx index c049567..8ef4bc0 100644 --- a/frontend/src/pages/pc/admin/AdminMemberEdit.jsx +++ b/frontend/src/pages/pc/admin/AdminMemberEdit.jsx @@ -33,7 +33,7 @@ function AdminMemberEdit() { // 멤버 상세 조회 const { data: memberData, isLoading: loading, isError } = useQuery({ queryKey: ['admin', 'member', name], - queryFn: () => fetchAdminApi(`/api/admin/members/${encodeURIComponent(name)}`), + queryFn: () => fetchAdminApi(`/api/members/${encodeURIComponent(name)}`), enabled: isAuthenticated, }); @@ -117,7 +117,7 @@ function AdminMemberEdit() { form.append('image', imageFile); } - await fetchAdminFormData(`/api/admin/members/${encodeURIComponent(name)}`, form, 'PUT'); + await fetchAdminFormData(`/api/members/${encodeURIComponent(name)}`, form, 'PUT'); // 목록 캐시 무효화 (목록 페이지에서 최신 데이터 표시) queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); diff --git a/frontend/src/pages/pc/admin/AdminMembers.jsx b/frontend/src/pages/pc/admin/AdminMembers.jsx index 5f1001a..7b662c2 100644 --- a/frontend/src/pages/pc/admin/AdminMembers.jsx +++ b/frontend/src/pages/pc/admin/AdminMembers.jsx @@ -68,7 +68,7 @@ function AdminMembers() { // 멤버 목록 조회 const { data: members = [], isLoading: loading, isError } = useQuery({ queryKey: ['admin', 'members'], - queryFn: () => fetchAdminApi('/api/admin/members'), + queryFn: () => fetchAdminApi('/api/members'), enabled: isAuthenticated, });