From 7db389ae49e58d5b09fa9671bb73bb457e331427 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 21:09:40 +0900 Subject: [PATCH 001/116] =?UTF-8?q?feat:=20=EC=A7=80=EC=97=AD=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20API=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../region/business/RegionService.kt | 75 +++++++++++++++++++ .../roomescape/region/docs/RegionAPI.kt | 45 +++++++++++ .../region/exception/RegionException.kt | 21 ++++++ .../persistence/RegionRepository.kt | 65 +++++++++++++++- .../infrastructure/web/RegionController.kt | 53 +++++++++++++ .../region/infrastructure/web/RegionDTO.kt | 32 ++++++++ .../roomescape/region/RegionApiFailTest.kt | 65 ++++++++++++++++ .../roomescape/region/RegionApiSuccessTest.kt | 58 ++++++++++++++ 8 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/roomescape/region/business/RegionService.kt create mode 100644 src/main/kotlin/roomescape/region/docs/RegionAPI.kt create mode 100644 src/main/kotlin/roomescape/region/exception/RegionException.kt create mode 100644 src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt create mode 100644 src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt create mode 100644 src/test/kotlin/roomescape/region/RegionApiFailTest.kt create mode 100644 src/test/kotlin/roomescape/region/RegionApiSuccessTest.kt diff --git a/src/main/kotlin/roomescape/region/business/RegionService.kt b/src/main/kotlin/roomescape/region/business/RegionService.kt new file mode 100644 index 00000000..bb2a994c --- /dev/null +++ b/src/main/kotlin/roomescape/region/business/RegionService.kt @@ -0,0 +1,75 @@ +package roomescape.region.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.region.exception.RegionErrorCode +import roomescape.region.exception.RegionException +import roomescape.region.infrastructure.persistence.RegionRepository +import roomescape.region.infrastructure.web.* + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class RegionService( + private val regionRepository: RegionRepository +) { + @Transactional(readOnly = true) + fun readAllSido(): SidoListResponse { + log.info { "[RegionService.readAllSido] 모든 시/도 조회 시작" } + val result: List> = regionRepository.readAllSido() + + if (result.isEmpty()) { + log.warn { "[RegionService.readAllSido] 시/도 조회 실패" } + throw RegionException(RegionErrorCode.SIDO_CODE_NOT_FOUND) + } + + return SidoListResponse(result.map { SidoResponse(code = it.first, name = it.second) }).also { + log.info { "[RegionService.readAllSido] ${it.sidoList.size}개의 시/도 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findSigunguBySido(sidoCode: String): SigunguListResponse { + log.info { "[RegionService.findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" } + val result: List> = regionRepository.findAllSigunguBySido(sidoCode) + + if (result.isEmpty()) { + log.warn { "[RegionService.findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" } + throw RegionException(RegionErrorCode.SIGUNGU_CODE_NOT_FOUND) + } + + return SigunguListResponse(result.map { SigunguResponse(code = it.first, name = it.second) }).also { + log.info { "[RegionService.findSigunguBySido] sidoCode=${sidoCode}인 ${it.sigunguList.size}개의 시/군/구 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findDongBySidoAndSigungu(sidoCode: String, sigunguCode: String): DongListResponse { + log.info { "[RegionService.findDongBySidoAndSigungu] 행정동 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" } + val result: List> = regionRepository.findAllDongBySidoAndSigungu(sidoCode, sigunguCode) + + if (result.isEmpty()) { + log.warn { "[RegionService.findDongBySidoAndSigungu] 행정동 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" } + throw RegionException(RegionErrorCode.DONG_CODE_NOT_FOUND) + } + + return DongListResponse(result.map { DongResponse(code = it.first, name = it.second) }).also { + log.info { "[RegionService.findDongBySidoAndSigungu] sidoCode=${sidoCode}, sigunguCode=${sigunguCode}인 ${it.dongList.size}개의 행정동 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findRegionCode(sidoCode: String, sigunguCode: String, dongCode: String): RegionCodeResponse { + log.info { "[RegionService.findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode} / dongCode=${dongCode}" } + + return regionRepository.findRegionCode(sidoCode, sigunguCode, dongCode)?.let { + log.info { "[RegionService.findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode} / dongCode=${dongCode}" } + RegionCodeResponse(it) + } ?: run { + log.warn { "[RegionService.findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode} / dongCode=${dongCode}" } + throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/region/docs/RegionAPI.kt b/src/main/kotlin/roomescape/region/docs/RegionAPI.kt new file mode 100644 index 00000000..c1076376 --- /dev/null +++ b/src/main/kotlin/roomescape/region/docs/RegionAPI.kt @@ -0,0 +1,45 @@ +package roomescape.region.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RequestParam +import roomescape.auth.web.support.Public +import roomescape.common.dto.response.CommonApiResponse +import roomescape.region.infrastructure.web.DongListResponse +import roomescape.region.infrastructure.web.RegionCodeResponse +import roomescape.region.infrastructure.web.SidoListResponse +import roomescape.region.infrastructure.web.SigunguListResponse + +interface RegionAPI { + + @Public + @Operation(summary = "지역 코드 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findRegionCode( + @RequestParam(name = "sidoCode", required = true) sidoCode: String, + @RequestParam(name = "sigunguCode", required = true) sigunguCode: String, + @RequestParam(name = "dongCode", required = true) dongCode: String, + ): ResponseEntity> + + @Public + @Operation(summary = "모든 시 / 도 목록 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun readAllSido(): ResponseEntity> + + @Public + @Operation(summary = "모든 시 / 군 / 구 목록 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findAllSigunguBySido( + @RequestParam(required = true) sidoCode: String + ): ResponseEntity> + + @Public + @Operation(summary = "모든 행정동 목록 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findAllDongBySigungu( + @RequestParam(name = "sidoCode", required = true) sidoCode: String, + @RequestParam(name = "sigunguCode", required = true) sigunguCode: String + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/region/exception/RegionException.kt b/src/main/kotlin/roomescape/region/exception/RegionException.kt new file mode 100644 index 00000000..9bee2e9a --- /dev/null +++ b/src/main/kotlin/roomescape/region/exception/RegionException.kt @@ -0,0 +1,21 @@ +package roomescape.region.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class RegionException( + override val errorCode: RegionErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) + +enum class RegionErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + REGION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "지역 코드를 찾을 수 없어요."), + SIDO_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R002", "시/도 를 찾을 수 없어요."), + SIGUNGU_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R003", "시/군/구 를 찾을 수 없어요."), + DONG_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R004", "행정동을 찾을 수 없어요."), +} \ No newline at end of file diff --git a/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt b/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt index d8eb4988..63043239 100644 --- a/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt +++ b/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt @@ -1,5 +1,68 @@ package roomescape.region.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param -interface RegionRepository : JpaRepository \ No newline at end of file +interface RegionRepository : JpaRepository { + + @Query(""" + SELECT + new kotlin.Pair(r.sidoCode, r.sidoName) + FROM + RegionEntity r + GROUP BY + r.sidoCode + ORDER BY + r.sidoName + """) + fun readAllSido(): List> + + @Query(""" + SELECT + new kotlin.Pair(r.sigunguCode, r.sigunguName) + FROM + RegionEntity r + WHERE + r.sidoCode = :sidoCode + GROUP BY + r.sigunguCode + ORDER BY + r.sigunguName + """) + fun findAllSigunguBySido( + @Param("sidoCode") sidoCode: String + ): List> + + @Query(""" + SELECT + new kotlin.Pair(r.dongCode, r.dongName) + FROM + RegionEntity r + WHERE + r.sidoCode = :sidoCode + AND r.sigunguCode = :sigunguCode + ORDER BY + r.dongName + """) + fun findAllDongBySidoAndSigungu( + @Param("sidoCode") sidoCode: String, + @Param("sigunguCode") sigunguCode: String + ): List> + + @Query(""" + SELECT + r.code + FROM + RegionEntity r + WHERE + r.sidoCode = :sidoCode + AND r.sigunguCode = :sigunguCode + AND r.dongCode = :dongCode + """) + fun findRegionCode( + @Param("sidoCode") sidoCode: String, + @Param("sigunguCode") sigunguCode: String, + @Param("dongCode") dongCode: String, + ): String? +} diff --git a/src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt b/src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt new file mode 100644 index 00000000..13e034a0 --- /dev/null +++ b/src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt @@ -0,0 +1,53 @@ +package roomescape.region.infrastructure.web + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import roomescape.common.dto.response.CommonApiResponse +import roomescape.region.business.RegionService +import roomescape.region.docs.RegionAPI + +@RestController +@RequestMapping("/regions") +class RegionController( + private val regionService: RegionService +) : RegionAPI { + @GetMapping("/code") + override fun findRegionCode( + @RequestParam(name = "sidoCode", required = true) sidoCode: String, + @RequestParam(name = "sigunguCode", required = true) sigunguCode: String, + @RequestParam(name = "dongCode", required = true) dongCode: String, + ): ResponseEntity> { + val response = regionService.findRegionCode(sidoCode, sigunguCode, dongCode) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/sido") + override fun readAllSido(): ResponseEntity> { + val response = regionService.readAllSido() + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/sigungu") + override fun findAllSigunguBySido( + @RequestParam(required = true) sidoCode: String + ): ResponseEntity> { + val response = regionService.findSigunguBySido(sidoCode) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/dong") + override fun findAllDongBySigungu( + @RequestParam(name = "sidoCode", required = true) sidoCode: String, + @RequestParam(name = "sigunguCode", required = true) sigunguCode: String + ): ResponseEntity> { + val response = regionService.findDongBySidoAndSigungu(sidoCode, sigunguCode) + + return ResponseEntity.ok(CommonApiResponse(response)) + } +} diff --git a/src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt b/src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt new file mode 100644 index 00000000..ecbb8ec8 --- /dev/null +++ b/src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt @@ -0,0 +1,32 @@ +package roomescape.region.infrastructure.web + +data class SidoResponse( + val code: String, + val name: String, +) + +data class SidoListResponse( + val sidoList: List +) + +data class SigunguResponse( + val code: String, + val name: String, +) + +data class SigunguListResponse( + val sigunguList: List +) + +data class DongResponse( + val code: String, + val name: String, +) + +data class DongListResponse( + val dongList: List +) + +data class RegionCodeResponse( + val code: String +) diff --git a/src/test/kotlin/roomescape/region/RegionApiFailTest.kt b/src/test/kotlin/roomescape/region/RegionApiFailTest.kt new file mode 100644 index 00000000..eb84db13 --- /dev/null +++ b/src/test/kotlin/roomescape/region/RegionApiFailTest.kt @@ -0,0 +1,65 @@ +package roomescape.region + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import org.springframework.http.HttpMethod +import roomescape.region.exception.RegionErrorCode +import roomescape.region.infrastructure.persistence.RegionRepository +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.runExceptionTest + +class RegionApiFailTest( + @MockkBean private val regionRepository: RegionRepository +): FunSpecSpringbootTest() { + init { + context("조회 실패") { + test("시/도") { + every { + regionRepository.readAllSido() + } returns emptyList() + + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/regions/sido", + expectedErrorCode = RegionErrorCode.SIDO_CODE_NOT_FOUND, + ) + } + + test("시/군/구") { + every { + regionRepository.findAllSigunguBySido(any()) + } returns emptyList() + + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/regions/sigungu?sidoCode=11", + expectedErrorCode = RegionErrorCode.SIGUNGU_CODE_NOT_FOUND, + ) + } + + test("행정동") { + every { + regionRepository.findAllDongBySidoAndSigungu(any(), any()) + } returns emptyList() + + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/regions/dong?sidoCode=11&sigunguCode=110", + expectedErrorCode = RegionErrorCode.DONG_CODE_NOT_FOUND, + ) + } + + test("지역 코드") { + every { + regionRepository.findRegionCode(any(), any(), any()) + } returns null + + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/regions/code?sidoCode=11&sigunguCode=110&dongCode=10100", + expectedErrorCode = RegionErrorCode.REGION_CODE_NOT_FOUND, + ) + } + } + } +} diff --git a/src/test/kotlin/roomescape/region/RegionApiSuccessTest.kt b/src/test/kotlin/roomescape/region/RegionApiSuccessTest.kt new file mode 100644 index 00000000..6e4156bd --- /dev/null +++ b/src/test/kotlin/roomescape/region/RegionApiSuccessTest.kt @@ -0,0 +1,58 @@ +package roomescape.region + +import com.ninjasquad.springmockk.MockkBean +import io.kotest.matchers.shouldBe +import io.mockk.every +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import roomescape.region.exception.RegionErrorCode +import roomescape.region.infrastructure.persistence.RegionRepository +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.runExceptionTest +import roomescape.supports.runTest + +class RegionApiSuccessTest: FunSpecSpringbootTest() { + init { + context("시/도 -> 시/군/구 -> 행정동 -> 지역 코드 순으로 조회한다.") { + test("정상 응답") { + val sidoCode: String = runTest( + on = { + get("/regions/sido") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).extract().path("data.sidoList[0].code") + + val sigunguCode: String = runTest( + on = { + get("/regions/sigungu?sidoCode=$sidoCode") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).extract().path("data.sigunguList[0].code") + + val dongCode: String = runTest( + on = { + get("/regions/dong?sidoCode=$sidoCode&sigunguCode=$sigunguCode") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).extract().path("data.dongList[0].code") + + val regionCode: String = runTest( + on = { + get("/regions/code?sidoCode=$sidoCode&sigunguCode=$sigunguCode&dongCode=${dongCode}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).extract().path("data.code") + + regionCode shouldBe "$sidoCode$sigunguCode$dongCode" + } + } + } +} -- 2.47.2 From a64371b3d2581f76b4e3fc975292ac94007bb3e7 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 21:11:03 +0900 Subject: [PATCH 002/116] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EC=84=A0=ED=83=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/region/regionAPI.ts | 18 +++ frontend/src/api/region/regionTypes.ts | 30 +++++ frontend/src/css/signup-page-v2.css | 11 +- frontend/src/pages/SignupPage.tsx | 151 ++++++++++++++++++++++++- 4 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 frontend/src/api/region/regionAPI.ts create mode 100644 frontend/src/api/region/regionTypes.ts diff --git a/frontend/src/api/region/regionAPI.ts b/frontend/src/api/region/regionAPI.ts new file mode 100644 index 00000000..e9feec0e --- /dev/null +++ b/frontend/src/api/region/regionAPI.ts @@ -0,0 +1,18 @@ +import apiClient from "@_api/apiClient"; +import type { DongListResponse, RegionCodeResponse, SidoListResponse, SigunguListResponse } from "./regionTypes"; + +export const fetchSidoList = async (): Promise => { + return await apiClient.get(`/regions/sido`); +}; + +export const fetchSigunguList = async (sidoCode: string): Promise => { + return await apiClient.get(`/regions/sigungu?sidoCode=${sidoCode}`); +} + +export const fetchDongList = async (sidoCode: string, sigunguCode: string): Promise => { + return await apiClient.get(`/regions/dong?sidoCode=${sidoCode}&sigunguCode=${sigunguCode}`); +} + +export const fetchRegionCode = async (sidoCode: string, sigunguCode: string, dongCode: string): Promise => { + return await apiClient.get(`/regions/code?sidoCode=${sidoCode}&sigunguCode=${sigunguCode}&dongCode=${dongCode}`); +} diff --git a/frontend/src/api/region/regionTypes.ts b/frontend/src/api/region/regionTypes.ts new file mode 100644 index 00000000..5fefdbdd --- /dev/null +++ b/frontend/src/api/region/regionTypes.ts @@ -0,0 +1,30 @@ +export interface SidoResponse { + code: string, + name: string, +} + +export interface SidoListResponse { + sidoList: SidoResponse[] +} + +export interface SigunguResponse { + code: string, + name: string, +} + +export interface SigunguListResponse { + sigunguList: SigunguResponse[] +} + +export interface DongResponse { + code: string, + name: string, +} + +export interface DongListResponse { + dongList: DongResponse[] +} + +export interface RegionCodeResponse { + code: string +} diff --git a/frontend/src/css/signup-page-v2.css b/frontend/src/css/signup-page-v2.css index 7918185b..8d95c48b 100644 --- a/frontend/src/css/signup-page-v2.css +++ b/frontend/src/css/signup-page-v2.css @@ -68,4 +68,13 @@ color: #E53E3E; font-size: 12px; margin-top: 4px; -} \ No newline at end of file +} + +.region-select-group { + display: flex; + gap: 10px; +} + +.region-select-group select { + flex: 1; +} diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx index 845ac67d..e4238cf5 100644 --- a/frontend/src/pages/SignupPage.tsx +++ b/frontend/src/pages/SignupPage.tsx @@ -1,8 +1,19 @@ -import {signup} from '@_api/user/userAPI'; -import type {UserCreateRequest, UserCreateResponse} from '@_api/user/userTypes'; +import { + fetchDongList, + fetchRegionCode, + fetchSidoList, + fetchSigunguList, +} from '@_api/region/regionAPI'; +import type { + DongResponse, + SidoResponse, + SigunguResponse, +} from '@_api/region/regionTypes'; +import { signup } from '@_api/user/userAPI'; +import type { UserCreateRequest, UserCreateResponse } from '@_api/user/userTypes'; import '@_css/signup-page-v2.css'; -import React, {useEffect, useState} from 'react'; -import {useNavigate} from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; const MIN_PASSWORD_LENGTH = 8; @@ -14,8 +25,67 @@ const SignupPage: React.FC = () => { const [errors, setErrors] = useState>({}); const [hasSubmitted, setHasSubmitted] = useState(false); + const [sidoList, setSidoList] = useState([]); + const [sigunguList, setSigunguList] = useState([]); + const [dongList, setDongList] = useState([]); + const [selectedSidoCode, setSelectedSidoCode] = useState(''); + const [selectedSigunguCode, setSelectedSigunguCode] = useState(''); + const [selectedDongCode, setSelectedDongCode] = useState(''); + const navigate = useNavigate(); + useEffect(() => { + const fetchSido = async () => { + try { + const response = await fetchSidoList(); + setSidoList(response.sidoList); + } catch (error) { + console.error('시/도 목록을 불러오는 데 실패했습니다.', error); + } + }; + fetchSido(); + }, []); + + useEffect(() => { + if (selectedSidoCode) { + const fetchSigungu = async () => { + try { + const response = await fetchSigunguList(selectedSidoCode); + setSigunguList(response.sigunguList); + setDongList([]); + setSelectedSigunguCode(''); + setSelectedDongCode(''); + } catch (error) { + console.error('시/군/구 목록을 불러오는 데 실패했습니다.', error); + } + }; + fetchSigungu(); + } else { + setSigunguList([]); + setDongList([]); + setSelectedSigunguCode(''); + setSelectedDongCode(''); + } + }, [selectedSidoCode]); + + useEffect(() => { + if (selectedSidoCode && selectedSigunguCode) { + const fetchDong = async () => { + try { + const response = await fetchDongList(selectedSidoCode, selectedSigunguCode); + setDongList(response.dongList); + setSelectedDongCode(''); + } catch (error) { + console.error('읍/면/동 목록을 불러오는 데 실패했습니다.', error); + } + }; + fetchDong(); + } else { + setDongList([]); + setSelectedDongCode(''); + } + }, [selectedSigunguCode]); + const validate = () => { const newErrors: Record = {}; @@ -36,6 +106,12 @@ const SignupPage: React.FC = () => { newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)'; } + if (selectedSidoCode || selectedSigunguCode || selectedDongCode) { + if (!selectedSidoCode || !selectedSigunguCode || !selectedDongCode) { + newErrors.region = '모든 지역 정보를 선택해주세요.'; + } + } + return newErrors; }; @@ -44,7 +120,7 @@ const SignupPage: React.FC = () => { if (hasSubmitted) { setErrors(validate()); } - }, [email, password, name, phone, hasSubmitted]); + }, [email, password, name, phone, hasSubmitted, selectedSidoCode, selectedSigunguCode, selectedDongCode]); const handleSignup = async (e: React.FormEvent) => { e.preventDefault(); @@ -55,7 +131,23 @@ const SignupPage: React.FC = () => { if (Object.keys(newErrors).length > 0) return; - const request: UserCreateRequest = { email, password, name, phone, regionCode: null }; + let regionCode: string | null = null; + if (selectedSidoCode && selectedSigunguCode && selectedDongCode) { + try { + const response = await fetchRegionCode( + selectedSidoCode, + selectedSigunguCode, + selectedDongCode + ); + regionCode = response.code; + } catch (error) { + alert('지역 코드를 가져오는 데 실패했습니다.'); + console.error(error); + return; + } + } + + const request: UserCreateRequest = { email, password, name, phone, regionCode }; try { const response: UserCreateResponse = await signup(request); alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`); @@ -133,6 +225,53 @@ const SignupPage: React.FC = () => { )} +
+ +
+ + + +
+ {hasSubmitted && errors.region && ( +

{errors.region}

+ )} +
+ + + + + ); +}; + +export default AdminLoginPage; diff --git a/frontend/src/pages/admin/AdminNavbar.tsx b/frontend/src/pages/admin/AdminNavbar.tsx index 8928fb1f..a5563d6f 100644 --- a/frontend/src/pages/admin/AdminNavbar.tsx +++ b/frontend/src/pages/admin/AdminNavbar.tsx @@ -1,10 +1,10 @@ +import {useAdminAuth} from '@_context/AdminAuthContext'; import React from 'react'; import {Link, useNavigate} from 'react-router-dom'; -import {useAuth} from '@_context/AuthContext'; import '@_css/navbar.css'; const AdminNavbar: React.FC = () => { - const { loggedIn, userName, logout } = useAuth(); + const { isAdmin, name, logout } = useAdminAuth(); const navigate = useNavigate(); const handleLogout = async (e: React.MouseEvent) => { @@ -25,12 +25,12 @@ const AdminNavbar: React.FC = () => { 일정
- {!loggedIn ? ( - + {!isAdmin ? ( + ) : (
Profile - {userName || 'Profile'} + {name || 'Profile'} diff --git a/frontend/src/pages/admin/AdminSchedulePage.tsx b/frontend/src/pages/admin/AdminSchedulePage.tsx index 9af77826..05d09faa 100644 --- a/frontend/src/pages/admin/AdminSchedulePage.tsx +++ b/frontend/src/pages/admin/AdminSchedulePage.tsx @@ -53,7 +53,7 @@ const AdminSchedulePage: React.FC = () => { const handleError = (err: any) => { if (isLoginRequiredError(err)) { alert('로그인이 필요해요.'); - navigate('/login', { state: { from: location } }); + navigate('/admin/login', { state: { from: location } }); } else { const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; alert(message); diff --git a/frontend/src/pages/admin/AdminThemeEditPage.tsx b/frontend/src/pages/admin/AdminThemeEditPage.tsx index 91af8d32..25658360 100644 --- a/frontend/src/pages/admin/AdminThemeEditPage.tsx +++ b/frontend/src/pages/admin/AdminThemeEditPage.tsx @@ -25,7 +25,7 @@ const AdminThemeEditPage: React.FC = () => { const handleError = (err: any) => { if (isLoginRequiredError(err)) { alert('로그인이 필요해요.'); - navigate('/login', { state: { from: location } }); + navigate('/admin/login', { state: { from: location } }); } else { const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; alert(message); diff --git a/frontend/src/pages/admin/AdminThemePage.tsx b/frontend/src/pages/admin/AdminThemePage.tsx index 8d69b19a..d9d7dd4b 100644 --- a/frontend/src/pages/admin/AdminThemePage.tsx +++ b/frontend/src/pages/admin/AdminThemePage.tsx @@ -13,7 +13,7 @@ const AdminThemePage: React.FC = () => { const handleError = (err: any) => { if (isLoginRequiredError(err)) { alert('로그인이 필요해요.'); - navigate('/login', { state: { from: location } }); + navigate('/admin/login', { state: { from: location } }); } else { const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; alert(message); -- 2.47.2 From 63251d67eade511db6751f8600f484c38f8450de Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:12:12 +0900 Subject: [PATCH 017/116] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20LoginCredent?= =?UTF-8?q?ial=20=ED=9A=8C=EC=9B=90=20/=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=ED=97=AC=ED=8D=BC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/admin/business/AdminService.kt | 9 ++-- .../roomescape/common/dto/CommonAuth.kt | 46 +++++++++++++++++-- .../roomescape/user/business/UserService.kt | 2 +- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt index 21f789be..95657df2 100644 --- a/src/main/kotlin/roomescape/admin/business/AdminService.kt +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -13,6 +13,7 @@ import roomescape.common.dto.AdminLoginCredentials import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.OperatorInfo import roomescape.common.dto.PrincipalType +import roomescape.common.dto.toCredentials private val log: KLogger = KotlinLogging.logger {} @@ -33,15 +34,15 @@ class AdminService( @Transactional(readOnly = true) fun findCredentialsByAccount(account: String): AdminLoginCredentials { - log.info { "[AdminService.findInfoByAccount] 관리자 조회 시작: account=${account}" } + log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 시작: account=${account}" } return adminRepository.findByAccount(account) ?.let { - log.info { "[AdminService.findByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" } - AdminLoginCredentials(it.id, it.password, it.permissionLevel) + log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" } + it.toCredentials() } ?: run { - log.info { "[AdminService.findInfoByAccount] 관리자 조회 실패: account=${account}" } + log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 실패: account=${account}" } throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND) } } diff --git a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt index 7d67a872..e567b417 100644 --- a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt +++ b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt @@ -1,24 +1,64 @@ package roomescape.common.dto +import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +import roomescape.admin.infrastructure.persistence.AdminType +import roomescape.auth.web.AdminLoginSuccessResponse +import roomescape.auth.web.LoginSuccessResponse +import roomescape.auth.web.UserLoginSuccessResponse +import roomescape.user.infrastructure.persistence.UserEntity const val MDC_PRINCIPAL_ID_KEY: String = "principal_id" abstract class LoginCredentials { abstract val id: Long abstract val password: String + abstract val name: String + + abstract fun toResponse(accessToken: String): LoginSuccessResponse } data class AdminLoginCredentials( override val id: Long, override val password: String, - val permissionLevel: AdminPermissionLevel -) : LoginCredentials() + override val name: String, + val type: AdminType, + val storeId: Long?, + val permissionLevel: AdminPermissionLevel, +) : LoginCredentials() { + override fun toResponse(accessToken: String) = AdminLoginSuccessResponse( + accessToken = accessToken, + name = name, + type = type, + storeId = storeId + ) +} + +fun AdminEntity.toCredentials() = AdminLoginCredentials( + id = this.id, + password = this.password, + name = this.name, + type = this.type, + storeId = this.storeId, + permissionLevel = this.permissionLevel +) data class UserLoginCredentials( override val id: Long, override val password: String, -) : LoginCredentials() + override val name: String, +) : LoginCredentials() { + override fun toResponse(accessToken: String) = UserLoginSuccessResponse( + accessToken = accessToken, + name = name + ) +} + +fun UserEntity.toCredentials() = UserLoginCredentials( + id = this.id, + password = this.password, + name = this.name, +) data class CurrentUserContext( val id: Long, diff --git a/src/main/kotlin/roomescape/user/business/UserService.kt b/src/main/kotlin/roomescape/user/business/UserService.kt index 2a530be9..ed4b206c 100644 --- a/src/main/kotlin/roomescape/user/business/UserService.kt +++ b/src/main/kotlin/roomescape/user/business/UserService.kt @@ -47,7 +47,7 @@ class UserService( return userRepository.findByEmail(email) ?.let { log.info { "[UserService.findCredentialsByAccount] 회원 조회 완료: id=${it.id}" } - UserLoginCredentials(it.id, it.password) + it.toCredentials() } ?: run { log.info { "[UserService.findCredentialsByAccount] 회원 조회 실패" } -- 2.47.2 From a021ce8e73e5b73602d08360a777562a6ff66d72 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:13:22 +0900 Subject: [PATCH 018/116] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20/=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=8B=9C=20jwt=20claim=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/auth/business/AuthService.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/business/AuthService.kt b/src/main/kotlin/roomescape/auth/business/AuthService.kt index 3d3096a6..96a1fb93 100644 --- a/src/main/kotlin/roomescape/auth/business/AuthService.kt +++ b/src/main/kotlin/roomescape/auth/business/AuthService.kt @@ -18,8 +18,9 @@ import roomescape.user.business.UserService private val log: KLogger = KotlinLogging.logger {} +const val CLAIM_ADMIN_TYPE_KEY = "admin_type" const val CLAIM_PERMISSION_KEY = "permission" -const val CLAIM_TYPE_KEY = "principal_type" +const val CLAIM_STORE_ID_KEY = "store_id" @Service class AuthService( @@ -34,7 +35,6 @@ class AuthService( context: LoginContext ): LoginSuccessResponse { log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } - val (credentials, extraClaims) = getCredentials(request) try { @@ -44,7 +44,7 @@ class AuthService( loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context) - return LoginSuccessResponse(accessToken).also { + return credentials.toResponse(accessToken).also { log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" } } @@ -97,15 +97,14 @@ class AuthService( val credentials: LoginCredentials = when (request.principalType) { PrincipalType.ADMIN -> { adminService.findCredentialsByAccount(request.account).also { - extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel) - extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN) + extraClaims.put(CLAIM_ADMIN_TYPE_KEY, it.type.name) + extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel.name) + it.storeId?.also { storeId -> extraClaims.put(CLAIM_STORE_ID_KEY, storeId.toString()) } } } PrincipalType.USER -> { - userService.findCredentialsByAccount(request.account).also { - extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER) - } + userService.findCredentialsByAccount(request.account) } } -- 2.47.2 From e1aa0323587b4cdad0e98976667f1ff07a10331f Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:14:34 +0900 Subject: [PATCH 019/116] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20API=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=9E=AC=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/auth/docs/AuthAPI.kt | 12 ----------- .../roomescape/auth/web/AuthController.kt | 8 -------- .../kotlin/roomescape/auth/web/AuthDTO.kt | 20 ++++++++++++++++--- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt index fe80bfc9..091218d7 100644 --- a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt +++ b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt @@ -29,18 +29,6 @@ interface AuthAPI { servletRequest: HttpServletRequest ): ResponseEntity> - @Operation(summary = "로그인 상태 확인") - @ApiResponses( - ApiResponse( - responseCode = "200", - description = "입력된 ID / 결과(Boolean)을 반환합니다.", - useReturnTypeSchema = true - ), - ) - fun checkLogin( - @CurrentUser user: CurrentUserContext - ): ResponseEntity> - @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) @ApiResponses( ApiResponse(responseCode = "200"), diff --git a/src/main/kotlin/roomescape/auth/web/AuthController.kt b/src/main/kotlin/roomescape/auth/web/AuthController.kt index 3ecd6973..bd5013cf 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthController.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthController.kt @@ -3,7 +3,6 @@ package roomescape.auth.web import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -29,13 +28,6 @@ class AuthController( return ResponseEntity.ok(CommonApiResponse(response)) } - @GetMapping("/login/check") - override fun checkLogin( - @CurrentUser user: CurrentUserContext, - ): ResponseEntity> { - return ResponseEntity.ok(CommonApiResponse(user)) - } - @PostMapping("/logout") override fun logout( @CurrentUser user: CurrentUserContext, diff --git a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt index 03eef491..a1622c26 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt @@ -1,6 +1,7 @@ package roomescape.auth.web import jakarta.servlet.http.HttpServletRequest +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.common.dto.PrincipalType data class LoginContext( @@ -19,6 +20,19 @@ data class LoginRequest( val principalType: PrincipalType ) -data class LoginSuccessResponse( - val accessToken: String -) +abstract class LoginSuccessResponse { + abstract val accessToken: String + abstract val name: String +} + +data class UserLoginSuccessResponse( + override val accessToken: String, + override val name: String, +) : LoginSuccessResponse() + +data class AdminLoginSuccessResponse( + override val accessToken: String, + override val name: String, + val type: AdminType, + val storeId: Long?, +) : LoginSuccessResponse() -- 2.47.2 From 498e8c8e752f5390f3cc58912625ad2d75c63924 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:17:32 +0900 Subject: [PATCH 020/116] =?UTF-8?q?refactor:=20Admin=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=9C=A0=ED=8B=B8=20=EC=9E=AC=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/supports/Fixtures.kt | 54 ++++++++++++++++--- .../roomescape/supports/KotestConfig.kt | 8 ++- .../roomescape/supports/RestAssuredUtils.kt | 29 ++++++++-- 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/test/kotlin/roomescape/supports/Fixtures.kt b/src/test/kotlin/roomescape/supports/Fixtures.kt index 9e1772ef..7c77fc2c 100644 --- a/src/test/kotlin/roomescape/supports/Fixtures.kt +++ b/src/test/kotlin/roomescape/supports/Fixtures.kt @@ -3,19 +3,21 @@ package roomescape.supports import com.github.f4b6a3.tsid.TsidFactory import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.common.config.next -import roomescape.user.infrastructure.persistence.UserEntity -import roomescape.user.infrastructure.persistence.UserStatus -import roomescape.user.web.MIN_PASSWORD_LENGTH -import roomescape.user.web.UserCreateRequest import roomescape.payment.infrastructure.client.* import roomescape.payment.infrastructure.common.* import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentConfirmRequest import roomescape.reservation.web.PendingReservationCreateRequest import roomescape.schedule.web.ScheduleCreateRequest +import roomescape.store.infrastructure.persistence.StoreEntity import roomescape.theme.infrastructure.persistence.Difficulty import roomescape.theme.web.ThemeCreateRequest +import roomescape.user.infrastructure.persistence.UserEntity +import roomescape.user.infrastructure.persistence.UserStatus +import roomescape.user.web.MIN_PASSWORD_LENGTH +import roomescape.user.web.UserCreateRequest import java.time.LocalDate import java.time.LocalTime import java.time.OffsetDateTime @@ -23,8 +25,44 @@ import java.time.OffsetDateTime const val INVALID_PK: Long = 9999L val tsidFactory = TsidFactory(0) +fun randomPhoneNumber(): String { + val prefix = "010" + val middle = (1000..9999).random() + val last = (1000..9999).random() + return "$prefix$middle$last" +} + +object StoreFixture { + fun create( + id: Long = tsidFactory.next(), + name: String = "테스트행복점", + address: String = "서울특별시 강북구 행복길 123", + businessRegNum: String = "123-45-67890", + regionCode: String = "1111000000" + ) = StoreEntity( + id = id, + name = name, + address = address, + businessRegNum = businessRegNum, + regionCode = regionCode + ) +} + object AdminFixture { val default: AdminEntity = create() + val storeDefault: AdminEntity = create( + account = "admin-store", + phone = randomPhoneNumber(), + type = AdminType.STORE, + storeId = tsidFactory.next() + ) + + val hqDefault: AdminEntity = create( + account = "admin-hq", + phone = randomPhoneNumber(), + type = AdminType.HQ, + storeId = null + ) fun create( id: Long = tsidFactory.next(), @@ -32,6 +70,8 @@ object AdminFixture { password: String = "adminPassword", name: String = "admin12345", phone: String = "01012345678", + type: AdminType = AdminType.STORE, + storeId: Long? = tsidFactory.next(), permissionLevel: AdminPermissionLevel = AdminPermissionLevel.FULL_ACCESS ): AdminEntity = AdminEntity( id = id, @@ -39,8 +79,10 @@ object AdminFixture { password = password, name = name, phone = phone, - permissionLevel = permissionLevel - ) + type = type, + storeId = storeId, + permissionLevel = permissionLevel, + ) } object UserFixture { diff --git a/src/test/kotlin/roomescape/supports/KotestConfig.kt b/src/test/kotlin/roomescape/supports/KotestConfig.kt index d5b76834..553afd7f 100644 --- a/src/test/kotlin/roomescape/supports/KotestConfig.kt +++ b/src/test/kotlin/roomescape/supports/KotestConfig.kt @@ -14,12 +14,13 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.test.context.ActiveProfiles import roomescape.admin.infrastructure.persistence.AdminRepository -import roomescape.user.infrastructure.persistence.UserRepository import roomescape.payment.business.PaymentWriter import roomescape.payment.infrastructure.persistence.PaymentRepository import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.schedule.infrastructure.persistence.ScheduleRepository +import roomescape.store.infrastructure.persistence.StoreRepository import roomescape.theme.infrastructure.persistence.ThemeRepository +import roomescape.user.infrastructure.persistence.UserRepository object KotestConfig : AbstractProjectConfig() { override fun extensions(): List = listOf(SpringExtension) @@ -37,6 +38,9 @@ abstract class FunSpecSpringbootTest : FunSpec({ @Autowired private lateinit var adminRepository: AdminRepository + @Autowired + private lateinit var storeRepository: StoreRepository + @Autowired lateinit var dummyInitializer: DummyInitializer @@ -47,7 +51,7 @@ abstract class FunSpecSpringbootTest : FunSpec({ override suspend fun beforeSpec(spec: Spec) { RestAssured.port = port - authUtil = AuthUtil(userRepository, adminRepository) + authUtil = AuthUtil(userRepository, adminRepository, storeRepository) } } diff --git a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt index fd1d8391..e9ade94c 100644 --- a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt @@ -17,15 +17,26 @@ import roomescape.admin.infrastructure.persistence.AdminRepository import roomescape.auth.web.LoginRequest import roomescape.common.dto.PrincipalType import roomescape.common.exception.ErrorCode +import roomescape.store.infrastructure.persistence.StoreRepository import roomescape.user.infrastructure.persistence.UserEntity import roomescape.user.infrastructure.persistence.UserRepository import roomescape.user.web.UserCreateRequest +import kotlin.random.Random class AuthUtil( private val userRepository: UserRepository, - private val adminRepository: AdminRepository + private val adminRepository: AdminRepository, + private val storeRepository: StoreRepository, ) { fun createAdmin(admin: AdminEntity): AdminEntity { + val storeId = admin.storeId + if (storeId != null && storeRepository.findByIdOrNull(storeId) == null) { + storeRepository.save(StoreFixture.create( + id = storeId, + businessRegNum = generateBusinessRegNum(), + )) + } + return adminRepository.save(admin) } @@ -46,10 +57,8 @@ class AuthUtil( } fun adminLogin(admin: AdminEntity): String { - if (adminRepository.findByAccount(admin.account) == null) { - adminRepository.save(admin) - } - val requestBody = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN) + val saved = createAdmin(admin) + val requestBody = LoginRequest(saved.account, saved.password, PrincipalType.ADMIN) return Given { contentType(MediaType.APPLICATION_JSON_VALUE) @@ -63,6 +72,9 @@ class AuthUtil( } } + fun defaultStoreAdminLogin(): String = adminLogin(AdminFixture.storeDefault) + fun defaultHqAdminLogin(): String = adminLogin(AdminFixture.hqDefault) + fun defaultAdminLogin(): String = adminLogin(AdminFixture.default) fun userLogin(user: UserEntity): String { @@ -172,3 +184,10 @@ fun ValidatableResponse.assertProperties(props: Set, propsNameIfList: St else -> error("Unexpected data type: ${json::class}") } } + +private fun generateBusinessRegNum(): String { + val part1 = Random.nextInt(100, 1000) + val part2 = Random.nextInt(10, 100) + val part3 = Random.nextInt(10000, 100000) + return "$part1-$part2-$part3" +} \ No newline at end of file -- 2.47.2 From 9b13448abd68fc5d56215a9db6feaebe886b36f8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:17:57 +0900 Subject: [PATCH 021/116] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=9C=20=EC=9D=B8=EC=A6=9D=20API=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/auth/AuthApiTest.kt | 151 ++++++++---------- 1 file changed, 65 insertions(+), 86 deletions(-) diff --git a/src/test/kotlin/roomescape/auth/AuthApiTest.kt b/src/test/kotlin/roomescape/auth/AuthApiTest.kt index 81c50b3a..fdacef30 100644 --- a/src/test/kotlin/roomescape/auth/AuthApiTest.kt +++ b/src/test/kotlin/roomescape/auth/AuthApiTest.kt @@ -10,18 +10,20 @@ import io.restassured.response.ValidatableResponse import org.hamcrest.CoreMatchers.equalTo import org.springframework.http.HttpStatus import roomescape.admin.exception.AdminErrorCode +import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY import roomescape.auth.business.CLAIM_PERMISSION_KEY +import roomescape.auth.business.CLAIM_STORE_ID_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.infrastructure.persistence.LoginHistoryRepository import roomescape.auth.web.LoginRequest import roomescape.common.dto.PrincipalType -import roomescape.user.exception.UserErrorCode -import roomescape.user.infrastructure.persistence.UserEntity import roomescape.supports.AdminFixture import roomescape.supports.FunSpecSpringbootTest import roomescape.supports.UserFixture import roomescape.supports.runTest +import roomescape.user.exception.UserErrorCode +import roomescape.user.infrastructure.persistence.UserEntity class AuthApiTest( @SpykBean private val jwtUtils: JwtUtils, @@ -31,17 +33,25 @@ class AuthApiTest( init { context("로그인을 시도한다.") { context("성공 응답") { - test("관리자") { - val admin = authUtil.createAdmin(AdminFixture.default) - runLoginSuccessTest( - id = admin.id, - account = admin.account, - password = admin.password, - type = PrincipalType.ADMIN, - ) { - val token: String = it.extract().path("data.accessToken") - jwtUtils.extractSubject(token) shouldBe admin.id.toString() - jwtUtils.extractClaim(token, CLAIM_PERMISSION_KEY) shouldBe admin.permissionLevel.name + listOf( + AdminFixture.storeDefault, + AdminFixture.hqDefault + ).forEach { + test("${it.type} 타입 관리자") { + val admin = authUtil.createAdmin(it) + + runLoginSuccessTest( + id = admin.id, + account = admin.account, + password = admin.password, + type = PrincipalType.ADMIN, + ) { + val token: String = it.extract().path("data.accessToken") + jwtUtils.extractSubject(token) shouldBe admin.id.toString() + jwtUtils.extractClaim(token, CLAIM_STORE_ID_KEY) shouldBe admin.storeId?.toString() + jwtUtils.extractClaim(token, CLAIM_ADMIN_TYPE_KEY) shouldBe admin.type.name + jwtUtils.extractClaim(token, CLAIM_PERMISSION_KEY) shouldBe admin.permissionLevel.name + } } } @@ -61,52 +71,54 @@ class AuthApiTest( } context("실패 응답") { - test("비밀번호가 틀린 경우") { - val admin = authUtil.createAdmin(AdminFixture.default) - val request = LoginRequest(admin.account, "wrong_password", PrincipalType.ADMIN) + context("계정이 맞으면 로그인 실패 이력을 남긴다.") { + test("비밀번호가 틀린 경우") { + val admin = authUtil.createAdmin(AdminFixture.default) + val request = LoginRequest(admin.account, "wrong_password", PrincipalType.ADMIN) - runTest( - using = { - body(request) - }, - on = { - post("/auth/login") - }, - expect = { - statusCode(HttpStatus.UNAUTHORIZED.value()) - body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode)) - } - ).also { - assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { - it.success shouldBe false - it.principalType shouldBe PrincipalType.ADMIN + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.UNAUTHORIZED.value()) + body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode)) + } + ).also { + assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { + it.success shouldBe false + it.principalType shouldBe PrincipalType.ADMIN + } } } - } - test("토큰 생성 과정에서 오류가 발생하는 경우") { - val admin = authUtil.createAdmin(AdminFixture.default) - val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN) + test("토큰 생성 과정에서 오류가 발생하는 경우") { + val admin = authUtil.createAdmin(AdminFixture.default) + val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN) - every { - jwtUtils.createToken(any(), any()) - } throws RuntimeException("토큰 생성 실패") + every { + jwtUtils.createToken(any(), any()) + } throws RuntimeException("토큰 생성 실패") - runTest( - using = { - body(request) - }, - on = { - post("/auth/login") - }, - expect = { - statusCode(HttpStatus.INTERNAL_SERVER_ERROR.value()) - body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode)) - } - ).also { - assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { - it.success shouldBe false - it.principalType shouldBe PrincipalType.ADMIN + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.INTERNAL_SERVER_ERROR.value()) + body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode)) + } + ).also { + assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { + it.success shouldBe false + it.principalType shouldBe PrincipalType.ADMIN + } } } } @@ -162,39 +174,6 @@ class AuthApiTest( } } } - - context("로그인 상태를 확인한다.") { - test("성공 응답") { - val token = authUtil.defaultUserLogin() - - runTest( - token = token, - on = { - get("/auth/login/check") - }, - expect = { - statusCode(HttpStatus.OK.value()) - } - ).also { - val name: String = it.extract().path("data.name") - val type: String = it.extract().path("data.type") - - name.isBlank() shouldBe false - type shouldBe PrincipalType.USER.name - } - } - - test("로그인 상태가 아니면 실패한다.") { - runTest( - on = { - get("/auth/login/check") - }, - expect = { - statusCode(HttpStatus.UNAUTHORIZED.value()) - } - ) - } - } } private fun runLoginSuccessTest( -- 2.47.2 From dcb4233f5df998f41e0c4a990aea80e5fe211636 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:19:31 +0900 Subject: [PATCH 022/116] =?UTF-8?q?refactor:=20Resolver=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95(CurrentUser=20->=20User)=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A0=84=EC=9A=A9=20Admin=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/roomescape/auth/docs/AuthAPI.kt | 4 ++-- src/main/kotlin/roomescape/auth/web/AuthController.kt | 4 ++-- .../kotlin/roomescape/auth/web/support/AuthAnnotations.kt | 6 +++++- .../web/support/resolver/CurrentUserContextResolver.kt | 4 ++-- src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt | 4 ++-- .../kotlin/roomescape/payment/web/PaymentController.kt | 4 ++-- .../kotlin/roomescape/reservation/docs/ReservationAPI.kt | 8 ++++---- .../roomescape/reservation/web/ReservationController.kt | 8 ++++---- src/main/kotlin/roomescape/user/docs/UserAPI.kt | 4 ++-- src/main/kotlin/roomescape/user/web/UserController.kt | 4 ++-- 10 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt index 091218d7..6ebe2e4d 100644 --- a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt +++ b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt @@ -11,7 +11,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginSuccessResponse -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.auth.web.support.Public import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @@ -34,7 +34,7 @@ interface AuthAPI { ApiResponse(responseCode = "200"), ) fun logout( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, servletResponse: HttpServletResponse ): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/auth/web/AuthController.kt b/src/main/kotlin/roomescape/auth/web/AuthController.kt index bd5013cf..7e173128 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthController.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthController.kt @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import roomescape.auth.business.AuthService import roomescape.auth.docs.AuthAPI -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @@ -30,7 +30,7 @@ class AuthController( @PostMapping("/logout") override fun logout( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, servletResponse: HttpServletResponse ): ResponseEntity> { return ResponseEntity.ok().build() diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index 7d18db7a..bc3ac597 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -22,4 +22,8 @@ annotation class Public @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) -annotation class CurrentUser +annotation class User + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Admin diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt index 5ee2285f..aaffc484 100644 --- a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt @@ -13,7 +13,7 @@ import roomescape.auth.business.AuthService import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.auth.web.support.accessToken private val log: KLogger = KotlinLogging.logger {} @@ -25,7 +25,7 @@ class CurrentUserContextResolver( ) : HandlerMethodArgumentResolver { override fun supportsParameter(parameter: MethodParameter): Boolean { - return parameter.hasParameterAnnotation(CurrentUser::class.java) + return parameter.hasParameterAnnotation(User::class.java) } override fun resolveArgument( diff --git a/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt b/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt index 075ce787..a1cd9946 100644 --- a/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt +++ b/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt @@ -7,7 +7,7 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.auth.web.support.UserOnly import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @@ -29,7 +29,7 @@ interface PaymentAPI { @Operation(summary = "결제 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun cancelPayment( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, @Valid @RequestBody request: PaymentCancelRequest ): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/payment/web/PaymentController.kt b/src/main/kotlin/roomescape/payment/web/PaymentController.kt index 0d8093b3..f318f33d 100644 --- a/src/main/kotlin/roomescape/payment/web/PaymentController.kt +++ b/src/main/kotlin/roomescape/payment/web/PaymentController.kt @@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse import roomescape.payment.business.PaymentService @@ -31,7 +31,7 @@ class PaymentController( @PostMapping("/cancel") override fun cancelPayment( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, @Valid @RequestBody request: PaymentCancelRequest ): ResponseEntity> { paymentService.cancel(user.id, request) diff --git a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt index 77379716..ef3e9535 100644 --- a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt +++ b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam import roomescape.auth.web.support.Authenticated -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.auth.web.support.Public import roomescape.auth.web.support.UserOnly import roomescape.common.dto.CurrentUserContext @@ -29,7 +29,7 @@ interface ReservationAPI { @Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createPendingReservation( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, @Valid @RequestBody request: PendingReservationCreateRequest ): ResponseEntity> @@ -44,7 +44,7 @@ interface ReservationAPI { @Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun cancelReservation( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, @PathVariable id: Long, @Valid @RequestBody request: ReservationCancelRequest ): ResponseEntity> @@ -53,7 +53,7 @@ interface ReservationAPI { @Operation(summary = "회원별 예약 요약 목록 조회", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findSummaryByMemberId( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, ): ResponseEntity> @UserOnly diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt index 73c81718..30cc3c63 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt @@ -3,7 +3,7 @@ package roomescape.reservation.web import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse import roomescape.reservation.business.ReservationService @@ -26,7 +26,7 @@ class ReservationController( @PostMapping("/pending") override fun createPendingReservation( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, @Valid @RequestBody request: PendingReservationCreateRequest ): ResponseEntity> { val response = reservationService.createPendingReservation(user, request) @@ -45,7 +45,7 @@ class ReservationController( @PostMapping("/{id}/cancel") override fun cancelReservation( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, @PathVariable id: Long, @Valid @RequestBody request: ReservationCancelRequest ): ResponseEntity> { @@ -56,7 +56,7 @@ class ReservationController( @GetMapping("/summary") override fun findSummaryByMemberId( - @CurrentUser user: CurrentUserContext, + @User user: CurrentUserContext, ): ResponseEntity> { val response = reservationService.findUserSummaryReservation(user) diff --git a/src/main/kotlin/roomescape/user/docs/UserAPI.kt b/src/main/kotlin/roomescape/user/docs/UserAPI.kt index 2e8fd7cf..93a0bbca 100644 --- a/src/main/kotlin/roomescape/user/docs/UserAPI.kt +++ b/src/main/kotlin/roomescape/user/docs/UserAPI.kt @@ -7,7 +7,7 @@ import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.auth.web.support.Public import roomescape.auth.web.support.UserOnly import roomescape.common.dto.CurrentUserContext @@ -42,7 +42,7 @@ interface UserAPI { ) ) fun findContact( - @CurrentUser user: CurrentUserContext + @User user: CurrentUserContext ): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/user/web/UserController.kt b/src/main/kotlin/roomescape/user/web/UserController.kt index 871dc7d5..db48c1a0 100644 --- a/src/main/kotlin/roomescape/user/web/UserController.kt +++ b/src/main/kotlin/roomescape/user/web/UserController.kt @@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.User import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse import roomescape.user.business.UserService @@ -30,7 +30,7 @@ class UserController( @GetMapping("/contact") override fun findContact( - @CurrentUser user: CurrentUserContext + @User user: CurrentUserContext ): ResponseEntity> { val response = userService.findContactById(user.id) -- 2.47.2 From c3ab9be6c53b812099fd997cf7bb9480d0b2742a Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:20:23 +0900 Subject: [PATCH 023/116] =?UTF-8?q?rename:=20CurrentUserContextResolver=20?= =?UTF-8?q?->=20UserContextResolver=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...CurrentUserContextResolver.kt => UserContextResolver.kt} | 2 +- src/main/kotlin/roomescape/common/config/WebMvcConfig.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/main/kotlin/roomescape/auth/web/support/resolver/{CurrentUserContextResolver.kt => UserContextResolver.kt} (98%) diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/UserContextResolver.kt similarity index 98% rename from src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt rename to src/main/kotlin/roomescape/auth/web/support/resolver/UserContextResolver.kt index aaffc484..ffd76fab 100644 --- a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/resolver/UserContextResolver.kt @@ -19,7 +19,7 @@ import roomescape.auth.web.support.accessToken private val log: KLogger = KotlinLogging.logger {} @Component -class CurrentUserContextResolver( +class UserContextResolver( private val jwtUtils: JwtUtils, private val authService: AuthService ) : HandlerMethodArgumentResolver { diff --git a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt index 6ee4e748..df4e8398 100644 --- a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt +++ b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt @@ -7,18 +7,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import roomescape.auth.web.support.interceptors.AdminInterceptor import roomescape.auth.web.support.interceptors.AuthenticatedInterceptor import roomescape.auth.web.support.interceptors.UserInterceptor -import roomescape.auth.web.support.resolver.CurrentUserContextResolver +import roomescape.auth.web.support.resolver.UserContextResolver @Configuration class WebMvcConfig( private val adminInterceptor: AdminInterceptor, private val userInterceptor: UserInterceptor, private val authenticatedInterceptor: AuthenticatedInterceptor, - private val currentUserContextResolver: CurrentUserContextResolver + private val userContextResolver: UserContextResolver ) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { - resolvers.add(currentUserContextResolver) + resolvers.add(userContextResolver) } override fun addInterceptors(registry: InterceptorRegistry) { -- 2.47.2 From c6dd8a977c949661123dc192701533a1769a3e67 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:27:53 +0900 Subject: [PATCH 024/116] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20/=20=ED=9A=8C=EC=9B=90=20=EB=B6=84=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EA=B3=B5=ED=86=B5=20API=EC=97=90=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=98=EB=8A=94=20Authenticated=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/auth/business/AuthService.kt | 18 -------- .../auth/web/support/AuthAnnotations.kt | 4 -- .../interceptors/AuthenticatedInterceptor.kt | 44 ------------------- .../roomescape/common/config/WebMvcConfig.kt | 3 -- .../reservation/docs/ReservationAPI.kt | 4 +- 5 files changed, 1 insertion(+), 72 deletions(-) delete mode 100644 src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt diff --git a/src/main/kotlin/roomescape/auth/business/AuthService.kt b/src/main/kotlin/roomescape/auth/business/AuthService.kt index 96a1fb93..5366af04 100644 --- a/src/main/kotlin/roomescape/auth/business/AuthService.kt +++ b/src/main/kotlin/roomescape/auth/business/AuthService.kt @@ -11,7 +11,6 @@ import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.LoginContext import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginSuccessResponse -import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.LoginCredentials import roomescape.common.dto.PrincipalType import roomescape.user.business.UserService @@ -65,23 +64,6 @@ class AuthService( } } - @Transactional(readOnly = true) - fun findContextById(id: Long, type: PrincipalType): CurrentUserContext { - log.info { "[AuthService.checkLogin] 로그인 확인 시작: id=${id}, type=${type}" } - - return when (type) { - PrincipalType.ADMIN -> { - adminService.findContextById(id) - } - - PrincipalType.USER -> { - userService.findContextById(id) - } - }.also { - log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" } - } - } - private fun verifyPasswordOrThrow( request: LoginRequest, credentials: LoginCredentials diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index bc3ac597..003e4261 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -12,10 +12,6 @@ annotation class AdminOnly( @Retention(AnnotationRetention.RUNTIME) annotation class UserOnly -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class Authenticated - @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class Public diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt deleted file mode 100644 index f69629bc..00000000 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt +++ /dev/null @@ -1,44 +0,0 @@ -package roomescape.auth.web.support.interceptors - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.stereotype.Component -import org.springframework.web.method.HandlerMethod -import org.springframework.web.servlet.HandlerInterceptor -import roomescape.auth.business.AuthService -import roomescape.auth.infrastructure.jwt.JwtUtils -import roomescape.auth.web.support.Authenticated -import roomescape.auth.web.support.accessToken - -private val log: KLogger = KotlinLogging.logger {} - -@Component -class AuthenticatedInterceptor( - private val jwtUtils: JwtUtils, - private val authService: AuthService -) : HandlerInterceptor { - - override fun preHandle( - request: HttpServletRequest, - response: HttpServletResponse, - handler: Any - ): Boolean { - if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(Authenticated::class.java) == null)) { - return true - } - - val token: String? = request.accessToken() - val (id, type) = jwtUtils.extractIdAndType(token) - - try { - authService.findContextById(id, type) - log.info { "[AuthenticatedInterceptor] 인증 완료. id=$id, type=${type}" } - - return true - } catch (e: Exception) { - throw e - } - } -} diff --git a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt index df4e8398..3fe6bec6 100644 --- a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt +++ b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt @@ -5,7 +5,6 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import roomescape.auth.web.support.interceptors.AdminInterceptor -import roomescape.auth.web.support.interceptors.AuthenticatedInterceptor import roomescape.auth.web.support.interceptors.UserInterceptor import roomescape.auth.web.support.resolver.UserContextResolver @@ -13,7 +12,6 @@ import roomescape.auth.web.support.resolver.UserContextResolver class WebMvcConfig( private val adminInterceptor: AdminInterceptor, private val userInterceptor: UserInterceptor, - private val authenticatedInterceptor: AuthenticatedInterceptor, private val userContextResolver: UserContextResolver ) : WebMvcConfigurer { @@ -24,6 +22,5 @@ class WebMvcConfig( override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(adminInterceptor) registry.addInterceptor(userInterceptor) - registry.addInterceptor(authenticatedInterceptor) } } diff --git a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt index ef3e9535..66a8589f 100644 --- a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt +++ b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt @@ -8,9 +8,8 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam -import roomescape.auth.web.support.Authenticated -import roomescape.auth.web.support.User import roomescape.auth.web.support.Public +import roomescape.auth.web.support.User import roomescape.auth.web.support.UserOnly import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @@ -40,7 +39,6 @@ interface ReservationAPI { @PathVariable("id") id: Long ): ResponseEntity> - @Authenticated @Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun cancelReservation( -- 2.47.2 From 5aa6a6cc2c641b772d7d42f35a8f59ee18d533e5 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 21:32:14 +0900 Subject: [PATCH 025/116] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20/=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20Resolver=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/admin/business/AdminService.kt | 15 +++--- .../support/resolver/AdminContextResolver.kt | 49 +++++++++++++++++++ .../support/resolver/UserContextResolver.kt | 10 ++-- .../roomescape/common/config/WebMvcConfig.kt | 5 +- .../roomescape/common/dto/CommonAuth.kt | 27 +++++++--- .../roomescape/user/business/UserService.kt | 4 +- 6 files changed, 87 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/roomescape/auth/web/support/resolver/AdminContextResolver.kt diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt index 95657df2..ee938ae1 100644 --- a/src/main/kotlin/roomescape/admin/business/AdminService.kt +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -9,11 +9,7 @@ import roomescape.admin.exception.AdminErrorCode import roomescape.admin.exception.AdminException import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminRepository -import roomescape.common.dto.AdminLoginCredentials -import roomescape.common.dto.CurrentUserContext -import roomescape.common.dto.OperatorInfo -import roomescape.common.dto.PrincipalType -import roomescape.common.dto.toCredentials +import roomescape.common.dto.* private val log: KLogger = KotlinLogging.logger {} @@ -22,14 +18,15 @@ class AdminService( private val adminRepository: AdminRepository, ) { @Transactional(readOnly = true) - fun findContextById(id: Long): CurrentUserContext { + fun findContextById(id: Long): CurrentAdminContext { log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 시작: id=${id}" } val admin: AdminEntity = findOrThrow(id) - return CurrentUserContext(admin.id, admin.name, PrincipalType.ADMIN).also { - log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 완료: id=${id}" } - } + return admin.toContext() + .also { + log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 완료: id=${id}" } + } } @Transactional(readOnly = true) diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/AdminContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/AdminContextResolver.kt new file mode 100644 index 00000000..81fdbe77 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/support/resolver/AdminContextResolver.kt @@ -0,0 +1,49 @@ +package roomescape.auth.web.support.resolver + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import org.springframework.core.MethodParameter +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import roomescape.admin.business.AdminService +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.web.support.Admin +import roomescape.auth.web.support.accessToken + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class AdminContextResolver( + private val jwtUtils: JwtUtils, + private val adminService: AdminService, +) : HandlerMethodArgumentResolver { + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(Admin::class.java) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? + ): Any? { + val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest + val token: String? = request.accessToken() + + try { + val id: Long = jwtUtils.extractSubject(token).toLong() + + return adminService.findContextById(id) + } catch (e: Exception) { + log.info { "[AdminContextResolver] 회원 조회 실패. message=${e.message}" } + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/UserContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/UserContextResolver.kt index ffd76fab..7c731c5d 100644 --- a/src/main/kotlin/roomescape/auth/web/support/resolver/UserContextResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/resolver/UserContextResolver.kt @@ -9,19 +9,19 @@ import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer -import roomescape.auth.business.AuthService import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.User import roomescape.auth.web.support.accessToken +import roomescape.user.business.UserService private val log: KLogger = KotlinLogging.logger {} @Component class UserContextResolver( private val jwtUtils: JwtUtils, - private val authService: AuthService + private val userService: UserService, ) : HandlerMethodArgumentResolver { override fun supportsParameter(parameter: MethodParameter): Boolean { @@ -38,11 +38,11 @@ class UserContextResolver( val token: String? = request.accessToken() try { - val (id, type) = jwtUtils.extractIdAndType(token) + val id: Long = jwtUtils.extractSubject(token).toLong() - return authService.findContextById(id, type) + return userService.findContextById(id) } catch (e: Exception) { - log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" } + log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" } throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) } } diff --git a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt index 3fe6bec6..b7b182a2 100644 --- a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt +++ b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt @@ -6,16 +6,19 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import roomescape.auth.web.support.interceptors.AdminInterceptor import roomescape.auth.web.support.interceptors.UserInterceptor +import roomescape.auth.web.support.resolver.AdminContextResolver import roomescape.auth.web.support.resolver.UserContextResolver @Configuration class WebMvcConfig( private val adminInterceptor: AdminInterceptor, private val userInterceptor: UserInterceptor, - private val userContextResolver: UserContextResolver + private val adminContextResolver: AdminContextResolver, + private val userContextResolver: UserContextResolver, ) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(adminContextResolver) resolvers.add(userContextResolver) } diff --git a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt index e567b417..7262d561 100644 --- a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt +++ b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt @@ -60,12 +60,6 @@ fun UserEntity.toCredentials() = UserLoginCredentials( name = this.name, ) -data class CurrentUserContext( - val id: Long, - val name: String, - val type: PrincipalType -); - enum class PrincipalType { USER, ADMIN } @@ -74,3 +68,24 @@ data class OperatorInfo( val id: Long, val name: String ) + +data class CurrentUserContext( + val id: Long, + val name: String, +) + +data class CurrentAdminContext( + val id: Long, + val name: String, + val type: AdminType, + val storeId: Long?, + val permissionLevel: AdminPermissionLevel +) + +fun AdminEntity.toContext() = CurrentAdminContext( + id = this.id, + name = this.name, + type = this.type, + storeId = this.storeId, + permissionLevel = this.permissionLevel +) diff --git a/src/main/kotlin/roomescape/user/business/UserService.kt b/src/main/kotlin/roomescape/user/business/UserService.kt index ed4b206c..627cbc54 100644 --- a/src/main/kotlin/roomescape/user/business/UserService.kt +++ b/src/main/kotlin/roomescape/user/business/UserService.kt @@ -8,8 +8,8 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import roomescape.common.config.next import roomescape.common.dto.CurrentUserContext -import roomescape.common.dto.PrincipalType import roomescape.common.dto.UserLoginCredentials +import roomescape.common.dto.toCredentials import roomescape.user.exception.UserErrorCode import roomescape.user.exception.UserException import roomescape.user.infrastructure.persistence.* @@ -34,7 +34,7 @@ class UserService( log.info { "[UserService.findContextById] 현재 로그인된 회원 조회 시작: id=${id}" } val user: UserEntity = findOrThrow(id) - return CurrentUserContext(user.id, user.name, PrincipalType.USER) + return CurrentUserContext(user.id, user.name) .also { log.info { "[UserService.findContextById] 현재 로그인된 회원 조회 완료: id=${id}" } } -- 2.47.2 From e3b0693a3c6e453bec096ffb6d698154d8f37f1b Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 22:12:06 +0900 Subject: [PATCH 026/116] =?UTF-8?q?refactor:=20\@AdminOnly=EC=97=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=83=80=EC=9E=85(STORE,=20HQ)?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/jwt/JwtUtils.kt | 19 ----- .../auth/web/support/AuthAnnotations.kt | 2 + .../support/interceptors/AdminInterceptor.kt | 79 ++++++++++++++----- .../support/interceptors/UserInterceptor.kt | 20 ++--- .../roomescape/schedule/docs/ScheduleAPI.kt | 9 ++- .../kotlin/roomescape/theme/docs/ThemeApi.kt | 12 +-- 6 files changed, 83 insertions(+), 58 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt index 8e74a755..b68d612b 100644 --- a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt @@ -6,14 +6,10 @@ import io.jsonwebtoken.Claims import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys -import org.slf4j.MDC import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component -import roomescape.auth.business.CLAIM_TYPE_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException -import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY -import roomescape.common.dto.PrincipalType import java.util.* import javax.crypto.SecretKey @@ -47,21 +43,6 @@ class JwtUtils( } } - fun extractIdAndType(token: String?): Pair { - val id: Long = extractSubject(token) - .also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) } - .toLong() - - val type: PrincipalType = extractClaim(token, CLAIM_TYPE_KEY) - ?.let { PrincipalType.valueOf(it) } - ?: run { - log.info { "[JwtUtils.extractIdAndType] 회원 타입 조회 실패. id=$id" } - throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) - } - - return id to type - } - fun extractSubject(token: String?): String { if (token.isNullOrBlank()) { throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index 003e4261..d97d2166 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -1,10 +1,12 @@ package roomescape.auth.web.support +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class AdminOnly( + val type: AdminType, val privilege: Privilege ) diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt index b9f0cc63..8c1d610c 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt @@ -4,17 +4,21 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +import roomescape.admin.infrastructure.persistence.AdminType +import roomescape.admin.infrastructure.persistence.Privilege +import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY import roomescape.auth.business.CLAIM_PERMISSION_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.accessToken -import roomescape.common.dto.PrincipalType +import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY private val log: KLogger = KotlinLogging.logger {} @@ -30,32 +34,67 @@ class AdminInterceptor( if (handler !is HandlerMethod) { return true } - val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true - val token: String? = request.accessToken() - val (id, type) = jwtUtils.extractIdAndType(token) - val permission: AdminPermissionLevel = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY) - ?.let { - AdminPermissionLevel.valueOf(it) - } - ?: run { - if (type != PrincipalType.ADMIN) { - log.warn { "[AdminInterceptor] 회원의 관리자 API 접근: id=${id}" } - throw AuthException(AuthErrorCode.ACCESS_DENIED) - } - log.warn { "[AdminInterceptor] 토큰에서 이용자 권한이 조회되지 않음: id=${id}" } - throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) - } + try { + run { + val id: String = jwtUtils.extractSubject(token).also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) } + val type: AdminType = validateTypeAndGet(token, annotation.type) + val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege) - if (!permission.hasPrivilege(annotation.privilege)) { - log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" } + log.info { "[AdminInterceptor] 인증 완료. adminId=$id, type=${type}, permission=${permission}" } + } + return true + } catch (e: Exception) { + log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" } + throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) + } + } + + private fun validateTypeAndGet(token: String?, requiredType: AdminType): AdminType { + val typeClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_ADMIN_TYPE_KEY) + + if (typeClaim == null) { + log.warn { "[AdminInterceptor] 관리자 타입 조회 실패: token=${token}" } + throw AuthException(AuthErrorCode.INVALID_TOKEN) + } + + val type = try { + AdminType.valueOf(typeClaim) + } catch (_: IllegalArgumentException) { + log.warn { "[AdminInterceptor] 관리자 타입 변환 실패: token=${token}, typeClaim=${typeClaim}" } + throw AuthException(AuthErrorCode.INVALID_TOKEN) + } + + if (type != AdminType.HQ && type != requiredType) { + log.warn { "[AdminInterceptor] 관리자 권한 부족: requiredType=${requiredType} / current=${type}" } throw AuthException(AuthErrorCode.ACCESS_DENIED) } - log.info { "[AdminInterceptor] 인증 완료. adminId=$id, permission=${permission}" } + return type + } - return true + private fun validatePermissionAndGet(token: String?, requiredPrivilege: Privilege): AdminPermissionLevel { + val permissionClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY) + + if (permissionClaim == null) { + log.warn { "[AdminInterceptor] 관리자 권한 조회 실패: token=${token}" } + throw AuthException(AuthErrorCode.INVALID_TOKEN) + } + + val permission = try { + AdminPermissionLevel.valueOf(permissionClaim) + } catch (_: IllegalArgumentException) { + log.warn { "[AdminInterceptor] 관리자 권한 변환 실패: token=${token}, permissionClaim=${permissionClaim}" } + throw AuthException(AuthErrorCode.INVALID_TOKEN) + } + + if (!permission.hasPrivilege(requiredPrivilege)) { + log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${requiredPrivilege} / current=${permission.privileges}" } + throw AuthException(AuthErrorCode.ACCESS_DENIED) + } + + return permission } } diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt index ac42cd0f..8c9ebc81 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt @@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor @@ -12,7 +13,7 @@ import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.UserOnly import roomescape.auth.web.support.accessToken -import roomescape.common.dto.PrincipalType +import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY private val log: KLogger = KotlinLogging.logger {} @@ -29,16 +30,17 @@ class UserInterceptor( if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) { return true } - val token: String? = request.accessToken() - val (id, type) = jwtUtils.extractIdAndType(token) - if (type != PrincipalType.USER) { - log.warn { "[UserInterceptor] 관리자의 회원 API 접근: id=${id}" } - throw AuthException(AuthErrorCode.ACCESS_DENIED) + try { + jwtUtils.extractSubject(token).also { + MDC.put(MDC_PRINCIPAL_ID_KEY, it) + log.info { "[UserInterceptor] 인증 완료. userId=$it" } + } + return true + } catch (e: Exception) { + log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" } + throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) } - - log.info { "[UserInterceptor] 인증 완료. userId=$id" } - return true } } diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt index e0009e9f..de5d416e 100644 --- a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.Public @@ -53,21 +54,21 @@ interface ScheduleAPI { @PathVariable("id") id: Long ): ResponseEntity> - @AdminOnly(privilege = Privilege.READ_DETAIL) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_DETAIL) @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) fun findScheduleDetail( @PathVariable("id") id: Long ): ResponseEntity> - @AdminOnly(privilege = Privilege.CREATE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE) @Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createSchedule( @Valid @RequestBody request: ScheduleCreateRequest ): ResponseEntity> - @AdminOnly(privilege = Privilege.UPDATE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) @Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateSchedule( @@ -75,7 +76,7 @@ interface ScheduleAPI { @Valid @RequestBody request: ScheduleUpdateRequest ): ResponseEntity> - @AdminOnly(privilege = Privilege.DELETE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE) @Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteSchedule( diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index 5f465d64..3406dd98 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -8,6 +8,7 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.Public @@ -16,28 +17,27 @@ import roomescape.theme.web.* @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") interface ThemeAPIV2 { - - @AdminOnly(privilege = Privilege.READ_SUMMARY) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemes(): ResponseEntity> - @AdminOnly(privilege = Privilege.READ_DETAIL) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_DETAIL) @Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity> - @AdminOnly(privilege = Privilege.CREATE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity> - @AdminOnly(privilege = Privilege.DELETE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteTheme(@PathVariable id: Long): ResponseEntity> - @AdminOnly(privilege = Privilege.UPDATE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) @Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateTheme( -- 2.47.2 From 3d9a4c650ef4a24b733cb1cb6b78ac0b55714ba3 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 22:40:23 +0900 Subject: [PATCH 027/116] =?UTF-8?q?remove:=20=EC=82=AC=EC=9A=A9=EB=90=A0?= =?UTF-8?q?=20=EA=B2=83=20=EA=B0=99=EC=A7=80=20=EC=95=8A=EB=8B=A4=EA=B3=A0?= =?UTF-8?q?=20=ED=8C=90=EB=8B=A8=EB=90=98=EB=8A=94=20AdminResolver=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/admin/business/AdminService.kt | 12 ----- .../auth/web/support/AuthAnnotations.kt | 4 -- .../support/interceptors/AdminInterceptor.kt | 15 ++++-- .../support/resolver/AdminContextResolver.kt | 49 ------------------- .../roomescape/common/config/WebMvcConfig.kt | 3 -- .../roomescape/common/dto/CommonAuth.kt | 16 ------ 6 files changed, 12 insertions(+), 87 deletions(-) delete mode 100644 src/main/kotlin/roomescape/auth/web/support/resolver/AdminContextResolver.kt diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt index ee938ae1..30e8b04c 100644 --- a/src/main/kotlin/roomescape/admin/business/AdminService.kt +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -17,18 +17,6 @@ private val log: KLogger = KotlinLogging.logger {} class AdminService( private val adminRepository: AdminRepository, ) { - @Transactional(readOnly = true) - fun findContextById(id: Long): CurrentAdminContext { - log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 시작: id=${id}" } - - val admin: AdminEntity = findOrThrow(id) - - return admin.toContext() - .also { - log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 완료: id=${id}" } - } - } - @Transactional(readOnly = true) fun findCredentialsByAccount(account: String): AdminLoginCredentials { log.info { "[AdminService.findCredentialsByAccount] 관리자 조회 시작: account=${account}" } diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index d97d2166..3bc17e9b 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -21,7 +21,3 @@ annotation class Public @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) annotation class User - -@Target(AnnotationTarget.VALUE_PARAMETER) -@Retention(AnnotationRetention.RUNTIME) -annotation class Admin diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt index 8c1d610c..2bd227f6 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt @@ -47,17 +47,26 @@ class AdminInterceptor( } return true } catch (e: Exception) { - log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" } - throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) + when (e) { + is AuthException -> { throw e } + else -> { + log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" } + throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) + } + } } } private fun validateTypeAndGet(token: String?, requiredType: AdminType): AdminType { val typeClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_ADMIN_TYPE_KEY) + /** + * 이전의 id 추출 과정에서 토큰이 유효한지 검증했기 때문에 typeClaim 이 null 이라는 것은 + * 회원 토큰일 가능성이 큼. (관리자 토큰에는 CLAIM_ADMIN_TYPE_KEY 가 무조건 존재함) + */ if (typeClaim == null) { log.warn { "[AdminInterceptor] 관리자 타입 조회 실패: token=${token}" } - throw AuthException(AuthErrorCode.INVALID_TOKEN) + throw AuthException(AuthErrorCode.ACCESS_DENIED) } val type = try { diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/AdminContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/AdminContextResolver.kt deleted file mode 100644 index 81fdbe77..00000000 --- a/src/main/kotlin/roomescape/auth/web/support/resolver/AdminContextResolver.kt +++ /dev/null @@ -1,49 +0,0 @@ -package roomescape.auth.web.support.resolver - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import jakarta.servlet.http.HttpServletRequest -import org.springframework.core.MethodParameter -import org.springframework.stereotype.Component -import org.springframework.web.bind.support.WebDataBinderFactory -import org.springframework.web.context.request.NativeWebRequest -import org.springframework.web.method.support.HandlerMethodArgumentResolver -import org.springframework.web.method.support.ModelAndViewContainer -import roomescape.admin.business.AdminService -import roomescape.auth.exception.AuthErrorCode -import roomescape.auth.exception.AuthException -import roomescape.auth.infrastructure.jwt.JwtUtils -import roomescape.auth.web.support.Admin -import roomescape.auth.web.support.accessToken - -private val log: KLogger = KotlinLogging.logger {} - -@Component -class AdminContextResolver( - private val jwtUtils: JwtUtils, - private val adminService: AdminService, -) : HandlerMethodArgumentResolver { - - override fun supportsParameter(parameter: MethodParameter): Boolean { - return parameter.hasParameterAnnotation(Admin::class.java) - } - - override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory? - ): Any? { - val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest - val token: String? = request.accessToken() - - try { - val id: Long = jwtUtils.extractSubject(token).toLong() - - return adminService.findContextById(id) - } catch (e: Exception) { - log.info { "[AdminContextResolver] 회원 조회 실패. message=${e.message}" } - throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) - } - } -} diff --git a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt index b7b182a2..96eb747c 100644 --- a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt +++ b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt @@ -6,19 +6,16 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import roomescape.auth.web.support.interceptors.AdminInterceptor import roomescape.auth.web.support.interceptors.UserInterceptor -import roomescape.auth.web.support.resolver.AdminContextResolver import roomescape.auth.web.support.resolver.UserContextResolver @Configuration class WebMvcConfig( private val adminInterceptor: AdminInterceptor, private val userInterceptor: UserInterceptor, - private val adminContextResolver: AdminContextResolver, private val userContextResolver: UserContextResolver, ) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { - resolvers.add(adminContextResolver) resolvers.add(userContextResolver) } diff --git a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt index 7262d561..9265fcc6 100644 --- a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt +++ b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt @@ -73,19 +73,3 @@ data class CurrentUserContext( val id: Long, val name: String, ) - -data class CurrentAdminContext( - val id: Long, - val name: String, - val type: AdminType, - val storeId: Long?, - val permissionLevel: AdminPermissionLevel -) - -fun AdminEntity.toContext() = CurrentAdminContext( - id = this.id, - name = this.name, - type = this.type, - storeId = this.storeId, - permissionLevel = this.permissionLevel -) -- 2.47.2 From aecf499ea57287bf2f55060e983eaadc2d117295 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 22:40:55 +0900 Subject: [PATCH 028/116] =?UTF-8?q?refactor:=20UserInterceptor=EC=97=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=86=A0=ED=81=B0=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/interceptors/UserInterceptor.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt index 8c9ebc81..ce02d644 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt @@ -8,6 +8,7 @@ import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor +import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils @@ -33,14 +34,26 @@ class UserInterceptor( val token: String? = request.accessToken() try { - jwtUtils.extractSubject(token).also { - MDC.put(MDC_PRINCIPAL_ID_KEY, it) - log.info { "[UserInterceptor] 인증 완료. userId=$it" } + val id: String = jwtUtils.extractSubject(token).also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) } + + /** + * CLAIM_ADMIN_TYPE_KEY 가 존재하면 관리자 토큰임 + */ + jwtUtils.extractClaim(token, key = CLAIM_ADMIN_TYPE_KEY)?.also { + log.warn { "[UserInterceptor] 관리자 토큰으로 접근 시도. userId=$id, adminType=$it" } + throw AuthException(AuthErrorCode.ACCESS_DENIED) } + + log.info { "[UserInterceptor] 인증 완료. userId=$id" } return true } catch (e: Exception) { - log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" } - throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) + when (e) { + is AuthException -> { throw e } + else -> { + log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" } + throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) + } + } } } } -- 2.47.2 From f27ce7cd3a4f46127bc5a3f9a45174286daace61 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 22:41:16 +0900 Subject: [PATCH 029/116] =?UTF-8?q?refactor:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=83=80=EC=9E=85=20=EC=B2=B4=ED=81=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/reservation/business/ReservationService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index eb642bfb..e584a11d 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -164,7 +164,7 @@ class ReservationService( reservation: ReservationEntity, cancelReason: String ) { - if (user.type != PrincipalType.ADMIN && reservation.userId != user.id) { + if (reservation.userId != user.id) { log.warn { "[ReservationService.createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" } throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION) } -- 2.47.2 From 3ec96f3c3510c1c79c885866eac87542c5313533 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 22:41:33 +0900 Subject: [PATCH 030/116] =?UTF-8?q?refactor:=20API=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EB=8F=99=EC=82=AC=ED=95=AD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/payment/PaymentAPITest.kt | 12 ++-- .../reservation/ReservationApiTest.kt | 63 +++++-------------- .../roomescape/schedule/ScheduleApiTest.kt | 32 +++++----- .../roomescape/supports/RestAssuredUtils.kt | 14 ++--- .../kotlin/roomescape/theme/ThemeApiTest.kt | 56 ++++++++--------- .../kotlin/roomescape/user/UserApiTest.kt | 2 +- 6 files changed, 74 insertions(+), 105 deletions(-) diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt index 97947b5e..834b8caf 100644 --- a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -43,7 +43,7 @@ class PaymentAPITest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -165,7 +165,7 @@ class PaymentAPITest( PaymentMethod.entries.filter { it !in supportedMethod }.forEach { test("결제 수단: ${it.koreanName}") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = authUtil.defaultUserLogin() ) @@ -209,7 +209,7 @@ class PaymentAPITest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, requestBody = PaymentFixture.cancelRequest, @@ -222,7 +222,7 @@ class PaymentAPITest( val userToken = authUtil.defaultUserLogin() val confirmRequest = PaymentFixture.confirmRequest val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = userToken ) @@ -268,7 +268,7 @@ class PaymentAPITest( test("예약에 대한 결제 정보가 없으면 실패한다.") { val userToken = authUtil.defaultUserLogin() val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = userToken, ) @@ -312,7 +312,7 @@ class PaymentAPITest( val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = authUtil.defaultUserLogin(), ) diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt index caedb284..2db18f1a 100644 --- a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -55,7 +55,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -66,7 +66,7 @@ class ReservationApiTest( test("정상 생성") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.HOLD ) @@ -95,7 +95,7 @@ class ReservationApiTest( test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.AVAILABLE ) @@ -116,7 +116,7 @@ class ReservationApiTest( } test("예약 인원이 테마의 최소 인원보다 작거나 최대 인원보다 많으면 실패한다.") { - val adminToken = authUtil.defaultAdminLogin() + val adminToken = authUtil.defaultStoreAdminLogin() val theme: ThemeEntity = dummyInitializer.createTheme( adminToken = adminToken, request = ThemeFixture.createRequest @@ -190,7 +190,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -202,7 +202,7 @@ class ReservationApiTest( val userToken = authUtil.defaultUserLogin() val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = userToken, ) @@ -250,7 +250,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -262,7 +262,7 @@ class ReservationApiTest( val userToken = authUtil.defaultUserLogin() val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = userToken, ) @@ -299,9 +299,9 @@ class ReservationApiTest( ) } - test("관리자가 아닌 회원은 다른 회원의 예약을 취소할 수 없다.") { + test("다른 회원의 예약을 취소할 수 없다.") { val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = authUtil.defaultUserLogin(), ) @@ -316,37 +316,6 @@ class ReservationApiTest( expectedErrorCode = ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION ) } - - test("관리자는 다른 회원의 예약을 취소할 수 있다.") { - val adminToken = authUtil.defaultAdminLogin() - - val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = adminToken, - reserverToken = authUtil.defaultUserLogin(), - ) - - runTest( - token = adminToken, - using = { - body(ReservationCancelRequest(cancelReason = "test")) - }, - on = { - post("/reservations/${reservation.id}/cancel") - }, - expect = { - statusCode(HttpStatus.OK.value()) - } - ).also { - val updatedReservation = reservationRepository.findByIdOrNull(reservation.id) - ?: throw AssertionError("Unexpected Exception Occurred.") - val updatedSchedule = scheduleRepository.findByIdOrNull(updatedReservation.scheduleId) - ?: throw AssertionError("Unexpected Exception Occurred.") - - updatedSchedule.status shouldBe ScheduleStatus.AVAILABLE - updatedReservation.status shouldBe ReservationStatus.CANCELED - canceledReservationRepository.findAll()[0].reservationId shouldBe updatedReservation.id - } - } } context("나의 예약 목록을 조회한다.") { @@ -363,7 +332,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -373,7 +342,7 @@ class ReservationApiTest( test("정상 응답") { val userToken = authUtil.defaultUserLogin() - val adminToken = authUtil.defaultAdminLogin() + val adminToken = authUtil.defaultStoreAdminLogin() for (i in 1..3) { dummyInitializer.createConfirmReservation( @@ -429,7 +398,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -444,7 +413,7 @@ class ReservationApiTest( beforeTest { reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = authUtil.defaultUserLogin(), ) } @@ -587,7 +556,7 @@ class ReservationApiTest( test("예약은 있지만, 결제 정보를 찾을 수 없으면 null로 지정한다.") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = authUtil.defaultUserLogin(), ) @@ -605,7 +574,7 @@ class ReservationApiTest( test("예약과 결제는 있지만, 결제 세부 내역이 없으면 세부 내역만 null로 지정한다..") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), reserverToken = authUtil.defaultUserLogin(), ) diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt index faa26730..274267a3 100644 --- a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -32,7 +32,7 @@ class ScheduleApiTest( val endpoint = "/schedules/themes?date=$date" test("정상 응답") { - val adminToken = authUtil.defaultAdminLogin() + val adminToken = authUtil.defaultStoreAdminLogin() for (i in 1..10) { dummyInitializer.createSchedule( @@ -61,7 +61,7 @@ class ScheduleApiTest( test("정상 응답") { val date = LocalDate.now().plusDays(1) - val adminToken = authUtil.defaultAdminLogin() + val adminToken = authUtil.defaultStoreAdminLogin() val createdSchedule = dummyInitializer.createSchedule( adminToken = adminToken, request = createRequest.copy(date = date, time = LocalTime.now()) @@ -129,7 +129,7 @@ class ScheduleApiTest( } test("정상 응답") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val createdSchedule = dummyInitializer.createSchedule( adminToken = token, @@ -161,7 +161,7 @@ class ScheduleApiTest( test("일정이 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.GET, endpoint = "/schedules/$INVALID_PK", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND @@ -208,7 +208,7 @@ class ScheduleApiTest( } test("정상 생성 및 감사 정보 확인") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val themeId: Long = dummyInitializer.createTheme( adminToken = token, @@ -246,7 +246,7 @@ class ScheduleApiTest( } test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val date = LocalDate.now().plusDays(1) val time = LocalTime.of(10, 0) @@ -267,7 +267,7 @@ class ScheduleApiTest( } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val body = createRequest.copy(LocalDate.now(), LocalTime.now().minusMinutes(1)) runExceptionTest( @@ -294,7 +294,7 @@ class ScheduleApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.PATCH, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -304,7 +304,7 @@ class ScheduleApiTest( test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태이면 정상 응답") { val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), request = createRequest ) @@ -334,7 +334,7 @@ class ScheduleApiTest( } test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태가 아니면 실패한다.") { - val adminToken = authUtil.defaultAdminLogin() + val adminToken = authUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = adminToken, @@ -414,7 +414,7 @@ class ScheduleApiTest( test("정상 수정 및 감사 정보 변경 확인") { val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), request = createRequest.copy( date = LocalDate.now().plusDays(1), time = LocalTime.now().plusMinutes(1), @@ -449,7 +449,7 @@ class ScheduleApiTest( } test("입력값이 없으면 수정하지 않는다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, @@ -477,7 +477,7 @@ class ScheduleApiTest( test("일정이 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = "/schedules/${INVALID_PK}", @@ -486,7 +486,7 @@ class ScheduleApiTest( } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = @@ -540,7 +540,7 @@ class ScheduleApiTest( } test("정상 삭제") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = createRequest @@ -560,7 +560,7 @@ class ScheduleApiTest( } test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, diff --git a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt index e9ade94c..301fa68b 100644 --- a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt @@ -31,10 +31,12 @@ class AuthUtil( fun createAdmin(admin: AdminEntity): AdminEntity { val storeId = admin.storeId if (storeId != null && storeRepository.findByIdOrNull(storeId) == null) { - storeRepository.save(StoreFixture.create( - id = storeId, - businessRegNum = generateBusinessRegNum(), - )) + storeRepository.save( + StoreFixture.create( + id = storeId, + businessRegNum = generateBusinessRegNum(), + ) + ) } return adminRepository.save(admin) @@ -75,8 +77,6 @@ class AuthUtil( fun defaultStoreAdminLogin(): String = adminLogin(AdminFixture.storeDefault) fun defaultHqAdminLogin(): String = adminLogin(AdminFixture.hqDefault) - fun defaultAdminLogin(): String = adminLogin(AdminFixture.default) - fun userLogin(user: UserEntity): String { if (userRepository.findByEmail(user.email) == null) { userRepository.save(user) @@ -190,4 +190,4 @@ private fun generateBusinessRegNum(): String { val part2 = Random.nextInt(10, 100) val part3 = Random.nextInt(10000, 100000) return "$part1-$part2-$part3" -} \ No newline at end of file +} diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index 105bd0e3..01aa4fa3 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -69,7 +69,7 @@ class ThemeApiTest( test("정상 생성 및 감사 정보 확인") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, @@ -97,7 +97,7 @@ class ThemeApiTest( } test("이미 동일한 이름의 테마가 있으면 실패한다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val commonName = "test123" dummyInitializer.createTheme( adminToken = token, @@ -120,7 +120,7 @@ class ThemeApiTest( } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -143,7 +143,7 @@ class ThemeApiTest( } test("field: availableMinutes") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -157,7 +157,7 @@ class ThemeApiTest( } test("field: expectedMinutesFrom") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -171,7 +171,7 @@ class ThemeApiTest( } test("field: expectedMinutesTo") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -187,7 +187,7 @@ class ThemeApiTest( context("시간 범위가 잘못 지정되면 실패한다.") { test("최소 예상 시간 > 최대 예상 시간") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -204,7 +204,7 @@ class ThemeApiTest( } test("최대 예상 시간 > 이용 가능 시간") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -234,7 +234,7 @@ class ThemeApiTest( } test("field: minParticipants") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -248,7 +248,7 @@ class ThemeApiTest( } test("field: maxParticipants") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -264,7 +264,7 @@ class ThemeApiTest( context("인원 범위가 잘못 지정되면 실패한다.") { test("최소 인원 > 최대 인원") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() runTest( token = token, using = { @@ -284,7 +284,7 @@ class ThemeApiTest( context("입력된 모든 ID에 대한 테마를 조회한다.") { test("정상 응답") { - val adminToken = authUtil.defaultAdminLogin() + val adminToken = authUtil.defaultStoreAdminLogin() val themeSize = 3 val themeIds = mutableListOf() @@ -309,7 +309,7 @@ class ThemeApiTest( } test("없는 테마가 있으면 생략한다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val themeSize = 3 val themeIds = mutableListOf() @@ -363,7 +363,7 @@ class ThemeApiTest( test("비공개 테마까지 포함하여 간단한 정보만 조회된다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() requests.forEach { dummyInitializer.createTheme(token, it) } runTest( @@ -384,7 +384,7 @@ class ThemeApiTest( context("예약 페이지에서 테마를 조회한다.") { test("공개된 테마의 전체 정보가 조회된다.") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() listOf( createRequest.copy(name = "open", isOpen = true), createRequest.copy(name = "close", isOpen = false) @@ -447,7 +447,7 @@ class ThemeApiTest( } test("정상 응답") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -475,7 +475,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.GET, endpoint = "/admin/themes/$INVALID_PK", expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND @@ -520,7 +520,7 @@ class ThemeApiTest( } test("정상 삭제") { - val token = authUtil.defaultAdminLogin() + val token = authUtil.defaultStoreAdminLogin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -541,7 +541,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.DELETE, endpoint = "/admin/themes/$INVALID_PK", expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND @@ -592,7 +592,7 @@ class ThemeApiTest( test("정상 수정 및 감사 정보 변경 확인") { val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), request = createRequest.copy(name = "theme-${Random.nextInt()}") ) val otherAdminToken: String = authUtil.adminLogin( @@ -622,12 +622,12 @@ class ThemeApiTest( test("입력값이 없으면 수정하지 않는다.") { val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = authUtil.defaultAdminLogin(), + adminToken = authUtil.defaultStoreAdminLogin(), request = createRequest.copy(name = "theme-${Random.nextInt()}") ) runTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), using = { body(ThemeUpdateRequest()) }, @@ -647,7 +647,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.PATCH, endpoint = "/admin/themes/$INVALID_PK", requestBody = updateRequest, @@ -656,7 +656,7 @@ class ThemeApiTest( } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { - val adminToken = authUtil.defaultAdminLogin() + val adminToken = authUtil.defaultStoreAdminLogin() val createdTheme: ThemeEntity = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -676,7 +676,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultAdminLogin() + adminToken = authUtil.defaultStoreAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -719,7 +719,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultAdminLogin() + adminToken = authUtil.defaultStoreAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -760,7 +760,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultAdminLogin() + adminToken = authUtil.defaultStoreAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -793,7 +793,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultAdminLogin() + adminToken = authUtil.defaultStoreAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") diff --git a/src/test/kotlin/roomescape/user/UserApiTest.kt b/src/test/kotlin/roomescape/user/UserApiTest.kt index 8db87e46..b396ba97 100644 --- a/src/test/kotlin/roomescape/user/UserApiTest.kt +++ b/src/test/kotlin/roomescape/user/UserApiTest.kt @@ -145,7 +145,7 @@ class UserApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultAdminLogin(), + token = authUtil.defaultStoreAdminLogin(), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED -- 2.47.2 From d9ef3b0305be7c4a594926e438dded29d8969b26 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 22:43:41 +0900 Subject: [PATCH 031/116] =?UTF-8?q?refactor:=20region=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20DTO=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/region/business/RegionService.kt | 6 +++++- src/main/kotlin/roomescape/region/docs/RegionAPI.kt | 7 +++---- .../{infrastructure => }/web/RegionController.kt | 2 +- .../region/{infrastructure => }/web/RegionDTO.kt | 11 +---------- 4 files changed, 10 insertions(+), 16 deletions(-) rename src/main/kotlin/roomescape/region/{infrastructure => }/web/RegionController.kt (97%) rename src/main/kotlin/roomescape/region/{infrastructure => }/web/RegionDTO.kt (65%) diff --git a/src/main/kotlin/roomescape/region/business/RegionService.kt b/src/main/kotlin/roomescape/region/business/RegionService.kt index cf7ff64b..5d6a9486 100644 --- a/src/main/kotlin/roomescape/region/business/RegionService.kt +++ b/src/main/kotlin/roomescape/region/business/RegionService.kt @@ -7,7 +7,11 @@ import org.springframework.transaction.annotation.Transactional import roomescape.region.exception.RegionErrorCode import roomescape.region.exception.RegionException import roomescape.region.infrastructure.persistence.RegionRepository -import roomescape.region.infrastructure.web.* +import roomescape.region.web.RegionCodeResponse +import roomescape.region.web.SidoListResponse +import roomescape.region.web.SidoResponse +import roomescape.region.web.SigunguListResponse +import roomescape.region.web.SigunguResponse private val log: KLogger = KotlinLogging.logger {} diff --git a/src/main/kotlin/roomescape/region/docs/RegionAPI.kt b/src/main/kotlin/roomescape/region/docs/RegionAPI.kt index 037c1317..faa022bf 100644 --- a/src/main/kotlin/roomescape/region/docs/RegionAPI.kt +++ b/src/main/kotlin/roomescape/region/docs/RegionAPI.kt @@ -7,10 +7,9 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestParam import roomescape.auth.web.support.Public import roomescape.common.dto.response.CommonApiResponse -import roomescape.region.infrastructure.web.DongListResponse -import roomescape.region.infrastructure.web.RegionCodeResponse -import roomescape.region.infrastructure.web.SidoListResponse -import roomescape.region.infrastructure.web.SigunguListResponse +import roomescape.region.web.RegionCodeResponse +import roomescape.region.web.SidoListResponse +import roomescape.region.web.SigunguListResponse interface RegionAPI { diff --git a/src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt b/src/main/kotlin/roomescape/region/web/RegionController.kt similarity index 97% rename from src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt rename to src/main/kotlin/roomescape/region/web/RegionController.kt index 084eb276..ea4d50c0 100644 --- a/src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt +++ b/src/main/kotlin/roomescape/region/web/RegionController.kt @@ -1,4 +1,4 @@ -package roomescape.region.infrastructure.web +package roomescape.region.web import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping diff --git a/src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt b/src/main/kotlin/roomescape/region/web/RegionDTO.kt similarity index 65% rename from src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt rename to src/main/kotlin/roomescape/region/web/RegionDTO.kt index ecbb8ec8..3046e1cb 100644 --- a/src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt +++ b/src/main/kotlin/roomescape/region/web/RegionDTO.kt @@ -1,4 +1,4 @@ -package roomescape.region.infrastructure.web +package roomescape.region.web data class SidoResponse( val code: String, @@ -18,15 +18,6 @@ data class SigunguListResponse( val sigunguList: List ) -data class DongResponse( - val code: String, - val name: String, -) - -data class DongListResponse( - val dongList: List -) - data class RegionCodeResponse( val code: String ) -- 2.47.2 From ccac3625518efc4f68dc9e50e71bc51535c02fc8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 22:45:43 +0900 Subject: [PATCH 032/116] =?UTF-8?q?refactor:=20\@AdminOnly=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=ED=83=80=EC=9E=85(STORE)=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20-=20HQ=20=EA=B6=8C=ED=95=9C=EC=9D=B4=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EA=B2=BD=EC=9A=B0=EC=97=90=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/auth/web/support/AuthAnnotations.kt | 2 +- .../kotlin/roomescape/schedule/docs/ScheduleAPI.kt | 9 ++++----- src/main/kotlin/roomescape/theme/docs/ThemeApi.kt | 11 +++++------ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index 3bc17e9b..a1692c91 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -6,7 +6,7 @@ import roomescape.admin.infrastructure.persistence.Privilege @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class AdminOnly( - val type: AdminType, + val type: AdminType = AdminType.STORE, val privilege: Privilege ) diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt index de5d416e..e0009e9f 100644 --- a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -9,7 +9,6 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam -import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.Public @@ -54,21 +53,21 @@ interface ScheduleAPI { @PathVariable("id") id: Long ): ResponseEntity> - @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_DETAIL) + @AdminOnly(privilege = Privilege.READ_DETAIL) @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) fun findScheduleDetail( @PathVariable("id") id: Long ): ResponseEntity> - @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE) + @AdminOnly(privilege = Privilege.CREATE) @Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createSchedule( @Valid @RequestBody request: ScheduleCreateRequest ): ResponseEntity> - @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) + @AdminOnly(privilege = Privilege.UPDATE) @Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateSchedule( @@ -76,7 +75,7 @@ interface ScheduleAPI { @Valid @RequestBody request: ScheduleUpdateRequest ): ResponseEntity> - @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE) + @AdminOnly(privilege = Privilege.DELETE) @Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteSchedule( diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index 3406dd98..d76f91b2 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -8,7 +8,6 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody -import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.Public @@ -17,27 +16,27 @@ import roomescape.theme.web.* @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") interface ThemeAPIV2 { - @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_SUMMARY) + @AdminOnly(privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemes(): ResponseEntity> - @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_DETAIL) + @AdminOnly(privilege = Privilege.READ_DETAIL) @Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity> - @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE) + @AdminOnly(privilege = Privilege.CREATE) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity> - @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE) + @AdminOnly(privilege = Privilege.DELETE) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteTheme(@PathVariable id: Long): ResponseEntity> - @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) + @AdminOnly(privilege = Privilege.UPDATE) @Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateTheme( -- 2.47.2 From ecf0d6740a3f8f870a72e32338e104e3dda51893 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 23:16:14 +0900 Subject: [PATCH 033/116] =?UTF-8?q?rename:=20ThemeAPIV2=20->=20ThemeAPI=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/roomescape/theme/docs/ThemeApi.kt | 4 ++-- src/main/kotlin/roomescape/theme/web/ThemeController.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index d76f91b2..f3a42007 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -15,8 +15,8 @@ import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.web.* @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") -interface ThemeAPIV2 { - @AdminOnly(privilege = Privilege.READ_SUMMARY) +interface ThemeAPI { + @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemes(): ResponseEntity> diff --git a/src/main/kotlin/roomescape/theme/web/ThemeController.kt b/src/main/kotlin/roomescape/theme/web/ThemeController.kt index 0fde6279..dbd66da5 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeController.kt @@ -4,13 +4,13 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.business.ThemeService -import roomescape.theme.docs.ThemeAPIV2 +import roomescape.theme.docs.ThemeAPI import java.net.URI @RestController class ThemeController( private val themeService: ThemeService, -) : ThemeAPIV2 { +) : ThemeAPI { @PostMapping("/themes/retrieve") override fun findThemesByIds( -- 2.47.2 From 7d2fd3b667d9c656e77ea0f64c0dd1f12b39f66d Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 23:16:38 +0900 Subject: [PATCH 034/116] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=EC=9D=98?= =?UTF-8?q?=20=ED=85=8C=EB=A7=88=20API=EB=A5=BC=20=EB=AA=A8=EB=91=90=20HQ?= =?UTF-8?q?=20=EC=96=B4=EB=93=9C=EB=AF=BC=20=EA=B6=8C=ED=95=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/roomescape/theme/docs/ThemeApi.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index f3a42007..a367e9bd 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -8,6 +8,7 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.Public @@ -21,22 +22,22 @@ interface ThemeAPI { @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemes(): ResponseEntity> - @AdminOnly(privilege = Privilege.READ_DETAIL) + @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL) @Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity> - @AdminOnly(privilege = Privilege.CREATE) + @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity> - @AdminOnly(privilege = Privilege.DELETE) + @AdminOnly(type = AdminType.HQ, privilege = Privilege.DELETE) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteTheme(@PathVariable id: Long): ResponseEntity> - @AdminOnly(privilege = Privilege.UPDATE) + @AdminOnly(type = AdminType.HQ, privilege = Privilege.UPDATE) @Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateTheme( -- 2.47.2 From c3eceedea1da38d9cd93cff8e7e8a0d8cd65408f Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 23:35:22 +0900 Subject: [PATCH 035/116] =?UTF-8?q?test:=20=ED=85=8C=EB=A7=88=20API=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/supports/Fixtures.kt | 43 ++++- .../kotlin/roomescape/theme/ThemeApiTest.kt | 163 +++++++++++++----- 2 files changed, 159 insertions(+), 47 deletions(-) diff --git a/src/test/kotlin/roomescape/supports/Fixtures.kt b/src/test/kotlin/roomescape/supports/Fixtures.kt index 7c77fc2c..683900c0 100644 --- a/src/test/kotlin/roomescape/supports/Fixtures.kt +++ b/src/test/kotlin/roomescape/supports/Fixtures.kt @@ -64,12 +64,53 @@ object AdminFixture { storeId = null ) + fun createStoreAdmin( + id: Long = tsidFactory.next(), + account: String = "admin", + password: String = "adminPassword", + name: String = "admin12345", + phone: String = randomPhoneNumber(), + storeId: Long = tsidFactory.next(), + permissionLevel: AdminPermissionLevel = AdminPermissionLevel.FULL_ACCESS + ): AdminEntity { + return create( + id = id, + account = account, + password = password, + name = name, + phone = phone, + type = AdminType.STORE, + storeId = storeId, + permissionLevel = permissionLevel + ) + } + + fun createHqAdmin( + id: Long = tsidFactory.next(), + account: String = "admin", + password: String = "adminPassword", + name: String = "admin12345", + phone: String = randomPhoneNumber(), + permissionLevel: AdminPermissionLevel = AdminPermissionLevel.FULL_ACCESS + ): AdminEntity { + return create( + id = id, + account = account, + password = password, + name = name, + phone = phone, + type = AdminType.HQ, + storeId = null, + permissionLevel = permissionLevel + ) + } + fun create( id: Long = tsidFactory.next(), account: String = "admin", password: String = "adminPassword", name: String = "admin12345", - phone: String = "01012345678", + phone: String = randomPhoneNumber(), type: AdminType = AdminType.STORE, storeId: Long? = tsidFactory.next(), permissionLevel: AdminPermissionLevel = AdminPermissionLevel.FULL_ACCESS diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index 01aa4fa3..9e6e1645 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -11,6 +11,7 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.auth.exception.AuthErrorCode import roomescape.theme.business.MIN_DURATION import roomescape.theme.business.MIN_PARTICIPANTS @@ -52,9 +53,9 @@ class ThemeApiTest( ) } - listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { - test("권한이 ${it}인 관리자") { - val admin = AdminFixture.create(permissionLevel = it) + AdminPermissionLevel.entries.forEach { + test("관리자: Type=${AdminType.STORE} / Permission=${it}") { + val admin = AdminFixture.createStoreAdmin(permissionLevel = it) runExceptionTest( token = authUtil.adminLogin(admin), @@ -64,12 +65,26 @@ class ThemeApiTest( expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } + + if (it == AdminPermissionLevel.READ_ALL || it == AdminPermissionLevel.READ_SUMMARY) { + test("관리자: Type=${AdminType.HQ} / Permission=${it}") { + val admin = AdminFixture.createHqAdmin(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } } } test("정상 생성 및 감사 정보 확인") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, @@ -97,7 +112,7 @@ class ThemeApiTest( } test("이미 동일한 이름의 테마가 있으면 실패한다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() val commonName = "test123" dummyInitializer.createTheme( adminToken = token, @@ -120,7 +135,7 @@ class ThemeApiTest( } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -143,7 +158,7 @@ class ThemeApiTest( } test("field: availableMinutes") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -157,7 +172,7 @@ class ThemeApiTest( } test("field: expectedMinutesFrom") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -171,7 +186,7 @@ class ThemeApiTest( } test("field: expectedMinutesTo") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -187,7 +202,7 @@ class ThemeApiTest( context("시간 범위가 잘못 지정되면 실패한다.") { test("최소 예상 시간 > 최대 예상 시간") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -204,7 +219,7 @@ class ThemeApiTest( } test("최대 예상 시간 > 이용 가능 시간") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -234,7 +249,7 @@ class ThemeApiTest( } test("field: minParticipants") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -248,7 +263,7 @@ class ThemeApiTest( } test("field: maxParticipants") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -264,7 +279,7 @@ class ThemeApiTest( context("인원 범위가 잘못 지정되면 실패한다.") { test("최소 인원 > 최대 인원") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -284,7 +299,7 @@ class ThemeApiTest( context("입력된 모든 ID에 대한 테마를 조회한다.") { test("정상 응답") { - val adminToken = authUtil.defaultStoreAdminLogin() + val adminToken = authUtil.defaultHqAdminLogin() val themeSize = 3 val themeIds = mutableListOf() @@ -309,7 +324,7 @@ class ThemeApiTest( } test("없는 테마가 있으면 생략한다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() val themeSize = 3 val themeIds = mutableListOf() @@ -359,11 +374,25 @@ class ThemeApiTest( expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } + + AdminPermissionLevel.entries.forEach { + test("관리자: Type=${AdminType.STORE} / Permission=${it}") { + val admin = AdminFixture.createStoreAdmin(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } } test("비공개 테마까지 포함하여 간단한 정보만 조회된다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() requests.forEach { dummyInitializer.createTheme(token, it) } runTest( @@ -384,7 +413,7 @@ class ThemeApiTest( context("예약 페이지에서 테마를 조회한다.") { test("공개된 테마의 전체 정보가 조회된다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() listOf( createRequest.copy(name = "open", isOpen = true), createRequest.copy(name = "close", isOpen = false) @@ -434,20 +463,35 @@ class ThemeApiTest( ) } - test("권한이 ${AdminPermissionLevel.READ_SUMMARY}인 관리자") { - val admin = AdminFixture.create(permissionLevel = AdminPermissionLevel.READ_SUMMARY) + AdminPermissionLevel.entries.forEach { + test("관리자: Type=${AdminType.STORE} / Permission=${it}") { + val admin = AdminFixture.createStoreAdmin(permissionLevel = it) - runExceptionTest( - token = authUtil.adminLogin(admin), - method = HttpMethod.GET, - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.ACCESS_DENIED - ) + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + if (it == AdminPermissionLevel.READ_SUMMARY) { + test("관리자: Type=${AdminType.HQ} / Permission=${it}") { + val admin = AdminFixture.createHqAdmin(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } } } test("정상 응답") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -475,7 +519,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = authUtil.defaultHqAdminLogin(), method = HttpMethod.GET, endpoint = "/admin/themes/$INVALID_PK", expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND @@ -505,9 +549,9 @@ class ThemeApiTest( ) } - listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { - test("권한이 ${it}인 관리자") { - val admin = AdminFixture.create(permissionLevel = it) + AdminPermissionLevel.entries.forEach { + test("관리자: Type=${AdminType.STORE} / Permission=${it}") { + val admin = AdminFixture.createStoreAdmin(permissionLevel = it) runExceptionTest( token = authUtil.adminLogin(admin), @@ -516,11 +560,24 @@ class ThemeApiTest( expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } + + if (it == AdminPermissionLevel.READ_ALL || it == AdminPermissionLevel.READ_SUMMARY) { + test("관리자: Type=${AdminType.HQ} / Permission=${it}") { + val admin = AdminFixture.createHqAdmin(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.DELETE, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } } } test("정상 삭제") { - val token = authUtil.defaultStoreAdminLogin() + val token = authUtil.defaultHqAdminLogin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -541,7 +598,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = authUtil.defaultHqAdminLogin(), method = HttpMethod.DELETE, endpoint = "/admin/themes/$INVALID_PK", expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND @@ -573,9 +630,9 @@ class ThemeApiTest( ) } - listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { - test("권한이 ${it}인 관리자") { - val admin = AdminFixture.create(permissionLevel = it) + AdminPermissionLevel.entries.forEach { + test("관리자: Type=${AdminType.STORE} / Permission=${it}") { + val admin = AdminFixture.createStoreAdmin(permissionLevel = it) runExceptionTest( token = authUtil.adminLogin(admin), @@ -585,6 +642,20 @@ class ThemeApiTest( expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } + + if (it == AdminPermissionLevel.READ_ALL || it == AdminPermissionLevel.READ_SUMMARY) { + test("관리자: Type=${AdminType.HQ} / Permission=${it}") { + val admin = AdminFixture.createHqAdmin(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.PATCH, + endpoint = endpoint, + requestBody = request, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } } } @@ -592,11 +663,11 @@ class ThemeApiTest( test("정상 수정 및 감사 정보 변경 확인") { val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = authUtil.defaultHqAdminLogin(), request = createRequest.copy(name = "theme-${Random.nextInt()}") ) val otherAdminToken: String = authUtil.adminLogin( - AdminFixture.create(account = "hello", phone = "0101828402") + AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE) ) runTest( @@ -622,12 +693,12 @@ class ThemeApiTest( test("입력값이 없으면 수정하지 않는다.") { val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = authUtil.defaultHqAdminLogin(), request = createRequest.copy(name = "theme-${Random.nextInt()}") ) runTest( - token = authUtil.defaultStoreAdminLogin(), + token = authUtil.defaultHqAdminLogin(), using = { body(ThemeUpdateRequest()) }, @@ -647,7 +718,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = authUtil.defaultHqAdminLogin(), method = HttpMethod.PATCH, endpoint = "/admin/themes/$INVALID_PK", requestBody = updateRequest, @@ -656,7 +727,7 @@ class ThemeApiTest( } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { - val adminToken = authUtil.defaultStoreAdminLogin() + val adminToken = authUtil.defaultHqAdminLogin() val createdTheme: ThemeEntity = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -676,7 +747,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultStoreAdminLogin() + adminToken = authUtil.defaultHqAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -719,7 +790,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultStoreAdminLogin() + adminToken = authUtil.defaultHqAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -760,7 +831,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultStoreAdminLogin() + adminToken = authUtil.defaultHqAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -793,7 +864,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultStoreAdminLogin() + adminToken = authUtil.defaultHqAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") -- 2.47.2 From 06549e8ac166baf8721ce915749789f3e627e52f Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 11:54:48 +0900 Subject: [PATCH 036/116] =?UTF-8?q?refactor:=20Theme=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=EC=9D=98=20isOpen=20->=20isActive=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/theme/business/ThemeService.kt | 2 +- .../infrastructure/persistence/ThemeEntity.kt | 6 +++--- .../persistence/ThemeRepository.kt | 4 ++-- .../theme/web/{ThemeDto.kt => AdminThemeDto.kt} | 16 ++++++++-------- src/main/resources/schema/schema-h2.sql | 2 +- src/test/kotlin/roomescape/supports/Fixtures.kt | 2 +- src/test/kotlin/roomescape/theme/ThemeApiTest.kt | 8 ++++---- 7 files changed, 20 insertions(+), 20 deletions(-) rename src/main/kotlin/roomescape/theme/web/{ThemeDto.kt => AdminThemeDto.kt} (95%) diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index a39572ae..17f38aac 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -131,7 +131,7 @@ class ThemeService( request.availableMinutes, request.expectedMinutesFrom, request.expectedMinutesTo, - request.isOpen, + request.isActive, ).also { log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" } } diff --git a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeEntity.kt b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeEntity.kt index df67be94..989b9a64 100644 --- a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeEntity.kt +++ b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeEntity.kt @@ -23,7 +23,7 @@ class ThemeEntity( var expectedMinutesTo: Short, @Column(columnDefinition = "TINYINT", length = 1) - var isOpen: Boolean + var isActive: Boolean ) : AuditingBaseEntity(id) { fun modifyIfNotNull( @@ -37,7 +37,7 @@ class ThemeEntity( availableMinutes: Short?, expectedMinutesFrom: Short?, expectedMinutesTo: Short?, - isOpen: Boolean? + isActive: Boolean? ) { name?.let { this.name = it } description?.let { this.description = it } @@ -49,7 +49,7 @@ class ThemeEntity( availableMinutes?.let { this.availableMinutes = it } expectedMinutesFrom?.let { this.expectedMinutesFrom = it } expectedMinutesTo?.let { this.expectedMinutesTo = it } - isOpen?.let { this.isOpen = it } + isActive?.let { this.isActive = it } } } diff --git a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt index 1773826b..8e55104b 100644 --- a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt +++ b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt @@ -5,8 +5,8 @@ import org.springframework.data.jpa.repository.Query interface ThemeRepository : JpaRepository { - @Query("SELECT t FROM ThemeEntity t WHERE t.isOpen = true") - fun findOpenedThemes(): List + @Query("SELECT t FROM ThemeEntity t WHERE t.isActive = true") + fun findActiveThemes(): List fun existsByName(name: String): Boolean } \ No newline at end of file diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDto.kt b/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt similarity index 95% rename from src/main/kotlin/roomescape/theme/web/ThemeDto.kt rename to src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt index 814f1076..e37070ee 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDto.kt +++ b/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt @@ -16,7 +16,7 @@ data class ThemeCreateRequest( val availableMinutes: Short, val expectedMinutesFrom: Short, val expectedMinutesTo: Short, - val isOpen: Boolean + val isActive: Boolean ) data class ThemeCreateResponseV2( @@ -35,7 +35,7 @@ fun ThemeCreateRequest.toEntity(id: Long) = ThemeEntity( availableMinutes = this.availableMinutes, expectedMinutesFrom = this.expectedMinutesFrom, expectedMinutesTo = this.expectedMinutesTo, - isOpen = this.isOpen + isActive = this.isActive ) data class ThemeUpdateRequest( @@ -49,7 +49,7 @@ data class ThemeUpdateRequest( val availableMinutes: Short? = null, val expectedMinutesFrom: Short? = null, val expectedMinutesTo: Short? = null, - val isOpen: Boolean? = null, + val isActive: Boolean? = null, ) { fun isAllParamsNull(): Boolean { return name == null && @@ -62,7 +62,7 @@ data class ThemeUpdateRequest( availableMinutes == null && expectedMinutesFrom == null && expectedMinutesTo == null && - isOpen == null + isActive == null } } @@ -71,7 +71,7 @@ data class AdminThemeSummaryResponse( val name: String, val difficulty: Difficulty, val price: Int, - val isOpen: Boolean + val isActive: Boolean ) fun ThemeEntity.toAdminThemeSummaryResponse() = AdminThemeSummaryResponse( @@ -79,7 +79,7 @@ fun ThemeEntity.toAdminThemeSummaryResponse() = AdminThemeSummaryResponse( name = this.name, difficulty = this.difficulty, price = this.price, - isOpen = this.isOpen + isActive = this.isActive ) data class AdminThemeSummaryListResponse( @@ -102,7 +102,7 @@ data class AdminThemeDetailResponse( val availableMinutes: Short, val expectedMinutesFrom: Short, val expectedMinutesTo: Short, - val isOpen: Boolean, + val isActive: Boolean, val createdAt: LocalDateTime, val createdBy: OperatorInfo, val updatedAt: LocalDateTime, @@ -122,7 +122,7 @@ fun ThemeEntity.toAdminThemeDetailResponse(createdBy: OperatorInfo, updatedBy: O availableMinutes = this.availableMinutes, expectedMinutesFrom = this.expectedMinutesFrom, expectedMinutesTo = this.expectedMinutesTo, - isOpen = this.isOpen, + isActive = this.isActive, createdAt = this.createdAt, createdBy = createdBy, updatedAt = this.updatedAt, diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index d4b6fe73..3a3c1b45 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -114,7 +114,7 @@ create table if not exists theme ( available_minutes smallint not null, expected_minutes_from smallint not null, expected_minutes_to smallint not null, - is_open boolean not null, + is_active boolean not null, created_at timestamp not null, created_by bigint not null, updated_at timestamp not null, diff --git a/src/test/kotlin/roomescape/supports/Fixtures.kt b/src/test/kotlin/roomescape/supports/Fixtures.kt index 683900c0..d793a0bd 100644 --- a/src/test/kotlin/roomescape/supports/Fixtures.kt +++ b/src/test/kotlin/roomescape/supports/Fixtures.kt @@ -168,7 +168,7 @@ object ThemeFixture { availableMinutes = 80, expectedMinutesFrom = 60, expectedMinutesTo = 70, - isOpen = true + isActive = true ) } diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index 9e6e1645..e07aa933 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -353,8 +353,8 @@ class ThemeApiTest( context("관리자가 모든 테마를 조회한다.") { val endpoint = "/admin/themes" val requests = listOf( - createRequest.copy(name = "open", isOpen = true), - createRequest.copy(name = "close", isOpen = false) + createRequest.copy(name = "open", isActive = true), + createRequest.copy(name = "close", isActive = false) ) context("권한이 없으면 접근할 수 없다.") { @@ -403,7 +403,7 @@ class ThemeApiTest( expect = { body("data.themes.size()", equalTo(requests.size)) assertProperties( - props = setOf("id", "name", "difficulty", "price", "isOpen"), + props = setOf("id", "name", "difficulty", "price", "isActive"), propsNameIfList = "themes", ) } @@ -507,7 +507,7 @@ class ThemeApiTest( body("data.id", equalTo(createdTheme.id)) assertProperties( props = setOf( - "id", "name", "description", "thumbnailUrl", "difficulty", "price", "isOpen", + "id", "name", "description", "thumbnailUrl", "difficulty", "price", "isActive", "minParticipants", "maxParticipants", "availableMinutes", "expectedMinutesFrom", "expectedMinutesTo", "createdAt", "createdBy", "updatedAt", "updatedBy" -- 2.47.2 From da88d66505698fbedf36d58637148b521e46b26b Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 11:55:27 +0900 Subject: [PATCH 037/116] =?UTF-8?q?refactor:=20=ED=85=8C=EB=A7=88=20API?= =?UTF-8?q?=EB=A5=BC=20=EA=B6=8C=ED=95=9C=EB=B3=84=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/theme/business/ThemeService.kt | 58 +++++++++++++------ .../kotlin/roomescape/theme/docs/ThemeApi.kt | 19 ++++-- ...eController.kt => AdminThemeController.kt} | 37 ++++++------ .../roomescape/theme/web/AdminThemeDto.kt | 18 +++++- .../theme/web/PublicThemeController.kt | 37 ++++++++++++ .../roomescape/theme/web/PublicThemeDto.kt | 44 ++++++++++++++ 6 files changed, 167 insertions(+), 46 deletions(-) rename src/main/kotlin/roomescape/theme/web/{ThemeController.kt => AdminThemeController.kt} (76%) create mode 100644 src/main/kotlin/roomescape/theme/web/PublicThemeController.kt create mode 100644 src/main/kotlin/roomescape/theme/web/PublicThemeDto.kt diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 17f38aac..e641a533 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -16,6 +16,13 @@ import roomescape.theme.web.* private val log: KLogger = KotlinLogging.logger {} +/** + * Structure: + * - Public: 모두가 접근 가능한 메서드 + * - Store Admin: 매장 관리자가 사용하는 메서드 + * - HQ Admin: 본사 관리자가 사용하는 메서드 + * - Common: 공통 메서드 + */ @Service class ThemeService( private val themeRepository: ThemeRepository, @@ -23,6 +30,17 @@ class ThemeService( private val tsidFactory: TsidFactory, private val adminService: AdminService ) { + // ======================================== + // Public (인증 불필요) + // ======================================== + @Transactional(readOnly = true) + fun findInfoById(id: Long): ThemeInfoResponse { + log.info { "[ThemeService.findById] 테마 조회 시작: id=$id" } + + return findOrThrow(id).toInfoResponse() + .also { log.info { "[ThemeService.findById] 테마 조회 완료: id=$id" } } + } + @Transactional(readOnly = true) fun findThemesByIds(request: ThemeIdListResponse): ThemeInfoListResponse { log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" } @@ -37,20 +55,14 @@ class ThemeService( result.add(theme) } - return result.toListResponse().also { + return result.toInfoListResponse().also { log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" } } } - @Transactional(readOnly = true) - fun findThemesForReservation(): ThemeInfoListResponse { - log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" } - - return themeRepository.findOpenedThemes() - .toListResponse() - .also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } } - } - + // ======================================== + // HQ Admin (본사) + // ======================================== @Transactional(readOnly = true) fun findAdminThemes(): AdminThemeSummaryListResponse { log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" } @@ -73,14 +85,6 @@ class ThemeService( .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } } - @Transactional(readOnly = true) - fun findSummaryById(id: Long): ThemeInfoResponse { - log.info { "[ThemeService.findById] 테마 조회 시작: id=$id" } - - return findOrThrow(id).toSummaryResponse() - .also { log.info { "[ThemeService.findById] 테마 조회 완료: id=$id" } } - } - @Transactional fun createTheme(request: ThemeCreateRequest): ThemeCreateResponseV2 { log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" } @@ -137,6 +141,24 @@ class ThemeService( } } + // ======================================== + // Store Admin (매장) + // ======================================== + @Transactional(readOnly = true) + fun findActiveThemes(): SimpleActiveThemeListResponse { + log.info { "[ThemeService.findActiveThemes] open 상태인 모든 테마 조회 시작" } + + return themeRepository.findActiveThemes() + .toSimpleActiveThemeResponse() + .also { + log.info { "[ThemeService.findActiveThemes] ${it.themes.size}개 테마 조회 완료" } + } + } + + + // ======================================== + // Common (공통 메서드) + // ======================================== private fun findOrThrow(id: Long): ThemeEntity { log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" } diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index a367e9bd..94d68e63 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -16,7 +16,7 @@ import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.web.* @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") -interface ThemeAPI { +interface HQAdminThemeAPI { @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @@ -44,14 +44,23 @@ interface ThemeAPI { @PathVariable id: Long, @Valid @RequestBody themeUpdateRequest: ThemeUpdateRequest ): ResponseEntity> +} - @Public - @Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.") +interface StoreAdminThemeAPI { + @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_SUMMARY) + @Operation(summary = "테마 조회", description = "현재 open 상태인 모든 테마 ID + 이름 조회", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findUserThemes(): ResponseEntity> + fun findActiveThemes(): ResponseEntity> +} +interface PublicThemeAPI { @Public - @Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.") + @Operation(summary = "입력된 모든 ID에 대한 테마 조회") @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findThemesByIds(request: ThemeIdListResponse): ResponseEntity> + + @Public + @Operation(summary = "입력된 테마 ID에 대한 정보 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findThemeInfoById(@PathVariable id: Long): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/theme/web/ThemeController.kt b/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt similarity index 76% rename from src/main/kotlin/roomescape/theme/web/ThemeController.kt rename to src/main/kotlin/roomescape/theme/web/AdminThemeController.kt index dbd66da5..5554ac16 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt @@ -4,30 +4,14 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.business.ThemeService -import roomescape.theme.docs.ThemeAPI +import roomescape.theme.docs.HQAdminThemeAPI +import roomescape.theme.docs.StoreAdminThemeAPI import java.net.URI @RestController -class ThemeController( +class HQAdminThemeController( private val themeService: ThemeService, -) : ThemeAPI { - - @PostMapping("/themes/retrieve") - override fun findThemesByIds( - @RequestBody request: ThemeIdListResponse - ): ResponseEntity> { - val response = themeService.findThemesByIds(request) - - return ResponseEntity.ok(CommonApiResponse(response)) - } - - @GetMapping("/themes") - override fun findUserThemes(): ResponseEntity> { - val response = themeService.findThemesForReservation() - - return ResponseEntity.ok(CommonApiResponse(response)) - } - +) : HQAdminThemeAPI { @GetMapping("/admin/themes") override fun findAdminThemes(): ResponseEntity> { val response = themeService.findAdminThemes() @@ -67,3 +51,16 @@ class ThemeController( return ResponseEntity.ok().build() } } + +@RestController +class StoreAdminController( + private val themeService: ThemeService +) : StoreAdminThemeAPI { + + @GetMapping("/admin/themes/active") + override fun findActiveThemes(): ResponseEntity> { + val response = themeService.findActiveThemes() + + return ResponseEntity.ok(CommonApiResponse(response)) + } +} diff --git a/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt b/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt index e37070ee..3306d22e 100644 --- a/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt +++ b/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt @@ -5,6 +5,18 @@ import roomescape.theme.infrastructure.persistence.Difficulty import roomescape.theme.infrastructure.persistence.ThemeEntity import java.time.LocalDateTime +/** + * Theme API DTO + * + * Structure: + * - HQ Admin DTO: 본사 관리자가 사용하는 테마 관련 DTO들 + * - Store Admin DTO: 매장 관리자가 사용하는 테마 관련 DTO들 + */ + + +// ======================================== +// HQ Admin DTO (본사) +// ======================================== data class ThemeCreateRequest( val name: String, val description: String, @@ -129,9 +141,9 @@ fun ThemeEntity.toAdminThemeDetailResponse(createdBy: OperatorInfo, updatedBy: O updatedBy = updatedBy ) -data class ThemeIdListResponse( - val themeIds: List -) +// ======================================== +// Store Admin DTO +// ======================================== data class ThemeInfoResponse( val id: Long, diff --git a/src/main/kotlin/roomescape/theme/web/PublicThemeController.kt b/src/main/kotlin/roomescape/theme/web/PublicThemeController.kt new file mode 100644 index 00000000..45eea3d0 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/web/PublicThemeController.kt @@ -0,0 +1,37 @@ +package roomescape.theme.web + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import roomescape.common.dto.response.CommonApiResponse +import roomescape.theme.business.ThemeService +import roomescape.theme.docs.PublicThemeAPI + +@RestController +@RequestMapping("/themes") +class PublicThemeController( + private val themeService: ThemeService, +): PublicThemeAPI { + + @PostMapping("/batch") + override fun findThemesByIds( + @RequestBody request: ThemeIdListRequest + ): ResponseEntity> { + val response = themeService.findThemesByIds(request) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/{id}") + override fun findThemeInfoById( + @PathVariable id: Long + ): ResponseEntity> { + val response = themeService.findInfoById(id) + + return ResponseEntity.ok(CommonApiResponse(response)) + } +} diff --git a/src/main/kotlin/roomescape/theme/web/PublicThemeDto.kt b/src/main/kotlin/roomescape/theme/web/PublicThemeDto.kt new file mode 100644 index 00000000..e79601a2 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/web/PublicThemeDto.kt @@ -0,0 +1,44 @@ +package roomescape.theme.web + +import roomescape.theme.infrastructure.persistence.Difficulty +import roomescape.theme.infrastructure.persistence.ThemeEntity + +data class ThemeIdListRequest( + val themeIds: List +) + +data class ThemeInfoResponse( + val id: Long, + val name: String, + val thumbnailUrl: String, + val description: String, + val difficulty: Difficulty, + val price: Int, + val minParticipants: Short, + val maxParticipants: Short, + val availableMinutes: Short, + val expectedMinutesFrom: Short, + val expectedMinutesTo: Short +) + +fun ThemeEntity.toInfoResponse() = ThemeInfoResponse( + id = this.id, + name = this.name, + thumbnailUrl = this.thumbnailUrl, + description = this.description, + difficulty = this.difficulty, + price = this.price, + minParticipants = this.minParticipants, + maxParticipants = this.maxParticipants, + availableMinutes = this.availableMinutes, + expectedMinutesFrom = this.expectedMinutesFrom, + expectedMinutesTo = this.expectedMinutesTo +) + +data class ThemeInfoListResponse( + val themes: List +) + +fun List.toInfoListResponse() = ThemeInfoListResponse( + themes = this.map { it.toInfoResponse() } +) -- 2.47.2 From 8205e83b4ae28467ede5559de8a09bf05b5bd259 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 12:08:46 +0900 Subject: [PATCH 038/116] =?UTF-8?q?refactor:=20DTO=20=EB=AA=85=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/theme/themeAPI.ts | 2 +- .../roomescape/theme/business/ThemeService.kt | 6 +-- .../kotlin/roomescape/theme/docs/ThemeApi.kt | 4 +- .../theme/web/AdminThemeController.kt | 2 +- .../roomescape/theme/web/AdminThemeDto.kt | 36 ++++------------ .../kotlin/roomescape/theme/ThemeApiTest.kt | 41 ++++++++++--------- 6 files changed, 38 insertions(+), 53 deletions(-) diff --git a/frontend/src/api/theme/themeAPI.ts b/frontend/src/api/theme/themeAPI.ts index 3db475d2..7282709c 100644 --- a/frontend/src/api/theme/themeAPI.ts +++ b/frontend/src/api/theme/themeAPI.ts @@ -34,5 +34,5 @@ export const fetchUserThemes = async (): Promise => { }; export const findThemesByIds = async (request: ThemeIdListResponse): Promise => { - return await apiClient.post('/themes/retrieve', request); + return await apiClient.post('/themes/batch', request); }; diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index e641a533..81f96d37 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -42,7 +42,7 @@ class ThemeService( } @Transactional(readOnly = true) - fun findThemesByIds(request: ThemeIdListResponse): ThemeInfoListResponse { + fun findThemesByIds(request: ThemeIdListRequest): ThemeInfoListResponse { log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" } val result: MutableList = mutableListOf() @@ -86,7 +86,7 @@ class ThemeService( } @Transactional - fun createTheme(request: ThemeCreateRequest): ThemeCreateResponseV2 { + fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse { log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" } themeValidator.validateCanCreate(request) @@ -95,7 +95,7 @@ class ThemeService( request.toEntity(tsidFactory.next()) ) - return ThemeCreateResponseV2(theme.id).also { + return ThemeCreateResponse(theme.id).also { log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" } } } diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index 94d68e63..e0d8db6f 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -30,7 +30,7 @@ interface HQAdminThemeAPI { @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity> + fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity> @AdminOnly(type = AdminType.HQ, privilege = Privilege.DELETE) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @@ -57,7 +57,7 @@ interface PublicThemeAPI { @Public @Operation(summary = "입력된 모든 ID에 대한 테마 조회") @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findThemesByIds(request: ThemeIdListResponse): ResponseEntity> + fun findThemesByIds(request: ThemeIdListRequest): ResponseEntity> @Public @Operation(summary = "입력된 테마 ID에 대한 정보 조회") diff --git a/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt b/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt index 5554ac16..e0412600 100644 --- a/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt @@ -27,7 +27,7 @@ class HQAdminThemeController( } @PostMapping("/admin/themes") - override fun createTheme(themeCreateRequest: ThemeCreateRequest): ResponseEntity> { + override fun createTheme(themeCreateRequest: ThemeCreateRequest): ResponseEntity> { val response = themeService.createTheme(themeCreateRequest) return ResponseEntity.created(URI.create("/admin/themes/${response.id}")) diff --git a/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt b/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt index 3306d22e..ef6d0cf2 100644 --- a/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt +++ b/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt @@ -31,7 +31,7 @@ data class ThemeCreateRequest( val isActive: Boolean ) -data class ThemeCreateResponseV2( +data class ThemeCreateResponse( val id: Long ) @@ -145,38 +145,20 @@ fun ThemeEntity.toAdminThemeDetailResponse(createdBy: OperatorInfo, updatedBy: O // Store Admin DTO // ======================================== -data class ThemeInfoResponse( +data class SimpleActiveThemeResponse( val id: Long, - val name: String, - val thumbnailUrl: String, - val description: String, - val difficulty: Difficulty, - val price: Int, - val minParticipants: Short, - val maxParticipants: Short, - val availableMinutes: Short, - val expectedMinutesFrom: Short, - val expectedMinutesTo: Short + val name: String ) -fun ThemeEntity.toSummaryResponse() = ThemeInfoResponse( +fun ThemeEntity.toSimpleActiveThemeResponse() = SimpleActiveThemeResponse( id = this.id, - name = this.name, - thumbnailUrl = this.thumbnailUrl, - description = this.description, - difficulty = this.difficulty, - price = this.price, - minParticipants = this.minParticipants, - maxParticipants = this.maxParticipants, - availableMinutes = this.availableMinutes, - expectedMinutesFrom = this.expectedMinutesFrom, - expectedMinutesTo = this.expectedMinutesTo + name = this.name ) -data class ThemeInfoListResponse( - val themes: List +data class SimpleActiveThemeListResponse( + val themes: List ) -fun List.toListResponse() = ThemeInfoListResponse( - themes = this.map { it.toSummaryResponse() } +fun List.toSimpleActiveThemeResponse() = SimpleActiveThemeListResponse( + themes = this.map { it.toSimpleActiveThemeResponse() } ) diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index e07aa933..4007516a 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -19,7 +19,7 @@ import roomescape.theme.business.MIN_PRICE import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.ThemeIdListResponse +import roomescape.theme.web.ThemeIdListRequest import roomescape.theme.web.ThemeUpdateRequest import roomescape.supports.* import roomescape.supports.ThemeFixture.createRequest @@ -311,10 +311,10 @@ class ThemeApiTest( runTest( token = authUtil.defaultUserLogin(), using = { - body(ThemeIdListResponse(themeIds)) + body(ThemeIdListRequest(themeIds)) }, on = { - post("/themes/retrieve") + post("/themes/batch") }, expect = { statusCode(HttpStatus.OK.value()) @@ -337,10 +337,10 @@ class ThemeApiTest( runTest( token = authUtil.defaultUserLogin(), using = { - body(ThemeIdListResponse(themeIds)) + body(ThemeIdListRequest(themeIds)) }, on = { - post("/themes/retrieve") + post("/themes/batch") }, expect = { statusCode(HttpStatus.OK.value()) @@ -411,35 +411,38 @@ class ThemeApiTest( } } - context("예약 페이지에서 테마를 조회한다.") { - test("공개된 테마의 전체 정보가 조회된다.") { - val token = authUtil.defaultHqAdminLogin() - listOf( - createRequest.copy(name = "open", isOpen = true), - createRequest.copy(name = "close", isOpen = false) - ).forEach { - dummyInitializer.createTheme(token, it) - } + context("ID로 테마 정보를 조회한다.") { + test("성공 응답") { + val createdTheme: ThemeEntity = dummyInitializer.createTheme( + adminToken = authUtil.defaultHqAdminLogin(), + request = createRequest + ) runTest( - token = authUtil.defaultUserLogin(), on = { - get("/themes") + get("/themes/${createdTheme.id}") }, expect = { - body("data.themes.size()", equalTo(1)) - body("data.themes[0].name", equalTo("open")) + body("data.id", equalTo(createdTheme.id)) + body("data.name", equalTo(createdTheme.name)) assertProperties( props = setOf( "id", "name", "thumbnailUrl", "description", "difficulty", "price", "minParticipants", "maxParticipants", "availableMinutes", "expectedMinutesFrom", "expectedMinutesTo" ), - propsNameIfList = "themes", ) } ) } + + test("테마가 없으면 실패한다.") { + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/themes/$INVALID_PK", + expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND + ) + } } context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") { -- 2.47.2 From 5b4df7bef6d9bb1ba0072417f3b613e6fdc4a4ef Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 12:09:05 +0900 Subject: [PATCH 039/116] =?UTF-8?q?refactor:=20DTO=20=EB=AA=85=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/reservation/business/ReservationService.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index e584a11d..daf402c5 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -8,7 +8,6 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import roomescape.common.config.next import roomescape.common.dto.CurrentUserContext -import roomescape.common.dto.PrincipalType import roomescape.common.util.DateUtils import roomescape.user.business.UserService import roomescape.user.web.UserContactResponse @@ -101,7 +100,7 @@ class ReservationService( return ReservationSummaryListResponse(reservations.map { val schedule: ScheduleSummaryResponse = scheduleService.findSummaryById(it.scheduleId) - val theme: ThemeInfoResponse = themeService.findSummaryById(schedule.themeId) + val theme: ThemeInfoResponse = themeService.findInfoById(schedule.themeId) ReservationSummaryResponse( id = it.id, @@ -183,7 +182,7 @@ class ReservationService( private fun validateCanCreate(request: PendingReservationCreateRequest) { val schedule = scheduleService.findSummaryById(request.scheduleId) - val theme = themeService.findSummaryById(schedule.themeId) + val theme = themeService.findInfoById(schedule.themeId) reservationValidator.validateCanCreate(schedule, theme, request) } -- 2.47.2 From 228ea32db123253f9d2edb07654fc30e43e2d7d4 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 12:28:29 +0900 Subject: [PATCH 040/116] =?UTF-8?q?refactor:=20AuthUtil=20->=20TestAuthUti?= =?UTF-8?q?l=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/auth/AuthApiTest.kt | 12 +- .../auth/FailOnSaveLoginHistoryTest.kt | 4 +- .../roomescape/payment/PaymentAPITest.kt | 24 +-- .../reservation/ReservationApiTest.kt | 72 ++++---- .../roomescape/schedule/ScheduleApiTest.kt | 60 +++--- .../roomescape/supports/KotestConfig.kt | 4 +- .../roomescape/supports/TestAuthUtil.kt | 94 ++++++++++ ...ThemeApiTest.kt => HQAdminThemeApiTest.kt} | 171 +++++------------- .../kotlin/roomescape/user/UserApiTest.kt | 6 +- 9 files changed, 226 insertions(+), 221 deletions(-) create mode 100644 src/test/kotlin/roomescape/supports/TestAuthUtil.kt rename src/test/kotlin/roomescape/theme/{ThemeApiTest.kt => HQAdminThemeApiTest.kt} (84%) diff --git a/src/test/kotlin/roomescape/auth/AuthApiTest.kt b/src/test/kotlin/roomescape/auth/AuthApiTest.kt index fdacef30..6d3c9173 100644 --- a/src/test/kotlin/roomescape/auth/AuthApiTest.kt +++ b/src/test/kotlin/roomescape/auth/AuthApiTest.kt @@ -38,7 +38,7 @@ class AuthApiTest( AdminFixture.hqDefault ).forEach { test("${it.type} 타입 관리자") { - val admin = authUtil.createAdmin(it) + val admin = testAuthUtil.createAdmin(it) runLoginSuccessTest( id = admin.id, @@ -56,7 +56,7 @@ class AuthApiTest( } test("회원") { - val user: UserEntity = authUtil.signup(UserFixture.createRequest) + val user: UserEntity = testAuthUtil.signup(UserFixture.createRequest) runLoginSuccessTest( id = user.id, @@ -73,7 +73,7 @@ class AuthApiTest( context("실패 응답") { context("계정이 맞으면 로그인 실패 이력을 남긴다.") { test("비밀번호가 틀린 경우") { - val admin = authUtil.createAdmin(AdminFixture.default) + val admin = testAuthUtil.createAdmin(AdminFixture.default) val request = LoginRequest(admin.account, "wrong_password", PrincipalType.ADMIN) runTest( @@ -96,7 +96,7 @@ class AuthApiTest( } test("토큰 생성 과정에서 오류가 발생하는 경우") { - val admin = authUtil.createAdmin(AdminFixture.default) + val admin = testAuthUtil.createAdmin(AdminFixture.default) val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN) every { @@ -125,7 +125,7 @@ class AuthApiTest( context("계정이 일치하지 않으면 로그인 실패 이력을 남기지 않는다.") { test("회원") { - val user = authUtil.signup(UserFixture.createRequest) + val user = testAuthUtil.signup(UserFixture.createRequest) val invalidEmail = "test@email.com".also { it shouldNotBe user.email } @@ -149,7 +149,7 @@ class AuthApiTest( } test("관리자") { - val admin = authUtil.createAdmin(AdminFixture.default) + val admin = testAuthUtil.createAdmin(AdminFixture.default) val invalidAccount = "invalid".also { it shouldNotBe admin.account } diff --git a/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt b/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt index da8a9aa6..b4d1e724 100644 --- a/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt +++ b/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt @@ -27,7 +27,7 @@ class FailOnSaveLoginHistoryTest( } test("회원") { - val user = authUtil.signup(UserFixture.createRequest) + val user = testAuthUtil.signup(UserFixture.createRequest) val request = LoginRequest(user.email, user.password, PrincipalType.USER) runTest( @@ -44,7 +44,7 @@ class FailOnSaveLoginHistoryTest( } test("관리자") { - val admin = authUtil.createAdmin(AdminFixture.default) + val admin = testAuthUtil.createAdmin(AdminFixture.default) val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN) runTest( diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt index 834b8caf..6949ac5a 100644 --- a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -43,7 +43,7 @@ class PaymentAPITest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -165,8 +165,8 @@ class PaymentAPITest( PaymentMethod.entries.filter { it !in supportedMethod }.forEach { test("결제 수단: ${it.koreanName}") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultStoreAdminLogin(), - reserverToken = authUtil.defaultUserLogin() + adminToken = testAuthUtil.defaultStoreAdminLogin(), + reserverToken = testAuthUtil.defaultUserLogin() ) val request = PaymentFixture.confirmRequest @@ -183,7 +183,7 @@ class PaymentAPITest( ) runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.POST, endpoint = "/payments?reservationId=${reservation.id}", requestBody = PaymentFixture.confirmRequest, @@ -209,7 +209,7 @@ class PaymentAPITest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, requestBody = PaymentFixture.cancelRequest, @@ -219,10 +219,10 @@ class PaymentAPITest( } test("정상 취소") { - val userToken = authUtil.defaultUserLogin() + val userToken = testAuthUtil.defaultUserLogin() val confirmRequest = PaymentFixture.confirmRequest val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), reserverToken = userToken ) @@ -266,9 +266,9 @@ class PaymentAPITest( } test("예약에 대한 결제 정보가 없으면 실패한다.") { - val userToken = authUtil.defaultUserLogin() + val userToken = testAuthUtil.defaultUserLogin() val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), reserverToken = userToken, ) @@ -312,8 +312,8 @@ class PaymentAPITest( val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = authUtil.defaultStoreAdminLogin(), - reserverToken = authUtil.defaultUserLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), + reserverToken = testAuthUtil.defaultUserLogin(), ) val method = if (easyPayDetail != null) { @@ -335,7 +335,7 @@ class PaymentAPITest( } returns clientResponse runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), using = { body(request) }, diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt index 2db18f1a..54df0d89 100644 --- a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -55,7 +55,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -66,13 +66,13 @@ class ReservationApiTest( test("정상 생성") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.HOLD ) runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), using = { body(commonRequest.copy(scheduleId = schedule.id)) }, @@ -95,13 +95,13 @@ class ReservationApiTest( test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.AVAILABLE ) runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), using = { body(commonRequest.copy(scheduleId = schedule.id)) }, @@ -116,7 +116,7 @@ class ReservationApiTest( } test("예약 인원이 테마의 최소 인원보다 작거나 최대 인원보다 많으면 실패한다.") { - val adminToken = authUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultStoreAdminLogin() val theme: ThemeEntity = dummyInitializer.createTheme( adminToken = adminToken, request = ThemeFixture.createRequest @@ -128,7 +128,7 @@ class ReservationApiTest( status = ScheduleStatus.HOLD ) - val userToken = authUtil.defaultUserLogin() + val userToken = testAuthUtil.defaultUserLogin() runExceptionTest( token = userToken, @@ -156,7 +156,7 @@ class ReservationApiTest( context("필수 입력값이 입력되지 않으면 실패한다.") { test("예약자명") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.POST, endpoint = endpoint, requestBody = commonRequest.copy(reserverName = ""), @@ -166,7 +166,7 @@ class ReservationApiTest( test("예약자 연락처") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.POST, endpoint = endpoint, requestBody = commonRequest.copy(reserverContact = ""), @@ -190,7 +190,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -199,10 +199,10 @@ class ReservationApiTest( } test("정상 응답") { - val userToken = authUtil.defaultUserLogin() + val userToken = testAuthUtil.defaultUserLogin() val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), reserverToken = userToken, ) @@ -228,7 +228,7 @@ class ReservationApiTest( test("예약이 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.POST, endpoint = "/reservations/$INVALID_PK/confirm", expectedErrorCode = ReservationErrorCode.RESERVATION_NOT_FOUND @@ -250,7 +250,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -259,10 +259,10 @@ class ReservationApiTest( } test("정상 응답") { - val userToken = authUtil.defaultUserLogin() + val userToken = testAuthUtil.defaultUserLogin() val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), reserverToken = userToken, ) @@ -291,7 +291,7 @@ class ReservationApiTest( test("예약이 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.POST, endpoint = "/reservations/$INVALID_PK/cancel", requestBody = ReservationCancelRequest(cancelReason = "test"), @@ -301,12 +301,12 @@ class ReservationApiTest( test("다른 회원의 예약을 취소할 수 없다.") { val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultStoreAdminLogin(), - reserverToken = authUtil.defaultUserLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), + reserverToken = testAuthUtil.defaultUserLogin(), ) val otherUserToken = - authUtil.userLogin(UserFixture.createUser(email = "test@test.com", phone = "01011111111")) + testAuthUtil.userLogin(UserFixture.createUser(email = "test@test.com", phone = "01011111111")) runExceptionTest( token = otherUserToken, @@ -332,7 +332,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -341,8 +341,8 @@ class ReservationApiTest( } test("정상 응답") { - val userToken = authUtil.defaultUserLogin() - val adminToken = authUtil.defaultStoreAdminLogin() + val userToken = testAuthUtil.defaultUserLogin() + val adminToken = testAuthUtil.defaultStoreAdminLogin() for (i in 1..3) { dummyInitializer.createConfirmReservation( @@ -398,7 +398,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -413,8 +413,8 @@ class ReservationApiTest( beforeTest { reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultStoreAdminLogin(), - reserverToken = authUtil.defaultUserLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), + reserverToken = testAuthUtil.defaultUserLogin(), ) } @@ -480,7 +480,7 @@ class ReservationApiTest( val cancelReason = "테스트입니다." - val user = authUtil.defaultUser() + val user = testAuthUtil.defaultUser() dummyInitializer.cancelPayment( userId = user.id, @@ -547,7 +547,7 @@ class ReservationApiTest( test("예약이 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.GET, endpoint = "/reservations/$INVALID_PK/detail", expectedErrorCode = ReservationErrorCode.RESERVATION_NOT_FOUND @@ -556,12 +556,12 @@ class ReservationApiTest( test("예약은 있지만, 결제 정보를 찾을 수 없으면 null로 지정한다.") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultStoreAdminLogin(), - reserverToken = authUtil.defaultUserLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), + reserverToken = testAuthUtil.defaultUserLogin(), ) runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), on = { get("/reservations/${reservation.id}/detail") }, @@ -574,8 +574,8 @@ class ReservationApiTest( test("예약과 결제는 있지만, 결제 세부 내역이 없으면 세부 내역만 null로 지정한다..") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.defaultStoreAdminLogin(), - reserverToken = authUtil.defaultUserLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), + reserverToken = testAuthUtil.defaultUserLogin(), ) dummyInitializer.createPayment( @@ -586,7 +586,7 @@ class ReservationApiTest( } runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), on = { get("/reservations/${reservation.id}/detail") }, @@ -621,7 +621,7 @@ class ReservationApiTest( reservation: ReservationEntity ): LinkedHashMap { return runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), on = { get("/reservations/${reservation.id}/detail") }, @@ -636,7 +636,7 @@ class ReservationApiTest( } private fun initializeForPopularThemeTest(): MostReservedThemeIdListResponse { - val user: UserEntity = authUtil.defaultUser() + val user: UserEntity = testAuthUtil.defaultUser() val themeIds: List = (1..5).map { themeRepository.save( diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt index 274267a3..14b9dd9b 100644 --- a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -32,7 +32,7 @@ class ScheduleApiTest( val endpoint = "/schedules/themes?date=$date" test("정상 응답") { - val adminToken = authUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultStoreAdminLogin() for (i in 1..10) { dummyInitializer.createSchedule( @@ -45,7 +45,7 @@ class ScheduleApiTest( } runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), on = { get(endpoint) }, @@ -61,7 +61,7 @@ class ScheduleApiTest( test("정상 응답") { val date = LocalDate.now().plusDays(1) - val adminToken = authUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultStoreAdminLogin() val createdSchedule = dummyInitializer.createSchedule( adminToken = adminToken, request = createRequest.copy(date = date, time = LocalTime.now()) @@ -79,7 +79,7 @@ class ScheduleApiTest( } runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), on = { get("/schedules?date=$date&themeId=${createdSchedule.themeId}") }, @@ -109,7 +109,7 @@ class ScheduleApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -120,7 +120,7 @@ class ScheduleApiTest( val admin = AdminFixture.create(permissionLevel = AdminPermissionLevel.READ_SUMMARY) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -129,7 +129,7 @@ class ScheduleApiTest( } test("정상 응답") { - val token = authUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule = dummyInitializer.createSchedule( adminToken = token, @@ -161,7 +161,7 @@ class ScheduleApiTest( test("일정이 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.GET, endpoint = "/schedules/$INVALID_PK", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND @@ -184,7 +184,7 @@ class ScheduleApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, @@ -197,7 +197,7 @@ class ScheduleApiTest( val admin = AdminFixture.create(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, @@ -208,7 +208,7 @@ class ScheduleApiTest( } test("정상 생성 및 감사 정보 확인") { - val token = authUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultStoreAdminLogin() val themeId: Long = dummyInitializer.createTheme( adminToken = token, @@ -246,7 +246,7 @@ class ScheduleApiTest( } test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultStoreAdminLogin() val date = LocalDate.now().plusDays(1) val time = LocalTime.of(10, 0) @@ -267,7 +267,7 @@ class ScheduleApiTest( } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultStoreAdminLogin() val body = createRequest.copy(LocalDate.now(), LocalTime.now().minusMinutes(1)) runExceptionTest( @@ -294,7 +294,7 @@ class ScheduleApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.PATCH, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -304,12 +304,12 @@ class ScheduleApiTest( test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태이면 정상 응답") { val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), request = createRequest ) runTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), on = { patch("/schedules/${createdSchedule.id}/hold") }, @@ -326,7 +326,7 @@ class ScheduleApiTest( test("예약이 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.PATCH, endpoint = "/schedules/$INVALID_PK/hold", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND @@ -334,7 +334,7 @@ class ScheduleApiTest( } test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태가 아니면 실패한다.") { - val adminToken = authUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = adminToken, @@ -361,7 +361,7 @@ class ScheduleApiTest( ) runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.PATCH, endpoint = "/schedules/${createdSchedule.id}/hold", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE @@ -389,7 +389,7 @@ class ScheduleApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = endpoint, @@ -402,7 +402,7 @@ class ScheduleApiTest( val admin = AdminFixture.create(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = endpoint, @@ -414,14 +414,14 @@ class ScheduleApiTest( test("정상 수정 및 감사 정보 변경 확인") { val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultStoreAdminLogin(), request = createRequest.copy( date = LocalDate.now().plusDays(1), time = LocalTime.now().plusMinutes(1), ) ) - val otherAdminToken = authUtil.adminLogin( + val otherAdminToken = testAuthUtil.adminLogin( AdminFixture.create(account = "otherAdmin", phone = "01099999999") ) @@ -449,7 +449,7 @@ class ScheduleApiTest( } test("입력값이 없으면 수정하지 않는다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, @@ -477,7 +477,7 @@ class ScheduleApiTest( test("일정이 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = "/schedules/${INVALID_PK}", @@ -486,7 +486,7 @@ class ScheduleApiTest( } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = @@ -518,7 +518,7 @@ class ScheduleApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.DELETE, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -530,7 +530,7 @@ class ScheduleApiTest( val admin = AdminFixture.create(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.DELETE, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -540,7 +540,7 @@ class ScheduleApiTest( } test("정상 삭제") { - val token = authUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = createRequest @@ -560,7 +560,7 @@ class ScheduleApiTest( } test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { - val token = authUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, diff --git a/src/test/kotlin/roomescape/supports/KotestConfig.kt b/src/test/kotlin/roomescape/supports/KotestConfig.kt index 553afd7f..5ee8b11c 100644 --- a/src/test/kotlin/roomescape/supports/KotestConfig.kt +++ b/src/test/kotlin/roomescape/supports/KotestConfig.kt @@ -47,11 +47,11 @@ abstract class FunSpecSpringbootTest : FunSpec({ @LocalServerPort var port: Int = 0 - lateinit var authUtil: AuthUtil + lateinit var testAuthUtil: TestAuthUtil override suspend fun beforeSpec(spec: Spec) { RestAssured.port = port - authUtil = AuthUtil(userRepository, adminRepository, storeRepository) + testAuthUtil = TestAuthUtil(userRepository, adminRepository, storeRepository) } } diff --git a/src/test/kotlin/roomescape/supports/TestAuthUtil.kt b/src/test/kotlin/roomescape/supports/TestAuthUtil.kt new file mode 100644 index 00000000..55e66d0d --- /dev/null +++ b/src/test/kotlin/roomescape/supports/TestAuthUtil.kt @@ -0,0 +1,94 @@ +package roomescape.supports + +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import roomescape.admin.infrastructure.persistence.AdminEntity +import roomescape.admin.infrastructure.persistence.AdminRepository +import roomescape.auth.web.LoginRequest +import roomescape.common.dto.PrincipalType +import roomescape.store.infrastructure.persistence.StoreRepository +import roomescape.user.infrastructure.persistence.UserEntity +import roomescape.user.infrastructure.persistence.UserRepository +import roomescape.user.web.UserCreateRequest + +class TestAuthUtil( + private val userRepository: UserRepository, + private val adminRepository: AdminRepository, + private val storeRepository: StoreRepository, +) { + fun createAdmin(admin: AdminEntity): AdminEntity { + val storeId = admin.storeId + if (storeId != null && storeRepository.findByIdOrNull(storeId) == null) { + storeRepository.save( + StoreFixture.create( + id = storeId, + businessRegNum = randomBusinessRegNum(), + ) + ) + } + + return adminRepository.save(admin) + } + + fun signup(request: UserCreateRequest): UserEntity { + val userId: Long = Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + body(request) + } When { + post("/users") + } Then { + statusCode(HttpStatus.OK.value()) + } Extract { + path("data.id") + } + + return userRepository.findByIdOrNull(userId) + ?: throw AssertionError("Unexpected Exception Occurred.") + } + + fun adminLogin(admin: AdminEntity): String { + val saved = createAdmin(admin) + val requestBody = LoginRequest(saved.account, saved.password, PrincipalType.ADMIN) + + return Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + body(requestBody) + } When { + post("/auth/login") + } Then { + statusCode(200) + } Extract { + path("data.accessToken") + } + } + + fun defaultStoreAdminLogin(): String = adminLogin(AdminFixture.storeDefault) + fun defaultHqAdminLogin(): String = adminLogin(AdminFixture.hqDefault) + + fun userLogin(user: UserEntity): String { + if (userRepository.findByEmail(user.email) == null) { + userRepository.save(user) + } + + return Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + body(LoginRequest(account = user.email, password = user.password, principalType = PrincipalType.USER)) + } When { + post("/auth/login") + } Then { + statusCode(200) + } Extract { + path("data.accessToken") + } + } + + fun defaultUserLogin(): String = userLogin(UserFixture.default) + + fun defaultUser(): UserEntity = userRepository.findByEmail(UserFixture.default.email) + ?: userRepository.save(UserFixture.default) +} \ No newline at end of file diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/HQAdminThemeApiTest.kt similarity index 84% rename from src/test/kotlin/roomescape/theme/ThemeApiTest.kt rename to src/test/kotlin/roomescape/theme/HQAdminThemeApiTest.kt index 4007516a..e253b886 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/HQAdminThemeApiTest.kt @@ -19,13 +19,12 @@ import roomescape.theme.business.MIN_PRICE import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.ThemeIdListRequest import roomescape.theme.web.ThemeUpdateRequest import roomescape.supports.* import roomescape.supports.ThemeFixture.createRequest import kotlin.random.Random -class ThemeApiTest( +class HQAdminThemeApiTest( private val themeRepository: ThemeRepository ) : FunSpecSpringbootTest() { @@ -45,7 +44,7 @@ class ThemeApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, @@ -58,7 +57,7 @@ class ThemeApiTest( val admin = AdminFixture.createStoreAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, @@ -71,7 +70,7 @@ class ThemeApiTest( val admin = AdminFixture.createHqAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, @@ -84,7 +83,7 @@ class ThemeApiTest( test("정상 생성 및 감사 정보 확인") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, @@ -112,7 +111,7 @@ class ThemeApiTest( } test("이미 동일한 이름의 테마가 있으면 실패한다.") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val commonName = "test123" dummyInitializer.createTheme( adminToken = token, @@ -135,7 +134,7 @@ class ThemeApiTest( } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -158,7 +157,7 @@ class ThemeApiTest( } test("field: availableMinutes") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -172,7 +171,7 @@ class ThemeApiTest( } test("field: expectedMinutesFrom") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -186,7 +185,7 @@ class ThemeApiTest( } test("field: expectedMinutesTo") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -202,7 +201,7 @@ class ThemeApiTest( context("시간 범위가 잘못 지정되면 실패한다.") { test("최소 예상 시간 > 최대 예상 시간") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -219,7 +218,7 @@ class ThemeApiTest( } test("최대 예상 시간 > 이용 가능 시간") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -249,7 +248,7 @@ class ThemeApiTest( } test("field: minParticipants") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -263,7 +262,7 @@ class ThemeApiTest( } test("field: maxParticipants") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -279,7 +278,7 @@ class ThemeApiTest( context("인원 범위가 잘못 지정되면 실패한다.") { test("최소 인원 > 최대 인원") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() runTest( token = token, using = { @@ -297,59 +296,6 @@ class ThemeApiTest( } } - context("입력된 모든 ID에 대한 테마를 조회한다.") { - test("정상 응답") { - val adminToken = authUtil.defaultHqAdminLogin() - val themeSize = 3 - val themeIds = mutableListOf() - - for (i in 1..themeSize) { - dummyInitializer.createTheme(adminToken, createRequest.copy(name = "test$i")) - .also { themeIds.add(it.id) } - } - - runTest( - token = authUtil.defaultUserLogin(), - using = { - body(ThemeIdListRequest(themeIds)) - }, - on = { - post("/themes/batch") - }, - expect = { - statusCode(HttpStatus.OK.value()) - body("data.themes.size()", equalTo(themeSize)) - } - ) - } - - test("없는 테마가 있으면 생략한다.") { - val token = authUtil.defaultHqAdminLogin() - val themeSize = 3 - val themeIds = mutableListOf() - - for (i in 1..themeSize) { - dummyInitializer.createTheme(token, createRequest.copy(name = "test$i")) - .also { themeIds.add(it.id) } - } - - themeIds.add(INVALID_PK) - runTest( - token = authUtil.defaultUserLogin(), - using = { - body(ThemeIdListRequest(themeIds)) - }, - on = { - post("/themes/batch") - }, - expect = { - statusCode(HttpStatus.OK.value()) - body("data.themes.size()", equalTo(themeSize)) - } - ) - } - } - context("관리자가 모든 테마를 조회한다.") { val endpoint = "/admin/themes" val requests = listOf( @@ -368,7 +314,7 @@ class ThemeApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -380,7 +326,7 @@ class ThemeApiTest( val admin = AdminFixture.createStoreAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, @@ -392,7 +338,7 @@ class ThemeApiTest( test("비공개 테마까지 포함하여 간단한 정보만 조회된다.") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() requests.forEach { dummyInitializer.createTheme(token, it) } runTest( @@ -411,40 +357,6 @@ class ThemeApiTest( } } - context("ID로 테마 정보를 조회한다.") { - test("성공 응답") { - val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = authUtil.defaultHqAdminLogin(), - request = createRequest - ) - - runTest( - on = { - get("/themes/${createdTheme.id}") - }, - expect = { - body("data.id", equalTo(createdTheme.id)) - body("data.name", equalTo(createdTheme.name)) - assertProperties( - props = setOf( - "id", "name", "thumbnailUrl", "description", "difficulty", "price", - "minParticipants", "maxParticipants", - "availableMinutes", "expectedMinutesFrom", "expectedMinutesTo" - ), - ) - } - ) - } - - test("테마가 없으면 실패한다.") { - runExceptionTest( - method = HttpMethod.GET, - endpoint = "/themes/$INVALID_PK", - expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND - ) - } - } - context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") { context("권한이 없으면 접근할 수 없다.") { val endpoint = "/admin/themes/$INVALID_PK" @@ -459,7 +371,7 @@ class ThemeApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -471,7 +383,7 @@ class ThemeApiTest( val admin = AdminFixture.createStoreAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -483,7 +395,7 @@ class ThemeApiTest( val admin = AdminFixture.createHqAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -494,7 +406,7 @@ class ThemeApiTest( } test("정상 응답") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -522,7 +434,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultHqAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.GET, endpoint = "/admin/themes/$INVALID_PK", expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND @@ -531,7 +443,6 @@ class ThemeApiTest( } context("테마를 삭제한다.") { - context("권한이 없으면 접근할 수 없다.") { val endpoint = "/admin/themes/${INVALID_PK}" @@ -545,7 +456,7 @@ class ThemeApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.DELETE, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -557,7 +468,7 @@ class ThemeApiTest( val admin = AdminFixture.createStoreAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.DELETE, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -569,7 +480,7 @@ class ThemeApiTest( val admin = AdminFixture.createHqAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.DELETE, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -580,7 +491,7 @@ class ThemeApiTest( } test("정상 삭제") { - val token = authUtil.defaultHqAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -601,7 +512,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultHqAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.DELETE, endpoint = "/admin/themes/$INVALID_PK", expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND @@ -625,7 +536,7 @@ class ThemeApiTest( test("회원") { runExceptionTest( - token = authUtil.defaultUserLogin(), + token = testAuthUtil.defaultUserLogin(), method = HttpMethod.PATCH, endpoint = endpoint, requestBody = request, @@ -638,7 +549,7 @@ class ThemeApiTest( val admin = AdminFixture.createStoreAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.PATCH, endpoint = endpoint, requestBody = request, @@ -651,7 +562,7 @@ class ThemeApiTest( val admin = AdminFixture.createHqAdmin(permissionLevel = it) runExceptionTest( - token = authUtil.adminLogin(admin), + token = testAuthUtil.adminLogin(admin), method = HttpMethod.PATCH, endpoint = endpoint, requestBody = request, @@ -666,10 +577,10 @@ class ThemeApiTest( test("정상 수정 및 감사 정보 변경 확인") { val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = authUtil.defaultHqAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), request = createRequest.copy(name = "theme-${Random.nextInt()}") ) - val otherAdminToken: String = authUtil.adminLogin( + val otherAdminToken: String = testAuthUtil.adminLogin( AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE) ) @@ -696,12 +607,12 @@ class ThemeApiTest( test("입력값이 없으면 수정하지 않는다.") { val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = authUtil.defaultHqAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), request = createRequest.copy(name = "theme-${Random.nextInt()}") ) runTest( - token = authUtil.defaultHqAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), using = { body(ThemeUpdateRequest()) }, @@ -721,7 +632,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runExceptionTest( - token = authUtil.defaultHqAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.PATCH, endpoint = "/admin/themes/$INVALID_PK", requestBody = updateRequest, @@ -730,7 +641,7 @@ class ThemeApiTest( } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { - val adminToken = authUtil.defaultHqAdminLogin() + val adminToken = testAuthUtil.defaultHqAdminLogin() val createdTheme: ThemeEntity = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -750,7 +661,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultHqAdminLogin() + adminToken = testAuthUtil.defaultHqAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -793,7 +704,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultHqAdminLogin() + adminToken = testAuthUtil.defaultHqAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -834,7 +745,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultHqAdminLogin() + adminToken = testAuthUtil.defaultHqAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -867,7 +778,7 @@ class ThemeApiTest( lateinit var createdTheme: ThemeEntity beforeTest { - adminToken = authUtil.defaultHqAdminLogin() + adminToken = testAuthUtil.defaultHqAdminLogin() createdTheme = dummyInitializer.createTheme( adminToken = adminToken, request = createRequest.copy(name = "theme-${Random.nextInt()}") diff --git a/src/test/kotlin/roomescape/user/UserApiTest.kt b/src/test/kotlin/roomescape/user/UserApiTest.kt index b396ba97..6fc8167e 100644 --- a/src/test/kotlin/roomescape/user/UserApiTest.kt +++ b/src/test/kotlin/roomescape/user/UserApiTest.kt @@ -145,7 +145,7 @@ class UserApiTest( test("관리자") { runExceptionTest( - token = authUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -154,8 +154,8 @@ class UserApiTest( } test("정상 응답") { - val user = authUtil.defaultUser() - val token = authUtil.userLogin(user) + val user = testAuthUtil.defaultUser() + val token = testAuthUtil.userLogin(user) runTest( token = token, -- 2.47.2 From 1de8d08cb75d252f1e4c25d0c99017e9708d927b Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 12:29:05 +0900 Subject: [PATCH 041/116] =?UTF-8?q?refactor:=20Fixture=EC=9D=98=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=EA=B0=92=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=9E=9C=EB=8D=A4=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/supports/Fixtures.kt | 54 +++++++++---------- .../kotlin/roomescape/supports/TestUtil.kt | 30 +++++++++++ 2 files changed, 54 insertions(+), 30 deletions(-) create mode 100644 src/test/kotlin/roomescape/supports/TestUtil.kt diff --git a/src/test/kotlin/roomescape/supports/Fixtures.kt b/src/test/kotlin/roomescape/supports/Fixtures.kt index d793a0bd..ef936341 100644 --- a/src/test/kotlin/roomescape/supports/Fixtures.kt +++ b/src/test/kotlin/roomescape/supports/Fixtures.kt @@ -25,17 +25,11 @@ import java.time.OffsetDateTime const val INVALID_PK: Long = 9999L val tsidFactory = TsidFactory(0) -fun randomPhoneNumber(): String { - val prefix = "010" - val middle = (1000..9999).random() - val last = (1000..9999).random() - return "$prefix$middle$last" -} object StoreFixture { fun create( id: Long = tsidFactory.next(), - name: String = "테스트행복점", + name: String = "테스트-${randomString()}점", address: String = "서울특별시 강북구 행복길 123", businessRegNum: String = "123-45-67890", regionCode: String = "1111000000" @@ -49,24 +43,21 @@ object StoreFixture { } object AdminFixture { - val default: AdminEntity = create() - val storeDefault: AdminEntity = create( - account = "admin-store", - phone = randomPhoneNumber(), - type = AdminType.STORE, - storeId = tsidFactory.next() + val default: AdminEntity = create( + account = "default" ) - val hqDefault: AdminEntity = create( - account = "admin-hq", - phone = randomPhoneNumber(), - type = AdminType.HQ, - storeId = null + val storeDefault: AdminEntity = createStoreAdmin( + account = "store-default", + ) + + val hqDefault: AdminEntity = createHqAdmin( + account = "hq-default", ) fun createStoreAdmin( id: Long = tsidFactory.next(), - account: String = "admin", + account: String = randomString(), password: String = "adminPassword", name: String = "admin12345", phone: String = randomPhoneNumber(), @@ -87,7 +78,7 @@ object AdminFixture { fun createHqAdmin( id: Long = tsidFactory.next(), - account: String = "admin", + account: String = randomString(), password: String = "adminPassword", name: String = "admin12345", phone: String = randomPhoneNumber(), @@ -107,9 +98,9 @@ object AdminFixture { fun create( id: Long = tsidFactory.next(), - account: String = "admin", + account: String = randomString(), password: String = "adminPassword", - name: String = "admin12345", + name: String = "admin", phone: String = randomPhoneNumber(), type: AdminType = AdminType.STORE, storeId: Long? = tsidFactory.next(), @@ -127,14 +118,17 @@ object AdminFixture { } object UserFixture { - val default: UserEntity = createUser() + val default: UserEntity = createUser( + name = "default", + email = "default-user@test.com" + ) fun createUser( id: Long = tsidFactory.next(), - name: String = "sample", - email: String = "sample@example.com", + name: String = randomString(), + email: String = randomEmail(), password: String = "a".repeat(MIN_PASSWORD_LENGTH), - phone: String = "01012345678", + phone: String = randomPhoneNumber(), regionCode: String = "1111000000", status: UserStatus = UserStatus.ACTIVE ): UserEntity = UserEntity( @@ -148,17 +142,17 @@ object UserFixture { ) val createRequest: UserCreateRequest = UserCreateRequest( - name = "sample", - email = "sample@example.com", + name = randomString(), + email = randomEmail(), password = "a".repeat(MIN_PASSWORD_LENGTH), - phone = "01012345678", + phone = randomPhoneNumber(), regionCode = "1111000000" ) } object ThemeFixture { val createRequest: ThemeCreateRequest = ThemeCreateRequest( - name = "Matilda Green", + name = randomString(), description = "constituto", thumbnailUrl = "https://duckduckgo.com/?q=mediocrem", difficulty = Difficulty.VERY_EASY, diff --git a/src/test/kotlin/roomescape/supports/TestUtil.kt b/src/test/kotlin/roomescape/supports/TestUtil.kt new file mode 100644 index 00000000..076be785 --- /dev/null +++ b/src/test/kotlin/roomescape/supports/TestUtil.kt @@ -0,0 +1,30 @@ +package roomescape.supports + +import kotlin.random.Random + +inline fun initialize(name: String, block: () -> T): T { + return block() +} + +fun randomPhoneNumber(): String { + val prefix = "010" + val middle = (1000..9999).random() + val last = (1000..9999).random() + return "$prefix$middle$last" +} + +fun randomString(): String { + val chars = ('a'..'z') + ('0'..'9') + return (1..10) + .map { chars.random() } + .joinToString("") +} + +fun randomEmail(): String = "${randomString()}@test.com" + +fun randomBusinessRegNum(): String { + val part1 = Random.nextInt(100, 1000) + val part2 = Random.nextInt(10, 100) + val part3 = Random.nextInt(10000, 100000) + return "$part1-$part2-$part3" +} -- 2.47.2 From d78199778f2ecf8d2353b969b79b8def336bb1f9 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 12:30:08 +0900 Subject: [PATCH 042/116] =?UTF-8?q?refactor:=20AuthUtil=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EB=94=B0=EB=A5=B8=20RestAssuredUtils=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/supports/RestAssuredUtils.kt | 96 ------------------- 1 file changed, 96 deletions(-) diff --git a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt index 301fa68b..69ab256a 100644 --- a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt @@ -1,6 +1,5 @@ package roomescape.supports -import io.restassured.module.kotlin.extensions.Extract import io.restassured.module.kotlin.extensions.Given import io.restassured.module.kotlin.extensions.Then import io.restassured.module.kotlin.extensions.When @@ -8,97 +7,9 @@ import io.restassured.response.Response import io.restassured.response.ValidatableResponse import io.restassured.specification.RequestSpecification import org.hamcrest.CoreMatchers.equalTo -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatus import org.springframework.http.MediaType -import roomescape.admin.infrastructure.persistence.AdminEntity -import roomescape.admin.infrastructure.persistence.AdminRepository -import roomescape.auth.web.LoginRequest -import roomescape.common.dto.PrincipalType import roomescape.common.exception.ErrorCode -import roomescape.store.infrastructure.persistence.StoreRepository -import roomescape.user.infrastructure.persistence.UserEntity -import roomescape.user.infrastructure.persistence.UserRepository -import roomescape.user.web.UserCreateRequest -import kotlin.random.Random - -class AuthUtil( - private val userRepository: UserRepository, - private val adminRepository: AdminRepository, - private val storeRepository: StoreRepository, -) { - fun createAdmin(admin: AdminEntity): AdminEntity { - val storeId = admin.storeId - if (storeId != null && storeRepository.findByIdOrNull(storeId) == null) { - storeRepository.save( - StoreFixture.create( - id = storeId, - businessRegNum = generateBusinessRegNum(), - ) - ) - } - - return adminRepository.save(admin) - } - - fun signup(request: UserCreateRequest): UserEntity { - val userId: Long = Given { - contentType(MediaType.APPLICATION_JSON_VALUE) - body(request) - } When { - post("/users") - } Then { - statusCode(HttpStatus.OK.value()) - } Extract { - path("data.id") - } - - return userRepository.findByIdOrNull(userId) - ?: throw AssertionError("Unexpected Exception Occurred.") - } - - fun adminLogin(admin: AdminEntity): String { - val saved = createAdmin(admin) - val requestBody = LoginRequest(saved.account, saved.password, PrincipalType.ADMIN) - - return Given { - contentType(MediaType.APPLICATION_JSON_VALUE) - body(requestBody) - } When { - post("/auth/login") - } Then { - statusCode(200) - } Extract { - path("data.accessToken") - } - } - - fun defaultStoreAdminLogin(): String = adminLogin(AdminFixture.storeDefault) - fun defaultHqAdminLogin(): String = adminLogin(AdminFixture.hqDefault) - - fun userLogin(user: UserEntity): String { - if (userRepository.findByEmail(user.email) == null) { - userRepository.save(user) - } - - return Given { - contentType(MediaType.APPLICATION_JSON_VALUE) - body(LoginRequest(account = user.email, password = user.password, principalType = PrincipalType.USER)) - } When { - post("/auth/login") - } Then { - statusCode(200) - } Extract { - path("data.accessToken") - } - } - - fun defaultUserLogin(): String = userLogin(UserFixture.default) - - fun defaultUser(): UserEntity = userRepository.findByEmail(UserFixture.default.email) - ?: userRepository.save(UserFixture.default) -} fun runTest( token: String? = null, @@ -184,10 +95,3 @@ fun ValidatableResponse.assertProperties(props: Set, propsNameIfList: St else -> error("Unexpected data type: ${json::class}") } } - -private fun generateBusinessRegNum(): String { - val part1 = Random.nextInt(100, 1000) - val part2 = Random.nextInt(10, 100) - val part3 = Random.nextInt(10000, 100000) - return "$part1-$part2-$part3" -} -- 2.47.2 From 78c699c69abc69e03fd8daa4fc60cc8a6b1d5285 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 12:30:45 +0900 Subject: [PATCH 043/116] =?UTF-8?q?refactor:=20=ED=85=8C=EB=A7=88=20API=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B6=8C=ED=95=9C=EB=B3=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/theme/PublicThemeApiTest.kt | 100 ++++++++++++++++++ .../theme/StoreAdminThemeApiTest.kt | 61 +++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/test/kotlin/roomescape/theme/PublicThemeApiTest.kt create mode 100644 src/test/kotlin/roomescape/theme/StoreAdminThemeApiTest.kt diff --git a/src/test/kotlin/roomescape/theme/PublicThemeApiTest.kt b/src/test/kotlin/roomescape/theme/PublicThemeApiTest.kt new file mode 100644 index 00000000..b89a0b7a --- /dev/null +++ b/src/test/kotlin/roomescape/theme/PublicThemeApiTest.kt @@ -0,0 +1,100 @@ +package roomescape.theme + +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import roomescape.supports.* +import roomescape.supports.ThemeFixture.createRequest +import roomescape.theme.exception.ThemeErrorCode +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.theme.web.ThemeIdListRequest + +class PublicThemeApiTest : FunSpecSpringbootTest() { + init { + context("입력된 모든 ID에 대한 테마를 조회한다.") { + test("정상 응답") { + val adminToken = testAuthUtil.defaultHqAdminLogin() + val themeSize = 3 + val themeIds = mutableListOf() + + for (i in 1..themeSize) { + dummyInitializer.createTheme(adminToken, createRequest.copy(name = "test$i")) + .also { themeIds.add(it.id) } + } + + runTest( + using = { + body(ThemeIdListRequest(themeIds)) + }, + on = { + post("/themes/batch") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.themes.size()", equalTo(themeSize)) + } + ) + } + + test("없는 테마가 있으면 생략한다.") { + val token = testAuthUtil.defaultHqAdminLogin() + val themeSize = 3 + val themeIds = mutableListOf() + + for (i in 1..themeSize) { + dummyInitializer.createTheme(token, createRequest.copy(name = "test$i")) + .also { themeIds.add(it.id) } + } + + themeIds.add(INVALID_PK) + runTest( + using = { + body(ThemeIdListRequest(themeIds)) + }, + on = { + post("/themes/batch") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.themes.size()", equalTo(themeSize)) + } + ) + } + } + + + context("ID로 테마 정보를 조회한다.") { + test("성공 응답") { + val createdTheme: ThemeEntity = dummyInitializer.createTheme( + adminToken = testAuthUtil.defaultHqAdminLogin(), + request = createRequest + ) + + runTest( + on = { + get("/themes/${createdTheme.id}") + }, + expect = { + body("data.id", equalTo(createdTheme.id)) + body("data.name", equalTo(createdTheme.name)) + assertProperties( + props = setOf( + "id", "name", "thumbnailUrl", "description", "difficulty", "price", + "minParticipants", "maxParticipants", + "availableMinutes", "expectedMinutesFrom", "expectedMinutesTo" + ), + ) + } + ) + } + + test("테마가 없으면 실패한다.") { + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/themes/$INVALID_PK", + expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND + ) + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/roomescape/theme/StoreAdminThemeApiTest.kt b/src/test/kotlin/roomescape/theme/StoreAdminThemeApiTest.kt new file mode 100644 index 00000000..8563ecd7 --- /dev/null +++ b/src/test/kotlin/roomescape/theme/StoreAdminThemeApiTest.kt @@ -0,0 +1,61 @@ +package roomescape.theme + +import org.springframework.http.HttpMethod +import roomescape.auth.exception.AuthErrorCode +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.ThemeFixture.createRequest +import roomescape.supports.assertProperties +import roomescape.supports.runExceptionTest +import roomescape.supports.runTest + +class StoreAdminThemeApiTest : FunSpecSpringbootTest() { + init { + context("현재 active 상태인 모든 테마의 ID, 이름을 조회한다.") { + val endpoint = "/admin/themes/active" + + context("권한이 없으면 접근할 수 없다.") { + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = testAuthUtil.defaultUserLogin(), + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + + test("정상 응답") { + run { + val token = testAuthUtil.defaultHqAdminLogin() + dummyInitializer.createTheme(token, createRequest.copy(name = "test1", isActive = true)) + dummyInitializer.createTheme(token, createRequest.copy(name = "test2", isActive = false)) + dummyInitializer.createTheme(token, createRequest.copy(name = "test3", isActive = true)) + } + + runTest( + token = testAuthUtil.defaultStoreAdminLogin(), + on = { + get(endpoint) + }, + expect = { + statusCode(200) + assertProperties( + props = setOf("id", "user", "applicationDateTime", "payment"), + propsNameIfList = "themes" + ) + }, + ) + } + } + } +} -- 2.47.2 From 5fa5e5c49dc5ae1cf1b798a2b3f70cc9796dba8b Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 13:30:27 +0900 Subject: [PATCH 044/116] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EA=B0=80=EB=8F=85=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=9C=20pri?= =?UTF-8?q?ntln=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/supports/RestAssuredUtils.kt | 1 + .../roomescape/supports/TestAuthUtil.kt | 26 +++++++++++++++---- .../kotlin/roomescape/supports/TestUtil.kt | 6 ++++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt index 69ab256a..31aaa1c7 100644 --- a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt @@ -17,6 +17,7 @@ fun runTest( on: RequestSpecification.() -> Response, expect: ValidatableResponse.() -> Unit ): ValidatableResponse { + println("[runTest] 테스트 시작") return Given { contentType(MediaType.APPLICATION_JSON_VALUE) token?.also { header("Authorization", "Bearer $token") } diff --git a/src/test/kotlin/roomescape/supports/TestAuthUtil.kt b/src/test/kotlin/roomescape/supports/TestAuthUtil.kt index 55e66d0d..a429c424 100644 --- a/src/test/kotlin/roomescape/supports/TestAuthUtil.kt +++ b/src/test/kotlin/roomescape/supports/TestAuthUtil.kt @@ -22,20 +22,28 @@ class TestAuthUtil( private val storeRepository: StoreRepository, ) { fun createAdmin(admin: AdminEntity): AdminEntity { + println("[TestAuthUtil] 관리자 생성 시작. id=${admin.id}") + val storeId = admin.storeId if (storeId != null && storeRepository.findByIdOrNull(storeId) == null) { + println("[TestAuthUtil] 매장 정보 없음. 매장 생성 시작. storeId=${storeId} adminId=${admin.id}") storeRepository.save( StoreFixture.create( id = storeId, businessRegNum = randomBusinessRegNum(), ) - ) + ).also { + println("[TestAuthUtil] 매장 생성 완료. storeId=${storeId} adminId=${admin.id}") + } } - return adminRepository.save(admin) + return adminRepository.save(admin).also { + println("[TestAuthUtil] 관리자 생성 완료. id=${admin.id}") + } } - fun signup(request: UserCreateRequest): UserEntity { + fun signup(request: UserCreateRequest): UserEntity { + println("[TestAuthUtil] 회원가입 시작: $request") val userId: Long = Given { contentType(MediaType.APPLICATION_JSON_VALUE) body(request) @@ -48,14 +56,16 @@ class TestAuthUtil( } return userRepository.findByIdOrNull(userId) + ?.also { println("[TestAuthUtil] 회원가입 완료. 회원 반환: $userId") } ?: throw AssertionError("Unexpected Exception Occurred.") } fun adminLogin(admin: AdminEntity): String { + println("[TestAuthUtil] 관리자 로그인 시작. id=${admin.id}, account=${admin.account}") val saved = createAdmin(admin) val requestBody = LoginRequest(saved.account, saved.password, PrincipalType.ADMIN) - return Given { + val token: String = Given { contentType(MediaType.APPLICATION_JSON_VALUE) body(requestBody) } When { @@ -65,17 +75,21 @@ class TestAuthUtil( } Extract { path("data.accessToken") } + + return token.also { println("[TestAuthUtil] 관리자 로그인 완료. id=${admin.id}, account=${admin.account}") } } fun defaultStoreAdminLogin(): String = adminLogin(AdminFixture.storeDefault) fun defaultHqAdminLogin(): String = adminLogin(AdminFixture.hqDefault) fun userLogin(user: UserEntity): String { + println("[TestAuthUtil] 회원 로그인 시작. id=${user.id}, email=${user.email}") if (userRepository.findByEmail(user.email) == null) { + println("[TestAuthUtil] 회원 정보 없음. 회원 생성 시작. email=${user.email}") userRepository.save(user) } - return Given { + val token: String = Given { contentType(MediaType.APPLICATION_JSON_VALUE) body(LoginRequest(account = user.email, password = user.password, principalType = PrincipalType.USER)) } When { @@ -85,6 +99,8 @@ class TestAuthUtil( } Extract { path("data.accessToken") } + + return token.also { println("[TestAuthUtil] 회원 로그인 완료. id=${user.id}, email=${user.email}") } } fun defaultUserLogin(): String = userLogin(UserFixture.default) diff --git a/src/test/kotlin/roomescape/supports/TestUtil.kt b/src/test/kotlin/roomescape/supports/TestUtil.kt index 076be785..10c4d735 100644 --- a/src/test/kotlin/roomescape/supports/TestUtil.kt +++ b/src/test/kotlin/roomescape/supports/TestUtil.kt @@ -3,7 +3,11 @@ package roomescape.supports import kotlin.random.Random inline fun initialize(name: String, block: () -> T): T { - return block() + println("초기화 작업 시작: $name") + return block().also { + println("초기화 작업 완료: $name") + println("===================================") + } } fun randomPhoneNumber(): String { -- 2.47.2 From d8fa110f3f8139903deaed3b120b3568d8436b3b Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 13:31:55 +0900 Subject: [PATCH 045/116] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20API=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/payment/PaymentAPITest.kt | 12 +++---- .../reservation/ReservationApiTest.kt | 30 ++++++++--------- .../roomescape/schedule/ScheduleApiTest.kt | 32 +++++++++---------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt index 6949ac5a..23a13073 100644 --- a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -43,7 +43,7 @@ class PaymentAPITest( test("관리자") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -165,7 +165,7 @@ class PaymentAPITest( PaymentMethod.entries.filter { it !in supportedMethod }.forEach { test("결제 수단: ${it.koreanName}") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = testAuthUtil.defaultUserLogin() ) @@ -209,7 +209,7 @@ class PaymentAPITest( test("관리자") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, requestBody = PaymentFixture.cancelRequest, @@ -222,7 +222,7 @@ class PaymentAPITest( val userToken = testAuthUtil.defaultUserLogin() val confirmRequest = PaymentFixture.confirmRequest val reservation = dummyInitializer.createConfirmReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = userToken ) @@ -268,7 +268,7 @@ class PaymentAPITest( test("예약에 대한 결제 정보가 없으면 실패한다.") { val userToken = testAuthUtil.defaultUserLogin() val reservation = dummyInitializer.createConfirmReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = userToken, ) @@ -312,7 +312,7 @@ class PaymentAPITest( val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = testAuthUtil.defaultUserLogin(), ) diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt index 54df0d89..59b06fe9 100644 --- a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -55,7 +55,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -66,7 +66,7 @@ class ReservationApiTest( test("정상 생성") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.HOLD ) @@ -95,7 +95,7 @@ class ReservationApiTest( test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.AVAILABLE ) @@ -116,7 +116,7 @@ class ReservationApiTest( } test("예약 인원이 테마의 최소 인원보다 작거나 최대 인원보다 많으면 실패한다.") { - val adminToken = testAuthUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultHqAdminLogin() val theme: ThemeEntity = dummyInitializer.createTheme( adminToken = adminToken, request = ThemeFixture.createRequest @@ -190,7 +190,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -202,7 +202,7 @@ class ReservationApiTest( val userToken = testAuthUtil.defaultUserLogin() val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = userToken, ) @@ -250,7 +250,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -262,7 +262,7 @@ class ReservationApiTest( val userToken = testAuthUtil.defaultUserLogin() val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = userToken, ) @@ -301,7 +301,7 @@ class ReservationApiTest( test("다른 회원의 예약을 취소할 수 없다.") { val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = testAuthUtil.defaultUserLogin(), ) @@ -332,7 +332,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -342,7 +342,7 @@ class ReservationApiTest( test("정상 응답") { val userToken = testAuthUtil.defaultUserLogin() - val adminToken = testAuthUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultHqAdminLogin() for (i in 1..3) { dummyInitializer.createConfirmReservation( @@ -398,7 +398,7 @@ class ReservationApiTest( test("관리자") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -413,7 +413,7 @@ class ReservationApiTest( beforeTest { reservation = dummyInitializer.createConfirmReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = testAuthUtil.defaultUserLogin(), ) } @@ -556,7 +556,7 @@ class ReservationApiTest( test("예약은 있지만, 결제 정보를 찾을 수 없으면 null로 지정한다.") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = testAuthUtil.defaultUserLogin(), ) @@ -574,7 +574,7 @@ class ReservationApiTest( test("예약과 결제는 있지만, 결제 세부 내역이 없으면 세부 내역만 null로 지정한다..") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), reserverToken = testAuthUtil.defaultUserLogin(), ) diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt index 14b9dd9b..e4d0fb0b 100644 --- a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -32,7 +32,7 @@ class ScheduleApiTest( val endpoint = "/schedules/themes?date=$date" test("정상 응답") { - val adminToken = testAuthUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultHqAdminLogin() for (i in 1..10) { dummyInitializer.createSchedule( @@ -61,7 +61,7 @@ class ScheduleApiTest( test("정상 응답") { val date = LocalDate.now().plusDays(1) - val adminToken = testAuthUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultHqAdminLogin() val createdSchedule = dummyInitializer.createSchedule( adminToken = adminToken, request = createRequest.copy(date = date, time = LocalTime.now()) @@ -129,7 +129,7 @@ class ScheduleApiTest( } test("정상 응답") { - val token = testAuthUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val createdSchedule = dummyInitializer.createSchedule( adminToken = token, @@ -161,7 +161,7 @@ class ScheduleApiTest( test("일정이 없으면 실패한다.") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.GET, endpoint = "/schedules/$INVALID_PK", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND @@ -208,7 +208,7 @@ class ScheduleApiTest( } test("정상 생성 및 감사 정보 확인") { - val token = testAuthUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val themeId: Long = dummyInitializer.createTheme( adminToken = token, @@ -246,7 +246,7 @@ class ScheduleApiTest( } test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") { - val token = testAuthUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val date = LocalDate.now().plusDays(1) val time = LocalTime.of(10, 0) @@ -267,7 +267,7 @@ class ScheduleApiTest( } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { - val token = testAuthUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val body = createRequest.copy(LocalDate.now(), LocalTime.now().minusMinutes(1)) runExceptionTest( @@ -294,7 +294,7 @@ class ScheduleApiTest( test("관리자") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.PATCH, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -304,7 +304,7 @@ class ScheduleApiTest( test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태이면 정상 응답") { val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), request = createRequest ) @@ -334,7 +334,7 @@ class ScheduleApiTest( } test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태가 아니면 실패한다.") { - val adminToken = testAuthUtil.defaultStoreAdminLogin() + val adminToken = testAuthUtil.defaultHqAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = adminToken, @@ -414,7 +414,7 @@ class ScheduleApiTest( test("정상 수정 및 감사 정보 변경 확인") { val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = testAuthUtil.defaultStoreAdminLogin(), + adminToken = testAuthUtil.defaultHqAdminLogin(), request = createRequest.copy( date = LocalDate.now().plusDays(1), time = LocalTime.now().plusMinutes(1), @@ -449,7 +449,7 @@ class ScheduleApiTest( } test("입력값이 없으면 수정하지 않는다.") { - val token = testAuthUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, @@ -477,7 +477,7 @@ class ScheduleApiTest( test("일정이 없으면 실패한다.") { runExceptionTest( - token = testAuthUtil.defaultStoreAdminLogin(), + token = testAuthUtil.defaultHqAdminLogin(), method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = "/schedules/${INVALID_PK}", @@ -486,7 +486,7 @@ class ScheduleApiTest( } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { - val token = testAuthUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = @@ -540,7 +540,7 @@ class ScheduleApiTest( } test("정상 삭제") { - val token = testAuthUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = createRequest @@ -560,7 +560,7 @@ class ScheduleApiTest( } test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { - val token = testAuthUtil.defaultStoreAdminLogin() + val token = testAuthUtil.defaultHqAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, -- 2.47.2 From 747ecbf058a24ec8679c500328808df6470912ca Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 13:32:33 +0900 Subject: [PATCH 046/116] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(ID=EA=B0=80=201=EC=9D=B4=EB=A9=B4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20->=20=EC=A1=B0=ED=9A=8C=20=ED=9B=84=20=EC=97=86?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EC=83=9D=EC=84=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/supports/DummyInitializer.kt | 18 +++++++++--------- .../kotlin/roomescape/supports/Fixtures.kt | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/test/kotlin/roomescape/supports/DummyInitializer.kt b/src/test/kotlin/roomescape/supports/DummyInitializer.kt index fcce8092..6b4309f6 100644 --- a/src/test/kotlin/roomescape/supports/DummyInitializer.kt +++ b/src/test/kotlin/roomescape/supports/DummyInitializer.kt @@ -57,7 +57,7 @@ class DummyInitializer( request: ScheduleCreateRequest, status: ScheduleStatus = ScheduleStatus.AVAILABLE ): ScheduleEntity { - val themeId: Long = if (request.themeId > 1L) { + val themeId: Long = if (themeRepository.existsById(request.themeId)) { request.themeId } else { createTheme( @@ -94,10 +94,10 @@ class DummyInitializer( scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, ): ReservationEntity { - val themeId: Long = if (scheduleRequest.themeId > 1) { + val themeId: Long = if (themeRepository.existsById(scheduleRequest.themeId)) { scheduleRequest.themeId - } else if (reservationRequest.scheduleId > 1) { - scheduleRepository.findByIdOrNull(reservationRequest.scheduleId)!!.themeId + } else if (themeRepository.existsById(reservationRequest.scheduleId)) { + reservationRequest.scheduleId } else { createTheme( adminToken = adminToken, @@ -105,7 +105,7 @@ class DummyInitializer( ).id } - val scheduleId: Long = if (reservationRequest.scheduleId > 1) { + val scheduleId: Long = if (scheduleRepository.existsById(reservationRequest.scheduleId)) { reservationRequest.scheduleId } else { createSchedule( @@ -128,10 +128,10 @@ class DummyInitializer( scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, ): ReservationEntity { - val themeId: Long = if (scheduleRequest.themeId > 1) { + val themeId: Long = if (themeRepository.existsById(scheduleRequest.themeId)) { scheduleRequest.themeId - } else if (reservationRequest.scheduleId > 1) { - scheduleRepository.findByIdOrNull(reservationRequest.scheduleId)!!.themeId + } else if (themeRepository.existsById(reservationRequest.scheduleId)) { + reservationRequest.scheduleId } else { createTheme( adminToken = adminToken, @@ -139,7 +139,7 @@ class DummyInitializer( ).id } - val scheduleId: Long = if (reservationRequest.scheduleId > 1) { + val scheduleId: Long = if (scheduleRepository.existsById(reservationRequest.scheduleId)) { reservationRequest.scheduleId } else { createSchedule( diff --git a/src/test/kotlin/roomescape/supports/Fixtures.kt b/src/test/kotlin/roomescape/supports/Fixtures.kt index ef936341..ecc056bd 100644 --- a/src/test/kotlin/roomescape/supports/Fixtures.kt +++ b/src/test/kotlin/roomescape/supports/Fixtures.kt @@ -170,7 +170,7 @@ object ScheduleFixture { val createRequest: ScheduleCreateRequest = ScheduleCreateRequest( date = LocalDate.now().plusDays(1), time = LocalTime.now(), - themeId = 1L + themeId = tsidFactory.next() ) } @@ -183,7 +183,7 @@ object PaymentFixture { ) val cancelRequest: PaymentCancelRequest = PaymentCancelRequest( - reservationId = 1L, + reservationId = tsidFactory.next(), cancelReason = "cancelReason", ) @@ -260,7 +260,7 @@ object PaymentFixture { object ReservationFixture { val pendingCreateRequest: PendingReservationCreateRequest = PendingReservationCreateRequest( - scheduleId = 1L, + scheduleId = tsidFactory.next(), reserverName = "Wilbur Stuart", reserverContact = "wilbur@example.com", participantCount = 5, -- 2.47.2 From cc0316d77a180a2c12901feaa3f7d5bd53779652 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 13:32:58 +0900 Subject: [PATCH 047/116] =?UTF-8?q?refactor:=20sql=20=EB=B0=8F=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=83=9D=EC=84=B1=EC=9A=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/roomescape/data/DataParser.kt | 7 +++++-- src/test/kotlin/roomescape/data/StoreDataInitializer.kt | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/test/kotlin/roomescape/data/DataParser.kt b/src/test/kotlin/roomescape/data/DataParser.kt index 86acb0d9..b0c7627d 100644 --- a/src/test/kotlin/roomescape/data/DataParser.kt +++ b/src/test/kotlin/roomescape/data/DataParser.kt @@ -16,7 +16,9 @@ class PopulationDataSqlParser() : StringSpec({ val regionCodePattern = Regex("^[0-9]{10}$") - "인구 데이터를 이용하여 지역 정보 SQL 파일로 변환하고, 추가로 $MIN_POPULATION_FOR_PER_STORE 이상의 시/군/구는 매장 데이터 생성을 위해 따로 분류한다." { + "인구 데이터를 이용하여 지역 정보 SQL 파일로 변환하고, 추가로 $MIN_POPULATION_FOR_PER_STORE 이상의 시/군/구는 매장 데이터 생성을 위해 따로 분류한다.".config( + enabled = false + ) { val populationXlsx = XSSFWorkbook(File("data/population.xlsx")) val sheet = populationXlsx.getSheetAt(0) val allRegion = mutableListOf>() @@ -55,7 +57,8 @@ class PopulationDataSqlParser() : StringSpec({ } if (populationInt >= MIN_POPULATION_FOR_PER_STORE) { - regionsMoreThanMinPopulation.add(listOf( + regionsMoreThanMinPopulation.add( + listOf( regionCode, sidoCode, sigunguCode, diff --git a/src/test/kotlin/roomescape/data/StoreDataInitializer.kt b/src/test/kotlin/roomescape/data/StoreDataInitializer.kt index fbcf64da..8d6e134c 100644 --- a/src/test/kotlin/roomescape/data/StoreDataInitializer.kt +++ b/src/test/kotlin/roomescape/data/StoreDataInitializer.kt @@ -19,7 +19,7 @@ class StoreDataInitializer : StringSpec({ "열정", "미래", "자유", "도전", "지혜", "행운" ) - "초기 매장 데이터를 준비한다." { + "초기 매장 데이터를 준비한다.".config(enabled = false) { val regions = initializeRegionWithStoreCount() val usedStoreName = mutableListOf() val usedBusinessRegNums = mutableListOf() -- 2.47.2 From 78baa271bbd18f03d26ac9f13dbb1f1bc95c9281 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 13:33:17 +0900 Subject: [PATCH 048/116] =?UTF-8?q?refactor:=20=ED=85=8C=EB=A7=88=20API=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20iniailize=20inline=20fun?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/theme/HQAdminThemeApiTest.kt | 357 ++++++++---------- .../roomescape/theme/PublicThemeApiTest.kt | 54 +-- .../theme/StoreAdminThemeApiTest.kt | 20 +- 3 files changed, 177 insertions(+), 254 deletions(-) diff --git a/src/test/kotlin/roomescape/theme/HQAdminThemeApiTest.kt b/src/test/kotlin/roomescape/theme/HQAdminThemeApiTest.kt index e253b886..50f2ba2f 100644 --- a/src/test/kotlin/roomescape/theme/HQAdminThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/HQAdminThemeApiTest.kt @@ -4,15 +4,15 @@ import io.kotest.matchers.date.shouldBeAfter import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe -import io.restassured.response.ValidatableResponse import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.CoreMatchers.notNullValue import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import roomescape.admin.infrastructure.persistence.AdminPermissionLevel import roomescape.admin.infrastructure.persistence.AdminType import roomescape.auth.exception.AuthErrorCode +import roomescape.supports.* +import roomescape.supports.ThemeFixture.createRequest import roomescape.theme.business.MIN_DURATION import roomescape.theme.business.MIN_PARTICIPANTS import roomescape.theme.business.MIN_PRICE @@ -20,9 +20,6 @@ import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.web.ThemeUpdateRequest -import roomescape.supports.* -import roomescape.supports.ThemeFixture.createRequest -import kotlin.random.Random class HQAdminThemeApiTest( private val themeRepository: ThemeRepository @@ -83,10 +80,8 @@ class HQAdminThemeApiTest( test("정상 생성 및 감사 정보 확인") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, + token = testAuthUtil.defaultHqAdminLogin(), using = { body(createRequest) }, @@ -95,7 +90,6 @@ class HQAdminThemeApiTest( }, expect = { statusCode(HttpStatus.CREATED.value()) - body("data.id", notNullValue()) } ).also { val createdThemeId: Long = it.extract().path("data.id") @@ -112,16 +106,15 @@ class HQAdminThemeApiTest( test("이미 동일한 이름의 테마가 있으면 실패한다.") { val token = testAuthUtil.defaultHqAdminLogin() - val commonName = "test123" - dummyInitializer.createTheme( - adminToken = token, - request = createRequest.copy(name = commonName) - ) + + val alreadyExistsName: String = initialize("테스트를 위한 테마 생성 및 이름 반환") { + dummyInitializer.createTheme(token, createRequest).name + } runTest( token = token, using = { - body(createRequest.copy(name = commonName)) + body(createRequest.copy(name = alreadyExistsName)) }, on = { post(endpoint) @@ -151,157 +144,100 @@ class HQAdminThemeApiTest( } context("입력된 시간이 ${MIN_DURATION}분 미만이면 실패한다.") { - val commonAssertion: ValidatableResponse.() -> Unit = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.DURATION_BELOW_MINIMUM.errorCode)) - } - test("field: availableMinutes") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, - using = { - body(createRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort())) - }, - on = { - post(endpoint) - }, - expect = commonAssertion + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = createRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort()), + expectedErrorCode = ThemeErrorCode.DURATION_BELOW_MINIMUM ) } test("field: expectedMinutesFrom") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, - using = { - body(createRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort())) - }, - on = { - post(endpoint) - }, - expect = commonAssertion + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = createRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort()), + expectedErrorCode = ThemeErrorCode.DURATION_BELOW_MINIMUM ) } test("field: expectedMinutesTo") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, - using = { - body(createRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort())) - }, - on = { - post(endpoint) - }, - expect = commonAssertion + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = createRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort()), + expectedErrorCode = ThemeErrorCode.DURATION_BELOW_MINIMUM ) } } context("시간 범위가 잘못 지정되면 실패한다.") { test("최소 예상 시간 > 최대 예상 시간") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, - using = { - body(createRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99)) - }, - on = { - post(endpoint) - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME.errorCode)) - } + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = createRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99), + expectedErrorCode = ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME ) } test("최대 예상 시간 > 이용 가능 시간") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, - using = { - body( - createRequest.copy( - availableMinutes = 100, - expectedMinutesFrom = 101, - expectedMinutesTo = 101 - ) - ) - }, - on = { - post(endpoint) - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME.errorCode)) - } + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = createRequest.copy( + availableMinutes = 100, + expectedMinutesFrom = 101, + expectedMinutesTo = 101 + ), + expectedErrorCode = ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME ) } } context("입력된 인원이 ${MIN_PARTICIPANTS}명 미만이면 실패한다.") { - val commonAssertion: ValidatableResponse.() -> Unit = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM.errorCode)) - } - test("field: minParticipants") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, - using = { - body(createRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort())) - }, - on = { - post(endpoint) - }, - expect = commonAssertion + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = createRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort()), + expectedErrorCode = ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM ) } test("field: maxParticipants") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, - using = { - body(createRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort())) - }, - on = { - post(endpoint) - }, - expect = commonAssertion + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = createRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort()), + expectedErrorCode = ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM ) } } context("인원 범위가 잘못 지정되면 실패한다.") { test("최소 인원 > 최대 인원") { - val token = testAuthUtil.defaultHqAdminLogin() - runTest( - token = token, - using = { - body(createRequest.copy(minParticipants = 10, maxParticipants = 9)) - }, - on = { - post(endpoint) - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT.errorCode)) - } + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = createRequest.copy(minParticipants = 10, maxParticipants = 9), + expectedErrorCode = ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT ) } } } - context("관리자가 모든 테마를 조회한다.") { + context("테마 요약 목록을 조회한다.") { val endpoint = "/admin/themes" - val requests = listOf( - createRequest.copy(name = "open", isActive = true), - createRequest.copy(name = "close", isActive = false) - ) context("권한이 없으면 접근할 수 없다.") { test("비회원") { @@ -337,17 +273,24 @@ class HQAdminThemeApiTest( } - test("비공개 테마까지 포함하여 간단한 정보만 조회된다.") { + test("정상 응답") { val token = testAuthUtil.defaultHqAdminLogin() - requests.forEach { dummyInitializer.createTheme(token, it) } + + val themes: List = initialize("Active 상태인 테마 1개 / Inactive 상태인 테마 2개 생성") { + listOf( + dummyInitializer.createTheme(token, createRequest.copy(name = "active-1", isActive = true)), + dummyInitializer.createTheme(token, createRequest.copy(name = "inactive-1", isActive = false)), + dummyInitializer.createTheme(token, createRequest.copy(name = "inactive-2", isActive = false)) + ) + } runTest( token = token, on = { - get("/admin/themes") + get(endpoint) }, expect = { - body("data.themes.size()", equalTo(requests.size)) + body("data.themes.size()", equalTo(themes.size)) assertProperties( props = setOf("id", "name", "difficulty", "price", "isActive"), propsNameIfList = "themes", @@ -407,10 +350,9 @@ class HQAdminThemeApiTest( test("정상 응답") { val token = testAuthUtil.defaultHqAdminLogin() - val createdTheme = dummyInitializer.createTheme( - adminToken = token, - request = createRequest - ) + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } runTest( token = token, @@ -492,10 +434,9 @@ class HQAdminThemeApiTest( test("정상 삭제") { val token = testAuthUtil.defaultHqAdminLogin() - val createdTheme = dummyInitializer.createTheme( - adminToken = token, - request = createRequest - ) + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } runTest( token = token, @@ -576,13 +517,15 @@ class HQAdminThemeApiTest( val updateRequest = ThemeUpdateRequest(name = "modified") test("정상 수정 및 감사 정보 변경 확인") { - val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = testAuthUtil.defaultHqAdminLogin(), - request = createRequest.copy(name = "theme-${Random.nextInt()}") - ) - val otherAdminToken: String = testAuthUtil.adminLogin( - AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE) - ) + val createdTheme = initialize("테스트를 위한 관리자1의 테마 생성") { + dummyInitializer.createTheme(testAuthUtil.defaultHqAdminLogin(), createRequest) + } + + val otherAdminToken: String = initialize("감사 정보 변경 확인을 위한 관리자2 로그인") { + testAuthUtil.adminLogin( + AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE) + ) + } runTest( token = otherAdminToken, @@ -606,13 +549,14 @@ class HQAdminThemeApiTest( } test("입력값이 없으면 수정하지 않는다.") { - val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = testAuthUtil.defaultHqAdminLogin(), - request = createRequest.copy(name = "theme-${Random.nextInt()}") - ) + val token = testAuthUtil.defaultHqAdminLogin() + + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } runTest( - token = testAuthUtil.defaultHqAdminLogin(), + token = token, using = { body(ThemeUpdateRequest()) }, @@ -641,14 +585,13 @@ class HQAdminThemeApiTest( } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { - val adminToken = testAuthUtil.defaultHqAdminLogin() - val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = adminToken, - request = createRequest.copy(name = "theme-${Random.nextInt()}") - ) + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", requestBody = updateRequest.copy(price = (MIN_PRICE - 1)), @@ -657,20 +600,14 @@ class HQAdminThemeApiTest( } context("입력된 시간이 ${MIN_DURATION}분 미만이면 실패한다.") { - lateinit var adminToken: String - lateinit var createdTheme: ThemeEntity - - beforeTest { - adminToken = testAuthUtil.defaultHqAdminLogin() - createdTheme = dummyInitializer.createTheme( - adminToken = adminToken, - request = createRequest.copy(name = "theme-${Random.nextInt()}") - ) - } - test("field: availableMinutes") { + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } + runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", requestBody = updateRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort()), @@ -679,8 +616,13 @@ class HQAdminThemeApiTest( } test("field: expectedMinutesFrom") { + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } + runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", requestBody = updateRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort()), @@ -689,8 +631,13 @@ class HQAdminThemeApiTest( } test("field: expectedMinutesTo") { + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } + runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", requestBody = updateRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort()), @@ -700,20 +647,14 @@ class HQAdminThemeApiTest( } context("시간 범위가 잘못 지정되면 실패한다.") { - lateinit var adminToken: String - lateinit var createdTheme: ThemeEntity - - beforeTest { - adminToken = testAuthUtil.defaultHqAdminLogin() - createdTheme = dummyInitializer.createTheme( - adminToken = adminToken, - request = createRequest.copy(name = "theme-${Random.nextInt()}") - ) - } - test("최소 예상 시간 > 최대 예상 시간") { + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } + runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", requestBody = updateRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99), @@ -723,17 +664,22 @@ class HQAdminThemeApiTest( test("최대 예상 시간 > 이용 가능 시간") { - val body = updateRequest.copy( + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } + + val requestBody = updateRequest.copy( availableMinutes = 100, expectedMinutesFrom = 101, expectedMinutesTo = 101 ) runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", - requestBody = body, + requestBody = requestBody, expectedErrorCode = ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME ) } @@ -741,20 +687,14 @@ class HQAdminThemeApiTest( context("입력된 인원이 ${MIN_PARTICIPANTS}명 미만이면 실패한다.") { - lateinit var adminToken: String - lateinit var createdTheme: ThemeEntity - - beforeTest { - adminToken = testAuthUtil.defaultHqAdminLogin() - createdTheme = dummyInitializer.createTheme( - adminToken = adminToken, - request = createRequest.copy(name = "theme-${Random.nextInt()}") - ) - } - test("field: minParticipants") { + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } + runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", requestBody = updateRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort()), @@ -763,8 +703,13 @@ class HQAdminThemeApiTest( } test("field: maxParticipants") { + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } + runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", requestBody = updateRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort()), @@ -774,20 +719,14 @@ class HQAdminThemeApiTest( } context("인원 범위가 잘못 지정되면 실패한다.") { - lateinit var adminToken: String - lateinit var createdTheme: ThemeEntity - - beforeTest { - adminToken = testAuthUtil.defaultHqAdminLogin() - createdTheme = dummyInitializer.createTheme( - adminToken = adminToken, - request = createRequest.copy(name = "theme-${Random.nextInt()}") - ) - } - test("최소 인원 > 최대 인원") { + val token = testAuthUtil.defaultHqAdminLogin() + val createdTheme = initialize("테스트를 위한 테마 생성") { + dummyInitializer.createTheme(token, createRequest) + } + runExceptionTest( - token = adminToken, + token = token, method = HttpMethod.PATCH, endpoint = "/admin/themes/${createdTheme.id}", requestBody = updateRequest.copy(minParticipants = 10, maxParticipants = 9), diff --git a/src/test/kotlin/roomescape/theme/PublicThemeApiTest.kt b/src/test/kotlin/roomescape/theme/PublicThemeApiTest.kt index b89a0b7a..a872dc87 100644 --- a/src/test/kotlin/roomescape/theme/PublicThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/PublicThemeApiTest.kt @@ -12,41 +12,17 @@ import roomescape.theme.web.ThemeIdListRequest class PublicThemeApiTest : FunSpecSpringbootTest() { init { context("입력된 모든 ID에 대한 테마를 조회한다.") { - test("정상 응답") { - val adminToken = testAuthUtil.defaultHqAdminLogin() - val themeSize = 3 - val themeIds = mutableListOf() - - for (i in 1..themeSize) { - dummyInitializer.createTheme(adminToken, createRequest.copy(name = "test$i")) - .also { themeIds.add(it.id) } - } - - runTest( - using = { - body(ThemeIdListRequest(themeIds)) - }, - on = { - post("/themes/batch") - }, - expect = { - statusCode(HttpStatus.OK.value()) - body("data.themes.size()", equalTo(themeSize)) + test("정상 응답 + 없는 테마가 있으면 생략한다.") { + val themeIds: List = initialize("목록 조회를 위한 3개의 테마 생성 및 일부 존재하지 않는 ID 추가") { + val token = testAuthUtil.defaultHqAdminLogin() + val themeIds = mutableListOf(INVALID_PK) + (1..3).forEach { + themeIds.add(dummyInitializer.createTheme(token, createRequest.copy(name = "test$it")).id) } - ) - } - test("없는 테마가 있으면 생략한다.") { - val token = testAuthUtil.defaultHqAdminLogin() - val themeSize = 3 - val themeIds = mutableListOf() - - for (i in 1..themeSize) { - dummyInitializer.createTheme(token, createRequest.copy(name = "test$i")) - .also { themeIds.add(it.id) } + themeIds } - themeIds.add(INVALID_PK) runTest( using = { body(ThemeIdListRequest(themeIds)) @@ -56,7 +32,7 @@ class PublicThemeApiTest : FunSpecSpringbootTest() { }, expect = { statusCode(HttpStatus.OK.value()) - body("data.themes.size()", equalTo(themeSize)) + body("data.themes.size()", equalTo(themeIds.filter { it != INVALID_PK }.size)) } ) } @@ -64,11 +40,13 @@ class PublicThemeApiTest : FunSpecSpringbootTest() { context("ID로 테마 정보를 조회한다.") { - test("성공 응답") { - val createdTheme: ThemeEntity = dummyInitializer.createTheme( - adminToken = testAuthUtil.defaultHqAdminLogin(), - request = createRequest - ) + test("정상 응답") { + val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") { + dummyInitializer.createTheme( + adminToken = testAuthUtil.defaultHqAdminLogin(), + request = createRequest + ) + } runTest( on = { @@ -97,4 +75,4 @@ class PublicThemeApiTest : FunSpecSpringbootTest() { } } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/roomescape/theme/StoreAdminThemeApiTest.kt b/src/test/kotlin/roomescape/theme/StoreAdminThemeApiTest.kt index 8563ecd7..69885294 100644 --- a/src/test/kotlin/roomescape/theme/StoreAdminThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/StoreAdminThemeApiTest.kt @@ -1,10 +1,12 @@ package roomescape.theme +import org.hamcrest.CoreMatchers.equalTo import org.springframework.http.HttpMethod import roomescape.auth.exception.AuthErrorCode import roomescape.supports.FunSpecSpringbootTest import roomescape.supports.ThemeFixture.createRequest import roomescape.supports.assertProperties +import roomescape.supports.initialize import roomescape.supports.runExceptionTest import roomescape.supports.runTest @@ -16,7 +18,7 @@ class StoreAdminThemeApiTest : FunSpecSpringbootTest() { context("권한이 없으면 접근할 수 없다.") { test("비회원") { runExceptionTest( - method = HttpMethod.POST, + method = HttpMethod.GET, requestBody = createRequest, endpoint = endpoint, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND @@ -26,7 +28,7 @@ class StoreAdminThemeApiTest : FunSpecSpringbootTest() { test("회원") { runExceptionTest( token = testAuthUtil.defaultUserLogin(), - method = HttpMethod.POST, + method = HttpMethod.GET, requestBody = createRequest, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED @@ -35,11 +37,14 @@ class StoreAdminThemeApiTest : FunSpecSpringbootTest() { } test("정상 응답") { - run { + val createdThemes = initialize("Active 상태 테마 2개 / Inactive 상태 테마 1개 생성") { val token = testAuthUtil.defaultHqAdminLogin() - dummyInitializer.createTheme(token, createRequest.copy(name = "test1", isActive = true)) - dummyInitializer.createTheme(token, createRequest.copy(name = "test2", isActive = false)) - dummyInitializer.createTheme(token, createRequest.copy(name = "test3", isActive = true)) + + listOf( + dummyInitializer.createTheme(token, createRequest.copy(name = "test1", isActive = true)), + dummyInitializer.createTheme(token, createRequest.copy(name = "test2", isActive = false)), + dummyInitializer.createTheme(token, createRequest.copy(name = "test3", isActive = true)) + ) } runTest( @@ -49,8 +54,9 @@ class StoreAdminThemeApiTest : FunSpecSpringbootTest() { }, expect = { statusCode(200) + body("data.themes.size()", equalTo(createdThemes.filter { it.isActive }.size)) assertProperties( - props = setOf("id", "user", "applicationDateTime", "payment"), + props = setOf("id", "name"), propsNameIfList = "themes" ) }, -- 2.47.2 From b839c76a65137e06b499e756701bf911581ea128 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 14:37:58 +0900 Subject: [PATCH 049/116] =?UTF-8?q?refactor:=20=ED=85=8C=EB=A7=88=20API?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=EB=B3=80=EA=B2=BD=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=8F=20HQ=20/=20STORE=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=96=B4=EB=93=9C=EB=AF=BC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/apiClient.ts | 2 - frontend/src/api/common/commonTypes.ts | 4 + frontend/src/api/schedule/scheduleAPI.ts | 6 +- frontend/src/api/schedule/scheduleTypes.ts | 6 +- frontend/src/api/theme/themeAPI.ts | 16 ++- frontend/src/api/theme/themeTypes.ts | 49 +++++-- frontend/src/context/AdminAuthContext.tsx | 6 +- frontend/src/css/admin-schedule-page.css | 102 +++++++++++++ frontend/src/pages/HomePage.tsx | 4 +- frontend/src/pages/ReservationStep1Page.tsx | 10 +- frontend/src/pages/admin/AdminNavbar.tsx | 8 +- .../src/pages/admin/AdminSchedulePage.tsx | 135 +++++++++++++----- .../src/pages/admin/AdminThemeEditPage.tsx | 17 +-- frontend/src/pages/admin/AdminThemePage.tsx | 2 +- 14 files changed, 285 insertions(+), 82 deletions(-) create mode 100644 frontend/src/api/common/commonTypes.ts diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 5871ff7f..63143b13 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -48,7 +48,6 @@ async function request( }, }; - const accessToken = localStorage.getItem('accessToken'); if (accessToken) { if (!config.headers) { @@ -57,7 +56,6 @@ async function request( config.headers['Authorization'] = `Bearer ${accessToken}`; } - if (method.toUpperCase() !== 'GET') { config.data = data; } diff --git a/frontend/src/api/common/commonTypes.ts b/frontend/src/api/common/commonTypes.ts new file mode 100644 index 00000000..e094754c --- /dev/null +++ b/frontend/src/api/common/commonTypes.ts @@ -0,0 +1,4 @@ +export interface OperatorInfo { + id: string; + name: string; +} diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts index 87d7d0ac..82b7e383 100644 --- a/frontend/src/api/schedule/scheduleAPI.ts +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -8,15 +8,15 @@ import type { ScheduleUpdateRequest } from './scheduleTypes'; -export const findAvailableThemesByDate = async (date: string): Promise => { +export const fetchAvailableThemesByDate = async (date: string): Promise => { return await apiClient.get(`/schedules/themes?date=${date}`); }; -export const findSchedules = async (date: string, themeId: string): Promise => { +export const fetchSchedulesByDateAndTheme = async (date: string, themeId: string): Promise => { return await apiClient.get(`/schedules?date=${date}&themeId=${themeId}`); }; -export const findScheduleById = async (id: string): Promise => { +export const fetchScheduleById = async (id: string): Promise => { return await apiClient.get(`/schedules/${id}`); } diff --git a/frontend/src/api/schedule/scheduleTypes.ts b/frontend/src/api/schedule/scheduleTypes.ts index 9a08ac1e..c31c9fb9 100644 --- a/frontend/src/api/schedule/scheduleTypes.ts +++ b/frontend/src/api/schedule/scheduleTypes.ts @@ -1,3 +1,5 @@ +import type { OperatorInfo } from '@_api/common/commonTypes'; + export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED'; export const ScheduleStatus = { @@ -44,7 +46,7 @@ export interface ScheduleDetailRetrieveResponse { time: string; // "HH:mm" status: ScheduleStatus; createdAt: string; // or Date - createdBy: string; + createdBy: OperatorInfo; updatedAt: string; // or Date - updatedBy: string; + updatedBy: OperatorInfo; } diff --git a/frontend/src/api/theme/themeAPI.ts b/frontend/src/api/theme/themeAPI.ts index 7282709c..43fb1516 100644 --- a/frontend/src/api/theme/themeAPI.ts +++ b/frontend/src/api/theme/themeAPI.ts @@ -2,10 +2,12 @@ import apiClient from '@_api/apiClient'; import type { AdminThemeDetailRetrieveResponse, AdminThemeSummaryRetrieveListResponse, + SimpleActiveThemeListResponse, ThemeCreateRequest, ThemeCreateResponse, ThemeIdListResponse, ThemeInfoListResponse, + ThemeInfoResponse, ThemeUpdateRequest } from './themeTypes'; @@ -29,10 +31,14 @@ export const deleteTheme = async (id: string): Promise => { await apiClient.del(`/admin/themes/${id}`); }; -export const fetchUserThemes = async (): Promise => { - return await apiClient.get('/themes'); -}; - -export const findThemesByIds = async (request: ThemeIdListResponse): Promise => { +export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise => { return await apiClient.post('/themes/batch', request); }; + +export const fetchThemeById = async (id: string): Promise => { + return await apiClient.get(`/themes/${id}`); +} + +export const fetchActiveThemes = async (): Promise => { + return await apiClient.get('/admin/themes/active'); +}; diff --git a/frontend/src/api/theme/themeTypes.ts b/frontend/src/api/theme/themeTypes.ts index ba28bc0d..0cc043ed 100644 --- a/frontend/src/api/theme/themeTypes.ts +++ b/frontend/src/api/theme/themeTypes.ts @@ -1,3 +1,5 @@ +import type { OperatorInfo } from '@_api/common/commonTypes'; + export interface AdminThemeDetailResponse { id: string; name: string; @@ -10,11 +12,11 @@ export interface AdminThemeDetailResponse { availableMinutes: number; expectedMinutesFrom: number; expectedMinutesTo: number; - isOpen: boolean; + isActive: boolean; createDate: string; // Assuming ISO string format updatedDate: string; // Assuming ISO string format - createdBy: string; - updatedBy: string; + createdBy: OperatorInfo; + updatedBy: OperatorInfo; } export interface ThemeCreateRequest { @@ -28,7 +30,7 @@ export interface ThemeCreateRequest { availableMinutes: number; expectedMinutesFrom: number; expectedMinutesTo: number; - isOpen: boolean; + isActive: boolean; } export interface ThemeCreateResponse { @@ -43,10 +45,11 @@ export interface ThemeUpdateRequest { price?: number; minParticipants?: number; maxParticipants?: number; + availableMinutes?: number; expectedMinutesFrom?: number; expectedMinutesTo?: number; - isOpen?: boolean; + isActive?: boolean; } export interface AdminThemeSummaryRetrieveResponse { @@ -54,7 +57,7 @@ export interface AdminThemeSummaryRetrieveResponse { name: string; difficulty: Difficulty; price: number; - isOpen: boolean; + isActive: boolean; } export interface AdminThemeSummaryRetrieveListResponse { @@ -73,11 +76,11 @@ export interface AdminThemeDetailRetrieveResponse { availableMinutes: number; expectedMinutesFrom: number; expectedMinutesTo: number; - isOpen: boolean; + isActive: boolean; createdAt: string; // LocalDateTime in Kotlin, map to string (ISO format) - createdBy: string; + createdBy: OperatorInfo; updatedAt: string; // LocalDateTime in Kotlin, map to string (ISO format) - updatedBy: string; + updatedBy: OperatorInfo; } export interface ThemeInfoResponse { @@ -102,18 +105,34 @@ export interface ThemeIdListResponse { themeIds: string[]; } -// @ts-ignore export enum Difficulty { - VERY_EASY = '매우 쉬움', - EASY = '쉬움', - NORMAL = '보통', - HARD = '어려움', - VERY_HARD = '매우 어려움', + VERY_EASY = 'VERY_EASY', + EASY = 'EASY', + NORMAL = 'NORMAL', + HARD = 'HARD', + VERY_HARD = 'VERY_HARD', } +export const DifficultyKoreanMap: Record = { + [Difficulty.VERY_EASY]: '매우 쉬움', + [Difficulty.EASY]: '쉬움', + [Difficulty.NORMAL]: '보통', + [Difficulty.HARD]: '어려움', + [Difficulty.VERY_HARD]: '매우 어려움', +}; + export function mapThemeResponse(res: any): ThemeInfoResponse { return { ...res, difficulty: Difficulty[res.difficulty as keyof typeof Difficulty], } +} + +export interface SimpleActiveThemeResponse { + id: string; + name: string; +} + +export interface SimpleActiveThemeListResponse { + themes: SimpleActiveThemeResponse[]; } \ No newline at end of file diff --git a/frontend/src/context/AdminAuthContext.tsx b/frontend/src/context/AdminAuthContext.tsx index 3cfe96d0..a62fe7f3 100644 --- a/frontend/src/context/AdminAuthContext.tsx +++ b/frontend/src/context/AdminAuthContext.tsx @@ -27,7 +27,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children useEffect(() => { try { - const token = localStorage.getItem('adminAccessToken'); + const token = localStorage.getItem('accessToken'); const storedName = localStorage.getItem('adminName'); const storedType = localStorage.getItem('adminType') as AdminType | null; const storedStoreId = localStorage.getItem('adminStoreId'); @@ -48,7 +48,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children const login = async (data: Omit) => { const response = await apiLogin(data); - localStorage.setItem('adminAccessToken', response.accessToken); + localStorage.setItem('accessToken', response.accessToken); localStorage.setItem('adminName', response.name); localStorage.setItem('adminType', response.type); if (response.storeId) { @@ -69,7 +69,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children try { await apiLogout(); } finally { - localStorage.removeItem('adminAccessToken'); + localStorage.removeItem('accessToken'); localStorage.removeItem('adminName'); localStorage.removeItem('adminType'); localStorage.removeItem('adminStoreId'); diff --git a/frontend/src/css/admin-schedule-page.css b/frontend/src/css/admin-schedule-page.css index 350d6c33..c4cb2775 100644 --- a/frontend/src/css/admin-schedule-page.css +++ b/frontend/src/css/admin-schedule-page.css @@ -211,4 +211,106 @@ th { .audit-body p strong { color: #212529; margin-right: 0.5rem; +} + +.theme-selector-button-group { + display: flex; + flex-direction: row !important; + align-items: flex-end; + gap: 0.5rem; +} + +.theme-selector-button-group .form-select { + flex-grow: 1; +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background-color: #fff; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 600px; + position: relative; +} + +.modal-close-btn { + position: absolute; + top: 1rem; + right: 1rem; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #888; +} + +.modal-title { + font-size: 1.75rem; + font-weight: bold; + margin-top: 0; + margin-bottom: 1.5rem; + text-align: center; +} + +.theme-modal-thumbnail { + width: 100%; + max-height: 300px; + object-fit: cover; + border-radius: 8px; + margin-bottom: 1.5rem; +} + +.theme-modal-description { + font-size: 1rem; + line-height: 1.6; + color: #555; + margin-bottom: 1.5rem; +} + +.theme-modal-info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + background-color: #f9f9f9; + padding: 1rem; + border-radius: 8px; +} + +.info-item { + display: flex; + justify-content: space-between; + padding: 0.5rem; + border-bottom: 1px solid #eee; +} + +.info-item:last-child { + border-bottom: none; +} + +.info-item strong { + font-weight: 600; + color: #333; +} + +.info-item span { + color: #666; +} + +.theme-details-button { + align-self: center !important; } \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index a5c22119..3d4461d5 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,7 +2,7 @@ import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI'; import '@_css/home-page-v2.css'; import React, {useEffect, useState} from 'react'; import {useNavigate} from 'react-router-dom'; -import {findThemesByIds} from '@_api/theme/themeAPI'; +import {fetchThemesByIds} from '@_api/theme/themeAPI'; import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes'; const HomePage: React.FC = () => { @@ -25,7 +25,7 @@ const HomePage: React.FC = () => { if (themeIds === undefined) return; if (themeIds.length === 0) return; - const response = await findThemesByIds({ themeIds: themeIds }); + const response = await fetchThemesByIds({ themeIds: themeIds }); setRanking(response.themes.map(mapThemeResponse)); } catch (err) { console.error('Error fetching ranking:', err); diff --git a/frontend/src/pages/ReservationStep1Page.tsx b/frontend/src/pages/ReservationStep1Page.tsx index b73f607a..df648370 100644 --- a/frontend/src/pages/ReservationStep1Page.tsx +++ b/frontend/src/pages/ReservationStep1Page.tsx @@ -1,7 +1,7 @@ import {isLoginRequiredError} from '@_api/apiClient'; -import {findAvailableThemesByDate, findSchedules, holdSchedule} from '@_api/schedule/scheduleAPI'; +import {fetchAvailableThemesByDate, fetchSchedulesByDateAndTheme, holdSchedule} from '@_api/schedule/scheduleAPI'; import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes'; -import {findThemesByIds} from '@_api/theme/themeAPI'; +import {fetchThemesByIds} from '@_api/theme/themeAPI'; import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes'; import '@_css/reservation-v2-1.css'; import React, {useEffect, useState} from 'react'; @@ -35,13 +35,13 @@ const ReservationStep1Page: React.FC = () => { useEffect(() => { if (selectedDate) { const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd - findAvailableThemesByDate(dateStr) + fetchAvailableThemesByDate(dateStr) .then(res => { console.log('Available themes response:', res); const themeIds: string[] = res.themeIds; console.log('Available theme IDs:', themeIds); if (themeIds.length > 0) { - return findThemesByIds({ themeIds }); + return fetchThemesByIds({ themeIds }); } else { return Promise.resolve({ themes: [] }); } @@ -69,7 +69,7 @@ const ReservationStep1Page: React.FC = () => { useEffect(() => { if (selectedDate && selectedTheme) { const dateStr = selectedDate.toLocaleDateString('en-CA'); - findSchedules(dateStr, selectedTheme.id) + fetchSchedulesByDateAndTheme(dateStr, selectedTheme.id) .then(res => { setSchedules(res.schedules); setSelectedSchedule(null); diff --git a/frontend/src/pages/admin/AdminNavbar.tsx b/frontend/src/pages/admin/AdminNavbar.tsx index a5563d6f..363ce561 100644 --- a/frontend/src/pages/admin/AdminNavbar.tsx +++ b/frontend/src/pages/admin/AdminNavbar.tsx @@ -1,10 +1,10 @@ -import {useAdminAuth} from '@_context/AdminAuthContext'; +import { useAdminAuth } from '@_context/AdminAuthContext'; import React from 'react'; -import {Link, useNavigate} from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import '@_css/navbar.css'; const AdminNavbar: React.FC = () => { - const { isAdmin, name, logout } = useAdminAuth(); + const { isAdmin, name, type, logout } = useAdminAuth(); const navigate = useNavigate(); const handleLogout = async (e: React.MouseEvent) => { @@ -21,7 +21,7 @@ const AdminNavbar: React.FC = () => {
@@ -318,6 +362,33 @@ const AdminSchedulePage: React.FC = () => {
+ + {isModalOpen && ( +
+
+ + {isLoadingThemeDetails ? ( +

로딩 중...

+ ) : selectedThemeDetails ? ( +
+

{selectedThemeDetails.name}

+ {selectedThemeDetails.name} +

{selectedThemeDetails.description}

+
+
난이도{DifficultyKoreanMap[selectedThemeDetails.difficulty]}
+
가격{selectedThemeDetails.price.toLocaleString()}원
+
최소 인원{selectedThemeDetails.minParticipants}명
+
최대 인원{selectedThemeDetails.maxParticipants}명
+
소요 시간{selectedThemeDetails.availableMinutes}분
+
예상 시간{selectedThemeDetails.expectedMinutesFrom} ~ {selectedThemeDetails.expectedMinutesTo}분
+
+
+ ) : ( +

테마 정보를 불러올 수 없습니다.

+ )} +
+
+ )} ); }; diff --git a/frontend/src/pages/admin/AdminThemeEditPage.tsx b/frontend/src/pages/admin/AdminThemeEditPage.tsx index 25658360..564abf3b 100644 --- a/frontend/src/pages/admin/AdminThemeEditPage.tsx +++ b/frontend/src/pages/admin/AdminThemeEditPage.tsx @@ -3,6 +3,7 @@ import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_ap import { type AdminThemeDetailResponse, Difficulty, + DifficultyKoreanMap, type ThemeCreateRequest, type ThemeUpdateRequest } from '@_api/theme/themeTypes'; @@ -46,7 +47,7 @@ const AdminThemeEditPage: React.FC = () => { availableMinutes: 60, expectedMinutesFrom: 50, expectedMinutesTo: 70, - isOpen: true, + isActive: true, }; setTheme(newTheme); setOriginalTheme(newTheme); @@ -67,7 +68,7 @@ const AdminThemeEditPage: React.FC = () => { availableMinutes: data.availableMinutes, expectedMinutesFrom: data.expectedMinutesFrom, expectedMinutesTo: data.expectedMinutesTo, - isOpen: data.isOpen, + isActive: data.isActive, createDate: data.createdAt, // Map createdAt to createDate updatedDate: data.updatedAt, // Map updatedAt to updatedDate createdBy: data.createdBy, @@ -85,7 +86,7 @@ const AdminThemeEditPage: React.FC = () => { const { name, value, type } = e.target; let processedValue: string | number | boolean = value; - if (name === 'isOpen') { + if (name === 'isActive') { processedValue = value === 'true'; } else if (type === 'checkbox') { processedValue = (e.target as HTMLInputElement).checked; @@ -178,12 +179,12 @@ const AdminThemeEditPage: React.FC = () => {
- - @@ -247,8 +248,8 @@ const AdminThemeEditPage: React.FC = () => {

생성일: {new Date(theme.createDate).toLocaleString()}

수정일: {new Date(theme.updatedDate).toLocaleString()}

-

생성자: {theme.createdBy}

-

수정자: {theme.updatedBy}

+

생성자: {theme.createdBy.name}

+

수정자: {theme.updatedBy.name}

)} diff --git a/frontend/src/pages/admin/AdminThemePage.tsx b/frontend/src/pages/admin/AdminThemePage.tsx index d9d7dd4b..eda4f2b7 100644 --- a/frontend/src/pages/admin/AdminThemePage.tsx +++ b/frontend/src/pages/admin/AdminThemePage.tsx @@ -65,7 +65,7 @@ const AdminThemePage: React.FC = () => { {theme.name} {theme.difficulty} {theme.price.toLocaleString()}원 - {theme.isOpen ? '공개' : '비공개'} + {theme.isActive ? '공개' : '비공개'} -- 2.47.2 From 8cd1084bd89e28e91c0fe2eddd6693804acdb2d6 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 14:46:14 +0900 Subject: [PATCH 050/116] =?UTF-8?q?fix:=20store=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=EC=97=90=EC=84=9C=20=EC=B6=94=EA=B0=80=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20contact=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/infrastructure/persistence/StoreEntity.kt | 9 +++++++++ src/main/resources/schema/schema-h2.sql | 4 ++++ src/test/kotlin/roomescape/data/StoreDataInitializer.kt | 9 ++++++--- src/test/kotlin/roomescape/supports/Fixtures.kt | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt index cb165a04..c73d4535 100644 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt +++ b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt @@ -16,9 +16,18 @@ import java.time.LocalDateTime class StoreEntity( id: Long, + @Column(unique = false) var name: String, + + @Column(unique = false) var address: String, + + @Column(unique = false) + var contact: String, + + @Column(unique = false) val businessRegNum: String, + val regionCode: String, ) : PersistableBaseEntity(id) { diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 3a3c1b45..66fee5a8 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -12,12 +12,16 @@ create table if not exists store( id bigint primary key, name varchar(20) not null, address varchar(100) not null, + contact varchar(50) not null, business_reg_num varchar(12) not null, region_code varchar(10) not null, created_at timestamp not null, updated_at timestamp not null, + constraint uk_store__name unique (name), + constraint uk_store__contact unique (contact), + constraint uk_store__address unique (address), constraint uk_store__business_reg_num unique (business_reg_num), constraint fk_store__region_code foreign key (region_code) references region (code) ); diff --git a/src/test/kotlin/roomescape/data/StoreDataInitializer.kt b/src/test/kotlin/roomescape/data/StoreDataInitializer.kt index 8d6e134c..76152f95 100644 --- a/src/test/kotlin/roomescape/data/StoreDataInitializer.kt +++ b/src/test/kotlin/roomescape/data/StoreDataInitializer.kt @@ -2,6 +2,7 @@ package roomescape.data import io.kotest.core.spec.style.StringSpec import roomescape.common.config.next +import roomescape.supports.randomPhoneNumber import roomescape.supports.tsidFactory import java.io.File import java.time.LocalDateTime @@ -39,6 +40,8 @@ class StoreDataInitializer : StringSpec({ } while (usedStoreName.contains(storeName)) usedStoreName.add(storeName) + val contact = randomPhoneNumber() + var businessRegNum: String do { businessRegNum = generateBusinessRegNum() @@ -51,15 +54,15 @@ class StoreDataInitializer : StringSpec({ val id: Long = tsidFactory.next().also { storeIds.add(it) } storeSqlRows.add( - "(${id}, '$storeName', '$address', '$businessRegNum', '${region.regionCode}', '$createdAt', '$updatedAt')" + "(${id}, '$storeName', '$address', '$contact', '$businessRegNum', '${region.regionCode}', '$createdAt', '$updatedAt')" ) storeDataRows.add( - "$id, $storeName, $address, $businessRegNum, ${region.regionCode}, $createdAt, $updatedAt" + "$id, $storeName, $address, $contact, $businessRegNum, ${region.regionCode}, $createdAt, $updatedAt" ) } } - StringBuilder("INSERT INTO store (id, name, address, business_reg_num, region_code, created_at, updated_at) VALUES ") + StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, created_at, updated_at) VALUES ") .append(storeSqlRows.joinToString(",\n")) .append(";") .toString() diff --git a/src/test/kotlin/roomescape/supports/Fixtures.kt b/src/test/kotlin/roomescape/supports/Fixtures.kt index ecc056bd..a83fb6f8 100644 --- a/src/test/kotlin/roomescape/supports/Fixtures.kt +++ b/src/test/kotlin/roomescape/supports/Fixtures.kt @@ -31,12 +31,14 @@ object StoreFixture { id: Long = tsidFactory.next(), name: String = "테스트-${randomString()}점", address: String = "서울특별시 강북구 행복길 123", + contact: String = randomPhoneNumber(), businessRegNum: String = "123-45-67890", regionCode: String = "1111000000" ) = StoreEntity( id = id, name = name, address = address, + contact = contact, businessRegNum = businessRegNum, regionCode = regionCode ) -- 2.47.2 From 2481e026eb559f4432ef80ac55442eefeb117398 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 15:45:35 +0900 Subject: [PATCH 051/116] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9E=A5=EC=9D=B4=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=EB=90=9C=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B4=88=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 2 + frontend/src/api/common/commonTypes.ts | 7 + frontend/src/api/region/regionTypes.ts | 6 + frontend/src/api/schedule/scheduleAPI.ts | 4 +- frontend/src/api/store/storeAPI.ts | 22 ++ frontend/src/api/store/storeTypes.ts | 32 +++ frontend/src/css/admin-store-page.css | 194 ++++++++++++++ frontend/src/pages/admin/AdminNavbar.tsx | 1 + .../src/pages/admin/AdminSchedulePage.tsx | 71 ++++-- frontend/src/pages/admin/AdminStorePage.tsx | 240 ++++++++++++++++++ .../roomescape/admin/business/AdminService.kt | 19 +- .../kotlin/roomescape/store/web/StoreDTO.kt | 52 ++++ 12 files changed, 624 insertions(+), 26 deletions(-) create mode 100644 frontend/src/api/store/storeAPI.ts create mode 100644 frontend/src/api/store/storeTypes.ts create mode 100644 frontend/src/css/admin-store-page.css create mode 100644 frontend/src/pages/admin/AdminStorePage.tsx create mode 100644 src/main/kotlin/roomescape/store/web/StoreDTO.kt diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a5e2ee4..ed5c0139 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import AdminLayout from './pages/admin/AdminLayout'; import AdminLoginPage from './pages/admin/AdminLoginPage'; import AdminPage from './pages/admin/AdminPage'; import AdminSchedulePage from './pages/admin/AdminSchedulePage'; +import AdminStorePage from './pages/admin/AdminStorePage'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminThemePage from './pages/admin/AdminThemePage'; import HomePage from '@_pages/HomePage'; @@ -32,6 +33,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/common/commonTypes.ts b/frontend/src/api/common/commonTypes.ts index e094754c..babcdab3 100644 --- a/frontend/src/api/common/commonTypes.ts +++ b/frontend/src/api/common/commonTypes.ts @@ -2,3 +2,10 @@ export interface OperatorInfo { id: string; name: string; } + +export interface AuditInfo { + createdAt: string; + updatedAt: string; + createdBy: OperatorInfo; + updatedBy: OperatorInfo; +} \ No newline at end of file diff --git a/frontend/src/api/region/regionTypes.ts b/frontend/src/api/region/regionTypes.ts index df351773..7c8f181d 100644 --- a/frontend/src/api/region/regionTypes.ts +++ b/frontend/src/api/region/regionTypes.ts @@ -19,3 +19,9 @@ export interface SigunguListResponse { export interface RegionCodeResponse { code: string } + +export interface RegionInfoResponse { + code: string, + sidoName: string, + sigunguName: string, +} \ No newline at end of file diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts index 82b7e383..978e9ddc 100644 --- a/frontend/src/api/schedule/scheduleAPI.ts +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -12,8 +12,8 @@ export const fetchAvailableThemesByDate = async (date: string): Promise(`/schedules/themes?date=${date}`); }; -export const fetchSchedulesByDateAndTheme = async (date: string, themeId: string): Promise => { - return await apiClient.get(`/schedules?date=${date}&themeId=${themeId}`); +export const fetchSchedulesByDateAndTheme = async (storeId: number, date: string, themeId: string): Promise => { + return await apiClient.get(`/schedules?storeId=${storeId}&date=${date}&themeId=${themeId}`); }; export const fetchScheduleById = async (id: string): Promise => { diff --git a/frontend/src/api/store/storeAPI.ts b/frontend/src/api/store/storeAPI.ts new file mode 100644 index 00000000..f9333bb0 --- /dev/null +++ b/frontend/src/api/store/storeAPI.ts @@ -0,0 +1,22 @@ +import apiClient from '@_api/apiClient'; +import { type SimpleStoreResponse, type StoreDetailResponse, type StoreRegisterRequest, type UpdateStoreRequest } from './storeTypes'; + +export const getStores = async (): Promise => { + return await apiClient.get('/admin/stores'); +}; + +export const getStoreDetail = async (id: number): Promise => { + return await apiClient.get(`/admin/stores/${id}`); +}; + +export const createStore = async (data: StoreRegisterRequest): Promise => { + return await apiClient.post('/admin/stores', data); +}; + +export const updateStore = async (id: number, data: UpdateStoreRequest): Promise => { + return await apiClient.put(`/admin/stores/${id}`, data); +}; + +export const deleteStore = async (id: number): Promise => { + await apiClient.del(`/admin/stores/${id}`); +}; diff --git a/frontend/src/api/store/storeTypes.ts b/frontend/src/api/store/storeTypes.ts new file mode 100644 index 00000000..bf54e0fc --- /dev/null +++ b/frontend/src/api/store/storeTypes.ts @@ -0,0 +1,32 @@ +import { type AuditInfo } from '@_api/common/commonTypes'; +import type { RegionInfoResponse } from '@_api/region/regionTypes'; + +export interface SimpleStoreResponse { + id: number; + name: string; +} + +export interface StoreDetailResponse { + id: number; + name: string; + address: string; + contact: string; + businessRegNum: string; + region: RegionInfoResponse; + audit: AuditInfo; +} + +export interface StoreRegisterRequest { + name: string; + address: string; + contact: string; + businessRegNum: string; + regionCode: string; +} + +export interface UpdateStoreRequest { + name: string; + address: string; + contact: string; + regionCode: string; +} diff --git a/frontend/src/css/admin-store-page.css b/frontend/src/css/admin-store-page.css new file mode 100644 index 00000000..7dc8924a --- /dev/null +++ b/frontend/src/css/admin-store-page.css @@ -0,0 +1,194 @@ +/* /src/css/admin-store-page.css */ +.admin-store-container { + max-width: 1400px; + margin: 40px auto; + padding: 40px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #f4f6f8; + border-radius: 16px; +} + +.admin-store-container .page-title { + font-size: 32px; + font-weight: 700; + color: #333d4b; + margin-bottom: 30px; + text-align: center; +} + +.section-card { + background-color: #ffffff; + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.table-header { + display: flex; + justify-content: flex-end; + margin-bottom: 20px; +} + +.table-container table { + width: 100%; + border-collapse: collapse; + font-size: 15px; +} + +.table-container th, +.table-container td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #e5e8eb; + vertical-align: middle; +} + +.table-container th { + background-color: #f9fafb; + color: #505a67; + font-weight: 600; +} + +.table-container tr:last-child td { + border-bottom: none; +} + +.table-container tr:hover { + background-color: #f4f6f8; +} + +.form-input, .form-select, .form-textarea { + width: 100%; + padding: 10px 12px; + font-size: 15px; + border: 1px solid #E5E8EB; + border-radius: 8px; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-input:focus, .form-select:focus, .form-textarea:focus { + outline: none; + border-color: #3182F6; + box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2); +} + +.btn { + padding: 8px 16px; + font-size: 15px; + font-weight: 600; + border-radius: 8px; + border: none; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #3182F6; + color: #ffffff; +} + +.btn-primary:hover { + background-color: #1B64DA; +} + +.btn-secondary { + background-color: #F2F4F6; + color: #4E5968; +} + +.btn-secondary:hover { + background-color: #E5E8EB; +} + +.btn-danger { + background-color: #e53e3e; + color: white; +} + +.btn-danger:hover { + background-color: #c53030; +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.details-row td { + padding: 0; + background-color: #f8f9fa; +} + +.details-container { + padding: 1.5rem; +} + +.details-form-card { + background-color: #fff; + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); + margin-bottom: 1.5rem; +} + +.form-row { + display: flex; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.form-group { + flex: 1; +} + +.form-label { + display: block; + margin-bottom: 8px; + font-size: 14px; + font-weight: 600; + color: #4E5968; +} + +.button-group { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.audit-info { + padding: 1.5rem; + border: 1px solid #dee2e6; + border-radius: 8px; + background-color: #fff; + margin-bottom: 1.5rem; +} + +.audit-title { + font-size: 1.1rem; + font-weight: 600; + color: #343a40; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #dee2e6; +} + +.audit-body p { + margin: 0.5rem 0; + font-size: 0.9rem; + color: #495057; +} + +.audit-body p strong { + color: #212529; + margin-right: 0.5rem; +} + +.add-store-form { + padding: 1.5rem; + background-color: #fdfdff; + border: 1px solid #e5e8eb; + border-radius: 8px; + margin-bottom: 2rem; +} diff --git a/frontend/src/pages/admin/AdminNavbar.tsx b/frontend/src/pages/admin/AdminNavbar.tsx index 363ce561..5cd0993c 100644 --- a/frontend/src/pages/admin/AdminNavbar.tsx +++ b/frontend/src/pages/admin/AdminNavbar.tsx @@ -22,6 +22,7 @@ const AdminNavbar: React.FC = () => {
홈 {type === 'HQ' && 테마} + {type === 'HQ' && 매장} 일정
diff --git a/frontend/src/pages/admin/AdminSchedulePage.tsx b/frontend/src/pages/admin/AdminSchedulePage.tsx index 99f652b2..ae0cec33 100644 --- a/frontend/src/pages/admin/AdminSchedulePage.tsx +++ b/frontend/src/pages/admin/AdminSchedulePage.tsx @@ -11,6 +11,8 @@ import { type ScheduleRetrieveResponse, ScheduleStatus } from '@_api/schedule/scheduleTypes'; +import { getStores } from '@_api/store/storeAPI'; +import { type SimpleStoreResponse } from '@_api/store/storeTypes'; import { fetchActiveThemes, fetchThemeById } from '@_api/theme/themeAPI'; import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes'; import { useAdminAuth } from '@_context/AdminAuthContext'; @@ -41,6 +43,8 @@ type ThemeForSchedule = { const AdminSchedulePage: React.FC = () => { const [schedules, setSchedules] = useState([]); const [themes, setThemes] = useState([]); + const [stores, setStores] = useState([]); + const [selectedStoreId, setSelectedStoreId] = useState(''); const [selectedThemeId, setSelectedThemeId] = useState(''); const [selectedDate, setSelectedDate] = useState(new Date().toLocaleDateString('en-CA')); @@ -59,7 +63,7 @@ const AdminSchedulePage: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); - const { type: adminType } = useAdminAuth(); + const { type: adminType, storeId: adminStoreId } = useAdminAuth(); const handleError = (err: any) => { if (isLoginRequiredError(err)) { @@ -75,26 +79,36 @@ const AdminSchedulePage: React.FC = () => { useEffect(() => { if (!adminType) return; - const fetchThemesForSchedule = async () => { + const fetchPrerequisites = async () => { try { - let themeData: ThemeForSchedule[]; - const res = await fetchActiveThemes(); - themeData = res.themes.map(t => ({ id: String(t.id), name: t.name })); + // Fetch themes + const themeRes = await fetchActiveThemes(); + const themeData = themeRes.themes.map(t => ({ id: String(t.id), name: t.name })); setThemes(themeData); if (themeData.length > 0) { setSelectedThemeId(themeData[0].id); } + + // Fetch stores for HQ admin + if (adminType === 'HQ') { + const storeRes = await getStores(); + setStores(storeRes); + if (storeRes.length > 0) { + setSelectedStoreId(String(storeRes[0].id)); + } + } } catch (error) { handleError(error); } }; - fetchThemesForSchedule(); + fetchPrerequisites(); }, [adminType]); const fetchSchedules = () => { - if (selectedDate && selectedThemeId) { - fetchSchedulesByDateAndTheme(selectedDate, selectedThemeId) + const storeId = adminType === 'HQ' ? selectedStoreId : adminStoreId; + if (storeId && selectedDate && selectedThemeId) { + fetchSchedulesByDateAndTheme(Number(storeId), selectedDate, selectedThemeId) .then(res => setSchedules(res.schedules)) .catch(err => { setSchedules([]); @@ -107,7 +121,7 @@ const AdminSchedulePage: React.FC = () => { useEffect(() => { fetchSchedules(); - }, [selectedDate, selectedThemeId]); + }, [selectedDate, selectedThemeId, selectedStoreId, adminType, adminStoreId]); const handleShowThemeDetails = async () => { if (!selectedThemeId) return; @@ -219,11 +233,28 @@ const AdminSchedulePage: React.FC = () => { } }; + const canModify = adminType === 'HQ'; + return (

일정 관리

+ {adminType === 'HQ' && ( +
+ + +
+ )}
{
-
- -
+ {canModify && ( +
+ +
+ )}
@@ -327,10 +360,12 @@ const AdminSchedulePage: React.FC = () => { ) : ( // --- VIEW MODE --- -
- - -
+ canModify && ( +
+ + +
+ ) )} ) : ( @@ -341,7 +376,7 @@ const AdminSchedulePage: React.FC = () => { )} ))} - {isAdding && ( + {isAdding && canModify && (
{ ); }; -export default AdminSchedulePage; +export default AdminSchedulePage; \ No newline at end of file diff --git a/frontend/src/pages/admin/AdminStorePage.tsx b/frontend/src/pages/admin/AdminStorePage.tsx new file mode 100644 index 00000000..4652b1ed --- /dev/null +++ b/frontend/src/pages/admin/AdminStorePage.tsx @@ -0,0 +1,240 @@ +import { isLoginRequiredError } from '@_api/apiClient'; +import { createStore, deleteStore, getStoreDetail, getStores, updateStore } from '@_api/store/storeAPI'; +import { type StoreRegisterRequest, type StoreDetailResponse, type SimpleStoreResponse, type UpdateStoreRequest } from '@_api/store/storeTypes'; +import { useAdminAuth } from '@_context/AdminAuthContext'; +import '@_css/admin-store-page.css'; +import React, { Fragment, useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +const AdminStorePage: React.FC = () => { + const [stores, setStores] = useState([]); + const [isAdding, setIsAdding] = useState(false); + const [newStore, setNewStore] = useState({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' }); + + const [expandedStoreId, setExpandedStoreId] = useState(null); + const [detailedStores, setDetailedStores] = useState<{ [key: number]: StoreDetailResponse }>({}); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editingStore, setEditingStore] = useState(null); + + const navigate = useNavigate(); + const location = useLocation(); + const { type: adminType } = useAdminAuth(); + + const handleError = (err: any) => { + if (isLoginRequiredError(err)) { + alert('로그인이 필요합니다.'); + navigate('/admin/login', { state: { from: location } }); + } else { + const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; + alert(message); + console.error(err); + } + }; + + const fetchStores = async () => { + try { + const storesData = await getStores(); + setStores(storesData); + } catch (error) { + handleError(error); + } + }; + + useEffect(() => { + if (adminType !== 'HQ') { + alert('접근 권한이 없습니다.'); + navigate('/admin'); + return; + } + fetchStores(); + }, [adminType, navigate]); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setNewStore(prev => ({ ...prev, [name]: value })); + }; + + const handleAddStore = async () => { + if (Object.values(newStore).some(val => val === '')) { + alert('모든 필드를 입력해주세요.'); + return; + } + try { + await createStore(newStore); + fetchStores(); + setIsAdding(false); + setNewStore({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' }); + } catch (error) { + handleError(error); + } + }; + + const handleToggleDetails = async (storeId: number) => { + const isAlreadyExpanded = expandedStoreId === storeId; + setIsEditing(false); + if (isAlreadyExpanded) { + setExpandedStoreId(null); + } else { + setExpandedStoreId(storeId); + if (!detailedStores[storeId]) { + setIsLoadingDetails(true); + try { + const details = await getStoreDetail(storeId); + setDetailedStores(prev => ({ ...prev, [storeId]: details })); + } catch (error) { + handleError(error); + } finally { + setIsLoadingDetails(false); + } + } + } + }; + + const handleDeleteStore = async (storeId: number) => { + if (window.confirm('정말 이 매장을 삭제하시겠습니까? 관련 데이터가 모두 삭제될 수 있습니다.')) { + try { + await deleteStore(storeId); + fetchStores(); + setExpandedStoreId(null); + } catch (error) { + handleError(error); + } + } + }; + + const handleEditClick = (store: StoreDetailResponse) => { + setEditingStore({ name: store.name, address: store.address, contact: store.contact }); + setIsEditing(true); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditingStore(null); + }; + + const handleEditChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + if (editingStore) { + setEditingStore(prev => ({ ...prev!, [name]: value })); + } + }; + + const handleSave = async (storeId: number) => { + if (!editingStore) return; + try { + const updatedStore = await updateStore(storeId, editingStore); + setDetailedStores(prev => ({ ...prev, [storeId]: updatedStore })); + setStores(prev => prev.map(s => s.id === storeId ? { ...s, name: updatedStore.name } : s)); + setIsEditing(false); + setEditingStore(null); + alert('매장 정보가 성공적으로 업데이트되었습니다.'); + } catch (error) { + handleError(error); + } + }; + + return ( +
+

매장 관리

+ +
+
+ +
+ + {isAdding && ( +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ )} + +
+ + + + + + + + + + {stores.map(store => ( + + + + + + + {expandedStoreId === store.id && ( + + + + )} + + ))} + +
ID매장명관리
{store.id}{store.name} + +
+
+ {isLoadingDetails ?

로딩 중...

: detailedStores[store.id] ? ( +
+
+

상세 정보

+
+

주소: {detailedStores[store.id].address}

+

연락처: {detailedStores[store.id].contact}

+

사업자등록번호: {detailedStores[store.id].businessRegNum}

+

지역 코드: {detailedStores[store.id].regionCode}

+

생성일: {new Date(detailedStores[store.id].createdAt).toLocaleString()}

+

수정일: {new Date(detailedStores[store.id].updatedAt).toLocaleString()}

+

생성자: {detailedStores[store.id].createdBy.name}

+

수정자: {detailedStores[store.id].updatedBy.name}

+
+
+ + {isEditing && editingStore ? ( +
+
+
+
+
+
+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) :

상세 정보를 불러올 수 없습니다.

} +
+
+
+
+
+ ); +}; + +export default AdminStorePage; \ No newline at end of file diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt index 30e8b04c..786fdadf 100644 --- a/src/main/kotlin/roomescape/admin/business/AdminService.kt +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -9,7 +9,11 @@ import roomescape.admin.exception.AdminErrorCode import roomescape.admin.exception.AdminException import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminRepository -import roomescape.common.dto.* +import roomescape.common.dto.AdminLoginCredentials +import roomescape.common.dto.AuditConstant +import roomescape.common.dto.OperatorInfo +import roomescape.common.dto.PrincipalType +import roomescape.common.dto.toCredentials private val log: KLogger = KotlinLogging.logger {} @@ -33,13 +37,16 @@ class AdminService( } @Transactional(readOnly = true) - fun findOperatorById(id: Long): OperatorInfo { + fun findOperatorOrNull(id: Long): OperatorInfo? { log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" } - val admin: AdminEntity = findOrThrow(id) - - return OperatorInfo(admin.id, admin.name).also { - log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" } + return adminRepository.findByIdOrNull(id)?.let { admin -> + OperatorInfo(admin.id, admin.name, PrincipalType.ADMIN).also { + log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" } + } + } ?: run { + log.info { "[AdminService.findOperatorById] 작업자 정보 조회 실패. id=${id}" } + null } } diff --git a/src/main/kotlin/roomescape/store/web/StoreDTO.kt b/src/main/kotlin/roomescape/store/web/StoreDTO.kt new file mode 100644 index 00000000..bf5211ed --- /dev/null +++ b/src/main/kotlin/roomescape/store/web/StoreDTO.kt @@ -0,0 +1,52 @@ +package roomescape.store.web + +import roomescape.common.dto.AuditInfo +import roomescape.region.web.RegionInfoResponse +import roomescape.store.infrastructure.persistence.StoreEntity + +data class SimpleStoreResponse( + val id: Long, + val name: String +) + +data class SimpleStoreListResponse( + val stores: List +) + +fun List.toSimpleListResponse() = SimpleStoreListResponse( + stores = this.map { SimpleStoreResponse(id = it.id, name = it.name) } +) + +data class StoreInfoResponse( + val id: Long, + val name: String, + val address: String, + val contact: String, + val businessRegNum: String +) + +fun StoreEntity.toInfoResponse() = StoreInfoResponse( + id = this.id, + name = this.name, + address = this.address, + contact = this.contact, + businessRegNum = this.businessRegNum +) + +data class StoreInfoListResponse( + val stores: List +) + +fun List.toInfoListResponse() = StoreInfoListResponse( + stores = this.map { it.toInfoResponse() } +) + +data class StoreDetailResponse( + val id: Long, + val name: String, + val address: String, + val contact: String, + val businessRegNum: String, + val region: RegionInfoResponse, + val audit: AuditInfo +) -- 2.47.2 From b41cddf3453b98b9946fc55a75b61aac9d9c2e39 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 15:46:21 +0900 Subject: [PATCH 052/116] =?UTF-8?q?refactor:=20Audit=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20DTO=20=EB=B3=84=EB=8F=84=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/common/dto/AuditDto.kt | 22 +++++++++++++++++++ .../roomescape/common/dto/CommonAuth.kt | 5 ----- 2 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/roomescape/common/dto/AuditDto.kt diff --git a/src/main/kotlin/roomescape/common/dto/AuditDto.kt b/src/main/kotlin/roomescape/common/dto/AuditDto.kt new file mode 100644 index 00000000..10931d5f --- /dev/null +++ b/src/main/kotlin/roomescape/common/dto/AuditDto.kt @@ -0,0 +1,22 @@ +package roomescape.common.dto + +import java.time.LocalDateTime + +object AuditConstant { + val UNKNOWN_OPERATOR = OperatorInfo( + id = 0, + name = "unknown" + ) +} + +data class OperatorInfo( + val id: Long, + val name: String, +) + +data class AuditInfo( + val createdAt: LocalDateTime, + val createdBy: OperatorInfo, + val modifiedAt: LocalDateTime, + val modifiedBy: OperatorInfo, +) diff --git a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt index 9265fcc6..af9b36f6 100644 --- a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt +++ b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt @@ -64,11 +64,6 @@ enum class PrincipalType { USER, ADMIN } -data class OperatorInfo( - val id: Long, - val name: String -) - data class CurrentUserContext( val id: Long, val name: String, -- 2.47.2 From bb6981666f76ef841489c754e415df730e586020 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 15:50:27 +0900 Subject: [PATCH 053/116] =?UTF-8?q?refactor:=20Operator=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EC=9C=BC=EB=A9=B4=20=EC=9A=B0=EC=84=A0=20Unknown?= =?UTF-8?q?=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/admin/business/AdminService.kt | 9 ++++----- .../roomescape/schedule/business/ScheduleService.kt | 4 ++-- .../kotlin/roomescape/theme/business/ThemeService.kt | 4 ++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt index 786fdadf..557baa68 100644 --- a/src/main/kotlin/roomescape/admin/business/AdminService.kt +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -12,7 +12,6 @@ import roomescape.admin.infrastructure.persistence.AdminRepository import roomescape.common.dto.AdminLoginCredentials import roomescape.common.dto.AuditConstant import roomescape.common.dto.OperatorInfo -import roomescape.common.dto.PrincipalType import roomescape.common.dto.toCredentials private val log: KLogger = KotlinLogging.logger {} @@ -37,16 +36,16 @@ class AdminService( } @Transactional(readOnly = true) - fun findOperatorOrNull(id: Long): OperatorInfo? { + fun findOperatorOrUnknown(id: Long): OperatorInfo { log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" } return adminRepository.findByIdOrNull(id)?.let { admin -> - OperatorInfo(admin.id, admin.name, PrincipalType.ADMIN).also { + OperatorInfo(admin.id, admin.name).also { log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" } } } ?: run { - log.info { "[AdminService.findOperatorById] 작업자 정보 조회 실패. id=${id}" } - null + log.warn { "[AdminService.findOperatorById] 작업자 정보 조회 실패. id=${id}" } + AuditConstant.UNKNOWN_OPERATOR } } diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt index 6d52db6b..12958b86 100644 --- a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt @@ -53,8 +53,8 @@ class ScheduleService( val schedule: ScheduleEntity = findOrThrow(id) - val createdBy = adminService.findOperatorById(schedule.createdBy) - val updatedBy = adminService.findOperatorById(schedule.updatedBy) + val createdBy = adminService.findOperatorOrUnknown(schedule.createdBy) + val updatedBy = adminService.findOperatorOrUnknown(schedule.updatedBy) return schedule.toDetailResponse(createdBy, updatedBy) .also { diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 81f96d37..89492250 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -78,8 +78,8 @@ class ThemeService( val theme: ThemeEntity = findOrThrow(id) - val createdBy = adminService.findOperatorById(theme.createdBy) - val updatedBy = adminService.findOperatorById(theme.updatedBy) + val createdBy = adminService.findOperatorOrUnknown(theme.createdBy) + val updatedBy = adminService.findOperatorOrUnknown(theme.updatedBy) return theme.toAdminThemeDetailResponse(createdBy, updatedBy) .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } -- 2.47.2 From 2d138ff32561d6184092d69a0fdf6ddf5c8d71ff Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 15:50:44 +0900 Subject: [PATCH 054/116] =?UTF-8?q?feat:=20=EC=A7=80=EC=97=AD=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20+=20=EC=9D=B4=EB=A6=84=EC=9D=B4=20=EB=8B=B4?= =?UTF-8?q?=EA=B8=B4=20=EC=83=88=EB=A1=9C=EC=9A=B4=20DTO=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/main/kotlin/roomescape/region/web/RegionDTO.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/roomescape/region/web/RegionDTO.kt b/src/main/kotlin/roomescape/region/web/RegionDTO.kt index 3046e1cb..dee2523a 100644 --- a/src/main/kotlin/roomescape/region/web/RegionDTO.kt +++ b/src/main/kotlin/roomescape/region/web/RegionDTO.kt @@ -21,3 +21,9 @@ data class SigunguListResponse( data class RegionCodeResponse( val code: String ) + +data class RegionInfoResponse( + val code: String, + val sidoName: String, + val sigunguName: String, +) -- 2.47.2 From cdf7a98867e2e7e16f8f9c57ddfe97916b447934 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 15:51:19 +0900 Subject: [PATCH 055/116] =?UTF-8?q?refactor:=20StoreEntity=EC=97=90=20'by'?= =?UTF-8?q?=20audit=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/persistence/StoreEntity.kt | 15 ++------------- src/main/resources/schema/schema-h2.sql | 2 ++ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt index c73d4535..8d4e9e9a 100644 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt +++ b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt @@ -4,11 +4,8 @@ import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EntityListeners import jakarta.persistence.Table -import org.springframework.data.annotation.CreatedDate -import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener -import roomescape.common.entity.PersistableBaseEntity -import java.time.LocalDateTime +import roomescape.common.entity.AuditingBaseEntity @Entity @EntityListeners(AuditingEntityListener::class) @@ -29,12 +26,4 @@ class StoreEntity( val businessRegNum: String, val regionCode: String, -) : PersistableBaseEntity(id) { - - @CreatedDate - @Column(updatable = false) - lateinit var createdAt: LocalDateTime - - @LastModifiedDate - lateinit var updatedAt: LocalDateTime -} \ No newline at end of file +) : AuditingBaseEntity(id) diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 66fee5a8..37cec409 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -17,7 +17,9 @@ create table if not exists store( region_code varchar(10) not null, created_at timestamp not null, + created_by bigint not null, updated_at timestamp not null, + updated_by bigint not null, constraint uk_store__name unique (name), constraint uk_store__contact unique (contact), -- 2.47.2 From 163b7991d302980ecffc6bf896ad14769f457eae Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 16:06:14 +0900 Subject: [PATCH 056/116] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/auth/authTypes.ts | 2 +- frontend/src/api/schedule/scheduleAPI.ts | 22 +++++++++---------- frontend/src/api/store/storeTypes.ts | 4 ++-- frontend/src/context/AdminAuthContext.tsx | 6 ++--- frontend/src/pages/ReservationStep1Page.tsx | 6 ++--- .../src/pages/admin/AdminSchedulePage.tsx | 20 ++++++++++++----- 6 files changed, 34 insertions(+), 26 deletions(-) diff --git a/frontend/src/api/auth/authTypes.ts b/frontend/src/api/auth/authTypes.ts index 2d53a88c..c67e1e92 100644 --- a/frontend/src/api/auth/authTypes.ts +++ b/frontend/src/api/auth/authTypes.ts @@ -28,7 +28,7 @@ export interface UserLoginSuccessResponse extends LoginSuccessResponse { export interface AdminLoginSuccessResponse extends LoginSuccessResponse { type: AdminType; - storeId: number | null; + storeId: string | null; } export interface CurrentUserContext { diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts index 978e9ddc..c4ee3cfc 100644 --- a/frontend/src/api/schedule/scheduleAPI.ts +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -8,28 +8,28 @@ import type { ScheduleUpdateRequest } from './scheduleTypes'; -export const fetchAvailableThemesByDate = async (date: string): Promise => { - return await apiClient.get(`/schedules/themes?date=${date}`); +export const fetchStoreAvailableThemesByDate = async (storeId: string, date: string): Promise => { + return await apiClient.get(`/stores/${storeId}/themes?date=${date}`); }; -export const fetchSchedulesByDateAndTheme = async (storeId: number, date: string, themeId: string): Promise => { - return await apiClient.get(`/schedules?storeId=${storeId}&date=${date}&themeId=${themeId}`); +export const fetchStoreSchedulesByDateAndTheme = async (storeId: string, date: string, themeId: string): Promise => { + return await apiClient.get(`/stores/${storeId}/schedules?date=${date}&themeId=${themeId}`); }; -export const fetchScheduleById = async (id: string): Promise => { - return await apiClient.get(`/schedules/${id}`); -} +export const fetchScheduleDetailById = async (id: string): Promise => { + return await apiClient.get(`/admin/schedules/${id}`); +}; -export const createSchedule = async (request: ScheduleCreateRequest): Promise => { - return await apiClient.post('/schedules', request); +export const createSchedule = async (storeId: string, request: ScheduleCreateRequest): Promise => { + return await apiClient.post(`/admin/stores/${storeId}/schedules`, request); }; export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise => { - await apiClient.patch(`/schedules/${id}`, request); + await apiClient.patch(`/admin/schedules/${id}`, request); }; export const deleteSchedule = async (id: string): Promise => { - await apiClient.del(`/schedules/${id}`); + await apiClient.del(`/admin/schedules/${id}`); }; export const holdSchedule = async (id: string): Promise => { diff --git a/frontend/src/api/store/storeTypes.ts b/frontend/src/api/store/storeTypes.ts index bf54e0fc..b036d664 100644 --- a/frontend/src/api/store/storeTypes.ts +++ b/frontend/src/api/store/storeTypes.ts @@ -2,12 +2,12 @@ import { type AuditInfo } from '@_api/common/commonTypes'; import type { RegionInfoResponse } from '@_api/region/regionTypes'; export interface SimpleStoreResponse { - id: number; + id: string; name: string; } export interface StoreDetailResponse { - id: number; + id: string; name: string; address: string; contact: string; diff --git a/frontend/src/context/AdminAuthContext.tsx b/frontend/src/context/AdminAuthContext.tsx index a62fe7f3..3857aeec 100644 --- a/frontend/src/context/AdminAuthContext.tsx +++ b/frontend/src/context/AdminAuthContext.tsx @@ -10,7 +10,7 @@ interface AdminAuthContextType { isAdmin: boolean; name: string | null; type: AdminType | null; - storeId: number | null; + storeId: string | null; loading: boolean; login: (data: Omit) => Promise; logout: () => Promise; @@ -22,7 +22,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children const [isAdmin, setIsAdmin] = useState(false); const [name, setName] = useState(null); const [type, setType] = useState(null); - const [storeId, setStoreId] = useState(null); + const [storeId, setStoreId] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -36,7 +36,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children setIsAdmin(true); setName(storedName); setType(storedType); - setStoreId(storedStoreId ? parseInt(storedStoreId, 10) : null); + setStoreId(storedStoreId ? storedStoreId : null); } } catch (error) { console.error("Failed to load admin auth state from storage", error); diff --git a/frontend/src/pages/ReservationStep1Page.tsx b/frontend/src/pages/ReservationStep1Page.tsx index df648370..0f3e16b0 100644 --- a/frontend/src/pages/ReservationStep1Page.tsx +++ b/frontend/src/pages/ReservationStep1Page.tsx @@ -1,5 +1,5 @@ import {isLoginRequiredError} from '@_api/apiClient'; -import {fetchAvailableThemesByDate, fetchSchedulesByDateAndTheme, holdSchedule} from '@_api/schedule/scheduleAPI'; +import {fetchStoreAvailableThemesByDate, fetchStoreSchedulesByDateAndTheme, holdSchedule} from '@_api/schedule/scheduleAPI'; import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes'; import {fetchThemesByIds} from '@_api/theme/themeAPI'; import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes'; @@ -35,7 +35,7 @@ const ReservationStep1Page: React.FC = () => { useEffect(() => { if (selectedDate) { const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd - fetchAvailableThemesByDate(dateStr) + fetchStoreAvailableThemesByDate(dateStr) .then(res => { console.log('Available themes response:', res); const themeIds: string[] = res.themeIds; @@ -69,7 +69,7 @@ const ReservationStep1Page: React.FC = () => { useEffect(() => { if (selectedDate && selectedTheme) { const dateStr = selectedDate.toLocaleDateString('en-CA'); - fetchSchedulesByDateAndTheme(dateStr, selectedTheme.id) + fetchStoreSchedulesByDateAndTheme(dateStr, selectedTheme.id) .then(res => { setSchedules(res.schedules); setSelectedSchedule(null); diff --git a/frontend/src/pages/admin/AdminSchedulePage.tsx b/frontend/src/pages/admin/AdminSchedulePage.tsx index ae0cec33..4382c195 100644 --- a/frontend/src/pages/admin/AdminSchedulePage.tsx +++ b/frontend/src/pages/admin/AdminSchedulePage.tsx @@ -2,8 +2,8 @@ import { isLoginRequiredError } from '@_api/apiClient'; import { createSchedule, deleteSchedule, - fetchScheduleById, - fetchSchedulesByDateAndTheme, + fetchScheduleDetailById, + fetchStoreSchedulesByDateAndTheme, updateSchedule } from '@_api/schedule/scheduleAPI'; import { @@ -108,7 +108,7 @@ const AdminSchedulePage: React.FC = () => { const fetchSchedules = () => { const storeId = adminType === 'HQ' ? selectedStoreId : adminStoreId; if (storeId && selectedDate && selectedThemeId) { - fetchSchedulesByDateAndTheme(Number(storeId), selectedDate, selectedThemeId) + fetchStoreSchedulesByDateAndTheme(storeId, selectedDate, selectedThemeId) .then(res => setSchedules(res.schedules)) .catch(err => { setSchedules([]); @@ -147,8 +147,16 @@ const AdminSchedulePage: React.FC = () => { alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.'); return; } + if (adminType !== 'STORE' || !adminStoreId) { + alert('매장 관리자만 일정을 추가할 수 있습니다.'); + return; + } + if (!selectedDate || !selectedThemeId) { + alert('날짜와 테마를 선택해주세요.'); + return; + } try { - await createSchedule({ + await createSchedule(adminStoreId, { date: selectedDate, themeId: selectedThemeId, time: newScheduleTime, @@ -183,7 +191,7 @@ const AdminSchedulePage: React.FC = () => { if (!detailedSchedules[scheduleId]) { setIsLoadingDetails(true); try { - const details = await fetchScheduleById(scheduleId); + const details = await fetchScheduleDetailById(scheduleId); setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details })); } catch (error) { handleError(error); @@ -222,7 +230,7 @@ const AdminSchedulePage: React.FC = () => { status: editingSchedule.status, }); // Refresh data - const details = await fetchScheduleById(editingSchedule.id); + const details = await fetchScheduleDetailById(editingSchedule.id); setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details })); setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s)); -- 2.47.2 From 072ca7c4573203aa9bcff081a74c3e18bf9a6650 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 17:49:19 +0900 Subject: [PATCH 057/116] =?UTF-8?q?remove:=20Room=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20Schedule=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=9D=98=20roomId=20->=20storeId=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/persistence/RoomEntity.kt | 24 ------------------- .../persistence/RoomRepository.kt | 5 ---- src/main/resources/schema/schema-h2.sql | 16 +------------ 3 files changed, 1 insertion(+), 44 deletions(-) delete mode 100644 src/main/kotlin/roomescape/store/infrastructure/persistence/RoomEntity.kt delete mode 100644 src/main/kotlin/roomescape/store/infrastructure/persistence/RoomRepository.kt diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/RoomEntity.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/RoomEntity.kt deleted file mode 100644 index 42f943b4..00000000 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/RoomEntity.kt +++ /dev/null @@ -1,24 +0,0 @@ -package roomescape.store.infrastructure.persistence - -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Table -import roomescape.common.entity.AuditingBaseEntity - -@Entity -@Table(name = "room") -class RoomEntity( - id: Long, - - var name: String, - val storeId: Long, - var maxCapacity: Short, - - @Enumerated(EnumType.STRING) - var status: RoomStatus -): AuditingBaseEntity(id) - -enum class RoomStatus { - AVAILABLE, DISABLED -} diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/RoomRepository.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/RoomRepository.kt deleted file mode 100644 index a51f194b..00000000 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/RoomRepository.kt +++ /dev/null @@ -1,5 +0,0 @@ -package roomescape.store.infrastructure.persistence - -import org.springframework.data.jpa.repository.JpaRepository - -interface RoomRepository : JpaRepository \ No newline at end of file diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 37cec409..374adaf9 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -28,21 +28,6 @@ create table if not exists store( constraint fk_store__region_code foreign key (region_code) references region (code) ); -create table if not exists room( - id bigint primary key , - name varchar(20) not null, - store_id bigint not null, - max_capacity smallint not null, - status varchar(20) not null, - - created_at timestamp not null, - created_by bigint not null, - updated_at timestamp not null, - updated_by bigint not null, - - constraint fk_room__store_id foreign key (store_id) references store (id) -); - create table if not exists users( id bigint primary key, name varchar(50) not null, @@ -131,6 +116,7 @@ create table if not exists schedule ( id bigint primary key, date date not null, time time not null, + store_id bigint not null, theme_id bigint not null, status varchar(30) not null, created_at timestamp not null, -- 2.47.2 From cf3a1488f7fb4093d8a697ba77304af17daf3be3 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 15 Sep 2025 17:50:29 +0900 Subject: [PATCH 058/116] =?UTF-8?q?refactor:=20store=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=20status=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Enum=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/infrastructure/persistence/StoreEntity.kt | 12 +++++++++++- src/main/resources/schema/schema-h2.sql | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt index 8d4e9e9a..faf79f46 100644 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt +++ b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt @@ -3,6 +3,8 @@ package roomescape.store.infrastructure.persistence import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EntityListeners +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.Table import org.springframework.data.jpa.domain.support.AuditingEntityListener import roomescape.common.entity.AuditingBaseEntity @@ -25,5 +27,13 @@ class StoreEntity( @Column(unique = false) val businessRegNum: String, - val regionCode: String, + var regionCode: String, + + @Enumerated(value = EnumType.STRING) + var status: StoreStatus ) : AuditingBaseEntity(id) + +enum class StoreStatus { + ACTIVE, + INACTIVE +} diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 374adaf9..81ee9a9c 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -15,6 +15,7 @@ create table if not exists store( contact varchar(50) not null, business_reg_num varchar(12) not null, region_code varchar(10) not null, + status varchar(20) not null, created_at timestamp not null, created_by bigint not null, -- 2.47.2 From afedaa21b85eb24ba94af0bca878ee1de519b024 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 16 Sep 2025 11:20:38 +0900 Subject: [PATCH 059/116] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20/=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20PK=20=EA=B8=B0=EB=B0=98=20MDC=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=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 --- .../support/interceptors/AdminInterceptor.kt | 5 ++-- .../support/interceptors/UserInterceptor.kt | 5 ++-- .../roomescape/common/config/JpaConfig.kt | 13 ++------- .../roomescape/common/dto/CommonAuth.kt | 1 - .../common/log/ApiLogMessageConverter.kt | 7 +++-- .../kotlin/roomescape/common/util/MDCUtils.kt | 27 +++++++++++++++++++ 6 files changed, 36 insertions(+), 22 deletions(-) create mode 100644 src/main/kotlin/roomescape/common/util/MDCUtils.kt diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt index 2bd227f6..ed608e37 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt @@ -4,7 +4,6 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor @@ -18,7 +17,7 @@ import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.accessToken -import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY +import roomescape.common.util.MdcPrincipalId private val log: KLogger = KotlinLogging.logger {} @@ -39,7 +38,7 @@ class AdminInterceptor( try { run { - val id: String = jwtUtils.extractSubject(token).also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) } + val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.set(it) } val type: AdminType = validateTypeAndGet(token, annotation.type) val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege) diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt index ce02d644..4db11d08 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt @@ -4,7 +4,6 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor @@ -14,7 +13,7 @@ import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.UserOnly import roomescape.auth.web.support.accessToken -import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY +import roomescape.common.util.MdcPrincipalId private val log: KLogger = KotlinLogging.logger {} @@ -34,7 +33,7 @@ class UserInterceptor( val token: String? = request.accessToken() try { - val id: String = jwtUtils.extractSubject(token).also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) } + val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.set(it) } /** * CLAIM_ADMIN_TYPE_KEY 가 존재하면 관리자 토큰임 diff --git a/src/main/kotlin/roomescape/common/config/JpaConfig.kt b/src/main/kotlin/roomescape/common/config/JpaConfig.kt index 8944e26a..29a14094 100644 --- a/src/main/kotlin/roomescape/common/config/JpaConfig.kt +++ b/src/main/kotlin/roomescape/common/config/JpaConfig.kt @@ -1,11 +1,10 @@ package roomescape.common.config -import org.slf4j.MDC import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.domain.AuditorAware import org.springframework.data.jpa.repository.config.EnableJpaAuditing -import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY +import roomescape.common.util.MdcPrincipalId import java.util.* @Configuration @@ -17,13 +16,5 @@ class JpaConfig { } class MdcAuditorAware : AuditorAware { - override fun getCurrentAuditor(): Optional { - val memberIdStr: String? = MDC.get(MDC_PRINCIPAL_ID_KEY) - - if (memberIdStr == null) { - return Optional.empty() - } else { - return Optional.of(memberIdStr.toLong()) - } - } + override fun getCurrentAuditor(): Optional = MdcPrincipalId.extractAsOptionalLongOrEmpty() } diff --git a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt index af9b36f6..3d037d55 100644 --- a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt +++ b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt @@ -8,7 +8,6 @@ import roomescape.auth.web.LoginSuccessResponse import roomescape.auth.web.UserLoginSuccessResponse import roomescape.user.infrastructure.persistence.UserEntity -const val MDC_PRINCIPAL_ID_KEY: String = "principal_id" abstract class LoginCredentials { abstract val id: Long diff --git a/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt b/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt index 85e042be..8cf19eab 100644 --- a/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt +++ b/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt @@ -2,8 +2,7 @@ package roomescape.common.log import com.fasterxml.jackson.databind.ObjectMapper import jakarta.servlet.http.HttpServletRequest -import org.slf4j.MDC -import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY +import roomescape.common.util.MdcPrincipalId enum class LogType { INCOMING_HTTP_REQUEST, @@ -34,7 +33,7 @@ class ApiLogMessageConverter( controllerPayload: Map, ): String { val payload: MutableMap = commonRequestPayload(LogType.CONTROLLER_INVOKED, request) - val memberId: Long? = MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong() + val memberId: Long? = MdcPrincipalId.extractAsLongOrNull() if (memberId != null) payload["principal_id"] = memberId else payload["principal_id"] = "NONE" payload.putAll(controllerPayload) @@ -48,7 +47,7 @@ class ApiLogMessageConverter( payload["endpoint"] = request.endpoint payload["status_code"] = request.httpStatus - MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLongOrNull() + MdcPrincipalId.extractAsLongOrNull() ?.let { payload["principal_id"] = it } ?: run { payload["principal_id"] = "NONE" } diff --git a/src/main/kotlin/roomescape/common/util/MDCUtils.kt b/src/main/kotlin/roomescape/common/util/MDCUtils.kt new file mode 100644 index 00000000..b5e477bb --- /dev/null +++ b/src/main/kotlin/roomescape/common/util/MDCUtils.kt @@ -0,0 +1,27 @@ +package roomescape.common.util + +import org.slf4j.MDC +import java.util.Optional + +private const val MDC_PRINCIPAL_ID_KEY = "principal_id" + +object MdcPrincipalId { + + fun extractAsLongOrNull(): Long? { + return MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong() + } + + fun extractAsOptionalLongOrEmpty(): Optional { + return MDC.get(MDC_PRINCIPAL_ID_KEY)?.let { + Optional.of(it.toLong()) + } ?: Optional.empty() + } + + fun set(id: String) { + MDC.put(MDC_PRINCIPAL_ID_KEY, id) + } + + fun clear() { + MDC.remove(MDC_PRINCIPAL_ID_KEY) + } +} -- 2.47.2 From 6ee7aa433908de9fb8a7581efd12e0cc380e9cc8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 16 Sep 2025 11:22:57 +0900 Subject: [PATCH 060/116] =?UTF-8?q?refactor:=20ScheduleEntity=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20updatedBy=EB=8A=94=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=8B=9C=EC=97=90=EB=A7=8C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/ScheduleEntity.kt | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt index e0198b84..1b87278d 100644 --- a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt +++ b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt @@ -1,22 +1,43 @@ package roomescape.schedule.infrastructure.persistence import jakarta.persistence.* -import roomescape.common.entity.AuditingBaseEntity +import org.springframework.data.annotation.CreatedBy +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import roomescape.common.entity.PersistableBaseEntity +import roomescape.common.util.MdcPrincipalId import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime @Entity +@EntityListeners(AuditingEntityListener::class) @Table(name = "schedule", uniqueConstraints = [UniqueConstraint(columnNames = ["date", "time", "theme_id"])]) class ScheduleEntity( id: Long, var date: LocalDate, var time: LocalTime, + val storeId: Long, var themeId: Long, @Enumerated(value = EnumType.STRING) - var status: ScheduleStatus -) : AuditingBaseEntity(id) { + var status: ScheduleStatus, +) : PersistableBaseEntity(id) { + @Column(updatable = false) + @CreatedDate + lateinit var createdAt: LocalDateTime + + @Column(updatable = false) + @CreatedBy + var createdBy: Long = 0L + + @Column + @LastModifiedDate + lateinit var updatedAt: LocalDateTime + + var updatedBy: Long = 0L fun modifyIfNotNull( time: LocalTime?, @@ -24,6 +45,7 @@ class ScheduleEntity( ) { time?.let { this.time = it } status?.let { this.status = it } + MdcPrincipalId.extractAsLongOrNull()?.also { this.updatedBy = it } } fun hold() { -- 2.47.2 From a6d028de45b5d017854765cf865a21079dc2c8f3 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 16 Sep 2025 22:03:11 +0900 Subject: [PATCH 061/116] =?UTF-8?q?refactor:=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=EC=97=90=20=ED=95=B4=EB=8B=B9?= =?UTF-8?q?=EB=90=98=EB=8A=94=20AdminType.ALL=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/admin/infrastructure/persistence/AdminEntity.kt | 3 ++- .../auth/web/support/interceptors/AdminInterceptor.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt index 9f356427..85387596 100644 --- a/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt +++ b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt @@ -30,7 +30,8 @@ class AdminEntity( enum class AdminType { HQ, - STORE + STORE, + ALL } enum class AdminPermissionLevel( diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt index ed608e37..4b521df0 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt @@ -75,7 +75,7 @@ class AdminInterceptor( throw AuthException(AuthErrorCode.INVALID_TOKEN) } - if (type != AdminType.HQ && type != requiredType) { + if (requiredType != AdminType.ALL && type != requiredType) { log.warn { "[AdminInterceptor] 관리자 권한 부족: requiredType=${requiredType} / current=${type}" } throw AuthException(AuthErrorCode.ACCESS_DENIED) } -- 2.47.2 From 4e13735d5fae87c7275414e5e8824d4246cefcda Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 16 Sep 2025 22:06:06 +0900 Subject: [PATCH 062/116] =?UTF-8?q?refactor:=20ScheduleAPI=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=A5=BC=20=EA=B0=81=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=ED=83=80=EC=9E=85=EB=B3=84=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/schedule/docs/ScheduleAPI.kt | 101 +++++++++--------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt index e0009e9f..ab1986ea 100644 --- a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -9,37 +9,59 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.Public import roomescape.auth.web.support.UserOnly +import roomescape.common.dto.AuditInfo import roomescape.common.dto.response.CommonApiResponse import roomescape.schedule.web.* import java.time.LocalDate -interface ScheduleAPI { +interface AdminScheduleAPI { - @Public - @Operation(summary = "입력된 날짜에 가능한 테마 목록 조회") - @ApiResponses(ApiResponse(responseCode = "200", description = "입력된 날짜에 가능한 테마 목록 조회", useReturnTypeSchema = true)) - fun findAvailableThemes( - @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate - ): ResponseEntity> + @AdminOnly(privilege = Privilege.READ_SUMMARY) + @Operation(summary = "일정 검색", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun searchSchedules( + @PathVariable("storeId") storeId: Long, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?, + @RequestParam(required = false) themeId: Long?, + ): ResponseEntity> - @Public - @Operation(summary = "입력된 날짜, 테마에 대한 모든 시간 조회") - @ApiResponses( - ApiResponse( - responseCode = "200", - description = "입력된 날짜, 테마에 대한 모든 시간 조회", - useReturnTypeSchema = true - ) - ) - fun findAllTime( - @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate, - @RequestParam("themeId") themeId: Long - ): ResponseEntity> + @AdminOnly(privilege = Privilege.READ_DETAIL) + @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun findScheduleAudit( + @PathVariable("id") id: Long + ): ResponseEntity> + @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE) + @Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun createSchedule( + @PathVariable("storeId") storeId: Long, + @Valid @RequestBody request: ScheduleCreateRequest + ): ResponseEntity> + + @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) + @Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun updateSchedule( + @PathVariable("id") id: Long, + @Valid @RequestBody request: ScheduleUpdateRequest + ): ResponseEntity> + + @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE) + @Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "204", useReturnTypeSchema = true)) + fun deleteSchedule( + @PathVariable("id") id: Long + ): ResponseEntity> +} + +interface UserScheduleAPI { @UserOnly @Operation(summary = "일정을 Hold 상태로 변경", tags = ["로그인이 필요한 API"]) @ApiResponses( @@ -52,33 +74,14 @@ interface ScheduleAPI { fun holdSchedule( @PathVariable("id") id: Long ): ResponseEntity> - - @AdminOnly(privilege = Privilege.READ_DETAIL) - @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) - @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) - fun findScheduleDetail( - @PathVariable("id") id: Long - ): ResponseEntity> - - @AdminOnly(privilege = Privilege.CREATE) - @Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) - @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun createSchedule( - @Valid @RequestBody request: ScheduleCreateRequest - ): ResponseEntity> - - @AdminOnly(privilege = Privilege.UPDATE) - @Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) - @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun updateSchedule( - @PathVariable("id") id: Long, - @Valid @RequestBody request: ScheduleUpdateRequest - ): ResponseEntity> - - @AdminOnly(privilege = Privilege.DELETE) - @Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) - @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) - fun deleteSchedule( - @PathVariable("id") id: Long - ): ResponseEntity> +} + +interface PublicScheduleAPI { + @Public + @Operation(summary = "특정 날짜 + 매장의 일정 조회") + @ApiResponses(ApiResponse(useReturnTypeSchema = true)) + fun getStoreSchedulesByDate( + @PathVariable("storeId") storeId: Long, + @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate + ): ResponseEntity> } -- 2.47.2 From 6cfa2cbd38f2978d5bc787447a6569dc32a87e49 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 16 Sep 2025 22:06:50 +0900 Subject: [PATCH 063/116] =?UTF-8?q?refactor:=20\@LastModifiedBy=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=9C=B4=EB=A8=BC=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=9C=20ScheduleEnti?= =?UTF-8?q?ty=20=EC=83=9D=EC=84=B1=20=EC=A0=84=EC=9A=A9=20=ED=8C=A9?= =?UTF-8?q?=ED=84=B0=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=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 --- .../persistence/ScheduleEntity.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt index 1b87278d..93e30bf7 100644 --- a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt +++ b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt @@ -45,12 +45,29 @@ class ScheduleEntity( ) { time?.let { this.time = it } status?.let { this.status = it } - MdcPrincipalId.extractAsLongOrNull()?.also { this.updatedBy = it } + updateLastModifiedBy() } fun hold() { this.status = ScheduleStatus.HOLD } + + fun updateLastModifiedBy() { + MdcPrincipalId.extractAsLongOrNull()?.also { this.updatedBy = it } + } +} + +object ScheduleEntityFactory { + fun create(id: Long, date: LocalDate, time: LocalTime, storeId: Long, themeId: Long): ScheduleEntity { + return ScheduleEntity( + id = id, + date = date, + time = time, + storeId = storeId, + themeId = themeId, + status = ScheduleStatus.AVAILABLE + ).apply { this.updateLastModifiedBy() } + } } enum class ScheduleStatus { -- 2.47.2 From b82c975cd076e53fda70c4d8ebf599765f077db0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 09:31:57 +0900 Subject: [PATCH 064/116] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/business/ReservationService.kt | 6 +++--- ...ublicThemeController.kt => ThemeController.kt} | 15 +++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) rename src/main/kotlin/roomescape/theme/web/{PublicThemeController.kt => ThemeController.kt} (78%) diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index daf402c5..27201d0a 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -9,8 +9,6 @@ import org.springframework.transaction.annotation.Transactional import roomescape.common.config.next import roomescape.common.dto.CurrentUserContext import roomescape.common.util.DateUtils -import roomescape.user.business.UserService -import roomescape.user.web.UserContactResponse import roomescape.payment.business.PaymentService import roomescape.payment.web.PaymentWithDetailResponse import roomescape.reservation.exception.ReservationErrorCode @@ -23,6 +21,8 @@ import roomescape.schedule.web.ScheduleSummaryResponse import roomescape.schedule.web.ScheduleUpdateRequest import roomescape.theme.business.ThemeService import roomescape.theme.web.ThemeInfoResponse +import roomescape.user.business.UserService +import roomescape.user.web.UserContactResponse import java.time.LocalDate import java.time.LocalDateTime @@ -52,7 +52,7 @@ class ReservationService( val reservation: ReservationEntity = request.toEntity(id = tsidFactory.next(), userId = user.id) return PendingReservationCreateResponse(reservationRepository.save(reservation).id) - .also { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } + .also { log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } } } @Transactional diff --git a/src/main/kotlin/roomescape/theme/web/PublicThemeController.kt b/src/main/kotlin/roomescape/theme/web/ThemeController.kt similarity index 78% rename from src/main/kotlin/roomescape/theme/web/PublicThemeController.kt rename to src/main/kotlin/roomescape/theme/web/ThemeController.kt index 45eea3d0..0c3898bd 100644 --- a/src/main/kotlin/roomescape/theme/web/PublicThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeController.kt @@ -1,5 +1,6 @@ package roomescape.theme.web +import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -7,25 +8,27 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import roomescape.auth.web.support.Public import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.business.ThemeService import roomescape.theme.docs.PublicThemeAPI @RestController @RequestMapping("/themes") -class PublicThemeController( +class ThemeController( private val themeService: ThemeService, -): PublicThemeAPI { - +) : PublicThemeAPI { + @Public @PostMapping("/batch") - override fun findThemesByIds( - @RequestBody request: ThemeIdListRequest + override fun findThemeInfosByIds( + @Valid @RequestBody request: ThemeIdListRequest ): ResponseEntity> { - val response = themeService.findThemesByIds(request) + val response = themeService.findAllInfosByIds(request) return ResponseEntity.ok(CommonApiResponse(response)) } + @Public @GetMapping("/{id}") override fun findThemeInfoById( @PathVariable id: Long -- 2.47.2 From 9c279e1ec24a7da28fd117c564b360964d802d7e Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:33:11 +0900 Subject: [PATCH 065/116] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=EA=B3=BC=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=8B=B4?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=88=EB=8A=94=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ScheduleWithThemeSummary.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/kotlin/roomescape/schedule/business/domain/ScheduleWithThemeSummary.kt diff --git a/src/main/kotlin/roomescape/schedule/business/domain/ScheduleWithThemeSummary.kt b/src/main/kotlin/roomescape/schedule/business/domain/ScheduleWithThemeSummary.kt new file mode 100644 index 00000000..5259b1e8 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/business/domain/ScheduleWithThemeSummary.kt @@ -0,0 +1,28 @@ +package roomescape.schedule.business.domain + +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.theme.infrastructure.persistence.Difficulty +import java.time.LocalDate +import java.time.LocalTime + +class ScheduleWithThemeSummary( + val id: Long, + val date: LocalDate, + val time: LocalTime, + val themeId: Long, + val themeName: String, + val themeDifficulty: Difficulty, + val themeAvailableMinutes: Short, + val status: ScheduleStatus +) { + fun getEndAt(): LocalTime { + return time.plusMinutes(themeAvailableMinutes.toLong()) + } + + fun containsTime(targetTime: LocalTime): Boolean { + val startFrom = this.time + val endAt = getEndAt() + + return targetTime >= startFrom && targetTime < endAt + } +} -- 2.47.2 From d5037664d7207eafed02fd92a997a9eb7a5170d8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:33:32 +0900 Subject: [PATCH 066/116] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=8B=9C=20=EC=A4=91=EB=B3=B5=EB=90=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/business/ScheduleValidator.kt | 18 ++++++++++++++---- .../schedule/exception/ScheduleErrorCode.kt | 3 ++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleValidator.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleValidator.kt index 4baf7790..e81739f4 100644 --- a/src/main/kotlin/roomescape/schedule/business/ScheduleValidator.kt +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleValidator.kt @@ -33,16 +33,17 @@ class ScheduleValidator( val date: LocalDate = schedule.date val time: LocalTime = request.time ?: schedule.time - validateDateTime(date, time) + validateNotInPast(date, time) } - fun validateCanCreate(request: ScheduleCreateRequest) { + fun validateCanCreate(storeId: Long, request: ScheduleCreateRequest) { val date: LocalDate = request.date val time: LocalTime = request.time val themeId: Long = request.themeId validateAlreadyExists(date, themeId, time) - validateDateTime(date, time) + validateNotInPast(date, time) + validateTimeNotConflict(storeId, request.date, request.themeId, request.time) } private fun validateAlreadyExists(date: LocalDate, themeId: Long, time: LocalTime) { @@ -54,7 +55,7 @@ class ScheduleValidator( } } - private fun validateDateTime(date: LocalDate, time: LocalTime) { + private fun validateNotInPast(date: LocalDate, time: LocalTime) { val dateTime = LocalDateTime.of(date, time) if (dateTime.isBefore(LocalDateTime.now())) { @@ -64,4 +65,13 @@ class ScheduleValidator( throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) } } + + private fun validateTimeNotConflict(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) { + scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId) + .firstOrNull { it.containsTime(time) } + ?.let { + log.info { "[ScheduleValidator.validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT) + } + } } diff --git a/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt b/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt index 096fce94..696d6630 100644 --- a/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt +++ b/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt @@ -12,5 +12,6 @@ enum class ScheduleErrorCode( SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."), PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."), SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."), - SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요.") + SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요."), + SCHEDULE_TIME_CONFLICT(HttpStatus.CONFLICT, "S006", "시간이 겹치는 다른 일정이 있어요.") } -- 2.47.2 From eec279c76fb9130ba6e708f05b2d0a3451df9c3b Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:34:35 +0900 Subject: [PATCH 067/116] =?UTF-8?q?feat:=20Repository=EC=97=90=20ScheduleW?= =?UTF-8?q?ithThemeSummary=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/ScheduleRepository.kt | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt index 98c3d960..1227edff 100644 --- a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt +++ b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt @@ -2,23 +2,40 @@ package roomescape.schedule.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query +import roomescape.schedule.business.domain.ScheduleWithThemeSummary import java.time.LocalDate import java.time.LocalTime interface ScheduleRepository : JpaRepository { - fun findAllByDate(date: LocalDate): List - - fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List - fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean @Query( """ - SELECT DISTINCT s.themeId - FROM ScheduleEntity s - WHERE s.date = :date - """ + SELECT + new roomescape.schedule.business.domain.ScheduleWithThemeSummary( + s._id, + s.date, + s.time, + t._id, + t.name, + t.difficulty, + t.availableMinutes, + s.status + ) + FROM + ScheduleEntity s + JOIN + ThemeEntity t ON t._id = s.themeId + WHERE + s.storeId = :storeId + AND s.date = :date + AND (:themeId IS NULL OR s.themeId = :themeId) + """ ) - fun findAllUniqueThemeIdByDate(date: LocalDate): List + fun findStoreSchedulesWithThemeByDate( + storeId: Long, + date: LocalDate, + themeId: Long? = null + ): List } -- 2.47.2 From c7f98c351500bca4b37695bc3ce6dc4b82d83083 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:35:01 +0900 Subject: [PATCH 068/116] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20DTO?= =?UTF-8?q?=EB=A5=BC=20=EA=B4=80=EB=A6=AC=EC=9E=90=20/=20=EB=B9=84=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=EB=A1=9C=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/web/AdminScheduleDto.kt | 55 ++++++++++++ .../roomescape/schedule/web/ScheduleDto.kt | 83 +++++++------------ 2 files changed, 84 insertions(+), 54 deletions(-) create mode 100644 src/main/kotlin/roomescape/schedule/web/AdminScheduleDto.kt diff --git a/src/main/kotlin/roomescape/schedule/web/AdminScheduleDto.kt b/src/main/kotlin/roomescape/schedule/web/AdminScheduleDto.kt new file mode 100644 index 00000000..a17e7abc --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/web/AdminScheduleDto.kt @@ -0,0 +1,55 @@ +package roomescape.schedule.web + +import roomescape.schedule.business.domain.ScheduleWithThemeSummary +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import java.time.LocalDate +import java.time.LocalTime + +// ======================================== +// All-Admin DTO (본사 + 매장) +// ======================================== +data class AdminScheduleSummaryResponse( + val id: Long, + val themeName: String, + val startFrom: LocalTime, + val endAt: LocalTime, + val status: ScheduleStatus, +) + +fun ScheduleWithThemeSummary.toAdminSummaryResponse() = AdminScheduleSummaryResponse( + id = this.id, + themeName = this.themeName, + startFrom = this.time, + endAt = this.getEndAt(), + status = this.status +) + +data class AdminScheduleSummaryListResponse( + val schedules: List +) + +fun List.toAdminSummaryListResponse() = AdminScheduleSummaryListResponse( + this.map { it.toAdminSummaryResponse() } +) + +// ======================================== +// Store Admin DTO (매장) +// ======================================== +data class ScheduleCreateRequest( + val date: LocalDate, + val time: LocalTime, + val themeId: Long +) + +data class ScheduleCreateResponse( + val id: Long +) + +data class ScheduleUpdateRequest( + val time: LocalTime? = null, + val status: ScheduleStatus? = null +) { + fun isAllParamsNull(): Boolean { + return time == null && status == null + } +} diff --git a/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt b/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt index 18856bac..4fe174a3 100644 --- a/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt +++ b/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt @@ -1,71 +1,46 @@ package roomescape.schedule.web -import roomescape.common.dto.OperatorInfo +import roomescape.schedule.business.domain.ScheduleWithThemeSummary import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.theme.infrastructure.persistence.Difficulty import java.time.LocalDate -import java.time.LocalDateTime import java.time.LocalTime -data class AvailableThemeIdListResponse( - val themeIds: List -) - -data class ScheduleByDateResponse( +// ======================================== +// Public (인증 불필요) +// ======================================== +data class ScheduleWithThemeResponse( val id: Long, - val time: LocalTime, + val startFrom: LocalTime, + val endAt: LocalTime, + val themeId: Long, + val themeName: String, + val themeDifficulty: Difficulty, val status: ScheduleStatus ) -data class ScheduleListByDateResponse( - val schedules: List -) - -fun List.toListResponse() = ScheduleListByDateResponse( - this.map { ScheduleByDateResponse(it.id, it.time, it.status) } -) - -data class ScheduleCreateRequest( - val date: LocalDate, - val time: LocalTime, - val themeId: Long -) - -data class ScheduleCreateResponse( - val id: Long -) - -data class ScheduleUpdateRequest( - val time: LocalTime? = null, - val status: ScheduleStatus? = null -) { - fun isAllParamsNull(): Boolean { - return time == null && status == null - } -} - -data class ScheduleDetailResponse( - val id: Long, - val date: LocalDate, - val time: LocalTime, - val status: ScheduleStatus, - val createdAt: LocalDateTime, - val createdBy: OperatorInfo, - val updatedAt: LocalDateTime, - val updatedBy: OperatorInfo, -) - -fun ScheduleEntity.toDetailResponse(createdBy: OperatorInfo, updatedBy: OperatorInfo) = ScheduleDetailResponse( +fun ScheduleWithThemeSummary.toResponse() = ScheduleWithThemeResponse( id = this.id, - date = this.date, - time = this.time, - status = this.status, - createdAt = this.createdAt, - createdBy = createdBy, - updatedAt = this.updatedAt, - updatedBy = updatedBy + startFrom = this.time, + endAt = this.getEndAt(), + themeId = this.themeId, + themeName = this.themeName, + themeDifficulty = this.themeDifficulty, + status = this.status ) +data class ScheduleWithThemeListResponse( + val schedules: List +) + +fun List.toResponse() = ScheduleWithThemeListResponse( + this.map { it.toResponse() } +) + +// ======================================== +// Other-Service (API 없이 다른 서비스에서 호출) +// ======================================== data class ScheduleSummaryResponse( val date: LocalDate, val time: LocalTime, -- 2.47.2 From 4aacaddcfcfd3fefdde4b7c74f5262ab1dc7dad4 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:36:07 +0900 Subject: [PATCH 069/116] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B6=8C=ED=95=9C=EB=B3=84=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/business/ScheduleService.kt | 178 +++++++++++------- 1 file changed, 107 insertions(+), 71 deletions(-) diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt index 12958b86..f2edfe9c 100644 --- a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt @@ -9,8 +9,12 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import roomescape.admin.business.AdminService import roomescape.common.config.next +import roomescape.common.dto.AuditInfo +import roomescape.common.dto.OperatorInfo +import roomescape.schedule.business.domain.ScheduleWithThemeSummary import roomescape.schedule.exception.ScheduleErrorCode import roomescape.schedule.infrastructure.persistence.ScheduleEntity +import roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import roomescape.schedule.infrastructure.persistence.ScheduleRepository import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.web.* @@ -18,6 +22,15 @@ import java.time.LocalDate private val log: KLogger = KotlinLogging.logger {} +/** + * Structure: + * - Public: 모두가 접근 가능 + * - User: 회원(로그인된 사용자)가 사용 가능 + * - All-Admin: 모든 관리자가 사용 가능 + * - Store-Admin: 매장 관리자만 사용 가능 + * - Other-Service: 다른 서비스에서 호출하는 메서드 + * - Common: 공통 메서드 + */ @Service class ScheduleService( private val scheduleRepository: ScheduleRepository, @@ -25,73 +38,25 @@ class ScheduleService( private val tsidFactory: TsidFactory, private val adminService: AdminService ) { - + // ======================================== + // Public (인증 불필요) + // ======================================== @Transactional(readOnly = true) - fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse { - log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" } + fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse { + log.info { "[ScheduleService.getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" } - return AvailableThemeIdListResponse(scheduleRepository.findAllUniqueThemeIdByDate(date)) + val schedules: List = + scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date) + + return schedules.toResponse() .also { - log.info { "[ScheduleService.findThemesByDate] date=${date} 인 ${it.themeIds.size}개 테마 조회 완료" } - } - } - - @Transactional(readOnly = true) - fun findSchedules(date: LocalDate, themeId: Long): ScheduleListByDateResponse { - log.info { "[ScheduleService.findSchedules] 동일한 날짜와 테마인 모든 일정 조회: date=${date}, themeId=${themeId}" } - - return scheduleRepository.findAllByDateAndThemeId(date, themeId) - .toListResponse() - .also { - log.info { "[ScheduleService.findSchedules] date=${date}, themeId=${themeId} 인 ${it.schedules.size}개 일정 조회 완료" } - } - } - - @Transactional(readOnly = true) - fun findDetail(id: Long): ScheduleDetailResponse { - log.info { "[ScheduleService.findDetail] 일정 상세 정보조회 시작: id=$id" } - - val schedule: ScheduleEntity = findOrThrow(id) - - val createdBy = adminService.findOperatorOrUnknown(schedule.createdBy) - val updatedBy = adminService.findOperatorOrUnknown(schedule.updatedBy) - - return schedule.toDetailResponse(createdBy, updatedBy) - .also { - log.info { "[ScheduleService.findDetail] 일정 상세 조회 완료: id=$id" } - } - } - - @Transactional(readOnly = true) - fun findSummaryById(id: Long): ScheduleSummaryResponse { - log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" } - - return findOrThrow(id).toSummaryResponse() - .also { - log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" } - } - } - - @Transactional - fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse { - log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" } - - scheduleValidator.validateCanCreate(request) - - val schedule = ScheduleEntity( - id = tsidFactory.next(), - date = request.date, - time = request.time, - themeId = request.themeId, - status = ScheduleStatus.AVAILABLE - ) - - return ScheduleCreateResponse(scheduleRepository.save(schedule).id) - .also { - log.info { "[ScheduleService.createSchedule] 일정 생성 완료: id=${it.id}" } + log.info { "[ScheduleService.getStoreScheduleByDate] storeId=${storeId}, date=$date 인 ${it.schedules.size}개 일정 조회 완료" } } } + // ======================================== + // User (회원 로그인 필요) + // ======================================== @Transactional fun holdSchedule(id: Long) { val schedule: ScheduleEntity = findOrThrow(id) @@ -104,6 +69,64 @@ class ScheduleService( throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) } + // ======================================== + // All-Admin (본사, 매장 모두 사용가능) + // ======================================== + @Transactional(readOnly = true) + fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse { + log.info { "[ScheduleService.searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" } + + val searchDate = date ?: LocalDate.now() + + val schedules: List = + scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate) + .filter { (themeId == null) || (it.themeId == themeId) } + .sortedBy { it.time } + + return schedules.toAdminSummaryListResponse() + .also { + log.info { "[ScheduleService.searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findScheduleAuditOrUnknown(id: Long): AuditInfo { + log.info { "[ScheduleService.findDetail] 일정 감사 정보 조회 시작: id=$id" } + + val schedule: ScheduleEntity = findOrThrow(id) + + val createdBy: OperatorInfo = adminService.findOperatorOrUnknown(schedule.createdBy) + val updatedBy: OperatorInfo = adminService.findOperatorOrUnknown(schedule.updatedBy) + + return AuditInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy) + .also { log.info { "[ScheduleService.findDetail] 일정 감사 정보 조회 완료: id=$id" } } + } + + // ======================================== + // Store-Admin (매장 관리자 로그인 필요) + // ======================================== + @Transactional + fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse { + log.info { "[ScheduleService.createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" } + + scheduleValidator.validateCanCreate(storeId, request) + + val schedule = ScheduleEntityFactory.create( + id = tsidFactory.next(), + date = request.date, + time = request.time, + storeId = storeId, + themeId = request.themeId + ).also { + scheduleRepository.save(it) + } + + return ScheduleCreateResponse(schedule.id) + .also { + log.info { "[ScheduleService.createSchedule] 일정 생성 완료: id=${it.id}" } + } + } + @Transactional fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" } @@ -113,14 +136,11 @@ class ScheduleService( return } - val schedule: ScheduleEntity = findOrThrow(id) + val schedule: ScheduleEntity = findOrThrow(id).also { + scheduleValidator.validateCanUpdate(it, request) + } - scheduleValidator.validateCanUpdate(schedule, request) - - schedule.modifyIfNotNull( - request.time, - request.status - ).also { + schedule.modifyIfNotNull(request.time, request.status).also { log.info { "[ScheduleService.updateSchedule] 일정 수정 완료: id=$id, request=${request}" } } } @@ -129,15 +149,31 @@ class ScheduleService( fun deleteSchedule(id: Long) { log.info { "[ScheduleService.deleteSchedule] 일정 삭제 시작: id=$id" } - val schedule: ScheduleEntity = findOrThrow(id) - - scheduleValidator.validateCanDelete(schedule) + val schedule: ScheduleEntity = findOrThrow(id).also { + scheduleValidator.validateCanDelete(it) + } scheduleRepository.delete(schedule).also { log.info { "[ScheduleService.deleteSchedule] 일정 삭제 완료: id=$id" } } } + // ======================================== + // Other-Service (API 없이 다른 서비스에서 호출) + // ======================================== + @Transactional(readOnly = true) + fun findSummaryById(id: Long): ScheduleSummaryResponse { + log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" } + + return findOrThrow(id).toSummaryResponse() + .also { + log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" } + } + } + + // ======================================== + // Common (공통 메서드) + // ======================================== private fun findOrThrow(id: Long): ScheduleEntity { log.info { "[ScheduleService.findOrThrow] 일정 조회 시작: id=$id" } -- 2.47.2 From cb9125ef1d8629e2e6accdfefd342e993093407d Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:36:38 +0900 Subject: [PATCH 070/116] =?UTF-8?q?refactor:=20ScheduleController=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20/=20=EB=B9=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/web/AdminScheduleController.kt | 66 ++++++++++++++++++ .../schedule/web/ScheduleController.kt | 68 +++---------------- 2 files changed, 77 insertions(+), 57 deletions(-) create mode 100644 src/main/kotlin/roomescape/schedule/web/AdminScheduleController.kt diff --git a/src/main/kotlin/roomescape/schedule/web/AdminScheduleController.kt b/src/main/kotlin/roomescape/schedule/web/AdminScheduleController.kt new file mode 100644 index 00000000..784a4245 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/web/AdminScheduleController.kt @@ -0,0 +1,66 @@ +package roomescape.schedule.web + +import jakarta.validation.Valid +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import roomescape.common.dto.AuditInfo +import roomescape.common.dto.response.CommonApiResponse +import roomescape.schedule.business.ScheduleService +import roomescape.schedule.docs.AdminScheduleAPI +import java.time.LocalDate + +@RestController +@RequestMapping("/admin") +class AdminScheduleController( + private val scheduleService: ScheduleService, +) : AdminScheduleAPI { + @GetMapping("/stores/{storeId}/schedules") + override fun searchSchedules( + @PathVariable("storeId") storeId: Long, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?, + @RequestParam(required = false) themeId: Long?, + ): ResponseEntity> { + val response = scheduleService.searchSchedules(storeId, date, themeId) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/schedules/{id}/audits") + override fun findScheduleAudit( + @PathVariable("id") id: Long + ): ResponseEntity> { + val response = scheduleService.findScheduleAuditOrUnknown(id) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PostMapping("/stores/{storeId}/schedules") + override fun createSchedule( + @PathVariable("storeId") storeId: Long, + @Valid @RequestBody request: ScheduleCreateRequest + ): ResponseEntity> { + val response = scheduleService.createSchedule(storeId, request) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PatchMapping("/schedules/{id}") + override fun updateSchedule( + @PathVariable("id") id: Long, + @Valid @RequestBody request: ScheduleUpdateRequest + ): ResponseEntity> { + scheduleService.updateSchedule(id, request) + + return ResponseEntity.ok(CommonApiResponse(Unit)) + } + + @DeleteMapping("/schedules/{id}") + override fun deleteSchedule( + @PathVariable("id") id: Long + ): ResponseEntity> { + scheduleService.deleteSchedule(id) + + return ResponseEntity.noContent().build() + } +} diff --git a/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt b/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt index 2d00a870..a57d30f4 100644 --- a/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt +++ b/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt @@ -1,57 +1,20 @@ package roomescape.schedule.web -import jakarta.validation.Valid import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import roomescape.common.dto.response.CommonApiResponse import roomescape.schedule.business.ScheduleService -import roomescape.schedule.docs.ScheduleAPI +import roomescape.schedule.docs.PublicScheduleAPI +import roomescape.schedule.docs.UserScheduleAPI import java.time.LocalDate @RestController -@RequestMapping("/schedules") class ScheduleController( private val scheduleService: ScheduleService -) : ScheduleAPI { - @GetMapping("/themes") - override fun findAvailableThemes( - @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate - ): ResponseEntity> { - val response = scheduleService.findThemesByDate(date) +) : UserScheduleAPI, PublicScheduleAPI { - return ResponseEntity.ok(CommonApiResponse(response)) - } - - @GetMapping - override fun findAllTime( - @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate, - @RequestParam("themeId") themeId: Long - ): ResponseEntity> { - val response = scheduleService.findSchedules(date, themeId) - - return ResponseEntity.ok(CommonApiResponse(response)) - } - - @GetMapping("/{id}") - override fun findScheduleDetail( - @PathVariable("id") id: Long - ): ResponseEntity> { - val response = scheduleService.findDetail(id) - - return ResponseEntity.ok(CommonApiResponse(response)) - } - - @PostMapping - override fun createSchedule( - @Valid @RequestBody request: ScheduleCreateRequest - ): ResponseEntity> { - val response = scheduleService.createSchedule(request) - - return ResponseEntity.ok(CommonApiResponse(response)) - } - - @PatchMapping("/{id}/hold") + @PostMapping("/schedules/{id}/hold") override fun holdSchedule( @PathVariable("id") id: Long ): ResponseEntity> { @@ -60,22 +23,13 @@ class ScheduleController( return ResponseEntity.ok(CommonApiResponse()) } - @PatchMapping("/{id}") - override fun updateSchedule( - @PathVariable("id") id: Long, - @Valid @RequestBody request: ScheduleUpdateRequest - ): ResponseEntity> { - scheduleService.updateSchedule(id, request) + @GetMapping("/stores/{storeId}/schedules") + override fun getStoreSchedulesByDate( + @PathVariable("storeId") storeId: Long, + @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate + ): ResponseEntity> { + val response = scheduleService.getStoreScheduleByDate(storeId, date) - return ResponseEntity.ok(CommonApiResponse(Unit)) - } - - @DeleteMapping("/{id}") - override fun deleteSchedule( - @PathVariable("id") id: Long - ): ResponseEntity> { - scheduleService.deleteSchedule(id) - - return ResponseEntity.noContent().build() + return ResponseEntity.ok(CommonApiResponse(response)) } } -- 2.47.2 From cf65ccf91548411818d29a233968265fcc9984ed Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:37:47 +0900 Subject: [PATCH 071/116] =?UTF-8?q?refactor:=20=EC=9E=85=EB=A0=A5=EB=90=9C?= =?UTF-8?q?=20Id=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20In=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/theme/business/ThemeService.kt | 23 ++++++------------- .../persistence/ThemeRepository.kt | 4 +++- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 89492250..7ee4fb80 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import roomescape.admin.business.AdminService import roomescape.common.config.next +import roomescape.common.dto.AuditInfo import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeException import roomescape.theme.infrastructure.persistence.ThemeEntity @@ -42,18 +43,9 @@ class ThemeService( } @Transactional(readOnly = true) - fun findThemesByIds(request: ThemeIdListRequest): ThemeInfoListResponse { + fun findAllInfosByIds(request: ThemeIdListRequest): ThemeInfoListResponse { log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" } - val result: MutableList = mutableListOf() - - for (id in request.themeIds) { - val theme: ThemeEntity? = themeRepository.findByIdOrNull(id) - if (theme == null) { - log.warn { "[ThemeService.findThemesByIds] id=${id} 인 테마 조회 실패" } - continue - } - result.add(theme) - } + val result: List = themeRepository.findAllByIdIn(request.themeIds) return result.toInfoListResponse().also { log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" } @@ -80,8 +72,9 @@ class ThemeService( val createdBy = adminService.findOperatorOrUnknown(theme.createdBy) val updatedBy = adminService.findOperatorOrUnknown(theme.updatedBy) + val audit = AuditInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy) - return theme.toAdminThemeDetailResponse(createdBy, updatedBy) + return theme.toAdminThemeDetailResponse(audit) .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } } @@ -91,9 +84,8 @@ class ThemeService( themeValidator.validateCanCreate(request) - val theme: ThemeEntity = themeRepository.save( - request.toEntity(tsidFactory.next()) - ) + val theme: ThemeEntity = request.toEntity(id = tsidFactory.next()) + .also { themeRepository.save(it) } return ThemeCreateResponse(theme.id).also { log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" } @@ -155,7 +147,6 @@ class ThemeService( } } - // ======================================== // Common (공통 메서드) // ======================================== diff --git a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt index 8e55104b..25934626 100644 --- a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt +++ b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt @@ -9,4 +9,6 @@ interface ThemeRepository : JpaRepository { fun findActiveThemes(): List fun existsByName(name: String): Boolean -} \ No newline at end of file + + fun findAllByIdIn(themeIds: List): List +} -- 2.47.2 From 0ef47b7f94f7b3fecda44d89ba129bb87e8284b2 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:39:13 +0900 Subject: [PATCH 072/116] =?UTF-8?q?refactor:=20=ED=85=8C=EB=A7=88=20DTO=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=EB=B3=80=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/theme/docs/ThemeApi.kt | 15 +++--- .../theme/web/AdminThemeController.kt | 27 +++++----- .../roomescape/theme/web/AdminThemeDto.kt | 49 +++---------------- .../web/{PublicThemeDto.kt => ThemeDto.kt} | 0 4 files changed, 24 insertions(+), 67 deletions(-) rename src/main/kotlin/roomescape/theme/web/{PublicThemeDto.kt => ThemeDto.kt} (100%) diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index e0d8db6f..a542842c 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -16,11 +16,11 @@ import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.web.* @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") -interface HQAdminThemeAPI { +interface AdminThemeAPI { @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findAdminThemes(): ResponseEntity> + fun getAdminThemeSummaries(): ResponseEntity> @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL) @Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @@ -42,24 +42,21 @@ interface HQAdminThemeAPI { @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateTheme( @PathVariable id: Long, - @Valid @RequestBody themeUpdateRequest: ThemeUpdateRequest + @Valid @RequestBody request: ThemeUpdateRequest ): ResponseEntity> -} -interface StoreAdminThemeAPI { - @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_SUMMARY) + @AdminOnly(privilege = Privilege.READ_SUMMARY) @Operation(summary = "테마 조회", description = "현재 open 상태인 모든 테마 ID + 이름 조회", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findActiveThemes(): ResponseEntity> + fun getActiveThemes(): ResponseEntity> } interface PublicThemeAPI { @Public @Operation(summary = "입력된 모든 ID에 대한 테마 조회") @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findThemesByIds(request: ThemeIdListRequest): ResponseEntity> + fun findThemeInfosByIds(request: ThemeIdListRequest): ResponseEntity> - @Public @Operation(summary = "입력된 테마 ID에 대한 정보 조회") @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findThemeInfoById(@PathVariable id: Long): ResponseEntity> diff --git a/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt b/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt index e0412600..b79004a2 100644 --- a/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/AdminThemeController.kt @@ -1,19 +1,20 @@ package roomescape.theme.web +import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.business.ThemeService -import roomescape.theme.docs.HQAdminThemeAPI -import roomescape.theme.docs.StoreAdminThemeAPI +import roomescape.theme.docs.AdminThemeAPI import java.net.URI @RestController -class HQAdminThemeController( +class AdminThemeController( private val themeService: ThemeService, -) : HQAdminThemeAPI { +) : AdminThemeAPI { + @GetMapping("/admin/themes") - override fun findAdminThemes(): ResponseEntity> { + override fun getAdminThemeSummaries(): ResponseEntity> { val response = themeService.findAdminThemes() return ResponseEntity.ok(CommonApiResponse(response)) @@ -27,7 +28,9 @@ class HQAdminThemeController( } @PostMapping("/admin/themes") - override fun createTheme(themeCreateRequest: ThemeCreateRequest): ResponseEntity> { + override fun createTheme( + @Valid @RequestBody themeCreateRequest: ThemeCreateRequest + ): ResponseEntity> { val response = themeService.createTheme(themeCreateRequest) return ResponseEntity.created(URI.create("/admin/themes/${response.id}")) @@ -44,21 +47,15 @@ class HQAdminThemeController( @PatchMapping("/admin/themes/{id}") override fun updateTheme( @PathVariable id: Long, - themeUpdateRequest: ThemeUpdateRequest + @Valid @RequestBody request: ThemeUpdateRequest ): ResponseEntity> { - themeService.updateTheme(id, themeUpdateRequest) + themeService.updateTheme(id, request) return ResponseEntity.ok().build() } -} - -@RestController -class StoreAdminController( - private val themeService: ThemeService -) : StoreAdminThemeAPI { @GetMapping("/admin/themes/active") - override fun findActiveThemes(): ResponseEntity> { + override fun getActiveThemes(): ResponseEntity> { val response = themeService.findActiveThemes() return ResponseEntity.ok(CommonApiResponse(response)) diff --git a/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt b/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt index ef6d0cf2..9b6be3ac 100644 --- a/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt +++ b/src/main/kotlin/roomescape/theme/web/AdminThemeDto.kt @@ -1,18 +1,8 @@ package roomescape.theme.web -import roomescape.common.dto.OperatorInfo +import roomescape.common.dto.AuditInfo import roomescape.theme.infrastructure.persistence.Difficulty import roomescape.theme.infrastructure.persistence.ThemeEntity -import java.time.LocalDateTime - -/** - * Theme API DTO - * - * Structure: - * - HQ Admin DTO: 본사 관리자가 사용하는 테마 관련 DTO들 - * - Store Admin DTO: 매장 관리자가 사용하는 테마 관련 DTO들 - */ - // ======================================== // HQ Admin DTO (본사) @@ -103,48 +93,21 @@ fun List.toAdminThemeSummaryListResponse() = AdminThemeSummaryListR ) data class AdminThemeDetailResponse( - val id: Long, - val name: String, - val description: String, - val thumbnailUrl: String, - val difficulty: Difficulty, - val price: Int, - val minParticipants: Short, - val maxParticipants: Short, - val availableMinutes: Short, - val expectedMinutesFrom: Short, - val expectedMinutesTo: Short, + val theme: ThemeInfoResponse, val isActive: Boolean, - val createdAt: LocalDateTime, - val createdBy: OperatorInfo, - val updatedAt: LocalDateTime, - val updatedBy: OperatorInfo, + val audit: AuditInfo ) -fun ThemeEntity.toAdminThemeDetailResponse(createdBy: OperatorInfo, updatedBy: OperatorInfo) = +fun ThemeEntity.toAdminThemeDetailResponse(audit: AuditInfo) = AdminThemeDetailResponse( - id = this.id, - name = this.name, - description = this.description, - thumbnailUrl = this.thumbnailUrl, - difficulty = this.difficulty, - price = this.price, - minParticipants = this.minParticipants, - maxParticipants = this.maxParticipants, - availableMinutes = this.availableMinutes, - expectedMinutesFrom = this.expectedMinutesFrom, - expectedMinutesTo = this.expectedMinutesTo, + theme = this.toInfoResponse(), isActive = this.isActive, - createdAt = this.createdAt, - createdBy = createdBy, - updatedAt = this.updatedAt, - updatedBy = updatedBy + audit = audit ) // ======================================== // Store Admin DTO // ======================================== - data class SimpleActiveThemeResponse( val id: Long, val name: String diff --git a/src/main/kotlin/roomescape/theme/web/PublicThemeDto.kt b/src/main/kotlin/roomescape/theme/web/ThemeDto.kt similarity index 100% rename from src/main/kotlin/roomescape/theme/web/PublicThemeDto.kt rename to src/main/kotlin/roomescape/theme/web/ThemeDto.kt -- 2.47.2 From 7fd278aa43325001634419c7d68ddb75c87ed84f Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:40:02 +0900 Subject: [PATCH 073/116] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9E=A5=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20CRUD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/store/business/StoreService.kt | 134 ++++++++++++++++++ .../kotlin/roomescape/store/docs/StoreAPI.kt | 64 +++++++++ .../store/exception/StoreException.kt | 19 +++ .../infrastructure/persistence/StoreEntity.kt | 25 ++-- .../persistence/StoreRepository.kt | 30 +++- .../store/web/AdminStoreController.kt | 52 +++++++ .../roomescape/store/web/AdminStoreDto.kt | 46 ++++++ .../roomescape/store/web/StoreController.kt | 35 +++++ .../kotlin/roomescape/store/web/StoreDTO.kt | 12 -- 9 files changed, 396 insertions(+), 21 deletions(-) create mode 100644 src/main/kotlin/roomescape/store/business/StoreService.kt create mode 100644 src/main/kotlin/roomescape/store/docs/StoreAPI.kt create mode 100644 src/main/kotlin/roomescape/store/exception/StoreException.kt create mode 100644 src/main/kotlin/roomescape/store/web/AdminStoreController.kt create mode 100644 src/main/kotlin/roomescape/store/web/AdminStoreDto.kt create mode 100644 src/main/kotlin/roomescape/store/web/StoreController.kt diff --git a/src/main/kotlin/roomescape/store/business/StoreService.kt b/src/main/kotlin/roomescape/store/business/StoreService.kt new file mode 100644 index 00000000..bc534d6e --- /dev/null +++ b/src/main/kotlin/roomescape/store/business/StoreService.kt @@ -0,0 +1,134 @@ +package roomescape.store.business + +import com.github.f4b6a3.tsid.TsidFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.admin.business.AdminService +import roomescape.common.config.next +import roomescape.common.dto.AuditInfo +import roomescape.region.business.RegionService +import roomescape.store.exception.StoreErrorCode +import roomescape.store.exception.StoreException +import roomescape.store.infrastructure.persistence.StoreEntity +import roomescape.store.infrastructure.persistence.StoreRepository +import roomescape.store.infrastructure.persistence.StoreStatus +import roomescape.store.web.* + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class StoreService( + private val storeRepository: StoreRepository, + private val adminService: AdminService, + private val regionService: RegionService, + private val tsidFactory: TsidFactory, +) { + @Transactional(readOnly = true) + fun getDetail(id: Long): DetailStoreResponse { + log.info { "[StoreService.getDetail] 매장 상세 조회 시작: id=${id}" } + + val store: StoreEntity = findOrThrow(id) + val region = regionService.findRegionInfo(store.regionCode) + val audit = getAuditInfo(store) + + return store.toDetailResponse(region, audit) + .also { log.info { "[StoreService.getDetail] 매장 상세 조회 완료: id=${id}" } } + } + + @Transactional + fun register(request: StoreRegisterRequest): StoreRegisterResponse { + log.info { "[StoreService.register] 매장 등록 시작: name=${request.name}" } + + val store = StoreEntity( + id = tsidFactory.next(), + name = request.name, + address = request.address, + contact = request.contact, + businessRegNum = request.businessRegNum, + regionCode = request.regionCode, + status = StoreStatus.ACTIVE, + ).also { + storeRepository.save(it) + } + + return StoreRegisterResponse(store.id).also { + log.info { "[StoreService.register] 매장 등록 완료: id=${store.id}, name=${request.name}" } + } + } + + @Transactional + fun update(id: Long, request: StoreUpdateRequest) { + log.info { "[StoreService.update] 매장 수정 시작: id=${id}, request=${request}" } + + findOrThrow(id).apply { + this.modifyIfNotNull(request.name, request.address, request.contact) + }.also { + log.info { "[StoreService.update] 매장 수정 완료: id=${id}" } + } + } + + @Transactional + fun disableById(id: Long) { + log.info { "[StoreService.inactive] 매장 비활성화 시작: id=${id}" } + + findOrThrow(id).apply { + this.disable() + }.also { + log.info { "[StoreService.inactive] 매장 비활성화 완료: id=${id}" } + } + } + + @Transactional(readOnly = true) + fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse { + log.info { "[StoreService.getAllActiveStores] 전체 매장 조회 시작" } + + val regionCode: String? = when { + sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED) + sidoCode != null -> "${sidoCode}${sigunguCode ?: ""}" + else -> null + } + + return storeRepository.findAllActiveStoresByRegion(regionCode).toSimpleListResponse() + .also { log.info { "[StoreService.getAllActiveStores] 전체 매장 조회 완료: total=${it.stores.size}" } } + } + + @Transactional(readOnly = true) + fun findStoreInfo(id: Long): StoreInfoResponse { + log.info { "[StoreService.findStoreInfo] 매장 정보 조회 시작: id=${id}" } + + val store: StoreEntity = findOrThrow(id) + + return store.toInfoResponse() + .also { log.info { "[StoreService.findStoreInfo] 매장 정보 조회 완료: id=${id}" } } + } + + private fun getAuditInfo(store: StoreEntity): AuditInfo { + log.info { "[StoreService.getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" } + val createdBy = adminService.findOperatorOrUnknown(store.createdBy) + val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy) + + return AuditInfo( + createdAt = store.createdAt, + createdBy = createdBy, + updatedAt = store.updatedAt, + updatedBy = updatedBy + ).also { + log.info { "[StoreService.getAuditInfo] 감사 정보 조회 완료: storeId=${store.id}" } + } + } + + private fun findOrThrow(id: Long): StoreEntity { + log.info { "[StoreService.findOrThrow] 매장 조회 시작: id=${id}" } + + return storeRepository.findActiveStoreById(id) + ?.also { + log.info { "[StoreService.findOrThrow] 매장 조회 완료: id=${id}" } + } + ?: run { + log.warn { "[StoreService.findOrThrow] 매장 조회 실패: id=${id}" } + throw StoreException(StoreErrorCode.STORE_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/store/docs/StoreAPI.kt b/src/main/kotlin/roomescape/store/docs/StoreAPI.kt new file mode 100644 index 00000000..110ba588 --- /dev/null +++ b/src/main/kotlin/roomescape/store/docs/StoreAPI.kt @@ -0,0 +1,64 @@ +package roomescape.store.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import roomescape.admin.infrastructure.persistence.AdminType +import roomescape.admin.infrastructure.persistence.Privilege +import roomescape.auth.web.support.AdminOnly +import roomescape.auth.web.support.Public +import roomescape.common.dto.response.CommonApiResponse +import roomescape.store.web.* + +interface AdminStoreAPI { + @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL) + @Operation(summary = "특정 매장의 상세 정보 조회") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun findStoreDetail( + @PathVariable id: Long + ): ResponseEntity> + + @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE) + @Operation(summary = "매장 등록") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun registerStore( + @Valid @RequestBody request: StoreRegisterRequest + ): ResponseEntity> + + @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) + @Operation(summary = "매장 정보 수정") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun updateStore( + @PathVariable id: Long, + @Valid @RequestBody request: StoreUpdateRequest + ): ResponseEntity> + + @AdminOnly(type = AdminType.HQ, privilege = Privilege.DELETE) + @Operation(summary = "매장 비활성화") + @ApiResponses(ApiResponse(responseCode = "204", useReturnTypeSchema = true)) + fun disableStore( + @PathVariable id: Long + ): ResponseEntity> +} + +interface PublicStoreAPI { + @Public + @Operation(summary = "모든 매장의 id / 이름 조회") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun getStores( + @RequestParam(value = "sido", required = false) sidoCode: String?, + @RequestParam(value = "sigungu", required = false) sigunguCode: String? + ): ResponseEntity> + + @Public + @Operation(summary = "특정 매장의 정보 조회") + @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) + fun getStoreInfo( + @PathVariable id: Long + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/store/exception/StoreException.kt b/src/main/kotlin/roomescape/store/exception/StoreException.kt new file mode 100644 index 00000000..71fe9781 --- /dev/null +++ b/src/main/kotlin/roomescape/store/exception/StoreException.kt @@ -0,0 +1,19 @@ +package roomescape.store.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class StoreException( + override val errorCode: StoreErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) + +enum class StoreErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "ST001", "매장을 찾을 수 없어요."), + SIDO_CODE_REQUIRED(HttpStatus.BAD_REQUEST, "ST002", "시/도 정보를 찾을 수 없어요. 다시 시도해주세요.") +} diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt index faf79f46..d59ce007 100644 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt +++ b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreEntity.kt @@ -1,11 +1,6 @@ package roomescape.store.infrastructure.persistence -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EntityListeners -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Table +import jakarta.persistence.* import org.springframework.data.jpa.domain.support.AuditingEntityListener import roomescape.common.entity.AuditingBaseEntity @@ -31,9 +26,23 @@ class StoreEntity( @Enumerated(value = EnumType.STRING) var status: StoreStatus -) : AuditingBaseEntity(id) +) : AuditingBaseEntity(id) { + fun modifyIfNotNull( + name: String?, + address: String?, + contact: String?, + ) { + name?.let { this.name = it } + address?.let { this.address = it } + contact?.let { this.contact = it } + } + + fun disable() { + this.status = StoreStatus.DISABLED + } +} enum class StoreStatus { ACTIVE, - INACTIVE + DISABLED } diff --git a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreRepository.kt b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreRepository.kt index 0a55ccc6..84d6b3a3 100644 --- a/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreRepository.kt +++ b/src/main/kotlin/roomescape/store/infrastructure/persistence/StoreRepository.kt @@ -1,5 +1,33 @@ package roomescape.store.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query -interface StoreRepository : JpaRepository \ No newline at end of file +interface StoreRepository : JpaRepository { + + @Query( + """ + SELECT + s + FROM + StoreEntity s + WHERE + s._id = :id + AND s.status = roomescape.store.infrastructure.persistence.StoreStatus.ACTIVE + """ + ) + fun findActiveStoreById(id: Long): StoreEntity? + + @Query( + """ + SELECT + s + FROM + StoreEntity s + WHERE + s.status = roomescape.store.infrastructure.persistence.StoreStatus.ACTIVE + AND (:regionCode IS NULL OR s.regionCode LIKE ':regionCode%') + """ + ) + fun findAllActiveStoresByRegion(regionCode: String?): List +} diff --git a/src/main/kotlin/roomescape/store/web/AdminStoreController.kt b/src/main/kotlin/roomescape/store/web/AdminStoreController.kt new file mode 100644 index 00000000..4fd1d077 --- /dev/null +++ b/src/main/kotlin/roomescape/store/web/AdminStoreController.kt @@ -0,0 +1,52 @@ +package roomescape.store.web + +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import roomescape.common.dto.response.CommonApiResponse +import roomescape.store.business.StoreService +import roomescape.store.docs.AdminStoreAPI + +@RestController +@RequestMapping("/admin/stores") +class AdminStoreController( + private val storeService: StoreService +) : AdminStoreAPI { + + @GetMapping("/{id}/detail") + override fun findStoreDetail( + @PathVariable id: Long + ): ResponseEntity> { + val response: DetailStoreResponse = storeService.getDetail(id) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PostMapping + override fun registerStore( + @Valid @RequestBody request: StoreRegisterRequest + ): ResponseEntity> { + val response: StoreRegisterResponse = storeService.register(request) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PatchMapping("/{id}") + override fun updateStore( + @PathVariable id: Long, + @Valid @RequestBody request: StoreUpdateRequest + ): ResponseEntity> { + storeService.update(id, request) + + return ResponseEntity.ok(CommonApiResponse()) + } + + @PostMapping("/{id}/disable") + override fun disableStore( + @PathVariable id: Long, + ): ResponseEntity> { + storeService.disableById(id) + + return ResponseEntity.ok(CommonApiResponse()) + } +} diff --git a/src/main/kotlin/roomescape/store/web/AdminStoreDto.kt b/src/main/kotlin/roomescape/store/web/AdminStoreDto.kt new file mode 100644 index 00000000..f3c7235e --- /dev/null +++ b/src/main/kotlin/roomescape/store/web/AdminStoreDto.kt @@ -0,0 +1,46 @@ +package roomescape.store.web + +import roomescape.common.dto.AuditInfo +import roomescape.region.web.RegionInfoResponse +import roomescape.store.infrastructure.persistence.StoreEntity + +data class StoreRegisterRequest( + val name: String, + val address: String, + val contact: String, + val businessRegNum: String, + val regionCode: String +) + +data class StoreRegisterResponse( + val id: Long +) + +data class StoreUpdateRequest( + val name: String?, + val address: String?, + val contact: String?, +) + +data class DetailStoreResponse( + val id: Long, + val name: String, + val address: String, + val contact: String, + val businessRegNum: String, + val region: RegionInfoResponse, + val audit: AuditInfo +) + +fun StoreEntity.toDetailResponse( + region: RegionInfoResponse, + audit: AuditInfo +) = DetailStoreResponse( + id = this.id, + name = this.name, + address = this.address, + contact = this.contact, + businessRegNum = this.businessRegNum, + region = region, + audit = audit, +) diff --git a/src/main/kotlin/roomescape/store/web/StoreController.kt b/src/main/kotlin/roomescape/store/web/StoreController.kt new file mode 100644 index 00000000..47a3937c --- /dev/null +++ b/src/main/kotlin/roomescape/store/web/StoreController.kt @@ -0,0 +1,35 @@ +package roomescape.store.web + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import roomescape.common.dto.response.CommonApiResponse +import roomescape.store.business.StoreService +import roomescape.store.docs.PublicStoreAPI + +@RestController +class StoreController( + private val storeService: StoreService +) : PublicStoreAPI { + + @GetMapping("/stores") + override fun getStores( + @RequestParam(value = "sido", required = false) sidoCode: String?, + @RequestParam(value = "sigungu", required = false) sigunguCode: String? + ): ResponseEntity> { + val response = storeService.getAllActiveStores(sidoCode, sigunguCode) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/stores/{id}") + override fun getStoreInfo( + @PathVariable id: Long + ): ResponseEntity> { + val response = storeService.findStoreInfo(id) + + return ResponseEntity.ok(CommonApiResponse(response)) + } +} diff --git a/src/main/kotlin/roomescape/store/web/StoreDTO.kt b/src/main/kotlin/roomescape/store/web/StoreDTO.kt index bf5211ed..aad2cd14 100644 --- a/src/main/kotlin/roomescape/store/web/StoreDTO.kt +++ b/src/main/kotlin/roomescape/store/web/StoreDTO.kt @@ -1,7 +1,5 @@ package roomescape.store.web -import roomescape.common.dto.AuditInfo -import roomescape.region.web.RegionInfoResponse import roomescape.store.infrastructure.persistence.StoreEntity data class SimpleStoreResponse( @@ -40,13 +38,3 @@ data class StoreInfoListResponse( fun List.toInfoListResponse() = StoreInfoListResponse( stores = this.map { it.toInfoResponse() } ) - -data class StoreDetailResponse( - val id: Long, - val name: String, - val address: String, - val contact: String, - val businessRegNum: String, - val region: RegionInfoResponse, - val audit: AuditInfo -) -- 2.47.2 From 7c967defcc689412da8452ba0e1093774b93da41 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:42:24 +0900 Subject: [PATCH 074/116] =?UTF-8?q?refactor:=20\@AdminOnly=EC=9D=98=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=ED=83=80=EC=9E=85=EC=9D=84=20STORE=20->?= =?UTF-8?q?=20ALL=EB=A1=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/auth/web/support/AuthAnnotations.kt | 2 +- src/main/kotlin/roomescape/common/dto/AuditDto.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index a1692c91..712f0b5e 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -6,7 +6,7 @@ import roomescape.admin.infrastructure.persistence.Privilege @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class AdminOnly( - val type: AdminType = AdminType.STORE, + val type: AdminType = AdminType.ALL, val privilege: Privilege ) diff --git a/src/main/kotlin/roomescape/common/dto/AuditDto.kt b/src/main/kotlin/roomescape/common/dto/AuditDto.kt index 10931d5f..e8c956e2 100644 --- a/src/main/kotlin/roomescape/common/dto/AuditDto.kt +++ b/src/main/kotlin/roomescape/common/dto/AuditDto.kt @@ -17,6 +17,6 @@ data class OperatorInfo( data class AuditInfo( val createdAt: LocalDateTime, val createdBy: OperatorInfo, - val modifiedAt: LocalDateTime, - val modifiedBy: OperatorInfo, + val updatedAt: LocalDateTime, + val updatedBy: OperatorInfo, ) -- 2.47.2 From 7a6afc72821216a5bb179244c7f9ebb9798865a8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:42:52 +0900 Subject: [PATCH 075/116] =?UTF-8?q?feat:=20=EB=8B=A4=EB=A5=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=A0=20=EC=A7=80=EC=97=AD=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../region/business/RegionService.kt | 19 +++++++++++++----- .../persistence/RegionRepository.kt | 20 +++++++++++++------ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/roomescape/region/business/RegionService.kt b/src/main/kotlin/roomescape/region/business/RegionService.kt index 5d6a9486..25966644 100644 --- a/src/main/kotlin/roomescape/region/business/RegionService.kt +++ b/src/main/kotlin/roomescape/region/business/RegionService.kt @@ -7,11 +7,7 @@ import org.springframework.transaction.annotation.Transactional import roomescape.region.exception.RegionErrorCode import roomescape.region.exception.RegionException import roomescape.region.infrastructure.persistence.RegionRepository -import roomescape.region.web.RegionCodeResponse -import roomescape.region.web.SidoListResponse -import roomescape.region.web.SidoResponse -import roomescape.region.web.SigunguListResponse -import roomescape.region.web.SigunguResponse +import roomescape.region.web.* private val log: KLogger = KotlinLogging.logger {} @@ -61,4 +57,17 @@ class RegionService( throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND) } } + + @Transactional(readOnly = true) + fun findRegionInfo(regionCode: String): RegionInfoResponse { + log.info { "[RegionService.findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" } + + return regionRepository.findByCode(regionCode)?.let { + log.info { "[RegionService.findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" } + RegionInfoResponse(it.code, it.sidoName, it.sigunguName) + } ?: run { + log.warn { "[RegionService.findRegionInfo] 지역 정보 조회 실패: regionCode=${regionCode}" } + throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND) + } + } } diff --git a/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt b/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt index fae9df2e..e17acb39 100644 --- a/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt +++ b/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt @@ -6,7 +6,8 @@ import org.springframework.data.repository.query.Param interface RegionRepository : JpaRepository { - @Query(""" + @Query( + """ SELECT new kotlin.Pair(r.sidoCode, r.sidoName) FROM @@ -15,10 +16,12 @@ interface RegionRepository : JpaRepository { r.sidoCode ORDER BY r.sidoName - """) + """ + ) fun readAllSido(): List> - @Query(""" + @Query( + """ SELECT new kotlin.Pair(r.sigunguCode, r.sigunguName) FROM @@ -29,12 +32,14 @@ interface RegionRepository : JpaRepository { r.sigunguCode ORDER BY r.sigunguName - """) + """ + ) fun findAllSigunguBySido( @Param("sidoCode") sidoCode: String ): List> - @Query(""" + @Query( + """ SELECT r.code FROM @@ -42,9 +47,12 @@ interface RegionRepository : JpaRepository { WHERE r.sidoCode = :sidoCode AND r.sigunguCode = :sigunguCode - """) + """ + ) fun findRegionCode( @Param("sidoCode") sidoCode: String, @Param("sigunguCode") sigunguCode: String, ): String? + + fun findByCode(regionCode: String): RegionEntity? } -- 2.47.2 From dc37ae6d1ab8b0d3435420e6997697ee0bdedb01 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 17 Sep 2025 10:43:22 +0900 Subject: [PATCH 076/116] =?UTF-8?q?feat:=20=EC=A7=80=EA=B8=88=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84=EB=90=9C=20=EB=A7=A4=EC=9E=A5=20?= =?UTF-8?q?=EB=93=B1=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=9C=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/apiClient.ts | 57 +- frontend/src/api/auth/authAPI.ts | 10 +- .../src/api/reservation/reservationTypes.ts | 18 + frontend/src/api/schedule/scheduleAPI.ts | 56 +- frontend/src/api/schedule/scheduleTypes.ts | 55 +- frontend/src/api/store/storeAPI.ts | 48 +- frontend/src/api/store/storeTypes.ts | 22 +- frontend/src/api/theme/themeAPI.ts | 26 +- frontend/src/api/theme/themeTypes.ts | 47 +- frontend/src/context/AdminAuthContext.tsx | 8 +- frontend/src/css/admin-schedule-page.css | 102 ++-- frontend/src/css/admin-store-page.css | 13 + frontend/src/css/home-page-v2.css | 33 +- frontend/src/css/my-reservation-v2.css | 42 +- frontend/src/css/reservation-v2-1.css | 545 ++++++++---------- frontend/src/pages/HomePage.tsx | 13 +- frontend/src/pages/MyReservationPage.tsx | 64 +- frontend/src/pages/ReservationFormPage.tsx | 91 +-- frontend/src/pages/ReservationStep1Page.tsx | 303 +++++----- frontend/src/pages/ReservationStep2Page.tsx | 45 +- frontend/src/pages/ReservationSuccessPage.tsx | 20 +- .../src/pages/admin/AdminSchedulePage.tsx | 363 ++++++------ frontend/src/pages/admin/AdminStorePage.tsx | 200 +++++-- .../src/pages/admin/AdminThemeEditPage.tsx | 118 ++-- frontend/src/pages/admin/AdminThemePage.tsx | 6 +- frontend/tsconfig.app.json | 1 + 26 files changed, 1279 insertions(+), 1027 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 63143b13..2db29e2d 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,5 +1,6 @@ import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios'; import JSONbig from 'json-bigint'; +import { PrincipalType } from './auth/authTypes'; // Create a JSONbig instance that stores big integers as strings const JSONbigString = JSONbig({ storeAsString: true }); @@ -38,7 +39,7 @@ async function request( method: Method, endpoint: string, data: object = {}, - isRequiredAuth: boolean = false + type: PrincipalType, ): Promise { const config: AxiosRequestConfig = { method, @@ -48,7 +49,9 @@ async function request( }, }; - const accessToken = localStorage.getItem('accessToken'); + const accessTokenKey = type === PrincipalType.ADMIN ? 'adminAccessToken' : 'accessToken'; + const accessToken = localStorage.getItem(accessTokenKey); + if (accessToken) { if (!config.headers) { config.headers = {}; @@ -70,30 +73,50 @@ async function request( } } -async function get(endpoint: string, isRequiredAuth: boolean = false): Promise { - return request('GET', endpoint, {}, isRequiredAuth); +async function get(endpoint: string): Promise { + return request('GET', endpoint, {}, PrincipalType.USER); } -async function post(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise { - return request('POST', endpoint, data, isRequiredAuth); +async function adminGet(endpoint: string): Promise { + return request('GET', endpoint, {}, PrincipalType.ADMIN); } -async function put(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise { - return request('PUT', endpoint, data, isRequiredAuth); +async function post(endpoint: string, data: object = {}): Promise { + return request('POST', endpoint, data, PrincipalType.USER); } -async function patch(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise { - return request('PATCH', endpoint, data, isRequiredAuth); +async function adminPost(endpoint: string, data: object = {}): Promise { + return request('POST', endpoint, data, PrincipalType.ADMIN); } -async function del(endpoint: string, isRequiredAuth: boolean = false): Promise { - return request('DELETE', endpoint, {}, isRequiredAuth); +async function put(endpoint: string, data: object = {}): Promise { + return request('PUT', endpoint, data, PrincipalType.USER); +} + +async function adminPut(endpoint: string, data: object = {}): Promise { + return request('PUT', endpoint, data, PrincipalType.ADMIN); +} + +async function patch(endpoint: string, data: object = {}): Promise { + return request('PATCH', endpoint, data, PrincipalType.USER); +} + +async function adminPatch(endpoint: string, data: object = {}): Promise { + return request('PATCH', endpoint, data, PrincipalType.ADMIN); +} + +async function del(endpoint: string): Promise { + return request('DELETE', endpoint, {}, PrincipalType.USER); +} + +async function adminDel(endpoint: string): Promise { + return request('DELETE', endpoint, {}, PrincipalType.ADMIN); } export default { - get, - post, - put, - patch, - del + get, adminGet, + post, adminPost, + put, adminPut, + patch, adminPatch, + del, adminDel, }; diff --git a/frontend/src/api/auth/authAPI.ts b/frontend/src/api/auth/authAPI.ts index daebb77a..141b3e45 100644 --- a/frontend/src/api/auth/authAPI.ts +++ b/frontend/src/api/auth/authAPI.ts @@ -12,20 +12,22 @@ export const userLogin = async ( return await apiClient.post( '/auth/login', { ...data, principalType: PrincipalType.USER }, - false, ); }; export const adminLogin = async ( data: Omit, ): Promise => { - return await apiClient.post( + return await apiClient.adminPost( '/auth/login', { ...data, principalType: PrincipalType.ADMIN }, - false, ); }; export const logout = async (): Promise => { - await apiClient.post('/auth/logout', {}, true); + await apiClient.post('/auth/logout', {}); }; + +export const adminLogout = async (): Promise => { + await apiClient.adminPost('/auth/logout', {}); +} diff --git a/frontend/src/api/reservation/reservationTypes.ts b/frontend/src/api/reservation/reservationTypes.ts index 5c19d21a..0de72737 100644 --- a/frontend/src/api/reservation/reservationTypes.ts +++ b/frontend/src/api/reservation/reservationTypes.ts @@ -1,6 +1,24 @@ import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes"; import type {UserContactRetrieveResponse} from "@_api/user/userTypes"; +export interface ReservationData { + scheduleId: string; + store: { + id: string; + name: string; + } + theme: { + id: string; + name: string; + price: number; + minParticipants: number; + maxParticipants: number; + } + date: string; // "yyyy-MM-dd" + startFrom: string; // "HH:mm ~ HH:mm" + endAt: string; +} + export const ReservationStatus = { PENDING: 'PENDING', CONFIRMED: 'CONFIRMED', diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts index c4ee3cfc..4ca28dc3 100644 --- a/frontend/src/api/schedule/scheduleAPI.ts +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -1,37 +1,49 @@ -import apiClient from '../apiClient'; -import type { - AvailableThemeIdListResponse, - ScheduleCreateRequest, - ScheduleCreateResponse, - ScheduleDetailRetrieveResponse, - ScheduleRetrieveListResponse, - ScheduleUpdateRequest -} from './scheduleTypes'; +import apiClient from "@_api/apiClient"; +import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes"; +import type { AuditInfo } from "@_api/common/commonTypes"; -export const fetchStoreAvailableThemesByDate = async (storeId: string, date: string): Promise => { - return await apiClient.get(`/stores/${storeId}/themes?date=${date}`); -}; +// admin +export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise => { + const queryParams: string[] = []; -export const fetchStoreSchedulesByDateAndTheme = async (storeId: string, date: string, themeId: string): Promise => { - return await apiClient.get(`/stores/${storeId}/schedules?date=${date}&themeId=${themeId}`); -}; + if (date && date.trim() !== '') { + queryParams.push(`date=${date}`); + } + + if (themeId && themeId.trim() !== '') { + queryParams.push(`themeId=${themeId}`); + } + + // 기본 URL에 쿼리 파라미터 추가 + const baseUrl = `/admin/stores/${storeId}/schedules`; + const fullUrl = queryParams.length > 0 + ? `${baseUrl}?${queryParams.join('&')}` + : baseUrl; -export const fetchScheduleDetailById = async (id: string): Promise => { - return await apiClient.get(`/admin/schedules/${id}`); -}; + return await apiClient.adminGet(fullUrl); +} + +export const fetchScheduleAudit = async (scheduleId: string): Promise => { + return await apiClient.adminGet(`/admin/schedules/${scheduleId}/audits`); +} export const createSchedule = async (storeId: string, request: ScheduleCreateRequest): Promise => { - return await apiClient.post(`/admin/stores/${storeId}/schedules`, request); + return await apiClient.adminPost(`/admin/stores/${storeId}/schedules`, request); }; export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise => { - await apiClient.patch(`/admin/schedules/${id}`, request); + return await apiClient.adminPatch(`/admin/schedules/${id}`, request); }; export const deleteSchedule = async (id: string): Promise => { - await apiClient.del(`/admin/schedules/${id}`); + return await apiClient.adminDel(`/admin/schedules/${id}`); }; +// public export const holdSchedule = async (id: string): Promise => { - await apiClient.patch(`/schedules/${id}/hold`, {}); + return await apiClient.post(`/schedules/${id}/hold`); +}; + +export const fetchSchedules = async (storeId: string, date: string): Promise => { + return await apiClient.get(`/stores/${storeId}/schedules?date=${date}`); }; diff --git a/frontend/src/api/schedule/scheduleTypes.ts b/frontend/src/api/schedule/scheduleTypes.ts index c31c9fb9..0acf9f64 100644 --- a/frontend/src/api/schedule/scheduleTypes.ts +++ b/frontend/src/api/schedule/scheduleTypes.ts @@ -1,4 +1,4 @@ -import type { OperatorInfo } from '@_api/common/commonTypes'; +import type { Difficulty } from '@_api/theme/themeTypes'; export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED'; @@ -9,24 +9,11 @@ export const ScheduleStatus = { BLOCKED: 'BLOCKED' as ScheduleStatus, }; -export interface AvailableThemeIdListResponse { - themeIds: string[]; -} - -export interface ScheduleRetrieveResponse { - id: string; - time: string; // "HH:mm" - status: ScheduleStatus; -} - -export interface ScheduleRetrieveListResponse { - schedules: ScheduleRetrieveResponse[]; -} - +// Admin export interface ScheduleCreateRequest { - date: string; // "yyyy-MM-dd" - time: string; // "HH:mm" + date: string; themeId: string; + time: string; } export interface ScheduleCreateResponse { @@ -40,13 +27,29 @@ export interface ScheduleUpdateRequest { status?: ScheduleStatus; } -export interface ScheduleDetailRetrieveResponse { - id: string; - date: string; // "yyyy-MM-dd" - time: string; // "HH:mm" - status: ScheduleStatus; - createdAt: string; // or Date - createdBy: OperatorInfo; - updatedAt: string; // or Date - updatedBy: OperatorInfo; +export interface AdminScheduleSummaryResponse { + id: string, + themeName: string, + startFrom: string, + endAt: string, + status: ScheduleStatus, } + +export interface AdminScheduleSummaryListResponse { + schedules: AdminScheduleSummaryResponse[]; +} + +// Public +export interface ScheduleWithThemeResponse { + id: string, + startFrom: string, + endAt: string, + themeId: string, + themeName: string, + themeDifficulty: Difficulty, + status: ScheduleStatus +} + +export interface ScheduleWithThemeListResponse { + schedules: ScheduleWithThemeResponse[]; +} \ No newline at end of file diff --git a/frontend/src/api/store/storeAPI.ts b/frontend/src/api/store/storeAPI.ts index f9333bb0..80c9135c 100644 --- a/frontend/src/api/store/storeAPI.ts +++ b/frontend/src/api/store/storeAPI.ts @@ -1,22 +1,48 @@ import apiClient from '@_api/apiClient'; -import { type SimpleStoreResponse, type StoreDetailResponse, type StoreRegisterRequest, type UpdateStoreRequest } from './storeTypes'; +import type { + SimpleStoreListResponse, + StoreCreateResponse, + StoreDetailResponse, + StoreInfoResponse, + StoreRegisterRequest, + UpdateStoreRequest +} from './storeTypes'; -export const getStores = async (): Promise => { - return await apiClient.get('/admin/stores'); +export const getStores = async (sidoCode?: string, sigunguCode?: string): Promise => { + const queryParams: string[] = []; + + if (sidoCode && sidoCode.trim() !== '') { + queryParams.push(`sidoCode=${sidoCode}`); + } + + if (sigunguCode && sigunguCode.trim() !== '') { + queryParams.push(`sigunguCode=${sigunguCode}`); + } + + const baseUrl = `/stores`; + const fullUrl = queryParams.length > 0 + ? `${baseUrl}?${queryParams.join('&')}` + : baseUrl; + + return await apiClient.get(fullUrl); }; -export const getStoreDetail = async (id: number): Promise => { - return await apiClient.get(`/admin/stores/${id}`); +export const getStoreInfo = async (id: string): Promise => { + return await apiClient.get(`/stores/${id}`); +} + +export const getStoreDetail = async (id: string): Promise => { + return await apiClient.adminGet(`/admin/stores/${id}/detail`); }; -export const createStore = async (data: StoreRegisterRequest): Promise => { - return await apiClient.post('/admin/stores', data); +export const createStore = async (request: StoreRegisterRequest): Promise => { + return await apiClient.adminPost('/admin/stores', request); }; -export const updateStore = async (id: number, data: UpdateStoreRequest): Promise => { - return await apiClient.put(`/admin/stores/${id}`, data); +export const updateStore = async (id: string, request: UpdateStoreRequest): Promise => { + await apiClient.adminPatch(`/admin/stores/${id}`, request); }; -export const deleteStore = async (id: number): Promise => { - await apiClient.del(`/admin/stores/${id}`); +export const deleteStore = async (id: string): Promise => { + await apiClient.adminPost(`/admin/stores/${id}/disable`, {}); }; diff --git a/frontend/src/api/store/storeTypes.ts b/frontend/src/api/store/storeTypes.ts index b036d664..376549c4 100644 --- a/frontend/src/api/store/storeTypes.ts +++ b/frontend/src/api/store/storeTypes.ts @@ -1,11 +1,15 @@ -import { type AuditInfo } from '@_api/common/commonTypes'; -import type { RegionInfoResponse } from '@_api/region/regionTypes'; +import {type AuditInfo} from '@_api/common/commonTypes'; +import type {RegionInfoResponse} from '@_api/region/regionTypes'; export interface SimpleStoreResponse { id: string; name: string; } +export interface SimpleStoreListResponse { + stores: SimpleStoreResponse[]; +} + export interface StoreDetailResponse { id: string; name: string; @@ -25,8 +29,20 @@ export interface StoreRegisterRequest { } export interface UpdateStoreRequest { + name?: string; + address?: string; + contact?: string; + regionCode?: string; +} + +export interface StoreInfoResponse { + id: string; name: string; address: string; contact: string; - regionCode: string; + businessRegNum: string; +} + +export interface StoreCreateResponse { + id: string; } diff --git a/frontend/src/api/theme/themeAPI.ts b/frontend/src/api/theme/themeAPI.ts index 43fb1516..f0f6bdf9 100644 --- a/frontend/src/api/theme/themeAPI.ts +++ b/frontend/src/api/theme/themeAPI.ts @@ -1,7 +1,7 @@ import apiClient from '@_api/apiClient'; import type { - AdminThemeDetailRetrieveResponse, - AdminThemeSummaryRetrieveListResponse, + AdminThemeDetailResponse, + AdminThemeSummaryListResponse, SimpleActiveThemeListResponse, ThemeCreateRequest, ThemeCreateResponse, @@ -11,24 +11,28 @@ import type { ThemeUpdateRequest } from './themeTypes'; -export const fetchAdminThemes = async (): Promise => { - return await apiClient.get('/admin/themes'); +export const fetchAdminThemes = async (): Promise => { + return await apiClient.adminGet('/admin/themes'); }; -export const fetchAdminThemeDetail = async (id: string): Promise => { - return await apiClient.get(`/admin/themes/${id}`); +export const fetchAdminThemeDetail = async (id: string): Promise => { + return await apiClient.adminGet(`/admin/themes/${id}`); }; export const createTheme = async (themeData: ThemeCreateRequest): Promise => { - return await apiClient.post('/admin/themes', themeData); + return await apiClient.adminPost('/admin/themes', themeData); }; export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise => { - await apiClient.patch(`/admin/themes/${id}`, themeData); + await apiClient.adminPatch(`/admin/themes/${id}`, themeData); }; export const deleteTheme = async (id: string): Promise => { - await apiClient.del(`/admin/themes/${id}`); + await apiClient.adminDel(`/admin/themes/${id}`); +}; + +export const fetchActiveThemes = async (): Promise => { + return await apiClient.adminGet('/admin/themes/active'); }; export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise => { @@ -38,7 +42,3 @@ export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise
=> { return await apiClient.get(`/themes/${id}`); } - -export const fetchActiveThemes = async (): Promise => { - return await apiClient.get('/admin/themes/active'); -}; diff --git a/frontend/src/api/theme/themeTypes.ts b/frontend/src/api/theme/themeTypes.ts index 0cc043ed..2b56d825 100644 --- a/frontend/src/api/theme/themeTypes.ts +++ b/frontend/src/api/theme/themeTypes.ts @@ -1,22 +1,9 @@ -import type { OperatorInfo } from '@_api/common/commonTypes'; +import type { AuditInfo } from '@_api/common/commonTypes'; export interface AdminThemeDetailResponse { - id: string; - name: string; - description: string; - thumbnailUrl: string; - difficulty: Difficulty; - price: number; - minParticipants: number; - maxParticipants: number; - availableMinutes: number; - expectedMinutesFrom: number; - expectedMinutesTo: number; + theme: ThemeInfoResponse; isActive: boolean; - createDate: string; // Assuming ISO string format - updatedDate: string; // Assuming ISO string format - createdBy: OperatorInfo; - updatedBy: OperatorInfo; + audit: AuditInfo } export interface ThemeCreateRequest { @@ -45,14 +32,13 @@ export interface ThemeUpdateRequest { price?: number; minParticipants?: number; maxParticipants?: number; - availableMinutes?: number; expectedMinutesFrom?: number; expectedMinutesTo?: number; isActive?: boolean; } -export interface AdminThemeSummaryRetrieveResponse { +export interface AdminThemeSummaryResponse { id: string; name: string; difficulty: Difficulty; @@ -60,27 +46,8 @@ export interface AdminThemeSummaryRetrieveResponse { isActive: boolean; } -export interface AdminThemeSummaryRetrieveListResponse { - themes: AdminThemeSummaryRetrieveResponse[]; -} - -export interface AdminThemeDetailRetrieveResponse { - id: string; - name: string; - description: string; - thumbnailUrl: string; - difficulty: Difficulty; - price: number; - minParticipants: number; - maxParticipants: number; - availableMinutes: number; - expectedMinutesFrom: number; - expectedMinutesTo: number; - isActive: boolean; - createdAt: string; // LocalDateTime in Kotlin, map to string (ISO format) - createdBy: OperatorInfo; - updatedAt: string; // LocalDateTime in Kotlin, map to string (ISO format) - updatedBy: OperatorInfo; +export interface AdminThemeSummaryListResponse { + themes: AdminThemeSummaryResponse[]; } export interface ThemeInfoResponse { @@ -135,4 +102,4 @@ export interface SimpleActiveThemeResponse { export interface SimpleActiveThemeListResponse { themes: SimpleActiveThemeResponse[]; -} \ No newline at end of file +} diff --git a/frontend/src/context/AdminAuthContext.tsx b/frontend/src/context/AdminAuthContext.tsx index 3857aeec..ba6c7cd1 100644 --- a/frontend/src/context/AdminAuthContext.tsx +++ b/frontend/src/context/AdminAuthContext.tsx @@ -1,4 +1,4 @@ -import { adminLogin as apiLogin, logout as apiLogout } from '@_api/auth/authAPI'; +import { adminLogin as apiLogin, adminLogout as apiLogout } from '@_api/auth/authAPI'; import { type AdminLoginSuccessResponse, type AdminType, @@ -27,7 +27,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children useEffect(() => { try { - const token = localStorage.getItem('accessToken'); + const token = localStorage.getItem('adminAccessToken'); const storedName = localStorage.getItem('adminName'); const storedType = localStorage.getItem('adminType') as AdminType | null; const storedStoreId = localStorage.getItem('adminStoreId'); @@ -48,7 +48,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children const login = async (data: Omit) => { const response = await apiLogin(data); - localStorage.setItem('accessToken', response.accessToken); + localStorage.setItem('adminAccessToken', response.accessToken); localStorage.setItem('adminName', response.name); localStorage.setItem('adminType', response.type); if (response.storeId) { @@ -69,7 +69,7 @@ export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children try { await apiLogout(); } finally { - localStorage.removeItem('accessToken'); + localStorage.removeItem('adminAccessToken'); localStorage.removeItem('adminName'); localStorage.removeItem('adminType'); localStorage.removeItem('adminStoreId'); diff --git a/frontend/src/css/admin-schedule-page.css b/frontend/src/css/admin-schedule-page.css index c4cb2775..e559a855 100644 --- a/frontend/src/css/admin-schedule-page.css +++ b/frontend/src/css/admin-schedule-page.css @@ -1,11 +1,13 @@ +/* New CSS content */ .admin-schedule-container { padding: 2rem; max-width: 1200px; margin: 0 auto; + font-size: 0.95rem; /* Slightly smaller base font */ } .page-title { - font-size: 2rem; + font-size: 1.8rem; /* smaller */ font-weight: bold; margin-bottom: 2rem; text-align: center; @@ -18,7 +20,7 @@ padding: 1.5rem; background-color: #f9f9f9; border-radius: 8px; - align-items: center; + align-items: flex-end; /* Align to bottom */ } .schedule-controls .form-group { @@ -26,18 +28,29 @@ flex-direction: column; } +/* Width adjustments */ +.schedule-controls .store-selector-group, +.schedule-controls .date-selector-group { + flex: 1 1 180px; +} + +.schedule-controls .theme-selector-group { + flex: 2 1 300px; +} + + .schedule-controls .form-label { - font-size: 0.9rem; + font-size: 0.85rem; /* smaller */ margin-bottom: 0.5rem; color: #555; } .schedule-controls .form-input, .schedule-controls .form-select { - padding: 0.75rem; + padding: 0.6rem; /* smaller */ border: 1px solid #ccc; border-radius: 4px; - font-size: 1rem; + font-size: 0.9rem; /* smaller */ } .section-card { @@ -63,10 +76,11 @@ table { } th, td { - padding: 1rem; + padding: 0.8rem; /* smaller */ text-align: left; border-bottom: 1px solid #eee; vertical-align: middle; + font-size: 0.9rem; /* smaller */ } th { @@ -75,11 +89,11 @@ th { } .btn { - padding: 0.5rem 1rem; + padding: 0.4rem 0.8rem; /* smaller */ border: none; border-radius: 4px; cursor: pointer; - font-size: 0.9rem; + font-size: 0.85rem; /* smaller */ transition: background-color 0.2s; white-space: nowrap; } @@ -174,8 +188,8 @@ th { font-size: 1rem; border: 1px solid #dee2e6; border-radius: 4px; - height: 3rem; - box-sizing: border-box; /* Ensures padding/border are included in height */ + height: auto; /* remove fixed height */ + box-sizing: border-box; } .details-form-container .button-group { @@ -190,7 +204,7 @@ th { border: 1px solid #dee2e6; border-radius: 8px; background-color: #fff; - margin-bottom: 1.5rem; /* Add margin to separate from buttons */ + margin-bottom: 1.5rem; } .audit-title { @@ -239,13 +253,13 @@ th { } .modal-content { - background-color: #fff; - padding: 2rem; - border-radius: 8px; - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); - width: 90%; - max-width: 600px; - position: relative; + background-color: #ffffff !important; + padding: 2rem !important; + border-radius: 8px !important; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important; + width: 90% !important; + max-width: 600px !important; + position: relative !important; } .modal-close-btn { @@ -282,35 +296,25 @@ th { margin-bottom: 1.5rem; } -.theme-modal-info-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; - background-color: #f9f9f9; - padding: 1rem; - border-radius: 8px; -} - -.info-item { - display: flex; - justify-content: space-between; - padding: 0.5rem; - border-bottom: 1px solid #eee; -} - -.info-item:last-child { - border-bottom: none; -} - -.info-item strong { - font-weight: 600; - color: #333; -} - -.info-item span { - color: #666; -} - .theme-details-button { - align-self: center !important; -} \ No newline at end of file + white-space: nowrap; +} + +.view-mode-buttons { + justify-content: flex-end; +} + +/* Added for modal info alignment */ +.modal-info-grid p { + display: flex; + align-items: flex-start; + margin: 0.6rem 0; + line-height: 1.5; +} +.modal-info-grid p strong { + flex: 0 0 130px; /* fixed width for labels */ + font-weight: 600; +} +.modal-info-grid p span { + flex: 1; +} diff --git a/frontend/src/css/admin-store-page.css b/frontend/src/css/admin-store-page.css index 7dc8924a..f8d5c0a4 100644 --- a/frontend/src/css/admin-store-page.css +++ b/frontend/src/css/admin-store-page.css @@ -16,6 +16,19 @@ text-align: center; } +.filter-controls { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1.5rem; + background-color: #f9f9f9; + border-radius: 8px; +} + +.filter-controls .form-group { + flex: 1; +} + .section-card { background-color: #ffffff; border-radius: 12px; diff --git a/frontend/src/css/home-page-v2.css b/frontend/src/css/home-page-v2.css index 2728cb68..6df4daae 100644 --- a/frontend/src/css/home-page-v2.css +++ b/frontend/src/css/home-page-v2.css @@ -81,15 +81,15 @@ } .theme-modal-content { - background-color: #ffffff; - padding: 30px; - border-radius: 16px; - width: 90%; - max-width: 600px; - box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2); - display: flex; - flex-direction: column; - gap: 20px; + background-color: #ffffff !important; + padding: 30px !important; + border-radius: 16px !important; + width: 90% !important; + max-width: 600px !important; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2) !important; + display: flex !important; + flex-direction: column !important; + gap: 20px !important; } .modal-thumbnail { @@ -163,3 +163,18 @@ .modal-button.close:hover { background-color: #5a6268; } + +/* Added for modal info alignment */ +.modal-info-grid p { + display: flex; + align-items: flex-start; + margin: 0.6rem 0; + line-height: 1.5; +} +.modal-info-grid p strong { + flex: 0 0 130px; /* fixed width for labels */ + font-weight: 600; +} +.modal-info-grid p span { + flex: 1; +} diff --git a/frontend/src/css/my-reservation-v2.css b/frontend/src/css/my-reservation-v2.css index b6fa89b4..7f3e5b93 100644 --- a/frontend/src/css/my-reservation-v2.css +++ b/frontend/src/css/my-reservation-v2.css @@ -177,16 +177,16 @@ } .modal-content-v2 { - background: #ffffff; - padding: 30px; - border-radius: 16px; - width: 90%; - max-width: 500px; - position: relative; - box-shadow: 0 5px 15px rgba(0,0,0,0.3); - animation: slide-up 0.3s ease-out; - max-height: 90vh; /* Prevent modal from being too tall */ - overflow-y: auto; /* Allow scrolling for long content */ + background: #ffffff !important; + padding: 30px !important; + border-radius: 16px !important; + width: 90% !important; + max-width: 500px !important; + position: relative !important; + box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important; + animation: slide-up 0.3s ease-out !important; + max-height: 90vh !important; /* Prevent modal from being too tall */ + overflow-y: auto !important; /* Allow scrolling for long content */ } @keyframes slide-up { @@ -240,13 +240,6 @@ color: #505a67; } -.modal-section-v2 p strong { - color: #333d4b; - font-weight: 600; - min-width: 100px; - display: inline-block; -} - .cancellation-section-v2 { background-color: #fcf2f2; padding: 15px; @@ -346,3 +339,18 @@ border-color: #007bff; box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); } + +/* Added for modal info alignment */ +.modal-info-grid p { + display: flex; + align-items: flex-start; + margin: 0.6rem 0; + line-height: 1.5; +} +.modal-info-grid p strong { + flex: 0 0 130px; /* fixed width for labels */ + font-weight: 600; +} +.modal-info-grid p span { + flex: 1; +} diff --git a/frontend/src/css/reservation-v2-1.css b/frontend/src/css/reservation-v2-1.css index e8ccbeeb..93fda84c 100644 --- a/frontend/src/css/reservation-v2-1.css +++ b/frontend/src/css/reservation-v2-1.css @@ -1,43 +1,43 @@ /* General Container */ .reservation-v21-container { - padding: 40px; + width: 100%; max-width: 900px; - margin: 40px auto; - background-color: #ffffff; - border-radius: 16px; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07); - font-family: 'Toss Product Sans', sans-serif; - color: #333D4B; + margin: 2rem auto; + padding: 2rem; + font-family: 'Pretendard', sans-serif; + background-color: #fff; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); } .page-title { - font-size: 28px; - font-weight: 700; - margin-bottom: 40px; - color: #191F28; text-align: center; + font-size: 2rem; + font-weight: 700; + margin-bottom: 2.5rem; + color: #212529; } -/* Step Sections */ +/* Step Section */ .step-section { - margin-bottom: 40px; - padding: 24px; - border: 1px solid #E5E8EB; - border-radius: 12px; - transition: all 0.3s ease; + margin-bottom: 3rem; + padding: 1.5rem; + border: 1px solid #f1f3f5; + border-radius: 8px; + background-color: #f8f9fa; } .step-section.disabled { opacity: 0.5; pointer-events: none; - background-color: #F9FAFB; } .step-section h3 { - font-size: 20px; + font-size: 1.5rem; font-weight: 600; - margin-bottom: 20px; - color: #191F28; + margin-top: 0; + margin-bottom: 1.5rem; + color: #343a40; } /* Date Carousel */ @@ -45,274 +45,241 @@ display: flex; align-items: center; justify-content: space-between; - gap: 10px; - margin: 20px 0; + margin-bottom: 1rem; +} + +.carousel-arrow { + background: none; + border: none; + font-size: 2rem; + color: #868e96; + cursor: pointer; + padding: 0 1rem; } .date-options-container { display: flex; - gap: 8px; - overflow-x: hidden; - flex-grow: 1; - justify-content: space-between; - margin: 0px 15px; + gap: 10px; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; } -.carousel-arrow, .today-button { - background-color: #F2F4F6; - border: 1px solid #E5E8EB; - border-radius: 50%; - width: 36px; - height: 36px; - font-size: 20px; - font-weight: bold; - color: #4E5968; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: background-color 0.2s; -} - -.today-button { - border-radius: 8px; - font-size: 14px; - font-weight: 600; - width: auto; - padding: 0 15px; -} - -.carousel-arrow:hover, .today-button:hover { - background-color: #E5E8EB; +.date-options-container::-webkit-scrollbar { + display: none; } .date-option { + text-align: center; cursor: pointer; - padding: 8px; - border-radius: 8px; + padding: 10px; + border-radius: 50%; + width: 60px; + height: 60px; display: flex; flex-direction: column; - align-items: center; justify-content: center; - border: 1px solid transparent; - transition: all 0.3s ease; - width: 60px; - flex-shrink: 0; -} - -.date-option:hover { - background-color: #f0f0f0; -} - -.date-option.active { - border: 1px solid #007bff; - background-color: #e7f3ff; + align-items: center; + transition: background-color 0.3s, color 0.3s; } .date-option .day-of-week { - font-size: 12px; - color: #888; -} - -.date-option.active .day-of-week { - color: #007bff; + font-size: 0.8rem; + margin-bottom: 4px; } .date-option .day-circle { - font-size: 16px; - font-weight: bold; - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-top: 4px; - background-color: #f0f0f0; - color: #333; + font-weight: 600; } -.date-option.active .day-circle { - background-color: #007bff; +.date-option.active { + background-color: #0064FF; color: white; } +.date-option:not(.active):hover { + background-color: #f1f3f5; +} + .date-option.disabled { - opacity: 0.5; + color: #ced4da; cursor: not-allowed; - pointer-events: none; } -.date-option.disabled .day-circle { - background-color: #E5E8EB; - color: #B0B8C1; -} - - -/* Theme List */ -.theme-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 16px; -} - -.theme-card { +.today-button { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 20px; + padding: 0.5rem 1rem; cursor: pointer; - border-radius: 12px; - overflow: hidden; - border: 2px solid #E5E8EB; - transition: all 0.2s ease-in-out; + margin-left: 1rem; + font-weight: 500; +} + +/* --- Region & Store Selectors --- */ +.region-store-selectors { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.region-store-selectors select { + flex: 1; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 8px; background-color: #fff; + font-size: 1rem; + cursor: pointer; + transition: border-color 0.2s; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23868e96%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'); + background-repeat: no-repeat; + background-position: right .7em top 50%; + background-size: .65em auto; +} + +.region-store-selectors select:disabled { + background-color: #f8f9fa; + cursor: not-allowed; + color: #adb5bd; +} + +.region-store-selectors select:focus { + outline: none; + border-color: #0064FF; +} + +/* --- Schedule List --- */ +.schedule-list { display: flex; flex-direction: column; + gap: 1.5rem; } -.theme-card:hover { - transform: translateY(-4px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +.theme-schedule-group { + background-color: #fff; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 1.5rem; } -.theme-card.active { - border-color: #3182F6; - box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2); -} - -.theme-thumbnail { - width: 100%; - height: 120px; - object-fit: cover; -} - -.theme-info { - padding: 16px; +.theme-header { display: flex; - flex-direction: column; - flex-grow: 1; + justify-content: space-between; + align-items: center; + padding-bottom: 1rem; + margin-bottom: 1rem; + border-bottom: 1px solid #f1f3f5; } -.theme-info h4 { - font-size: 16px; - font-weight: 600; - margin-bottom: 8px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.theme-info p { - font-size: 14px; - color: #6B7684; +.theme-header h4 { margin: 0; -} - -.theme-meta { - font-size: 14px; - color: #4E5968; - margin-bottom: 12px; - flex-grow: 1; -} - -.theme-meta p { - margin: 2px 0; -} -.theme-meta strong { - color: #333D4B; + font-size: 1.25rem; + font-weight: 600; + color: #343a40; } .theme-detail-button { - width: 100%; - padding: 8px; - font-size: 14px; - font-weight: 600; - border: none; - background-color: #F2F4F6; - color: #4E5968; - border-radius: 8px; + padding: 0.5rem 1rem; + font-size: 0.9rem; + background-color: transparent; + color: #0064FF; + border: 1px solid #0064FF; + border-radius: 6px; cursor: pointer; - transition: background-color 0.2s; + font-weight: 600; + transition: background-color 0.2s, color 0.2s; } .theme-detail-button:hover { - background-color: #E5E8EB; + background-color: #0064FF; + color: #fff; } /* Time Slots */ .time-slots { display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; } .time-slot { - cursor: pointer; - padding: 16px; - border-radius: 8px; + padding: 0.75rem; + border: 1px solid #dee2e6; + border-radius: 6px; text-align: center; - background-color: #F2F4F6; - font-weight: 600; - transition: all 0.2s ease-in-out; - position: relative; + cursor: pointer; + transition: all 0.2s; + background-color: #fff; } -.time-slot:hover { - background-color: #E5E8EB; +.time-slot:hover:not(.disabled) { + border-color: #0064FF; + color: #0064FF; } .time-slot.active { - background-color: #3182F6; - color: #ffffff; + background-color: #0064FF; + color: white; + border-color: #0064FF; + font-weight: 600; } .time-slot.disabled { - background-color: #F9FAFB; - color: #B0B8C1; + background-color: #f8f9fa; + color: #adb5bd; cursor: not-allowed; text-decoration: line-through; } .time-availability { - font-size: 12px; display: block; + font-size: 0.8rem; margin-top: 4px; - font-weight: 500; } .no-times { + color: #868e96; + padding: 2rem; text-align: center; - padding: 20px; - color: #8A94A2; + background-color: #fff; + border-radius: 8px; } -/* Next Step Button */ +/* --- Next Step Button --- */ .next-step-button-container { - display: flex; - justify-content: flex-end; - margin-top: 30px; + margin-top: 2rem; + text-align: center; } .next-step-button { - padding: 14px 28px; - font-size: 18px; + width: 100%; + max-width: 400px; + padding: 1rem; + font-size: 1.2rem; font-weight: 700; + color: #fff; + background-color: #0064FF; border: none; - background-color: #3182F6; - color: #ffffff; - border-radius: 12px; + border-radius: 8px; cursor: pointer; transition: background-color 0.2s; } +.next-step-button:hover:not(:disabled) { + background-color: #0053d1; +} + .next-step-button:disabled { - background-color: #B0B8C1; + background-color: #a0a0a0; cursor: not-allowed; } -.next-step-button:hover:not(:disabled) { - background-color: #1B64DA; -} -/* Modal Styles */ +/* --- Modal Styles --- */ .modal-overlay { position: fixed; top: 0; @@ -328,170 +295,158 @@ .modal-content { background-color: #ffffff !important; - padding: 32px !important; - border-radius: 16px !important; + padding: 2rem !important; + border-radius: 12px !important; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important; width: 90% !important; max-width: 500px !important; position: relative !important; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important; + max-height: 90vh !important; + overflow-y: auto !important; } .modal-close-button { position: absolute; - top: 16px; - right: 16px; + top: 1rem; + right: 1rem; background: none; border: none; - font-size: 24px; + font-size: 1.5rem; + color: #868e96; cursor: pointer; - color: #8A94A2; } .modal-theme-thumbnail { width: 100%; height: 200px; object-fit: cover; - border-radius: 12px; - margin-bottom: 24px; + border-radius: 8px; + margin-bottom: 1.5rem; } .modal-content h2 { - font-size: 24px; - font-weight: 700; - margin-bottom: 24px; - color: #191F28; + margin-top: 0; + margin-bottom: 2rem; + text-align: center; } .modal-section { - margin-bottom: 20px; + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #f1f3f5; +} + +.modal-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; } .modal-section h3 { - font-size: 18px; - font-weight: 600; - margin-bottom: 12px; - border-bottom: 1px solid #E5E8EB; - padding-bottom: 8px; + margin-top: 0; + margin-bottom: 1rem; + font-size: 1.1rem; + color: #495057; } .modal-section p { - font-size: 16px; + margin: 0.5rem 0; + color: #495057; line-height: 1.6; - margin-bottom: 8px; - color: #4E5968; -} - -.modal-section p strong { - color: #333D4B; - margin-right: 8px; } .modal-actions { display: flex; justify-content: flex-end; - gap: 12px; - margin-top: 30px; + gap: 1rem; + margin-top: 2rem; } -.modal-actions button { - padding: 12px 24px; - font-size: 16px; - font-weight: 600; +.modal-actions .cancel-button, +.modal-actions .confirm-button { + padding: 0.75rem 1.5rem; border-radius: 8px; - cursor: pointer; border: none; - transition: background-color 0.2s; + font-size: 1rem; + font-weight: 600; + cursor: pointer; } .modal-actions .cancel-button { - background-color: #E5E8EB; - color: #4E5968; -} -.modal-actions .cancel-button:hover { - background-color: #D1D6DB; + background-color: #f1f3f5; + color: #495057; } .modal-actions .confirm-button { - background-color: #3182F6; - color: #ffffff; -} -.modal-actions .confirm-button:hover { - background-color: #1B64DA; + background-color: #0064FF; + color: #fff; } -/* Styles for ReservationFormPage */ +/* --- Form Styles for ReservationFormPage --- */ .form-group { - margin-bottom: 20px; + margin-bottom: 1rem; } .form-group label { display: block; - font-weight: bold; - margin-bottom: 8px; - color: #333; + margin-bottom: 0.5rem; + font-weight: 600; + color: #495057; } -.form-group input[type="text"], -.form-group input[type="tel"], -.form-group input[type="number"], -.form-group textarea { +.form-input { width: 100%; - padding: 12px; - border: 1px solid #ccc; + padding: 0.75rem; + border: 1px solid #ddd; border-radius: 8px; - font-size: 16px; - box-sizing: border-box; - transition: border-color 0.2s, box-shadow 0.2s; + font-size: 1rem; } -.form-group input:focus, .form-group textarea:focus { - outline: none; - border-color: #3182F6; - box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2); -} - -.form-group textarea { - resize: vertical; - min-height: 100px; -} - -.participant-control { - display: flex; - align-items: center; -} - -.participant-control input { +/* Success Page */ +.success-icon { + font-size: 4rem; + color: #0064FF; text-align: center; - border-left: none; - border-right: none; - width: 60px; - border-radius: 0; + margin-bottom: 1.5rem; } -.participant-control button { - width: 44px; - height: 44px; - border: 1px solid #ccc; - background-color: #f0f0f0; - font-size: 20px; - cursor: pointer; +.success-page-actions { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 2.5rem; +} + +.success-page-actions .action-button { + padding: 0.8rem 1.6rem; + border-radius: 8px; + text-decoration: none; + font-size: 1rem; + font-weight: 600; transition: background-color 0.2s; } -.participant-control button:hover:not(:disabled) { - background-color: #e0e0e0; +.success-page-actions .action-button.secondary { + background-color: #f1f3f5; + color: #495057; } -.participant-control button:disabled { - background-color: #e9ecef; - cursor: not-allowed; - color: #aaa; +.success-page-actions .action-button:not(.secondary) { + background-color: #0064FF; + color: #fff; } -.participant-control button:first-of-type { - border-radius: 8px 0 0 8px; +/* Added for modal info alignment */ +.modal-info-grid p { + display: flex; + align-items: flex-start; + margin: 0.6rem 0; + line-height: 1.5; } - -.participant-control button:last-of-type { - border-radius: 0 8px 8px 0; +.modal-info-grid p strong { + flex: 0 0 130px; /* fixed width for labels */ + font-weight: 600; +} +.modal-info-grid p span { + flex: 1; } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 3d4461d5..e16e9d9c 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -3,7 +3,7 @@ import '@_css/home-page-v2.css'; import React, {useEffect, useState} from 'react'; import {useNavigate} from 'react-router-dom'; import {fetchThemesByIds} from '@_api/theme/themeAPI'; -import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes'; +import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes'; const HomePage: React.FC = () => { const [ranking, setRanking] = useState([]); @@ -71,11 +71,12 @@ const HomePage: React.FC = () => {

{selectedTheme.name}

{selectedTheme.description}

-
-

난이도: {selectedTheme.difficulty}

-

가격: {selectedTheme.price.toLocaleString()}원

-

예상 시간: {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분

-

이용 가능 인원: {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명

+
+

난이도:{DifficultyKoreanMap[selectedTheme.difficulty]}

+

이용 가능 인원:{selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명

+

1인당 요금:{selectedTheme.price.toLocaleString()}원

+

예상 시간:{selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분

+

이용 가능 시간:{selectedTheme.availableMinutes}분

diff --git a/frontend/src/pages/MyReservationPage.tsx b/frontend/src/pages/MyReservationPage.tsx index 24565bfc..84000f53 100644 --- a/frontend/src/pages/MyReservationPage.tsx +++ b/frontend/src/pages/MyReservationPage.tsx @@ -117,10 +117,10 @@ const CancellationView: React.FC<{ return (

취소 정보 확인

-
-

테마: {reservation.themeName}

-

신청 일시: {formatDisplayDateTime(reservation.applicationDateTime)}

- {reservation.payment &&

결제 금액: {reservation.payment.totalAmount.toLocaleString()}원

} +
+

테마:{reservation.themeName}

+

신청 일시:{formatDisplayDateTime(reservation.applicationDateTime)}

+ {reservation.payment &&

결제 금액:{reservation.payment.totalAmount.toLocaleString()}원

}