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:
parent
428a74a703
commit
b0f7169226
23 changed files with 1234 additions and 971 deletions
273
backend/package-lock.json
generated
273
backend/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
410
backend/src/routes/albums/index.js
Normal file
410
backend/src/routes/albums/index.js
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
252
backend/src/routes/albums/photos.js
Normal file
252
backend/src/routes/albums/photos.js
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
90
backend/src/routes/albums/teasers.js
Normal file
90
backend/src/routes/albums/teasers.js
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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' });
|
||||
|
||||
// 통계 라우트
|
||||
|
|
@ -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]
|
||||
);
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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];
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ import { fetchAdminApi } from "../index";
|
|||
|
||||
// 대시보드 통계 조회
|
||||
export async function getStats() {
|
||||
return fetchAdminApi("/api/admin/stats");
|
||||
return fetchAdminApi("/api/stats");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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분간 캐시
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue