From d29c1065f52a8270ea8705783a8898feb9a02da9 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 20 Nov 2025 01:37:32 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=207=EC=A3=BC=EC=B0=A8=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/mission.controller.js | 39 ++++------------------- src/controllers/store.controller.js | 11 +++++-- src/services/user.service.js | 46 ++++++--------------------- 3 files changed, 25 insertions(+), 71 deletions(-) diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index 3a34dd2..bc5f644 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -76,11 +76,7 @@ export const handleAddMission = async (req, res, next) => { deadline }); - res.status(StatusCodes.CREATED).json({ - success: true, - message: '미션이 성공적으로 추가되었습니다.', - data: result - }); + res.success(result, '미션이 성공적으로 추가되었습니다.', StatusCodes.CREATED); } catch (error) { next(error); } @@ -109,10 +105,7 @@ export const getUserMissions = async (req, res, next) => { const userId = parseInt(req.params.userId); const missions = await missionService.getUserMissions(userId); - res.status(StatusCodes.OK).json({ - success: true, - data: missions - }); + res.success(missions); } catch (error) { next(error); } @@ -148,11 +141,7 @@ export const completeUserMission = async (req, res, next) => { const { userId, missionId } = req.params; const result = await missionService.completeMission(parseInt(userId), parseInt(missionId)); - res.status(StatusCodes.OK).json({ - success: true, - message: '미션이 성공적으로 완료되었습니다.', - data: result - }); + res.success(result, '미션이 성공적으로 완료되었습니다.'); } catch (error) { next(error); } @@ -202,11 +191,7 @@ export const assignMissionToUser = async (req, res, next) => { const result = await missionService.assignMissionToUser(userId, missionId); - res.status(StatusCodes.CREATED).json({ - success: true, - message: '미션이 성공적으로 할당되었습니다.', - data: result - }); + res.success(result, '미션이 성공적으로 할당되었습니다.', StatusCodes.CREATED); } catch (error) { next(error); } @@ -235,10 +220,7 @@ export const getUserReviews = async (req, res, next) => { const userId = parseInt(req.params.userId); const reviews = await missionService.getUserReviews(userId); - res.status(StatusCodes.OK).json({ - success: true, - data: reviews - }); + res.success(reviews); } catch (error) { next(error); } @@ -267,10 +249,7 @@ export const getStoreMissions = async (req, res, next) => { const storeId = parseInt(req.params.storeId); const missions = await missionService.getMissionsByStoreId(storeId); - res.status(StatusCodes.OK).json({ - success: true, - data: missions - }); + res.success(missions); } catch (error) { next(error); } @@ -320,11 +299,7 @@ export const handleChallengeMission = async (req, res, next) => { const result = await missionService.challengeMission(missionId, userId); - res.status(StatusCodes.CREATED).json({ - success: true, - message: '미션 도전이 시작되었습니다.', - data: result - }); + res.success(result, '미션에 도전했습니다!', StatusCodes.CREATED); } catch (error) { next(error); } diff --git a/src/controllers/store.controller.js b/src/controllers/store.controller.js index 022f455..fb4edef 100644 --- a/src/controllers/store.controller.js +++ b/src/controllers/store.controller.js @@ -1,6 +1,7 @@ import * as storeService from '../services/store.service.js'; import { listStoreReviews, getStoreById as getStoreByIdService } from '../services/store.service.js'; import { StatusCodes } from 'http-status-codes'; +import { NotFoundError } from '../errors.js'; /** * POST /api/v1/stores 엔드포인트 핸들러 @@ -16,8 +17,14 @@ export const handleAddStore = async (req, res) => { const result = await storeService.addNewStore({ name, address, region }); return res.status(201).json(result); // 201 Created } catch (error) { - console.error(error); - return res.status(500).json({ message: '가게 추가 중 오류 발생' }); + if (error instanceof NotFoundError) { + throw error; + } + console.error('가게 추가 중 오류 발생:', error); + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + message: '가게 추가 중 오류가 발생했습니다.', + error: process.env.NODE_ENV === 'development' ? error.message : undefined + }); } }; diff --git a/src/services/user.service.js b/src/services/user.service.js index a0a3aab..72cc076 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -1,43 +1,15 @@ import bcrypt from 'bcrypt'; import { createUserWithPreferences, getUser } from '../repositories/user.repository.js'; -// Error handling with standard Error and status codes -class AppError extends Error { - constructor(message, statusCode) { - super(message); - this.statusCode = statusCode; - this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; - this.isOperational = true; - - Error.captureStackTrace(this, this.constructor); - } -} - -// Custom error classes for specific error types -class DuplicateUserEmailError extends AppError { - constructor(message = '이미 사용 중인 이메일입니다.') { - super(message, 409); - } -} - -class ValidationError extends AppError { - constructor(message = '유효성 검사에 실패했습니다.', errors = []) { - super(message, 400); - this.errors = errors; - } -} - -class InternalServerError extends AppError { - constructor(message = '서버 내부 오류가 발생했습니다.') { - super(message, 500); - } -} - -class NotFoundError extends AppError { - constructor(message = '요청하신 리소스를 찾을 수 없습니다.') { - super(message, 404); - } -} +// Error classes from centralized error handling +import { + DuplicateUserEmailError, + ValidationError, + InternalServerError, + NotFoundError, + ConflictError, + BadRequestError +} from '../errors.js'; // 비밀번호 해시 함수 const hashPassword = async (password) => { From 49394d63f62185e68e1a645970f73ee70452484e Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 20 Nov 2025 09:57:00 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=208=EC=A3=BC=EC=B0=A8=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EB=B6=81=20=EC=8B=A4=EC=8A=B5=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 131 +++++++++++++++++++++++++++-- package.json | 4 +- src/controllers/user.controller.js | 4 +- src/dtos/user.dto.js | 1 + src/index.js | 13 ++- src/services/user.service.js | 112 +++++++++++++++--------- test.html | 89 ++++++++++++++++++++ 7 files changed, 302 insertions(+), 52 deletions(-) create mode 100644 test.html diff --git a/package-lock.json b/package-lock.json index 50b7b4d..40c099c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", - "prisma": "^6.19.0" + "prisma": "^6.19.0", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "dotenv-cli": "^11.0.0", @@ -104,6 +106,13 @@ "@prisma/debug": "6.19.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -123,6 +132,18 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -141,7 +162,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/basic-auth": { @@ -213,7 +233,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -383,7 +402,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/confbox": { @@ -504,6 +522,15 @@ } } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -811,6 +838,12 @@ "node": ">= 0.8" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -889,6 +922,27 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -998,6 +1052,17 @@ "dev": true, "license": "ISC" }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1081,6 +1146,18 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -1221,7 +1298,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -1448,6 +1524,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1870,6 +1955,42 @@ "node": ">=4" } }, + "node_modules/swagger-autogen": { + "version": "2.23.7", + "resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz", + "integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.4.1", + "deepmerge": "^4.2.2", + "glob": "^7.1.7", + "json5": "^2.2.3" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.30.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", + "integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", diff --git a/package.json b/package.json index 49de9c3..f785719 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", - "prisma": "^6.19.0" + "prisma": "^6.19.0", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "dotenv-cli": "^11.0.0", diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index 8138760..6b04290 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -66,7 +66,7 @@ import { prisma } from '../db.config.js'; /** * 사용자 회원가입 컨트롤러 */ -export const signUp = async (req, res) => { +export const signUp = async (req, res, next) => { try { console.log('\n=== 회원가입 요청 시작 ==='); console.log('요청 바디:', JSON.stringify(req.body, null, 2)); @@ -81,7 +81,7 @@ export const signUp = async (req, res) => { console.log('변환된 사용자 데이터:', JSON.stringify(userData, null, 2)); // 회원가입 서비스 호출 - const user = await userSignUp(prisma, { + const user = await userSignUp({ ...userData, birth: userData.birth ? new Date(userData.birth) : null }); diff --git a/src/dtos/user.dto.js b/src/dtos/user.dto.js index d09a54c..581e13c 100644 --- a/src/dtos/user.dto.js +++ b/src/dtos/user.dto.js @@ -30,6 +30,7 @@ export const bodyToUser = (body) => { return { email: body.email, // 필수 + password: body.password, // 필수 name: body.name, // 필수 gender, // MALE, FEMALE, OTHER birth, // YYYY-MM-DD 형식 diff --git a/src/index.js b/src/index.js index 0343ac4..1f4012c 100644 --- a/src/index.js +++ b/src/index.js @@ -72,7 +72,10 @@ app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(cors({ - origin: process.env.CLIENT_URL || 'http://localhost:3000', + origin: [ + process.env.CLIENT_URL || 'http://localhost:3000', + 'http://127.0.0.1:5500' + ], credentials: true })); app.use(express.static('public')); // 정적 파일 제공 @@ -183,7 +186,13 @@ app.get('/', (req, res) => { const apiRouter = express.Router(); // 사용자 관련 라우트 -apiRouter.post("/users/signup", signUp); +apiRouter.post("/users/signup", async (req, res, next) => { + try { + await signUp(req, res, next); + } catch (error) { + next(error); + } +}); // 가게 관련 라우트 apiRouter.get('/stores/:storeId', getStoreById); diff --git a/src/services/user.service.js b/src/services/user.service.js index 72cc076..d5bd3c5 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -64,23 +64,27 @@ export const userSignUp = async (userData) => { // 1. 입력 데이터 유효성 검사 validateUserInput(userData); - // 2. 비밀번호 해싱 - const hashedPassword = await hashPassword(userData.password); - - // 3. 선호 카테고리 ID로 변환 - const foodCategoryIds = mapPreferencesToCategoryIds(userData.preferences); - - // 4. 사용자 정보와 선호 카테고리 등록 - const user = await createUserWithPreferences({ + // 2. 사용자 데이터 준비 + const userDataToCreate = { email: userData.email, - password: hashedPassword, name: userData.name, gender: userData.gender, birth: userData.birth ? new Date(userData.birth) : null, address: userData.address, - detailAddress: userData.detailAddress, - phoneNumber: userData.phoneNumber - }, foodCategoryIds); + detailAddress: userData.detailAddress || null, + phoneNumber: userData.phoneNumber || null + }; + + // 3. 비밀번호가 제공된 경우에만 해싱 + if (userData.password) { + userDataToCreate.password = await hashPassword(userData.password); + } + + // 4. 선호 카테고리 ID로 변환 + const foodCategoryIds = mapPreferencesToCategoryIds(userData.preferences || []); + + // 5. 사용자 정보와 선호 카테고리 등록 + const user = await createUserWithPreferences(userDataToCreate, foodCategoryIds); // 5. 등록된 사용자 정보 조회 (선호 카테고리 포함) const userWithPreferences = await getUser(user.id); @@ -113,60 +117,84 @@ export const userSignUp = async (userData) => { * @throws {ValidationError} 유효성 검사 실패 시 */ function validateUserInput(data) { - // 1. 필수 필드 검증 - const requiredFields = ['email', 'password', 'name', 'gender', 'birth', 'address']; - const missingFields = requiredFields.filter(field => !data[field]); + // 1. 필수 필드 검증 (password는 선택사항으로 변경) + const requiredFields = ['email', 'name', 'gender', 'birth', 'address']; + const missingFields = requiredFields.filter(field => !data[field] && data[field] !== 0); if (missingFields.length > 0) { - throw new ValidationError( - '필수 입력 항목이 누락되었습니다.', - missingFields.map(field => ({ + const details = {}; + missingFields.forEach((field, index) => { + details[index] = { field, message: `${field}은(는) 필수 입력 항목입니다.` - })) - ); + }; + }); + details.errors = ['필수 입력 항목이 누락되었습니다.']; + + const error = new ValidationError('유효성 검사에 실패했습니다.'); + error.details = details; + throw error; } // 2. 이메일 형식 검증 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(data.email)) { - throw new ValidationError('유효하지 않은 이메일 형식입니다.', [ - { field: 'email', message: '유효한 이메일 주소를 입력해주세요.' } - ]); + const error = new ValidationError('유효성 검사에 실패했습니다.'); + error.details = { + 0: { field: 'email', message: '유효한 이메일 주소를 입력해주세요.' }, + errors: ['유효하지 않은 이메일 형식입니다.'] + }; + throw error; } - // 3. 비밀번호 복잡성 검증 - const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/; - if (!passwordRegex.test(data.password)) { - throw new ValidationError('비밀번호는 최소 8자 이상, 영문, 숫자, 특수문자를 모두 포함해야 합니다.', [ - { - field: 'password', - message: '비밀번호는 영문, 숫자, 특수문자를 조합하여 8자 이상이어야 합니다.' - } - ]); + // 3. 비밀번호 복잡성 검증 (비밀번호가 있는 경우에만 검증) + if (data.password && data.password.trim() !== '') { + const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/; + if (!passwordRegex.test(data.password)) { + const error = new ValidationError('유효성 검사에 실패했습니다.'); + error.details = { + 0: { + field: 'password', + message: '비밀번호는 영문, 숫자, 특수문자를 조합하여 8자 이상이어야 합니다.' + }, + errors: ['비밀번호는 최소 8자 이상, 영문, 숫자, 특수문자를 모두 포함해야 합니다.'] + }; + throw error; + } } // 4. 성별 유효성 검증 - const validGenders = ['MALE', 'FEMALE', 'OTHER']; + const validGenders = ['MALE', 'FEMALE', 'OTHER', '남성', '여성', '기타']; if (!validGenders.includes(data.gender)) { - throw new ValidationError('유효하지 않은 성별입니다.', [ - { + const error = new ValidationError('유효성 검사에 실패했습니다.'); + error.details = { + 0: { field: 'gender', - message: '성별은 MALE, FEMALE, OTHER 중 하나여야 합니다.' - } - ]); + message: '성별은 MALE, FEMALE, OTHER, 남성, 여성, 기타 중 하나여야 합니다.' + }, + errors: ['유효하지 않은 성별입니다.'] + }; + throw error; } + // 성별을 영어로 변환 (한국어인 경우) + if (data.gender === '남성') data.gender = 'MALE'; + if (data.gender === '여성') data.gender = 'FEMALE'; + if (data.gender === '기타') data.gender = 'OTHER'; + // 5. 생년월일 유효성 검증 if (data.birth) { const birthDate = new Date(data.birth); if (isNaN(birthDate.getTime())) { - throw new ValidationError('유효하지 않은 생년월일 형식입니다.', [ - { + const error = new ValidationError('유효성 검사에 실패했습니다.'); + error.details = { + 0: { field: 'birth', message: '올바른 날짜 형식(YYYY-MM-DD)으로 입력해주세요.' - } - ]); + }, + errors: ['유효하지 않은 생년월일 형식입니다.'] + }; + throw error; } } } diff --git a/test.html b/test.html new file mode 100644 index 0000000..9e8f3b4 --- /dev/null +++ b/test.html @@ -0,0 +1,89 @@ + + + + + 내 API 테스트하기 + + +

회원가입 테스트

+ + + + + + \ No newline at end of file From 3512fd674d5972b9b269f299119123c6feee9a11 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 20 Nov 2025 10:16:08 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=A0=91=EC=86=8D=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.js | 41 ++++++++++++++++++++++++++++++++++++++++- test.html | 3 ++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 1f4012c..d41f6b4 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,8 @@ import cors from 'cors'; import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; +import swaggerAutogen from "swagger-autogen"; +import swaggerUiExpress from "swagger-ui-express"; // Get the current directory name in ES module const __filename = fileURLToPath(import.meta.url); @@ -194,6 +196,43 @@ apiRouter.post("/users/signup", async (req, res, next) => { } }); + +// Swagger Documentation +const swaggerSpec = { + definition: { + openapi: "3.0.0", + info: { + title: "UMC 9th API", + version: "1.0.0", + description: "UMC 9th Node.js 테스트 프로젝트 API 문서입니다." + }, + servers: [ + { + url: "http://localhost:3000", + description: "Local server" + } + ], + }, + apis: ["./src/**/*.js"] // Path to the API routes +}; + +// Swagger UI +app.use( + "/docs", + swaggerUiExpress.serve, + swaggerUiExpress.setup(swaggerSpec.definition, { + explorer: true, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: "UMC 9th API 문서" + }) +); + +// OpenAPI JSON +app.get("/openapi.json", (req, res) => { + res.setHeader("Content-Type", "application/json"); + res.send(swaggerSpec.definition); +}); + // 가게 관련 라우트 apiRouter.get('/stores/:storeId', getStoreById); apiRouter.post('/stores', handleAddStore); @@ -455,4 +494,4 @@ app.get('/set-logout', (req, res) => { res.send('로그아웃 완료 (쿠키 삭제). 메인으로'); }); -//7주차 끝 \ No newline at end of file +//7주차 끝 diff --git a/test.html b/test.html index 9e8f3b4..ef4919c 100644 --- a/test.html +++ b/test.html @@ -1,3 +1,4 @@ + \ No newline at end of file From 291a69e2c07e3f642cc2f799fa7b4830bfe812e9 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 20 Nov 2025 10:41:58 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=208=EC=A3=BC=EC=B0=A8=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EB=B6=81=20=EC=8B=A4=EC=8A=B5=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 239 ++++++++++++++++++++++++++++ package.json | 1 + src/controllers/store.controller.js | 37 ++++- src/controllers/user.controller.js | 78 +++++++++ src/index.js | 40 ++++- 5 files changed, 386 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40c099c..5e88075 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "morgan": "^1.10.1", "prisma": "^6.19.0", "swagger-autogen": "^2.23.7", + "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -27,6 +28,56 @@ "nodemon": "^3.1.10" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@prisma/client": { "version": "6.19.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", @@ -119,6 +170,12 @@ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -158,6 +215,12 @@ "node": ">= 8" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -364,6 +427,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -398,6 +467,15 @@ "consola": "^3.2.3" } }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -561,6 +639,18 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -711,6 +801,15 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1146,6 +1245,18 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1201,6 +1312,13 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1213,6 +1331,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -1237,6 +1362,12 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -1515,6 +1646,13 @@ "wrappy": "1" } }, + "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", + "peer": true + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1967,6 +2105,59 @@ "json5": "^2.2.3" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/swagger-ui-dist": { "version": "5.30.2", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", @@ -2062,6 +2253,15 @@ "node": ">= 0.8" } }, + "node_modules/validator": { + "version": "13.15.23", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2092,6 +2292,45 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } } } } diff --git a/package.json b/package.json index f785719..d9330f6 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "morgan": "^1.10.1", "prisma": "^6.19.0", "swagger-autogen": "^2.23.7", + "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, "devDependencies": { diff --git a/src/controllers/store.controller.js b/src/controllers/store.controller.js index fb4edef..f798500 100644 --- a/src/controllers/store.controller.js +++ b/src/controllers/store.controller.js @@ -29,12 +29,47 @@ export const handleAddStore = async (req, res) => { }; export const handleListStoreReviews = async (req, res, next) => { + /* + #swagger.summary = '상점 리뷰 목록 조회 API'; + #swagger.responses[200] = { + description: "상점 리뷰 목록 조회 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, + store: { type: "object", properties: { id: { type: "number" }, name: { type: "string" } } }, + user: { type: "object", properties: { id: { type: "number" }, email: { type: "string" }, name: { type: "string" } } }, + content: { type: "string" } + } + } + }, + pagination: { type: "object", properties: { cursor: { type: "number", nullable: true } }} + } + } + } + } + } + } + }; + */ try { const reviews = await storeService.listStoreReviews( req.params.storeId, typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : undefined ); - res.status(StatusCodes.OK).json(reviews); + res.success(reviews); } catch (error) { next(error); } diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index 6b04290..e6098db 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -66,6 +66,84 @@ import { prisma } from '../db.config.js'; /** * 사용자 회원가입 컨트롤러 */ +export const handleUserSignUp = async (req, res, next) => { + /* + #swagger.summary = '회원 가입 API'; + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { type: "string" }, + name: { type: "string" }, + gender: { type: "string" }, + birth: { type: "string", format: "date" }, + address: { type: "string" }, + detailAddress: { type: "string" }, + phoneNumber: { type: "string" }, + preferences: { type: "array", items: { type: "number" } } + }, + required: ["email", "name", "gender", "birth", "address", "phoneNumber"] + } + } + } + }; + #swagger.responses[200] = { + description: "회원 가입 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + email: { type: "string" }, + name: { type: "string" }, + preferCategory: { type: "array", items: { type: "string" } } + } + } + } + } + } + } + }; + #swagger.responses[400] = { + description: "회원 가입 실패 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "U001" }, + reason: { type: "string" }, + data: { type: "object" } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; + */ + try { + const user = bodyToUser(req.body); + const result = await userSignUp(user); + res.success(result); + } catch (error) { + next(error); + } +}; + export const signUp = async (req, res, next) => { try { console.log('\n=== 회원가입 요청 시작 ==='); diff --git a/src/index.js b/src/index.js index d41f6b4..e94dc6f 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,7 @@ import cors from 'cors'; import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; -import swaggerAutogen from "swagger-autogen"; +import swaggerJsdoc from 'swagger-jsdoc'; import swaggerUiExpress from "swagger-ui-express"; // Get the current directory name in ES module @@ -197,8 +197,8 @@ apiRouter.post("/users/signup", async (req, res, next) => { }); -// Swagger Documentation -const swaggerSpec = { +// Swagger 설정 +const options = { definition: { openapi: "3.0.0", info: { @@ -208,29 +208,46 @@ const swaggerSpec = { }, servers: [ { - url: "http://localhost:3000", + url: "http://localhost:3000/api/v1", description: "Local server" } ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT" + } + } + }, + security: [ + { + bearerAuth: [] + } + ] }, - apis: ["./src/**/*.js"] // Path to the API routes + apis: ["./src/**/*.js"] }; +const swaggerSpec = swaggerJsdoc(options); + // Swagger UI app.use( "/docs", swaggerUiExpress.serve, - swaggerUiExpress.setup(swaggerSpec.definition, { + swaggerUiExpress.setup(swaggerSpec, { explorer: true, customCss: '.swagger-ui .topbar { display: none }', customSiteTitle: "UMC 9th API 문서" }) ); -// OpenAPI JSON +// OpenAPI JSON - Swagger UI에서 숨김 app.get("/openapi.json", (req, res) => { + // #swagger.ignore = true res.setHeader("Content-Type", "application/json"); - res.send(swaggerSpec.definition); + res.send(swaggerSpec); }); // 가게 관련 라우트 @@ -247,6 +264,13 @@ apiRouter.post('/missions/:missionId/challenge', handleChallengeMission); // API 버저닝 app.use('/api/v1', apiRouter); +// Swagger 문서에서 숨길 라우트 +app.get('/openapi.json', (req, res) => { + // #swagger.ignore = true + res.setHeader("Content-Type", "application/json"); + res.send(swaggerSpec); +}); + // Global error handler middleware app.use((err, req, res, next) => { console.error('Error:', err); From 87a81bb6d40f0a403c93eea768cbb5c457357eb4 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 20 Nov 2025 11:03:57 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=208=EC=A3=BC=EC=B0=A8=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98/=20Swagger=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/mission.controller.js | 892 +++++++++++++++++++++++++- src/controllers/store.controller.js | 308 ++++++++- src/controllers/user.controller.js | 18 + 3 files changed, 1189 insertions(+), 29 deletions(-) diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index bc5f644..8f146b9 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -25,18 +25,24 @@ class NotFoundError extends AppError { } } +/** /** * @swagger * /api/v1/stores/{storeId}/missions: * post: - * summary: Add a new mission to a store + * summary: 가게에 새로운 미션 추가 + * description: 관리자가 특정 가게에 새로운 미션을 추가합니다 * tags: [Missions] + * security: + * - bearerAuth: [] * parameters: * - in: path * name: storeId * required: true * schema: * type: integer + * minimum: 1 + * description: 미션을 추가할 가게의 고유 ID * requestBody: * required: true * content: @@ -48,18 +54,85 @@ class NotFoundError extends AppError { * properties: * content: * type: string + * description: 미션 내용 + * example: "이 가게에서 3만원 이상 구매하기" * reward: - * type: number + * type: integer + * description: 미션 보상 포인트 + * example: 1000 * deadline: * type: string * format: date-time + * description: 미션 마감일 (ISO 8601 형식) + * example: "2023-12-31T23:59:59.000Z" * responses: * 201: - * description: Mission added successfully + * description: 성공적으로 미션이 추가됨 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * description: 생성된 미션 ID + * content: + * type: string + * reward: + * type: integer + * storeId: + * type: integer + * status: + * type: string + * enum: [ACTIVE, INACTIVE] + * deadline: + * type: string + * format: date-time + * createdAt: + * type: string + * format: date-time * 400: - * description: Invalid input + * description: 잘못된 요청 파라미터 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "미션 내용은 필수입니다." + * 401: + * description: 인증 실패 (관리자 권한 필요) + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "인증이 필요합니다." + * 403: + * description: 권한 없음 (관리자만 접근 가능) + * 404: + * description: 가게를 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "가게를 찾을 수 없습니다." * 500: - * description: Internal server error + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "미션 추가 중 오류가 발생했습니다." */ export const handleAddMission = async (req, res, next) => { try { @@ -86,19 +159,187 @@ export const handleAddMission = async (req, res, next) => { * @swagger * /api/v1/users/{userId}/missions: * get: - * summary: Get user's missions + * summary: 사용자 미션 목록 조회 + * description: 특정 사용자가 도전 중이거나 완료한 미션 목록을 조회합니다. * tags: [Missions] + * security: + * - bearerAuth: [] * parameters: * - in: path * name: userId * required: true * schema: * type: integer + * minimum: 1 + * description: 미션 목록을 조회할 사용자의 고유 ID + * - in: query + * name: status + * schema: + * type: string + * enum: [IN_PROGRESS, COMPLETED, FAILED] + * description: 미션 상태 필터링, 선택사항입니다 + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: 페이지 번호, 기본값은 1입니다 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 10 + * description: 페이지당 항목 수, 기본값 10, 최대 100까지 가능합니다 * responses: * 200: - * description: List of user's missions + * description: 성공적으로 사용자 미션 목록을 조회함 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * description: 미션 도전 기록 ID + * missionId: + * type: integer + * description: 미션 ID + * userId: + * type: integer + * description: 사용자 ID + * status: + * type: string + * enum: [IN_PROGRESS, COMPLETED, FAILED] + * description: 미션 진행 상태 + * progress: + * type: integer + * minimum: 0 + * maximum: 100 + * description: 미션 진행률 (%) + * startedAt: + * type: string + * format: date-time + * description: 미션 시작 일시 + * completedAt: + * type: string + * format: date-time + * description: 미션 완료 일시 (완료된 경우) + * mission: + * type: object + * properties: + * id: + * type: integer + * content: + * type: string + * reward: + * type: integer + * deadline: + * type: string + * format: date-time + * pagination: + * type: object + * properties: + * totalItems: + * type: integer + * description: 전체 항목 수 + * totalPages: + * type: integer + * description: 전체 페이지 수 + * currentPage: + * type: integer + * description: 현재 페이지 번호 + * itemsPerPage: + * type: integer + * description: 페이지당 항목 수 + * 400: + * description: 잘못된 요청 파라미터 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "VALIDATION_ERROR" + * message: + * type: string + * example: "잘못된 페이지 번호입니다." + * 401: + * description: 인증 실패 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "UNAUTHORIZED" + * message: + * type: string + * example: "인증이 필요합니다." + * 403: + * description: 권한 없음 (본인만 조회 가능) + * 404: + * description: 사용자를 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "USER_NOT_FOUND" + * message: + * type: string + * example: "사용자를 찾을 수 없습니다." * 500: - * description: Internal server error + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INTERNAL_SERVER_ERROR" + * message: + * type: string + * example: "미션 목록 조회 중 오류가 발생했습니다." */ export const getUserMissions = async (req, res, next) => { try { @@ -115,26 +356,165 @@ export const getUserMissions = async (req, res, next) => { * @swagger * /api/v1/users/{userId}/missions/{missionId}/complete: * patch: - * summary: Complete a mission + * summary: 미션 완료 처리 + * description: 사용자가 도전 중인 미션을 완료 상태로 변경합니다. * tags: [Missions] + * security: + * - bearerAuth: [] * parameters: * - in: path * name: userId * required: true * schema: * type: integer + * minimum: 1 + * description: 미션을 완료할 사용자의 고유 ID * - in: path * name: missionId * required: true * schema: * type: integer + * minimum: 1 + * description: 완료할 미션의 고유 ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * proofImage: + * type: string + * format: binary + * description: 미션 완료 증빙 이미지 (선택사항) * responses: * 200: - * description: Mission completed successfully + * description: 성공적으로 미션을 완료 처리했습니다. + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * id: + * type: integer + * description: 미션 도전 기록 ID + * status: + * type: string + * enum: [COMPLETED] + * example: "COMPLETED" + * completedAt: + * type: string + * format: date-time + * description: 미션 완료 일시 + * rewardEarned: + * type: integer + * description: 획득한 보상 포인트 + * message: + * type: string + * example: "미션을 성공적으로 완료했습니다!" + * 400: + * description: 잘못된 요청 파라미터 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "VALIDATION_ERROR" + * message: + * type: string + * example: "잘못된 요청 파라미터입니다." + * 401: + * description: 인증 실패 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "UNAUTHORIZED" + * message: + * type: string + * example: "인증이 필요합니다." + * 403: + * description: 권한 없음 (본인만 완료 처리 가능) * 404: - * description: Mission not found or already completed + * description: 미션 도전 기록을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "MISSION_RECORD_NOT_FOUND" + * message: + * type: string + * example: "미션 도전 기록을 찾을 수 없습니다." + * 409: + * description: 이미 완료되었거나 진행 중이 아닌 미션 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INVALID_MISSION_STATUS" + * message: + * type: string + * example: "이미 완료된 미션이거나 진행 중인 미션이 아닙니다." * 500: - * description: Internal server error + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INTERNAL_SERVER_ERROR" + * message: + * type: string + * example: "미션 완료 처리 중 오류가 발생했습니다." */ export const completeUserMission = async (req, res, next) => { try { @@ -151,14 +531,19 @@ export const completeUserMission = async (req, res, next) => { * @swagger * /api/v1/users/{userId}/missions: * post: - * summary: Assign a mission to a user + * summary: 사용자에게 미션 할당 + * description: 관리자가 특정 사용자에게 미션을 할당합니다. * tags: [Missions] + * security: + * - bearerAuth: [] * parameters: * - in: path * name: userId * required: true * schema: * type: integer + * minimum: 1 + * description: 미션을 할당할 사용자의 고유 ID * requestBody: * required: true * content: @@ -170,15 +555,156 @@ export const completeUserMission = async (req, res, next) => { * properties: * missionId: * type: integer + * description: 할당할 미션의 고유 ID + * example: 1 * responses: * 201: - * description: Mission assigned successfully + * description: 성공적으로 미션이 할당되었습니다. + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * id: + * type: integer + * description: 생성된 미션 할당 기록 ID + * missionId: + * type: integer + * userId: + * type: integer + * status: + * type: string + * enum: [ASSIGNED, IN_PROGRESS] + * example: "ASSIGNED" + * assignedAt: + * type: string + * format: date-time + * description: 미션 할당 일시 + * message: + * type: string + * example: "미션이 성공적으로 할당되었습니다." * 400: - * description: Invalid input + * description: 잘못된 요청 파라미터 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "VALIDATION_ERROR" + * message: + * type: string + * example: "미션 ID는 필수 항목입니다." + * 401: + * description: 인증 실패 (관리자 권한 필요) + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "UNAUTHORIZED" + * message: + * type: string + * example: "인증이 필요합니다." + * 403: + * description: 권한 없음 (관리자만 접근 가능) + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "FORBIDDEN" + * message: + * type: string + * example: "이 작업을 수행할 권한이 없습니다." * 404: - * description: Mission not found + * description: 리소스를 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "NOT_FOUND" + * message: + * type: string + * oneOf: + * - example: "사용자를 찾을 수 없습니다." + * - example: "미션을 찾을 수 없습니다." + * 409: + * description: 이미 할당된 미션 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "MISSION_ALREADY_ASSIGNED" + * message: + * type: string + * example: "이미 해당 사용자에게 할당된 미션입니다." * 500: - * description: Internal server error + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INTERNAL_SERVER_ERROR" + * message: + * type: string + * example: "미션 할당 중 오류가 발생했습니다." */ export const assignMissionToUser = async (req, res, next) => { try { @@ -201,19 +727,151 @@ export const assignMissionToUser = async (req, res, next) => { * @swagger * /api/v1/users/{userId}/reviews: * get: - * summary: Get user's reviews + * summary: 사용자 리뷰 조회 + * description: 특정 사용자가 작성한 모든 리뷰 목록을 조회합니다. * tags: [Missions] + * security: + * - bearerAuth: [] * parameters: * - in: path * name: userId * required: true * schema: * type: integer + * minimum: 1 + * description: 리뷰를 조회할 사용자의 고유 ID + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: 페이지 번호 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 10 + * description: 페이지당 항목 수 (최대 100) * responses: * 200: - * description: List of user's reviews + * description: 성공적으로 리뷰 목록을 조회함 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * description: 리뷰 ID + * content: + * type: string + * description: 리뷰 내용 + * rating: + * type: number + * minimum: 1 + * maximum: 5 + * description: 평점 (1~5) + * userId: + * type: integer + * description: 리뷰 작성자 ID + * storeId: + * type: integer + * description: 리뷰 대상 가게 ID + * createdAt: + * type: string + * format: date-time + * description: 리뷰 작성 일시 + * updatedAt: + * type: string + * format: date-time + * description: 리뷰 수정 일시 + * pagination: + * type: object + * properties: + * totalItems: + * type: integer + * description: 전체 항목 수 + * totalPages: + * type: integer + * description: 전체 페이지 수 + * currentPage: + * type: integer + * description: 현재 페이지 번호 + * itemsPerPage: + * type: integer + * description: 페이지당 항목 수 + * 400: + * description: 잘못된 요청 파라미터 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "VALIDATION_ERROR" + * message: + * type: string + * example: "잘못된 페이지 번호입니다." + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 (본인 또는 관리자만 조회 가능) + * 404: + * description: 사용자를 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "USER_NOT_FOUND" + * message: + * type: string + * example: "사용자를 찾을 수 없습니다." * 500: - * description: Internal server error + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INTERNAL_SERVER_ERROR" + * message: + * type: string + * example: "리뷰 목록 조회 중 오류가 발생했습니다." */ export const getUserReviews = async (req, res, next) => { try { @@ -244,6 +902,66 @@ export const getUserReviews = async (req, res, next) => { * 500: * description: Internal server error */ +/** + * @swagger + * /api/v1/stores/{storeId}/missions: + * get: + * summary: 가게의 미션 목록 조회 + * description: 특정 가게에 등록된 모든 미션 목록을 조회합니다. + * tags: [Missions] + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: integer + * minimum: 1 + * description: 미션 목록을 조회할 가게의 고유 ID + * responses: + * 200: + * description: 성공적으로 미션 목록을 조회함 + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * content: + * type: string + * reward: + * type: integer + * status: + * type: string + * enum: [ACTIVE, INACTIVE] + * deadline: + * type: string + * format: date-time + * 400: + * description: 잘못된 요청 파라미터 + * 404: + * description: 가게를 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "가게를 찾을 수 없습니다." + * 500: + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "미션 목록 조회 중 오류가 발생했습니다." + */ export const getStoreMissions = async (req, res, next) => { try { const storeId = parseInt(req.params.storeId); @@ -254,19 +972,23 @@ export const getStoreMissions = async (req, res, next) => { next(error); } }; - /** * @swagger * /api/v1/missions/{missionId}/challenge: * post: - * summary: Challenge a mission + * summary: 미션에 도전하기 + * description: 사용자가 특정 미션에 도전합니다. * tags: [Missions] + * security: + * - bearerAuth: [] * parameters: * - in: path * name: missionId * required: true * schema: * type: integer + * minimum: 1 + * description: 도전할 미션의 고유 ID * requestBody: * required: true * content: @@ -278,15 +1000,133 @@ export const getStoreMissions = async (req, res, next) => { * properties: * userId: * type: integer + * description: 미션에 도전하는 사용자의 ID + * example: 1 * responses: * 201: - * description: Mission challenged successfully + * description: 성공적으로 미션에 도전했습니다. + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * id: + * type: integer + * description: 생성된 미션 도전 기록 ID + * missionId: + * type: integer + * userId: + * type: integer + * status: + * type: string + * enum: [IN_PROGRESS, COMPLETED, FAILED] + * createdAt: + * type: string + * format: date-time + * message: + * type: string + * example: "미션에 도전했습니다!" * 400: - * description: Already challenging this mission + * description: 잘못된 요청 파라미터 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "VALIDATION_ERROR" + * message: + * type: string + * example: "사용자 ID는 필수 항목입니다." + * 401: + * description: 인증 실패 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "UNAUTHORIZED" + * message: + * type: string + * example: "인증이 필요합니다." * 404: - * description: Mission not found + * description: 미션을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "NOT_FOUND" + * message: + * type: string + * example: "미션을 찾을 수 없습니다." + * 409: + * description: 이미 도전 중이거나 완료한 미션 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "MISSION_ALREADY_TAKEN" + * message: + * type: string + * example: "이미 도전 중인 미션입니다." * 500: - * description: Internal server error + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * type: object + * properties: + * code: + * type: string + * example: "INTERNAL_SERVER_ERROR" + * message: + * type: string + * example: "미션 도전 중 오류가 발생했습니다." */ export const handleChallengeMission = async (req, res, next) => { try { diff --git a/src/controllers/store.controller.js b/src/controllers/store.controller.js index f798500..e2a7343 100644 --- a/src/controllers/store.controller.js +++ b/src/controllers/store.controller.js @@ -4,7 +4,68 @@ import { StatusCodes } from 'http-status-codes'; import { NotFoundError } from '../errors.js'; /** - * POST /api/v1/stores 엔드포인트 핸들러 + * @swagger + * /api/v1/stores: + * post: + * summary: 새로운 가게 추가 + * tags: [Store] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - address + * - region + * properties: + * name: + * type: string + * description: 가게 이름 + * address: + * type: string + * description: 가게 주소 + * region: + * type: string + * description: 지역 정보 + * responses: + * 201: + * description: 성공적으로 가게가 등록됨 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * description: 생성된 가게 ID + * name: + * type: string + * address: + * type: string + * region: + * type: string + * 400: + * description: 필수 파라미터 누락 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "모든 가게 정보를 입력해야 합니다." + * 500: + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "가게 추가 중 오류가 발생했습니다." */ export const handleAddStore = async (req, res) => { const { name, address, region } = req.body; @@ -28,6 +89,97 @@ export const handleAddStore = async (req, res) => { } }; +/** + * @swagger + * /api/v1/stores/{storeId}/reviews: + * get: + * summary: 가게 리뷰 목록 조회 + * tags: [Store] + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: integer + * description: 리뷰를 조회할 가게의 ID + * - in: query + * name: cursor + * schema: + * type: integer + * description: 페이지네이션을 위한 커서 (선택사항) + * responses: + * 200: + * description: 성공적으로 리뷰 목록을 가져옴 + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * description: 리뷰 ID + * content: + * type: string + * description: 리뷰 내용 + * rating: + * type: number + * description: 평점 (1~5) + * createdAt: + * type: string + * format: date-time + * description: 리뷰 작성 일시 + * user: + * type: object + * properties: + * id: + * type: integer + * description: 작성자 ID + * name: + * type: string + * description: 작성자 이름 + * pagination: + * type: object + * properties: + * cursor: + * type: integer + * nullable: true + * description: 다음 페이지 조회를 위한 커서 (없을 경우 null) + * 400: + * description: 잘못된 요청 파라미터 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "잘못된 요청 파라미터입니다." + * 404: + * description: 가게를 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "가게를 찾을 수 없습니다." + * 500: + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "리뷰 목록 조회 중 오류가 발생했습니다." + */ export const handleListStoreReviews = async (req, res, next) => { /* #swagger.summary = '상점 리뷰 목록 조회 API'; @@ -76,8 +228,62 @@ export const handleListStoreReviews = async (req, res, next) => { }; /** - * GET /api/v1/stores/:storeId - * 가게 상세 정보 조회 + * @swagger + * /api/v1/stores/{storeId}: + * get: + * summary: 가게 상세 정보 조회 + * tags: [Store] + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: integer + * description: 조회할 가게의 ID + * responses: + * 200: + * description: 성공적으로 가게 정보를 가져옴 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * description: 가게 ID + * name: + * type: string + * description: 가게 이름 + * address: + * type: string + * description: 가게 주소 + * region: + * type: string + * description: 지역 정보 + * createdAt: + * type: string + * format: date-time + * description: 생성 일시 + * 404: + * description: 가게를 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "가게를 찾을 수 없습니다." + * 500: + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "가게 정보 조회 중 오류가 발생했습니다." */ export const getStoreById = async (req, res, next) => { try { @@ -121,6 +327,102 @@ export const getStoreMissions = async (req, res, next) => { } }; +/** + * @swagger + * /api/v1/stores/{storeId}/reviews: + * post: + * summary: 가게 리뷰 작성 + * tags: [Store] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: integer + * description: 리뷰를 작성할 가게의 ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - content + * - rating + * properties: + * content: + * type: string + * description: 리뷰 내용 + * rating: + * type: integer + * minimum: 1 + * maximum: 5 + * description: 평점 (1~5) + * responses: + * 201: + * description: 성공적으로 리뷰가 작성됨 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * description: 생성된 리뷰 ID + * content: + * type: string + * rating: + * type: integer + * storeId: + * type: integer + * userId: + * type: integer + * createdAt: + * type: string + * format: date-time + * 400: + * description: 잘못된 요청 파라미터 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "리뷰 내용과 평점은 필수입니다." + * 401: + * description: 인증 실패 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "인증이 필요합니다." + * 404: + * description: 가게를 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "가게를 찾을 수 없습니다." + * 500: + * description: 서버 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "리뷰 작성 중 오류가 발생했습니다." + */ export const handleCreateStoreReview = async (req, res, next) => { try { const { content, rating, userId } = req.body; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index e6098db..a63fb20 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -9,6 +9,7 @@ import { prisma } from '../db.config.js'; * /api/users/signup: * post: * summary: 사용자 회원가입 + * description: 새로운 사용자를 시스템에 등록합니다. 이메일, 비밀번호, 이름, 성별, 생년월일, 주소는 필수 입력 항목입니다. * tags: [User] * requestBody: * required: true @@ -27,26 +28,43 @@ import { prisma } from '../db.config.js'; * email: * type: string * format: email + * description: 사용자 이메일 (로그인 ID로 사용) + * example: "user@example.com" * password: * type: string * format: password * minLength: 8 + * description: 비밀번호 (8자 이상) + * example: "password123!" * name: * type: string + * description: 사용자 실명 + * example: "홍길동" * gender: * type: string * enum: [MALE, FEMALE, OTHER] + * description: 성별 + * example: "MALE" * birth: * type: string * format: date + * description: 생년월일 (YYYY-MM-DD) + * example: "1990-01-01" * address: * type: string + * description: 기본 주소 + * example: "서울특별시 강남구 테헤란로 123" * detailAddress: * type: string + * description: 상세 주소 + * example: "101동 101호" * phoneNumber: * type: string + * description: 휴대폰 번호 ('-' 제외) + * example: "01012345678" * preferences: * type: array + * description: 선호 카테고리 목록 (선택사항) * items: * type: string * responses: From d697c910ee251a53b7a9275911c2a5bb215ae302 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 27 Nov 2025 03:16:13 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=208=EC=A3=BC=EC=B0=A8=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/mission.controller.js | 2 +- src/controllers/store.controller.js | 27 +++++---------- src/controllers/user.controller.js | 48 +++++++++++++++++++++++++++ src/dtos/user.dto.js | 3 -- src/index.js | 28 ++++------------ 5 files changed, 63 insertions(+), 45 deletions(-) diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index 8f146b9..b084100 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -962,7 +962,7 @@ export const getUserReviews = async (req, res, next) => { * type: string * example: "미션 목록 조회 중 오류가 발생했습니다." */ -export const getStoreMissions = async (req, res, next) => { +export const getMissionsByStore = async (req, res, next) => { try { const storeId = parseInt(req.params.storeId); const missions = await missionService.getMissionsByStoreId(storeId); diff --git a/src/controllers/store.controller.js b/src/controllers/store.controller.js index e2a7343..8c79b53 100644 --- a/src/controllers/store.controller.js +++ b/src/controllers/store.controller.js @@ -76,16 +76,10 @@ export const handleAddStore = async (req, res) => { try { const result = await storeService.addNewStore({ name, address, region }); - return res.status(201).json(result); // 201 Created + return res.success(result, '가게가 성공적으로 등록되었습니다.', 201); } catch (error) { - if (error instanceof NotFoundError) { - throw error; - } - console.error('가게 추가 중 오류 발생:', error); - return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ - message: '가게 추가 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? error.message : undefined - }); + // 에러를 next로 전달하여 전역 에러 핸들러에서 처리하도록 함 + next(error); } }; @@ -289,19 +283,14 @@ export const getStoreById = async (req, res, next) => { try { const store = await getStoreByIdService(req.params.storeId); if (!store) { - return res.status(StatusCodes.NOT_FOUND).json({ - success: false, - message: '가게를 찾을 수 없습니다.' - }); + const error = new Error('가게를 찾을 수 없습니다.'); + error.statusCode = StatusCodes.NOT_FOUND; + throw error; } - res.status(StatusCodes.OK).json({ - success: true, - data: store - }); + return res.success(store); } catch (error) { - console.error('Error listing store reviews:', error); - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: '가게 리뷰 조회 중 오류가 발생했습니다.' }); + next(error); } }; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index a63fb20..47376ae 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -6,6 +6,54 @@ import { prisma } from '../db.config.js'; /** * @swagger + * components: + * schemas: + * User: + * type: object + * properties: + * id: + * type: integer + * description: 사용자 고유 ID + * example: 1 + * email: + * type: string + * format: email + * description: 사용자 이메일 + * example: "user@example.com" + * name: + * type: string + * description: 사용자 이름 + * example: "홍길동" + * gender: + * type: string + * enum: [MALE, FEMALE, OTHER] + * description: 성별 + * example: "MALE" + * birth: + * type: string + * format: date + * description: 생년월일 (YYYY-MM-DD) + * example: "1990-01-01" + * address: + * type: string + * description: 기본 주소 + * example: "서울특별시 강남구 테헤란로 123" + * detailAddress: + * type: string + * description: 상세 주소 + * example: "101동 101호" + * phoneNumber: + * type: string + * nullable: true + * description: 휴대폰 번호 ('-' 제외) + * example: "01012345678" + * preferences: + * type: array + * description: 선호 카테고리 목록 + * items: + * type: string + * example: ["한식", "중식"] + * * /api/users/signup: * post: * summary: 사용자 회원가입 diff --git a/src/dtos/user.dto.js b/src/dtos/user.dto.js index 581e13c..8cfd6b2 100644 --- a/src/dtos/user.dto.js +++ b/src/dtos/user.dto.js @@ -45,9 +45,6 @@ export const bodyToUser = (body) => { - - - export const responseFromUser = ({ user, preferences = [] }) => { const preferFoods = preferences.length > 0 ? preferences.map((preference) => diff --git a/src/index.js b/src/index.js index e94dc6f..cb4a3cf 100644 --- a/src/index.js +++ b/src/index.js @@ -120,15 +120,20 @@ app.post('/api/v1/users/signup', signUp); // 가게 관련 라우트 app.get('/api/v1/stores/:storeId', getStoreById); app.post('/api/v1/stores', handleAddStore); + +// 가게 리뷰 관련 라우트 app.get('/api/v1/stores/:storeId/reviews', handleListStoreReviews); -app.post('/api/v1/stores/:storeId/reviews', handleCreateStoreReview); // 미션 관련 라우트 app.get('/api/v1/stores/:storeId/missions', getStoreMissions); app.get('/api/v1/users/:userId/missions', getUserMissions); app.patch('/api/v1/users/:userId/missions/:missionId/complete', completeUserMission); app.post('/api/v1/users/:userId/missions', assignMissionToUser); + +// 리뷰 관련 라우트 app.get('/api/v1/users/:userId/reviews', getUserReviews); +app.post('/api/v1/reviews', handleCreateStoreReview); +app.post('/api/v1/stores/:storeId/reviews', handleCreateStoreReview); // 미션 도전 관련 라우트 app.post('/api/v1/missions/:missionId/challenge', handleChallengeMission); @@ -362,27 +367,6 @@ app.use((err, req, res, next) => { res.status(response.statusCode).json(response); }); -// 가게 관련 라우트 -app.get('/api/v1/stores/:storeId', getStoreById); -app.post('/api/v1/stores', handleAddStore); -app.get('/api/v1/stores/:storeId/reviews', handleListStoreReviews); -app.post('/api/v1/stores/:storeId/reviews', handleCreateStoreReview); - -// 미션 관련 라우트 -app.get('/api/v1/stores/:storeId/missions', getStoreMissions); -app.get('/api/v1/users/:userId/missions', getUserMissions); -app.patch('/api/v1/users/:userId/missions/:missionId/complete', completeUserMission); -app.post('/api/v1/users/:userId/missions', assignMissionToUser); -app.get('/api/v1/users/:userId/reviews', getUserReviews); - -// 리뷰 관련 라우트 -app.post('/api/v1/reviews', handleCreateStoreReview); - -// 미션 도전 관련 라우트 -app.post('/api/v1/missions/:missionId/challenge', handleChallengeMission); - -// 미션 추가 (관리자용) -app.post('/api/v1/missions', handleAddMission); // 서버 시작 async function startServer() { From 8455c92bfa2b451439e01e57fd030ba712bc1fa0 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 27 Nov 2025 04:22:37 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=209=EC=A3=BC=EC=B0=A8=20=EC=8B=A4?= =?UTF-8?q?=EC=8A=B51=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 106 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 ++ src/auth.config.js | 106 +++++++++++++++++++++++++++++++++++++++++++++ src/index.js | 74 +++++++++++++++++++------------ 4 files changed, 261 insertions(+), 28 deletions(-) create mode 100644 src/auth.config.js diff --git a/package-lock.json b/package-lock.json index 5e88075..e24401b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,9 @@ "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", "prisma": "^6.19.0", "swagger-autogen": "^2.23.7", "swagger-jsdoc": "^6.2.8", @@ -227,6 +230,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -1589,6 +1601,12 @@ "node": "^14.16.0 || >=16.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1662,6 +1680,74 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -1697,6 +1783,11 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -2237,6 +2328,12 @@ "node": ">= 0.6" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -2253,6 +2350,15 @@ "node": ">= 0.8" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/validator": { "version": "13.15.23", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", diff --git a/package.json b/package.json index d9330f6..9de268a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", "prisma": "^6.19.0", "swagger-autogen": "^2.23.7", "swagger-jsdoc": "^6.2.8", diff --git a/src/auth.config.js b/src/auth.config.js new file mode 100644 index 0000000..00d4ee7 --- /dev/null +++ b/src/auth.config.js @@ -0,0 +1,106 @@ +import dotenv from "dotenv"; +import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { prisma } from "./db.config.js"; +import jwt from "jsonwebtoken"; // JWT 생성을 위해 import + +dotenv.config(); +const secret = process.env.JWT_SECRET; // .env의 비밀 키 + +export const generateAccessToken = (user) => { + return jwt.sign( + { id: user.id, email: user.email }, + secret, + { expiresIn: '1h' } + ); +}; + +export const generateRefreshToken = (user) => { + return jwt.sign( + { id: user.id }, + secret, + { expiresIn: '14d' } + ); +}; + + +// GoogleVerify +const googleVerify = async (profile) => { + const email = profile.emails?.[0]?.value; + if (!email) { + throw new Error(`profile.email was not found: ${profile}`); + } + + const user = await prisma.user.findFirst({ where: { email } }); + if (user !== null) { + return { id: user.id, email: user.email, name: user.name }; + } + + const created = await prisma.user.create({ + data: { + email, + name: profile.displayName, + gender: "추후 수정", + birth: new Date(1970, 0, 1), + address: "추후 수정", + detailAddress: "추후 수정", + phoneNumber: "추후 수정", + }, + }); + + return { id: created.id, email: created.email, name: created.name }; +}; + +// GoogleStrategy + +export const googleStrategy = new GoogleStrategy( + { + clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID, + clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET, + callbackURL: "/oauth2/callback/google", + scope: ["email", "profile"], + }, + + + async (accessToken, refreshToken, profile, cb) => { + try { + + const user = await googleVerify(profile); + + + const jwtAccessToken = generateAccessToken(user); + const jwtRefreshToken = generateRefreshToken(user); + + + + return cb(null, { + accessToken: jwtAccessToken, + refreshToken: jwtRefreshToken, + }); + + } catch (err) { + return cb(err); + } + } +); + +import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; + +const jwtOptions = { + // 요청 헤더의 'Authorization'에서 'Bearer ' 토큰을 추출 + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET, +}; + +export const jwtStrategy = new JwtStrategy(jwtOptions, async (payload, done) => { + try { + const user = await prisma.user.findFirst({ where: { id: payload.id } }); + + if (user) { + return done(null, user); + } else { + return done(null, false); + } + } catch (err) { + return done(err, false); + } +}); \ No newline at end of file diff --git a/src/index.js b/src/index.js index cb4a3cf..7793d9d 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,9 @@ import path from 'path'; import { fileURLToPath } from 'url'; import swaggerJsdoc from 'swagger-jsdoc'; import swaggerUiExpress from "swagger-ui-express"; +import passport from "passport"; +import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import { prisma } from "./db.config.js"; // Get the current directory name in ES module const __filename = fileURLToPath(import.meta.url); @@ -21,7 +24,6 @@ try { import morgan from 'morgan'; import cookieParser from 'cookie-parser'; -import { prisma } from './db.config.js'; // 컨트롤러 임포트 import { signUp } from './controllers/user.controller.js'; @@ -43,8 +45,9 @@ import { handleChallengeMission } from './controllers/mission.controller.js'; -// .env 파일 로드 -dotenv.config(); +// Passport 설정 +passport.use(googleStrategy); +passport.use(jwtStrategy); // Prisma 클라이언트 연결 확인 async function checkDatabaseConnection() { @@ -71,7 +74,7 @@ if (process.env.NODE_ENV !== 'production') { // 기본 미들웨어 설정 app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.urlencoded({ extended: false })); // 원래 true였음 app.use(cookieParser()); app.use(cors({ origin: [ @@ -81,6 +84,7 @@ app.use(cors({ credentials: true })); app.use(express.static('public')); // 정적 파일 제공 +app.use(passport.initialize()); // 성공/에러 응답 메서드 추가 app.use((req, res, next) => { @@ -235,6 +239,34 @@ const options = { apis: ["./src/**/*.js"] }; + +/* 9주차실습시작 */ +app.get("/oauth2/login/google", + passport.authenticate("google", { + session: false + }) +); +app.get( + "/oauth2/callback/google", + passport.authenticate("google", { + session: false, + failureRedirect: "/login-failed", + }), + (req, res) => { + const tokens = req.user; + + res.status(200).json({ + resultType: "SUCCESS", + error: null, + success: { + message: "Google 로그인 성공!", + tokens: tokens, // { "accessToken": "...", "refreshToken": "..." } + } + }); + } +); +/* 9주차실습끝 */ + const swaggerSpec = swaggerJsdoc(options); // Swagger UI @@ -447,24 +479,8 @@ startServer().catch(error => { app.get("/api/v1/stores/:storeId/reviews", handleListStoreReviews); - - -//7주차 시작 -const isLogin = (req, res, next) => { - // cookie-parser가 만들어준 req.cookies 객체에서 username을 확인 - const { username } = req.cookies; - - if (username) { - - console.log(`[인증 성공] ${username}님, 환영합니다.`); - next(); - } else { - - console.log('[인증 실패] 로그인이 필요합니다.'); - res.status(401).send(''); - } -}; - +// JWT 인증 미들웨어 +const isLogin = passport.authenticate('jwt', { session: false }); app.get('/', (req, res) => { res.send(` @@ -481,16 +497,18 @@ app.get('/login', (req, res) => { res.send('

로그인 페이지

로그인이 필요한 페이지에서 튕겨나오면 여기로 옵니다.

'); }); - app.get('/mypage', isLogin, (req, res) => { - res.send(` -

마이페이지

-

환영합니다, ${req.cookies.username}님!

-

이 페이지는 로그인한 사람만 볼 수 있습니다.

- `); + res.status(200).json({ + success: true, + message: `인증 성공! ${req.user.name}님의 마이페이지입니다.`, + data: { + user: req.user + } + }); }); + app.get('/set-login', (req, res) => { res.cookie('username', 'UMC9th', { maxAge: 3600000 }); res.send('로그인 쿠키(username=UMC9th) 생성 완료! 마이페이지로 이동'); From dafc72024f87a0c94f484465fe90fa26dbda12c5 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 27 Nov 2025 04:27:30 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=209=EC=A3=BC=EC=B0=A8=20=EB=AF=B8?= =?UTF-8?q?=EC=85=981=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A0=91=EA=B7=BC=20=EC=8B=9C=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/review.controller.js | 6 +++--- src/controllers/userChallenge.controller.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/controllers/review.controller.js b/src/controllers/review.controller.js index fead75e..abffd1f 100644 --- a/src/controllers/review.controller.js +++ b/src/controllers/review.controller.js @@ -3,11 +3,11 @@ import * as reviewService from '../services/review.service.js'; /** * POST /api/v1/stores/{storeId}/reviews 엔드포인트 핸들러 */ -export const handleAddReview = async (req, res) => { +export const addReview = async (req, res) => { // URL 경로에서 storeId 획득 const storeId = parseInt(req.params.storeId); - // 인증된 사용자 ID (현재는 ID 1로 가정) - const userId = 1; + // 인증된 사용자 ID 사용 + const userId = req.user.id; const { rating, content } = req.body; if (!rating || !content) { diff --git a/src/controllers/userChallenge.controller.js b/src/controllers/userChallenge.controller.js index 350049e..bfdf50e 100644 --- a/src/controllers/userChallenge.controller.js +++ b/src/controllers/userChallenge.controller.js @@ -4,8 +4,8 @@ import * as challengeService from '../services/userChallenge.service.js'; * POST /api/v1/users/{userId}/challenges 엔드포인트 핸들러 */ export const handleChallengeMission = async (req, res) => { - // URL 경로에서 userId 획득 (현재는 ID 1로 가정) - const userId = 1; + // 인증된 사용자 ID 사용 + const userId = req.user.id; // Body에서 도전할 미션 ID 획득 const { missionId } = req.body; From 47df8a2b92c81f21cbac154a0f6be6a1939286d4 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 27 Nov 2025 04:32:21 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=209=EC=A3=BC=EC=B0=A8=20=EB=AF=B8?= =?UTF-8?q?=EC=85=982=20=EC=9D=B8=EC=A6=9D=EB=90=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EA=B0=B1=EC=8B=A0=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20PATCH=20/users/me=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/user.controller.js | 84 +++++++++++++++++++++++++++- src/index.js | 3 +- src/services/user.service.js | 88 +++++++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index 47376ae..cc59acb 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -1,6 +1,6 @@ import { StatusCodes } from 'http-status-codes'; import { bodyToUser } from '../dtos/user.dto.js'; -import { userSignUp } from '../services/user.service.js'; +import { userSignUp, updateUser } from '../services/user.service.js'; import { ValidationError } from '../errors.js'; import { prisma } from '../db.config.js'; @@ -210,6 +210,88 @@ export const handleUserSignUp = async (req, res, next) => { } }; +/** + * @swagger + * /api/v1/users/me: + * put: + * tags: [User] + * summary: Update user information + * description: Update the authenticated user's information + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: User's name + * gender: + * type: string + * enum: [MALE, FEMALE, OTHER] + * description: User's gender + * birth: + * type: string + * format: date + * description: User's birth date (YYYY-MM-DD) + * address: + * type: string + * description: User's address + * detailAddress: + * type: string + * description: User's detailed address + * phoneNumber: + * type: string + * description: User's phone number (without hyphens) + * preferences: + * type: array + * items: + * type: string + * enum: [한식, 일식, 중식, 양식, 치킨, 분식, 고기/구이, 도시락, 야식, 패스트푸드, 디저트, 아시안푸드] + * description: User's food preferences + * responses: + * 200: + * description: User information updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 400: + * description: Invalid input data + * 401: + * description: Unauthorized + * 404: + * description: User not found + * 500: + * description: Internal server error + */ +export const updateMyProfile = async (req, res, next) => { + try { + const userId = req.user.id; + const updateData = req.body; + + // Validate input data + if (!updateData || Object.keys(updateData).length === 0) { + throw new ValidationError('업데이트할 정보를 입력해주세요.'); + } + + // Update user information + const updatedUser = await updateUser(userId, updateData); + + res.status(StatusCodes.OK).json({ + success: true, + message: '사용자 정보가 성공적으로 업데이트되었습니다.', + data: updatedUser + }); + } catch (error) { + console.error('사용자 정보 업데이트 중 오류 발생:', error); + next(error); + } +}; + export const signUp = async (req, res, next) => { try { console.log('\n=== 회원가입 요청 시작 ==='); diff --git a/src/index.js b/src/index.js index 7793d9d..5249fd1 100644 --- a/src/index.js +++ b/src/index.js @@ -26,7 +26,7 @@ import morgan from 'morgan'; import cookieParser from 'cookie-parser'; // 컨트롤러 임포트 -import { signUp } from './controllers/user.controller.js'; +import { signUp, updateMyProfile } from './controllers/user.controller.js'; import { handleAddStore, handleListStoreReviews, @@ -120,6 +120,7 @@ app.use((req, res, next) => { // 4. 라우트 설정 // 사용자 관련 라우트 app.post('/api/v1/users/signup', signUp); +app.put('/api/v1/users/me', passport.authenticate('jwt', { session: false }), updateMyProfile); // 가게 관련 라우트 app.get('/api/v1/stores/:storeId', getStoreById); diff --git a/src/services/user.service.js b/src/services/user.service.js index d5bd3c5..6a75a83 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -219,12 +219,96 @@ function mapPreferencesToCategoryIds(preferences = []) { * @param {Object} user - 사용자 객체 * @returns {Object} 민감 정보가 제거된 사용자 객체 */ -function excludeSensitiveData(user) { +export const excludeSensitiveData = (user) => { if (!user) return null; const { password, ...userWithoutPassword } = user; return userWithoutPassword; -} +}; + +/** + * 사용자 정보 업데이트 + * @param {number} userId - 업데이트할 사용자 ID + * @param {Object} updateData - 업데이트할 사용자 정보 + * @returns {Promise} 업데이트된 사용자 정보 + * @throws {NotFoundError} 사용자를 찾을 수 없을 때 + * @throws {ValidationError} 유효성 검사 실패 시 + */ +export const updateUser = async (userId, updateData) => { + try { + // 1. 사용자 존재 여부 확인 + const existingUser = await prisma.user.findUnique({ + where: { id: userId }, + include: { + preferences: true + } + }); + + if (!existingUser) { + throw new NotFoundError('사용자를 찾을 수 없습니다.'); + } + + // 2. 업데이트할 데이터 준비 + const dataToUpdate = { + name: updateData.name, + gender: updateData.gender, + birth: updateData.birth ? new Date(updateData.birth) : null, + address: updateData.address, + detailAddress: updateData.detailAddress || null, + phoneNumber: updateData.phoneNumber || null + }; + + // 3. 선호 카테고리 업데이트 + let foodCategoryIds = []; + if (updateData.preferences && updateData.preferences.length > 0) { + foodCategoryIds = mapPreferencesToCategoryIds(updateData.preferences); + } + + // 4. 트랜잭션으로 사용자 정보와 선호 카테고리 업데이트 + const [updatedUser] = await prisma.$transaction([ + // 사용자 정보 업데이트 + prisma.user.update({ + where: { id: userId }, + data: dataToUpdate, + include: { + preferences: { + include: { + foodCategory: true + } + } + } + }), + // 기존 선호 카테고리 삭제 + prisma.userPreference.deleteMany({ + where: { userId } + }), + // 새로운 선호 카테고리 추가 + ...(foodCategoryIds.length > 0 ? [ + prisma.userPreference.createMany({ + data: foodCategoryIds.map(categoryId => ({ + userId, + foodCategoryId: categoryId + })) + }) + ] : []) + ]); + + // 5. 업데이트된 사용자 정보 조회 (선호 카테고리 포함) + const userWithPreferences = await getUser(userId); + + // 6. 민감 정보 제거 후 반환 + return excludeSensitiveData(userWithPreferences); + + } catch (error) { + console.error('사용자 정보 업데이트 중 오류 발생:', error); + + if (error.code === 'P2002') { + throw new ValidationError('이미 사용 중인 이메일입니다.'); + } + + throw error; + } +}; /** * 이메일로 사용자 조회 From afb56fd504d6be0fa2976392c41cad21afceddb0 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 27 Nov 2025 04:47:32 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=209=EC=A3=BC=EC=B0=A8=20=EB=AF=B8?= =?UTF-8?q?=EC=85=983=20JWT=20=EC=9D=B8=EC=A6=9D=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=EC=9D=84=20=EC=A3=BC=EC=9A=94=20API=EC=97=90=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EB=B3=B4=ED=98=B8=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth.config.js | 58 +++++++++++++- src/controllers/mission.controller.js | 52 ++++++++++--- src/controllers/store.controller.js | 105 +++++++++++++++----------- src/index.js | 51 +++++++------ 4 files changed, 182 insertions(+), 84 deletions(-) diff --git a/src/auth.config.js b/src/auth.config.js index 00d4ee7..2b65b2d 100644 --- a/src/auth.config.js +++ b/src/auth.config.js @@ -100,7 +100,59 @@ export const jwtStrategy = new JwtStrategy(jwtOptions, async (payload, done) => } else { return done(null, false); } - } catch (err) { - return done(err, false); + } catch (error) { + return done(error, false); } -}); \ No newline at end of file +}); + +/** + * JWT 인증 미들웨어 + * 인증이 필요한 라우트에 사용 + */ +export const authenticateJWT = (req, res, next) => { + return passport.authenticate('jwt', { session: false }, (err, user, info) => { + if (err) { + console.error('JWT 인증 오류:', err); + return res.status(500).json({ + success: false, + message: '인증 처리 중 오류가 발생했습니다.' + }); + } + + if (!user) { + return res.status(401).json({ + success: false, + message: '로그인이 필요합니다.' + }); + } + + // 인증된 사용자 정보를 req.user에 저장 + req.user = user; + next(); + })(req, res, next); +}; + +/** + * 관리자 권한 확인 미들웨어 + * 관리자만 접근 가능한 라우트에 사용 + */ +export const requireAdmin = (req, res, next) => { + // authenticateJWT 미들웨어를 먼저 통과해야 함 + if (!req.user) { + return res.status(401).json({ + success: false, + message: '인증이 필요합니다.' + }); + } + + // 여기서는 간단히 isAdmin 플래그로 관리자 여부를 확인 + // 실제 구현에서는 사용자 역할(role)을 확인하는 로직으로 대체해야 함 + if (req.user.isAdmin) { + return next(); + } + + return res.status(403).json({ + success: false, + message: '관리자 권한이 필요합니다.' + }); +}; \ No newline at end of file diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index b084100..1ca1da9 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -1,7 +1,7 @@ import * as missionService from '../services/mission.service.js'; import { StatusCodes } from 'http-status-codes'; -// Error handling with standard Error and status codes +// 표준 Error와 상태 코드를 사용한 에러 처리 class AppError extends Error { constructor(message, statusCode) { super(message); @@ -343,10 +343,22 @@ export const handleAddMission = async (req, res, next) => { */ export const getUserMissions = async (req, res, next) => { try { - const userId = parseInt(req.params.userId); - const missions = await missionService.getUserMissions(userId); + const userId = req.user.id; // Use authenticated user's ID + const { status } = req.query; + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; - res.success(missions); + const result = await missionService.getUserMissions(userId, { status, page, limit }); + + res.success({ + data: result.missions, + pagination: { + totalItems: result.total, + totalPages: Math.ceil(result.total / limit), + currentPage: page, + itemsPerPage: limit + } + }); } catch (error) { next(error); } @@ -518,10 +530,17 @@ export const getUserMissions = async (req, res, next) => { */ export const completeUserMission = async (req, res, next) => { try { - const { userId, missionId } = req.params; - const result = await missionService.completeMission(parseInt(userId), parseInt(missionId)); + const userId = req.user.id; // Use authenticated user's ID + const { missionId } = req.params; - res.success(result, '미션이 성공적으로 완료되었습니다.'); + const result = await missionService.completeMission(userId, parseInt(missionId)); + + res.success({ + id: result.id, + status: result.status, + completedAt: result.completedAt, + rewardEarned: result.rewardEarned + }, '미션이 성공적으로 완료되었습니다!'); } catch (error) { next(error); } @@ -708,7 +727,7 @@ export const completeUserMission = async (req, res, next) => { */ export const assignMissionToUser = async (req, res, next) => { try { - const userId = parseInt(req.params.userId); + const userId = req.user.id; // Use authenticated user's ID const { missionId } = req.body; if (!missionId) { @@ -875,10 +894,21 @@ export const assignMissionToUser = async (req, res, next) => { */ export const getUserReviews = async (req, res, next) => { try { - const userId = parseInt(req.params.userId); - const reviews = await missionService.getUserReviews(userId); + const userId = req.user.id; // Use authenticated user's ID + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + + const result = await missionService.getUserReviews(userId, { page, limit }); - res.success(reviews); + res.success({ + data: result.reviews, + pagination: { + totalItems: result.total, + totalPages: Math.ceil(result.total / limit), + currentPage: page, + itemsPerPage: limit + } + }); } catch (error) { next(error); } diff --git a/src/controllers/store.controller.js b/src/controllers/store.controller.js index 8c79b53..3f49bed 100644 --- a/src/controllers/store.controller.js +++ b/src/controllers/store.controller.js @@ -67,18 +67,29 @@ import { NotFoundError } from '../errors.js'; * type: string * example: "가게 추가 중 오류가 발생했습니다." */ -export const handleAddStore = async (req, res) => { - const { name, address, region } = req.body; +export const handleAddStore = async (req, res, next) => { + try { + const { name, address, region } = req.body; - if (!name || !address || !region) { - return res.status(400).json({ message: '모든 가게 정보를 입력해야 합니다.' }); - } + if (!name || !address || !region) { + return res.status(StatusCodes.BAD_REQUEST).json({ + success: false, + message: '모든 가게 정보를 입력해야 합니다.' + }); + } - try { - const result = await storeService.addNewStore({ name, address, region }); - return res.success(result, '가게가 성공적으로 등록되었습니다.', 201); + const result = await storeService.addNewStore({ + name, + address, + region + }); + + return res.status(StatusCodes.CREATED).json({ + success: true, + message: '가게가 성공적으로 등록되었습니다.', + data: result + }); } catch (error) { - // 에러를 next로 전달하여 전역 에러 핸들러에서 처리하도록 함 next(error); } }; @@ -413,47 +424,49 @@ export const getStoreMissions = async (req, res, next) => { * example: "리뷰 작성 중 오류가 발생했습니다." */ export const handleCreateStoreReview = async (req, res, next) => { - try { - const { content, rating, userId } = req.body; - const storeId = parseInt(req.params.storeId); + try { + const userId = req.user.id; + const { content, rating } = req.body; + const storeId = parseInt(req.params.storeId); - // 테스트를 위해 인증 검사 일시 비활성화 - // if (!userId) { - // return res.status(StatusCodes.UNAUTHORIZED).json({ - // message: '로그인이 필요합니다.' - // }); - // } + if (!content || !rating) { + return res.status(StatusCodes.BAD_REQUEST).json({ + success: false, + message: '리뷰 내용과 평점은 필수입니다.' + }); + } - const review = await storeService.createStoreReview({ - content, - rating, - userId, - storeId - }); + const review = await storeService.createStoreReview({ + content, + rating, + userId, + storeId + }); - res.status(StatusCodes.CREATED).json({ - message: '리뷰가 성공적으로 등록되었습니다.', - data: review - }); - } catch (error) { - console.error('Error creating store review:', error); - - if (error.message.includes('가게를 찾을 수 없습니다')) { - return res.status(StatusCodes.NOT_FOUND).json({ - message: error.message - }); - } - - if (error.message.includes('필수') || error.message.includes('평점')) { - return res.status(StatusCodes.BAD_REQUEST).json({ - message: error.message - }); + return res.status(StatusCodes.CREATED).json({ + success: true, + message: '리뷰가 성공적으로 등록되었습니다.', + data: review + }); + } catch (error) { + console.error('Error creating store review:', error); + + if (error.message.includes('가게를 찾을 수 없습니다')) { + return res.status(StatusCodes.NOT_FOUND).json({ + success: false, + message: error.message + }); + } + + if (error.message.includes('필수') || error.message.includes('평점')) { + return res.status(StatusCodes.BAD_REQUEST).json({ + success: false, + message: error.message + }); + } + + next(error); } - - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ - message: '리뷰 등록 중 오류가 발생했습니다.' - }); - } }; // handleAddReview는 handleCreateStoreReview의 별칭으로 사용 diff --git a/src/index.js b/src/index.js index 5249fd1..3ec446a 100644 --- a/src/index.js +++ b/src/index.js @@ -9,11 +9,11 @@ import passport from "passport"; import { googleStrategy, jwtStrategy } from "./auth.config.js"; import { prisma } from "./db.config.js"; -// Get the current directory name in ES module +// ES 모듈에서 현재 디렉토리 이름 가져오기 const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// Load .env file from the root directory +// 루트 디렉토리에서 .env 파일 로드 const envPath = path.resolve(__dirname, '../../.env'); try { dotenv.config({ path: envPath }); @@ -25,6 +25,9 @@ try { import morgan from 'morgan'; import cookieParser from 'cookie-parser'; +// 미들웨어 임포트 +import { authenticateJWT, requireAdmin } from './auth.config.js'; + // 컨트롤러 임포트 import { signUp, updateMyProfile } from './controllers/user.controller.js'; import { @@ -120,31 +123,31 @@ app.use((req, res, next) => { // 4. 라우트 설정 // 사용자 관련 라우트 app.post('/api/v1/users/signup', signUp); -app.put('/api/v1/users/me', passport.authenticate('jwt', { session: false }), updateMyProfile); +app.put('/api/v1/users/me', authenticateJWT, updateMyProfile); // 가게 관련 라우트 app.get('/api/v1/stores/:storeId', getStoreById); -app.post('/api/v1/stores', handleAddStore); +app.post('/api/v1/stores', authenticateJWT, handleAddStore); // 가게 리뷰 관련 라우트 app.get('/api/v1/stores/:storeId/reviews', handleListStoreReviews); // 미션 관련 라우트 app.get('/api/v1/stores/:storeId/missions', getStoreMissions); -app.get('/api/v1/users/:userId/missions', getUserMissions); -app.patch('/api/v1/users/:userId/missions/:missionId/complete', completeUserMission); -app.post('/api/v1/users/:userId/missions', assignMissionToUser); +app.get('/api/v1/users/:userId/missions', authenticateJWT, getUserMissions); +app.patch('/api/v1/users/:userId/missions/:missionId/complete', authenticateJWT, completeUserMission); +app.post('/api/v1/users/:userId/missions', authenticateJWT, assignMissionToUser); // 리뷰 관련 라우트 -app.get('/api/v1/users/:userId/reviews', getUserReviews); -app.post('/api/v1/reviews', handleCreateStoreReview); -app.post('/api/v1/stores/:storeId/reviews', handleCreateStoreReview); +app.get('/api/v1/users/:userId/reviews', authenticateJWT, getUserReviews); +app.post('/api/v1/reviews', authenticateJWT, handleCreateStoreReview); +app.post('/api/v1/stores/:storeId/reviews', authenticateJWT, handleCreateStoreReview); // 미션 도전 관련 라우트 -app.post('/api/v1/missions/:missionId/challenge', handleChallengeMission); +app.post('/api/v1/missions/:missionId/challenge', authenticateJWT, handleChallengeMission); // 미션 추가 (관리자용) -app.post('/api/v1/missions', handleAddMission); +app.post('/api/v1/missions', authenticateJWT, requireAdmin, handleAddMission); // API 상태 확인을 위한 엔드포인트 app.get('/api/health', (req, res) => { @@ -309,16 +312,16 @@ app.get('/openapi.json', (req, res) => { res.send(swaggerSpec); }); -// Global error handler middleware +// 전역 에러 핸들링 미들웨어 app.use((err, req, res, next) => { console.error('Error:', err); - // If headers are already sent, delegate to the default Express error handler + // 헤더가 이미 전송된 경우 기본 Express 에러 핸들러에 위임 if (res.headersSent) { return next(err); } - // Default error response + // 기본 에러 응답 const statusCode = err.statusCode || 500; const response = { success: false, @@ -334,9 +337,9 @@ app.use((err, req, res, next) => { timestamp: new Date().toISOString() }; - // Handle specific error types + // 특정 에러 유형 처리 switch (true) { - // Validation errors (400) + // 유효성 검사 에러 (400) case err.name === 'ValidationError': case statusCode === 400: response.error.code = 'VALIDATION_ERROR'; @@ -352,7 +355,7 @@ app.use((err, req, res, next) => { response.statusCode = 404; break; - // Authentication errors (401) + // 인증 에러 (401) case err.name === 'UnauthorizedError': case statusCode === 401: response.error.code = 'UNAUTHORIZED'; @@ -360,7 +363,7 @@ app.use((err, req, res, next) => { response.statusCode = 401; break; - // Forbidden errors (403) + // 접근 거부 에러 (403) case err.name === 'ForbiddenError': case statusCode === 403: response.error.code = 'FORBIDDEN'; @@ -368,7 +371,7 @@ app.use((err, req, res, next) => { response.statusCode = 403; break; - // Conflict errors (409) + // 충돌 에러 (409) case err.name === 'ConflictError': case statusCode === 409: response.error.code = 'CONFLICT'; @@ -376,27 +379,27 @@ app.use((err, req, res, next) => { response.statusCode = 409; break; - // Rate limiting (429) + // 요청 한도 초과 (429) case err.name === 'RateLimitError': response.error.code = 'RATE_LIMIT_EXCEEDED'; response.error.message = err.message || '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.'; response.statusCode = 429; break; - // Default to 500 for unhandled errors + // 처리되지 않은 에러는 기본적으로 500 에러로 처리 default: response.statusCode = 500; response.error.code = 'INTERNAL_SERVER_ERROR'; response.error.message = '서버에서 오류가 발생했습니다.'; - // Don't leak error details in production + // 프로덕션 환경에서는 에러 상세 정보 노출 방지 if (process.env.NODE_ENV !== 'development') { delete response.error.stack; delete response.error.name; } } - // Send the error response + // 에러 응답 전송 res.status(response.statusCode).json(response); }); From e79aa3731631b88a0e7d6092ad4a1577c29ce333 Mon Sep 17 00:00:00 2001 From: jeongkyueun Date: Thu, 27 Nov 2025 04:57:50 +0900 Subject: [PATCH 11/11] =?UTF-8?q?docs:=20=EC=98=81=EC=96=B4=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C/=EC=84=A4=EB=AA=85=20=ED=95=9C=EA=B8=80=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/mission.controller.js | 8 +++++--- src/controllers/user.controller.js | 28 +++++++++++++-------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index 1ca1da9..12ac507 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -918,7 +918,7 @@ export const getUserReviews = async (req, res, next) => { * @swagger * /api/v1/stores/{storeId}/missions: * get: - * summary: Get all missions for a store + * summary: 가게의 모든 미션 조회 * tags: [Missions] * parameters: * - in: path @@ -926,11 +926,13 @@ export const getUserReviews = async (req, res, next) => { * required: true * schema: * type: integer + * minimum: 1 + * description: 미션을 조회할 가게의 고유 ID * responses: * 200: - * description: List of store's missions + * description: 가게의 미션 목록이 성공적으로 조회됨 * 500: - * description: Internal server error + * description: 서버 내부 오류 발생 */ /** * @swagger diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index cc59acb..33abb0e 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -215,8 +215,8 @@ export const handleUserSignUp = async (req, res, next) => { * /api/v1/users/me: * put: * tags: [User] - * summary: Update user information - * description: Update the authenticated user's information + * summary: 사용자 정보 수정 + * description: 인증된 사용자의 정보를 업데이트합니다. * security: * - bearerAuth: [] * requestBody: @@ -228,45 +228,45 @@ export const handleUserSignUp = async (req, res, next) => { * properties: * name: * type: string - * description: User's name + * description: 사용자 이름 * gender: * type: string * enum: [MALE, FEMALE, OTHER] - * description: User's gender + * description: 사용자 성별 * birth: * type: string * format: date - * description: User's birth date (YYYY-MM-DD) + * description: 사용자 생년월일 (YYYY-MM-DD 형식) * address: * type: string - * description: User's address + * description: 사용자 주소 * detailAddress: * type: string - * description: User's detailed address + * description: 사용자 상세 주소 * phoneNumber: * type: string - * description: User's phone number (without hyphens) + * description: 사용자 전화번호 (하이픈 없이 입력) * preferences: * type: array * items: * type: string * enum: [한식, 일식, 중식, 양식, 치킨, 분식, 고기/구이, 도시락, 야식, 패스트푸드, 디저트, 아시안푸드] - * description: User's food preferences + * description: 사용자 음식 선호도 * responses: * 200: - * description: User information updated successfully + * description: 사용자 정보가 성공적으로 업데이트됨 * content: * application/json: * schema: * $ref: '#/components/schemas/User' * 400: - * description: Invalid input data + * description: 잘못된 입력 데이터 * 401: - * description: Unauthorized + * description: 인증 실패 * 404: - * description: User not found + * description: 사용자를 찾을 수 없음 * 500: - * description: Internal server error + * description: 서버 내부 오류 */ export const updateMyProfile = async (req, res, next) => { try {