refactor: API 라우트 구조 통합 및 파일 분리

- /api/admin/* + /api/* 분리 구조를 /api/*로 통합
- GET 요청은 공개, POST/PUT/DELETE는 인증 필요로 변경
- albums 라우트를 기능별 파일로 분리 (index, photos, teasers)
- 프론트엔드 API 호출 경로 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-17 13:01:35 +09:00
parent 428a74a703
commit b0f7169226
23 changed files with 1234 additions and 971 deletions

View file

@ -12,6 +12,8 @@
"@fastify/jwt": "^10.0.0", "@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0", "@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0",
"@fastify/swagger": "^9.0.0",
"@scalar/fastify-api-reference": "^1.25.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fastify": "^5.2.1", "fastify": "^5.2.1",
@ -1172,6 +1174,29 @@
"glob": "^11.0.0" "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": { "node_modules/@img/colour": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@ -1696,6 +1721,121 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT" "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": { "node_modules/@smithy/abort-controller": {
"version": "4.2.8", "version": "4.2.8",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz",
@ -2450,6 +2590,20 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/ajv-formats": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
@ -2932,6 +3086,12 @@
"is-property": "^1.0.2" "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": { "node_modules/glob": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
@ -3085,12 +3245,50 @@
"dequal": "^2.0.3" "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": { "node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "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": { "node_modules/light-my-request": {
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@ -3259,6 +3457,24 @@
"node": ">=8.0.0" "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": { "node_modules/node-addon-api": {
"version": "8.5.0", "version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
@ -3306,6 +3522,12 @@
"node": ">=14.0.0" "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": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -3799,6 +4021,18 @@
], ],
"license": "MIT" "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": { "node_modules/thread-stream": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
@ -3835,6 +4069,21 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/uuid": {
"version": "8.3.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@ -3958,6 +4207,30 @@
"engines": { "engines": {
"node": ">=0.4" "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"
}
} }
} }
} }

View file

@ -11,6 +11,8 @@
"@fastify/jwt": "^10.0.0", "@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0", "@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0",
"@fastify/swagger": "^9.0.0",
"@scalar/fastify-api-reference": "^1.25.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fastify": "^5.2.1", "fastify": "^5.2.1",

View file

@ -3,6 +3,8 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import Fastify from 'fastify'; import Fastify from 'fastify';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import fastifySwagger from '@fastify/swagger';
import scalarApiReference from '@scalar/fastify-api-reference';
import multipart from '@fastify/multipart'; import multipart from '@fastify/multipart';
import config from './config/index.js'; import config from './config/index.js';
@ -15,8 +17,7 @@ import xBotPlugin from './services/x/index.js';
import schedulerPlugin from './plugins/scheduler.js'; import schedulerPlugin from './plugins/scheduler.js';
// 라우트 // 라우트
import adminRoutes from './routes/admin/index.js'; import routes from './routes/index.js';
import publicRoutes from './routes/public/index.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -47,9 +48,54 @@ export async function buildApp(opts = {}) {
await fastify.register(xBotPlugin); await fastify.register(xBotPlugin);
await fastify.register(schedulerPlugin); 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(routes, { prefix: '/api' });
await fastify.register(publicRoutes, { prefix: '/api' });
// 헬스 체크 엔드포인트 // 헬스 체크 엔드포인트
fastify.get('/api/health', async () => { fastify.get('/api/health', async () => {

View file

@ -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();
}
});
}

View file

@ -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();
}
});
}

View file

@ -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();
}
});
}

View file

@ -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();
}
});
}

View file

@ -1,14 +1,44 @@
import bcrypt from 'bcrypt'; 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 || {}; const { username, password } = request.body || {};
if (!username || !password) { 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', { 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], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
return { valid: true, user: request.user }; return { valid: true, user: request.user };

View file

@ -1,19 +1,20 @@
import authRoutes from './auth.js'; import authRoutes from './auth.js';
import membersRoutes from './members.js'; import membersRoutes from './members.js';
import albumsRoutes from './albums.js'; import albumsRoutes from './albums/index.js';
import statsRoutes from './stats.js'; import statsRoutes from './stats.js';
/** /**
* 어드민 라우트 통합 * 라우트 통합
* /api/*
*/ */
export default async function adminRoutes(fastify, opts) { export default async function routes(fastify, opts) {
// 인증 라우트 (prefix 없음) // 인증 라우트
fastify.register(authRoutes); fastify.register(authRoutes, { prefix: '/auth' });
// 멤버 관리 라우트 // 멤버 라우트
fastify.register(membersRoutes, { prefix: '/members' }); fastify.register(membersRoutes, { prefix: '/members' });
// 앨범 관리 라우트 // 앨범 라우트
fastify.register(albumsRoutes, { prefix: '/albums' }); fastify.register(albumsRoutes, { prefix: '/albums' });
// 통계 라우트 // 통계 라우트

View file

@ -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('/', { fastify.get('/', {
preHandler: [fastify.authenticate], schema: {
tags: ['members'],
summary: '전체 멤버 목록 조회',
},
}, async (request, reply) => { }, async (request, reply) => {
try { try {
const [members] = await fastify.db.query(` const [members] = await db.query(`
SELECT SELECT
m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former, m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former,
i.original_url as image_original, 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' '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', { fastify.get('/:name', {
preHandler: [fastify.authenticate], schema: {
tags: ['members'],
summary: '멤버 상세 조회',
params: {
type: 'object',
properties: {
name: { type: 'string', description: '멤버 이름' },
},
},
},
}, async (request, reply) => { }, async (request, reply) => {
const { name } = request.params; const { name } = request.params;
try { try {
const [members] = await fastify.db.query(` const [members] = await db.query(`
SELECT SELECT
m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former, m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former,
i.original_url as image_original, i.original_url as image_original,
@ -79,7 +94,7 @@ export default async function adminMembersRoutes(fastify, opts) {
const member = members[0]; const member = members[0];
// 별명 조회 // 별명 조회
const [nicknames] = await fastify.db.query( const [nicknames] = await db.query(
'SELECT nickname FROM member_nicknames WHERE member_id = ?', 'SELECT nickname FROM member_nicknames WHERE member_id = ?',
[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', { fastify.put('/:name', {
schema: {
tags: ['members'],
summary: '멤버 수정',
description: 'multipart/form-data로 이미지와 정보를 함께 전송',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
properties: {
name: { type: 'string', description: '멤버 이름' },
},
},
},
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { name } = request.params; const { name } = request.params;
@ -107,7 +134,7 @@ export default async function adminMembersRoutes(fastify, opts) {
try { try {
// 기존 멤버 조회 // 기존 멤버 조회
const [existing] = await fastify.db.query( const [existing] = await db.query(
'SELECT id, image_id FROM members WHERE name = ?', 'SELECT id, image_id FROM members WHERE name = ?',
[decodedName] [decodedName]
); );
@ -144,7 +171,7 @@ export default async function adminMembersRoutes(fastify, opts) {
const { originalUrl, mediumUrl, thumbUrl } = await uploadMemberImage(newName, imageBuffer); const { originalUrl, mediumUrl, thumbUrl } = await uploadMemberImage(newName, imageBuffer);
// images 테이블에 저장 // images 테이블에 저장
const [result] = await fastify.db.query( const [result] = await db.query(
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', 'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
[originalUrl, mediumUrl, thumbUrl] [originalUrl, mediumUrl, thumbUrl]
); );
@ -152,7 +179,7 @@ export default async function adminMembersRoutes(fastify, opts) {
} }
// 멤버 정보 업데이트 // 멤버 정보 업데이트
await fastify.db.query(` await db.query(`
UPDATE members SET UPDATE members SET
name = ?, name = ?,
name_en = ?, name_en = ?,
@ -173,7 +200,7 @@ export default async function adminMembersRoutes(fastify, opts) {
// 별명 업데이트 (기존 삭제 후 새로 추가) // 별명 업데이트 (기존 삭제 후 새로 추가)
if (fields.nicknames) { if (fields.nicknames) {
await fastify.db.query( await db.query(
'DELETE FROM member_nicknames WHERE member_id = ?', 'DELETE FROM member_nicknames WHERE member_id = ?',
[memberId] [memberId]
); );
@ -181,7 +208,7 @@ export default async function adminMembersRoutes(fastify, opts) {
const nicknames = JSON.parse(fields.nicknames); const nicknames = JSON.parse(fields.nicknames);
if (nicknames.length > 0) { if (nicknames.length > 0) {
const values = nicknames.map(n => [memberId, n]); const values = nicknames.map(n => [memberId, n]);
await fastify.db.query( await db.query(
'INSERT INTO member_nicknames (member_id, nickname) VALUES ?', 'INSERT INTO member_nicknames (member_id, nickname) VALUES ?',
[values] [values]
); );

View file

@ -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;
});
}

View file

@ -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' });
}

View file

@ -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];
});
}

View file

@ -1,17 +1,35 @@
/** /**
* 어드민 통계 라우트 * 통계 라우트
* 인증 필요
*/ */
export default async function statsRoutes(fastify, opts) { export default async function statsRoutes(fastify, opts) {
const { db } = fastify; const { db } = fastify;
// 모든 라우트에 인증 적용
fastify.addHook('preHandler', fastify.authenticate);
/** /**
* 대시보드 통계 조회 * GET /api/stats
* GET /api/admin/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 { try {
// 멤버 수 (현재 활동 중인 멤버만) // 멤버 수 (현재 활동 중인 멤버만)
const [[{ memberCount }]] = await db.query( const [[{ memberCount }]] = await db.query(

View file

@ -5,38 +5,38 @@ import { fetchAdminApi, fetchAdminFormData } from "../index";
// 앨범 목록 조회 // 앨범 목록 조회
export async function getAlbums() { export async function getAlbums() {
return fetchAdminApi("/api/admin/albums"); return fetchAdminApi("/api/albums");
} }
// 앨범 상세 조회 // 앨범 상세 조회
export async function getAlbum(id) { export async function getAlbum(id) {
return fetchAdminApi(`/api/admin/albums/${id}`); return fetchAdminApi(`/api/albums/${id}`);
} }
// 앨범 생성 // 앨범 생성
export async function createAlbum(formData) { export async function createAlbum(formData) {
return fetchAdminFormData("/api/admin/albums", formData, "POST"); return fetchAdminFormData("/api/albums", formData, "POST");
} }
// 앨범 수정 // 앨범 수정
export async function updateAlbum(id, formData) { 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) { 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) { export async function getAlbumPhotos(albumId) {
return fetchAdminApi(`/api/admin/albums/${albumId}/photos`); return fetchAdminApi(`/api/albums/${albumId}/photos`);
} }
// 앨범 사진 업로드 // 앨범 사진 업로드
export async function uploadAlbumPhotos(albumId, formData) { export async function uploadAlbumPhotos(albumId, formData) {
return fetchAdminFormData( return fetchAdminFormData(
`/api/admin/albums/${albumId}/photos`, `/api/albums/${albumId}/photos`,
formData, formData,
"POST" "POST"
); );
@ -44,19 +44,19 @@ export async function uploadAlbumPhotos(albumId, formData) {
// 앨범 사진 삭제 // 앨범 사진 삭제
export async function deleteAlbumPhoto(albumId, photoId) { export async function deleteAlbumPhoto(albumId, photoId) {
return fetchAdminApi(`/api/admin/albums/${albumId}/photos/${photoId}`, { return fetchAdminApi(`/api/albums/${albumId}/photos/${photoId}`, {
method: "DELETE", method: "DELETE",
}); });
} }
// 앨범 티저 목록 조회 // 앨범 티저 목록 조회
export async function getAlbumTeasers(albumId) { export async function getAlbumTeasers(albumId) {
return fetchAdminApi(`/api/admin/albums/${albumId}/teasers`); return fetchAdminApi(`/api/albums/${albumId}/teasers`);
} }
// 앨범 티저 삭제 // 앨범 티저 삭제
export async function deleteAlbumTeaser(albumId, teaserId) { export async function deleteAlbumTeaser(albumId, teaserId) {
return fetchAdminApi(`/api/admin/albums/${albumId}/teasers/${teaserId}`, { return fetchAdminApi(`/api/albums/${albumId}/teasers/${teaserId}`, {
method: "DELETE", method: "DELETE",
}); });
} }

View file

@ -5,12 +5,12 @@ import { fetchAdminApi } from "../index";
// 토큰 검증 // 토큰 검증
export async function verifyToken() { export async function verifyToken() {
return fetchAdminApi("/api/admin/verify"); return fetchAdminApi("/api/auth/verify");
} }
// 로그인 // 로그인
export async function login(username, password) { export async function login(username, password) {
const response = await fetch("/api/admin/login", { const response = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),

View file

@ -5,15 +5,15 @@ import { fetchAdminApi, fetchAdminFormData } from "../index";
// 멤버 목록 조회 // 멤버 목록 조회
export async function getMembers() { export async function getMembers() {
return fetchAdminApi("/api/admin/members"); return fetchAdminApi("/api/members");
} }
// 멤버 상세 조회 // 멤버 상세 조회
export async function getMember(id) { export async function getMember(id) {
return fetchAdminApi(`/api/admin/members/${id}`); return fetchAdminApi(`/api/members/${id}`);
} }
// 멤버 수정 // 멤버 수정
export async function updateMember(id, formData) { export async function updateMember(id, formData) {
return fetchAdminFormData(`/api/admin/members/${id}`, formData, "PUT"); return fetchAdminFormData(`/api/members/${id}`, formData, "PUT");
} }

View file

@ -5,5 +5,5 @@ import { fetchAdminApi } from "../index";
// 대시보드 통계 조회 // 대시보드 통계 조회
export async function getStats() { export async function getStats() {
return fetchAdminApi("/api/admin/stats"); return fetchAdminApi("/api/stats");
} }

View file

@ -14,7 +14,7 @@ function useAdminAuth() {
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ['admin', 'auth'], queryKey: ['admin', 'auth'],
queryFn: () => fetchAdminApi('/api/admin/verify'), queryFn: () => fetchAdminApi('/api/auth/verify'),
enabled: !!token, enabled: !!token,
retry: false, retry: false,
staleTime: 1000 * 60 * 5, // 5분간 캐시 staleTime: 1000 * 60 * 5, // 5분간 캐시

View file

@ -227,7 +227,7 @@ function AdminAlbumForm() {
try { try {
const token = localStorage.getItem('adminToken'); 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 method = isEditMode ? 'PUT' : 'POST';
const submitData = new FormData(); const submitData = new FormData();

View file

@ -402,7 +402,7 @@ function AdminAlbumPhotos() {
formData.append('photoType', photoType); formData.append('photoType', photoType);
// + SSE // + SSE
const response = await fetch(`/api/admin/albums/${albumId}/photos`, { const response = await fetch(`/api/albums/${albumId}/photos`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,

View file

@ -33,7 +33,7 @@ function AdminMemberEdit() {
// //
const { data: memberData, isLoading: loading, isError } = useQuery({ const { data: memberData, isLoading: loading, isError } = useQuery({
queryKey: ['admin', 'member', name], queryKey: ['admin', 'member', name],
queryFn: () => fetchAdminApi(`/api/admin/members/${encodeURIComponent(name)}`), queryFn: () => fetchAdminApi(`/api/members/${encodeURIComponent(name)}`),
enabled: isAuthenticated, enabled: isAuthenticated,
}); });
@ -117,7 +117,7 @@ function AdminMemberEdit() {
form.append('image', imageFile); 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'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });

View file

@ -68,7 +68,7 @@ function AdminMembers() {
// //
const { data: members = [], isLoading: loading, isError } = useQuery({ const { data: members = [], isLoading: loading, isError } = useQuery({
queryKey: ['admin', 'members'], queryKey: ['admin', 'members'],
queryFn: () => fetchAdminApi('/api/admin/members'), queryFn: () => fetchAdminApi('/api/members'),
enabled: isAuthenticated, enabled: isAuthenticated,
}); });