From 2a0fa0096cc338a29e11683882bd7ab742d3b218 Mon Sep 17 00:00:00 2001 From: HyeonsuLee Date: Mon, 3 Jun 2024 11:44:57 +0900 Subject: [PATCH 01/37] =?UTF-8?q?fix:=20dto=20@NotNull=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java index b62f209e2..4a954dd32 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java @@ -55,6 +55,8 @@ public record OwnerShopsRequest( String name, @Schema(description = "요일별 운영 시간과 휴무 여부", requiredMode = REQUIRED) + @Size(min = 7, max = 7, message = "The list must contain exactly 7 elements.") + @NotNull List open, @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) From 38044d907f6aff4a786231e3191aa4a9eff4a7e6 Mon Sep 17 00:00:00 2001 From: HyeonsuLee Date: Mon, 3 Jun 2024 12:11:23 +0900 Subject: [PATCH 02/37] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/acceptance/OwnerShopApiTest.java | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java index 72dcbcc56..b0990df6c 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java @@ -162,31 +162,50 @@ void createOwnerShop() { "https://test.com/test2.jpg", "https://test.com/test3.jpg" ], + "name": "테스트 상점2", "open": [ { - "close_time": [ - 21, - 0 - ], + "close_time": "21:00", "closed": false, "day_of_week": "MONDAY", - "open_time": [ - 9, - 0 - ] + "open_time": "09:00" }, { - "close_time": [ - 21, - 0 - ], + "close_time": "21:00", + "closed": false, + "day_of_week": "TUESDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", "closed": false, "day_of_week": "WEDNESDAY", - "open_time": [ - 9, - 0 - ] + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "THURSDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "FRIDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "SATURDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "SUNDAY", + "open_time": "09:00" } ], "pay_bank": true, @@ -198,6 +217,7 @@ void createOwnerShop() { .when() .post("/owner/shops") .then() + .log().all() .statusCode(HttpStatus.CREATED.value()) .extract(); @@ -211,7 +231,7 @@ void createOwnerShop() { softly.assertThat(result.getDescription()).isEqualTo("테스트 상점2입니다."); softly.assertThat(result.getName()).isEqualTo("테스트 상점2"); softly.assertThat(result.getShopImages()).hasSize(3); - softly.assertThat(result.getShopOpens()).hasSize(2); + softly.assertThat(result.getShopOpens()).hasSize(7); softly.assertThat(result.getShopCategories()).hasSize(1); } ); From 6f8d1cb87c2bc2c90fb817b43bc35c8b8c077347 Mon Sep 17 00:00:00 2001 From: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Date: Tue, 11 Jun 2024 21:17:53 +0900 Subject: [PATCH 03/37] =?UTF-8?q?fix=20:=20=ED=92=88=EC=A0=88=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=AC=B8=EA=B5=AC=20=EC=A0=90(.)=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20(#599)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : 품절 알림 문구 점 삭제 * fix : 품절 알림 문구 점 삭제 --- .../global/domain/notification/model/NotificationFactory.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java index e9c9131c6..5e1554da3 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java @@ -32,8 +32,8 @@ public Notification generateSoldOutNotification( ) { return new Notification( path, - "%s 품절되었습니다.".formatted(getPostposition(place, "이", "가")), - "다른 식단 보러 가기.", + "%s 품절되었습니다".formatted(getPostposition(place, "이", "가")), + "다른 식단 보러 가기", null, NotificationType.MESSAGE, target From 8e93c5d72b3103b1e41d7d332090a8e949d81aa2 Mon Sep 17 00:00:00 2001 From: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Date: Tue, 11 Jun 2024 23:28:03 +0900 Subject: [PATCH 04/37] =?UTF-8?q?feat=20:=20put=20/admin/members/{id}=20ap?= =?UTF-8?q?i=20=EC=B6=94=EA=B0=80=20(#595)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : put /admin/members/{id} api 추가 * fix: trackName 체크 메서드 생성 * fix: track 변경 체크 로직 및 track 수정 로직 수정 --- .../member/controller/AdminMemberApi.java | 18 ++++ .../controller/AdminMemberController.java | 14 ++- .../member/service/AdminMemberService.java | 13 +++ .../koin/domain/member/model/Member.java | 13 +++ .../admin/acceptance/AdminMemberApiTest.java | 92 ++++++++++++++++++- 5 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java index 5f83fbf19..d7f792414 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java @@ -7,6 +7,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -89,4 +90,21 @@ ResponseEntity deleteMember( @PathVariable("id") Integer memberId, @Auth(permit = {ADMIN}) Integer adminId ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "BCSDLab 회원 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/members/{id}") + ResponseEntity updateMember( + @PathVariable("id") Integer memberId, + @RequestBody @Valid AdminMemberRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); } diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java index 0cd69b87c..78431a2bc 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java @@ -8,6 +8,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -47,7 +48,6 @@ public ResponseEntity createMember( return ResponseEntity.status(HttpStatus.CREATED).build(); } - @GetMapping("/admin/members/{id}") public ResponseEntity getMember( @PathVariable("id") Integer memberId, @@ -62,6 +62,16 @@ public ResponseEntity deleteMember( @Auth(permit = {ADMIN}) Integer adminId ) { adminMemberService.deleteMember(memberId); - return null; + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @PutMapping("/admin/members/{id}") + public ResponseEntity updateMember( + @PathVariable("id") Integer memberId, + @RequestBody @Valid AdminMemberRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminMemberService.updateMember(memberId, request); + return ResponseEntity.status(HttpStatus.OK).build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java b/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java index b153e0a37..7b48aad13 100644 --- a/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java +++ b/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java @@ -54,4 +54,17 @@ public void deleteMember(Integer memberId) { Member member = adminMemberRepository.getById(memberId); member.delete(); } + + @Transactional + public void updateMember(Integer memberId, AdminMemberRequest request) { + Member member = adminMemberRepository.getById(memberId); + + String currentTrackName = member.getTrack().getName(); + String changedTrackName = request.track(); + if (!currentTrackName.equals(changedTrackName)) { + member.updateTrack(adminTrackRepository.getByName(request.track())); + } + + member.update(request.name(), request.studentNumber(), request.position(), request.email(), request.imageUrl()); + } } diff --git a/src/main/java/in/koreatech/koin/domain/member/model/Member.java b/src/main/java/in/koreatech/koin/domain/member/model/Member.java index c890224d8..cedbfcade 100644 --- a/src/main/java/in/koreatech/koin/domain/member/model/Member.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/Member.java @@ -4,6 +4,7 @@ import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; +import in.koreatech.koin.admin.member.dto.AdminMemberRequest; import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -83,4 +84,16 @@ private Member( public void delete() { this.isDeleted = true; } + + public void update(String name, String studentNumber, String position, String email, String imageUrl) { + this.name = name; + this.studentNumber = studentNumber; + this.position = position; + this.email = email; + this.imageUrl = imageUrl; + } + + public void updateTrack(Track track) { + this.track = track; + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java index 01920ece7..c52ed4a82 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java @@ -166,7 +166,7 @@ void deleteMember() { User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); - var response = RestAssured + RestAssured .given() .header("Authorization", "Bearer " + token) .when() @@ -175,7 +175,6 @@ void deleteMember() { .statusCode(HttpStatus.OK.value()) .extract(); - Member savedMember = adminMemberRepository.getById(memberId); SoftAssertions.assertSoftly(softly -> { @@ -188,4 +187,93 @@ void deleteMember() { softly.assertThat(savedMember.isDeleted()).isEqualTo(true); }); } + + @Test + @DisplayName("BCSDLab 회원 정보를 수정한다") + void updateMember() { + Member member = memberFixture.최준호(trackFixture.backend()); + Integer memberId = member.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + String jsonBody = """ + { + "name": "최준호", + "student_number": "2019136135", + "track": "BackEnd", + "position": "Mentor", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg" + } + """; + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(jsonBody) + .when() + .put("/admin/members/{id}", memberId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Member updatedMember = adminMemberRepository.getById(memberId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(updatedMember.getName()).isEqualTo("최준호"); + softly.assertThat(updatedMember.getStudentNumber()).isEqualTo("2019136135"); + softly.assertThat(updatedMember.getTrack().getName()).isEqualTo("BackEnd"); + softly.assertThat(updatedMember.getPosition()).isEqualTo("Mentor"); + softly.assertThat(updatedMember.getEmail()).isEqualTo("testjuno@gmail.com"); + softly.assertThat(updatedMember.getImageUrl()).isEqualTo("https://imagetest.com/juno.jpg"); + softly.assertThat(updatedMember.isDeleted()).isEqualTo(false); + }); + } + + @Test + @DisplayName("BCSDLab 회원 정보를 트랙과 함께 수정한다") + void updateMemberWithTrack() { + Member member = memberFixture.최준호(trackFixture.backend()); + trackFixture.frontend(); + Integer memberId = member.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + String jsonBody = """ + { + "name": "최준호", + "student_number": "2019136135", + "track": "FrontEnd", + "position": "Mentor", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg" + } + """; + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(jsonBody) + .when() + .put("/admin/members/{id}", memberId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Member updatedMember = adminMemberRepository.getById(memberId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(updatedMember.getName()).isEqualTo("최준호"); + softly.assertThat(updatedMember.getStudentNumber()).isEqualTo("2019136135"); + softly.assertThat(updatedMember.getTrack().getName()).isEqualTo("FrontEnd"); + softly.assertThat(updatedMember.getPosition()).isEqualTo("Mentor"); + softly.assertThat(updatedMember.getEmail()).isEqualTo("testjuno@gmail.com"); + softly.assertThat(updatedMember.getImageUrl()).isEqualTo("https://imagetest.com/juno.jpg"); + softly.assertThat(updatedMember.isDeleted()).isEqualTo(false); + }); + } } From a75e95fa436a6f9b57c23ce5ee827e17bc214924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= <46699595+ImTotem@users.noreply.github.com> Date: Fri, 14 Jun 2024 17:07:17 +0900 Subject: [PATCH 05/37] =?UTF-8?q?=08feat:=20=EB=B2=84=EC=8A=A4=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20(#586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 노선 정보 캐싱 - 정류장을 지나는 노선 정보들 캐싱 * refactor: 필터링 위치 변경 - 버스 시간 조회시 필터링 * feat: 크롤링 추가 * feat: 테스트 추가 * refactor: 피드백 반영 * chore: 주석 추가 * refactor: 피드백 반영 - save -> saveAll로 변경 --- .../domain/bus/model/city/CityBusRoute.java | 14 ++ .../bus/model/city/CityBusRouteCache.java | 46 ++++++ .../CityBusRouteCacheRepository.java | 24 +++ .../koin/domain/bus/service/BusService.java | 16 ++ .../koin/domain/bus/util/BusScheduler.java | 2 + .../koin/domain/bus/util/CityBusClient.java | 9 +- .../domain/bus/util/CityBusRouteClient.java | 151 ++++++++++++++++++ .../in/koreatech/koin/AcceptanceTest.java | 4 + .../koreatech/koin/acceptance/BusApiTest.java | 98 ++++++++++++ 9 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRoute.java create mode 100644 src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRouteCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRoute.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRoute.java new file mode 100644 index 000000000..c5edee27b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRoute.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.bus.model.city; + +import lombok.Builder; + +@Builder +public record CityBusRoute( + String endnodenm, // 종점, 병천3리 + String routeid, // 노선 ID, CAB285000142 + Long routeno, // 노선 번호, 400 + String routetp, // 노선 유형, 일반버스 + String startnodenm // 기점, 종합터미널 +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRouteCache.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRouteCache.java new file mode 100644 index 000000000..57fbb35fd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRouteCache.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.domain.bus.model.city; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash("CityBusRoute") +public class CityBusRouteCache { + + private static final long CACHE_EXPIRE_MINUTE = 2L; + + @Id + private final String id; + + private final Set busNumbers = new HashSet<>(); + + @TimeToLive(unit = TimeUnit.MINUTES) + private final Long expiration; + + @Builder + private CityBusRouteCache(String id, Set busNumbers, Long expiration) { + this.id = id; + this.busNumbers.addAll(busNumbers); + this.expiration = expiration; + } + + public static CityBusRouteCache of(String nodeId, Set busRoutes) { + return CityBusRouteCache.builder() + .id(nodeId) + .busNumbers(busRoutes.stream() + .map(CityBusRoute::routeno) + .collect(Collectors.toSet()) + ) + .expiration(CACHE_EXPIRE_MINUTE) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java new file mode 100644 index 000000000..d3e055d9c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.bus.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.bus.exception.BusCacheNotFoundException; +import in.koreatech.koin.domain.bus.model.city.CityBusRouteCache; + +public interface CityBusRouteCacheRepository extends Repository { + + CityBusRouteCache save(CityBusRouteCache cityBusRouteCache); + + List saveAll(List cityBusRouteCaches); + + List findAll(); + + Optional findById(String nodeId); + + default CityBusRouteCache getById(String nodeId) { + return findById(nodeId).orElseThrow(() -> BusCacheNotFoundException.withDetail("nodeId: " + nodeId)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java index 240aa0569..ed2a3afc5 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java +++ b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java @@ -12,6 +12,7 @@ import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +34,7 @@ import in.koreatech.koin.domain.bus.model.mongo.Route; import in.koreatech.koin.domain.bus.repository.BusRepository; import in.koreatech.koin.domain.bus.util.CityBusClient; +import in.koreatech.koin.domain.bus.util.CityBusRouteClient; import in.koreatech.koin.domain.bus.util.TmoneyExpressBusClient; import in.koreatech.koin.domain.version.dto.VersionResponse; import in.koreatech.koin.domain.version.service.VersionService; @@ -48,6 +50,7 @@ public class BusService { private final BusRepository busRepository; private final CityBusClient cityBusClient; private final TmoneyExpressBusClient tmoneyExpressBusClient; + private final CityBusRouteClient cityBusRouteClient; private final VersionService versionService; @Transactional @@ -57,7 +60,20 @@ public BusRemainTimeResponse getBusRemainTime(BusType busType, BusStation depart if (busType == BusType.CITY) { // 시내버스에서 상행, 하행 구분할때 사용하는 로직 BusDirection direction = getDirection(depart, arrival); + + Set departAvailableBusNumbers = cityBusRouteClient.getAvailableCityBus(depart.getNodeId(direction)); + Set arrivalAvailableBusNumbers = cityBusRouteClient.getAvailableCityBus(arrival.getNodeId(direction)); + + departAvailableBusNumbers.retainAll(arrivalAvailableBusNumbers); + var remainTimes = cityBusClient.getBusRemainTime(depart.getNodeId(direction)); + + remainTimes = remainTimes.stream() + .filter(remainTime -> + departAvailableBusNumbers.contains(remainTime.getBusNumber()) + ) + .toList(); + return toResponse(busType, remainTimes); } diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/BusScheduler.java b/src/main/java/in/koreatech/koin/domain/bus/util/BusScheduler.java index b5c712051..f000eeb61 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/util/BusScheduler.java +++ b/src/main/java/in/koreatech/koin/domain/bus/util/BusScheduler.java @@ -13,11 +13,13 @@ public class BusScheduler { private final CityBusClient cityBusClient; private final TmoneyExpressBusClient tmoneyExpressBusClient; + private final CityBusRouteClient cityBusRouteClient; @Scheduled(cron = "0 * * * * *") public void cacheCityBusByOpenApi() { try { cityBusClient.storeRemainTimeByOpenApi(); + cityBusRouteClient.storeCityBusRoute(); } catch (Exception e) { log.warn("시내버스 스케줄링 과정에서 오류가 발생했습니다."); } diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java index 9dfdc95ad..8de709982 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java +++ b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java @@ -12,7 +12,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.Optional; import org.springframework.beans.factory.annotation.Value; @@ -47,7 +46,6 @@ public class CityBusClient { private static final String ENCODE_TYPE = "UTF-8"; private static final String CHEONAN_CITY_CODE = "34010"; - private static final List AVAILABLE_CITY_BUS = List.of(400L, 402L, 405L); private static final Type arrivalInfoType = new TypeToken>() { }.getType(); @@ -82,12 +80,7 @@ public void storeRemainTimeByOpenApi() { List> arrivalInfosList = BusStationNode.getNodeIds().stream() .map(this::getOpenApiResponse) .map(this::extractBusArrivalInfo) - .map(cityBusArrivals -> cityBusArrivals.stream() - .filter(cityBusArrival -> - AVAILABLE_CITY_BUS.stream().anyMatch(busNumber -> - Objects.equals(busNumber, cityBusArrival.routeno())) - ).toList() - ).toList(); + .toList(); LocalDateTime updatedAt = LocalDateTime.now(clock); diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java new file mode 100644 index 000000000..9b906310e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java @@ -0,0 +1,151 @@ +package in.koreatech.koin.domain.bus.util; + +import static java.net.URLEncoder.encode; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Type; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +import in.koreatech.koin.domain.bus.exception.BusOpenApiException; +import in.koreatech.koin.domain.bus.model.city.CityBusRoute; +import in.koreatech.koin.domain.bus.model.city.CityBusRouteCache; +import in.koreatech.koin.domain.bus.model.enums.BusOpenApiResultCode; +import in.koreatech.koin.domain.bus.model.enums.BusStationNode; +import in.koreatech.koin.domain.bus.repository.CityBusRouteCacheRepository; + +/** + * OpenApi 상세: 국토교통부_(TAGO)_버스정류소정보 - 정류소별경유노선 목록조회 + * https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15098534 + */ +@Component +@Transactional(readOnly = true) +public class CityBusRouteClient { + + private static final Set AVAILABLE_CITY_BUS = Set.of(400L, 402L, 405L); + + private static final String ENCODE_TYPE = "UTF-8"; + private static final String CHEONAN_CITY_CODE = "34010"; + private static final Type availableCityBusType = new TypeToken>() { + }.getType(); + + private final String openApiKey; + private final Gson gson; + private final CityBusRouteCacheRepository cityBusRouteCacheRepository; + + public CityBusRouteClient( + @Value("${OPEN_API_KEY_PUBLIC}") String openApiKey, + Gson gson, + CityBusRouteCacheRepository cityBusRouteCacheRepository + ) { + this.openApiKey = openApiKey; + this.gson = gson; + this.cityBusRouteCacheRepository = cityBusRouteCacheRepository; + } + + public Set getAvailableCityBus(String nodeId) { + Optional routeCache = cityBusRouteCacheRepository.findById(nodeId); + if (routeCache.isEmpty()) { + return new HashSet<>(AVAILABLE_CITY_BUS); + } + + return routeCache.get().getBusNumbers(); + } + + @Transactional + public void storeCityBusRoute() { + cityBusRouteCacheRepository.saveAll( + BusStationNode.getNodeIds().stream() + .map(node -> + CityBusRouteCache.of( + node, + Set.copyOf(extractBusRouteInfo(getOpenApiResponse(node))) + ) + ).toList() + ); + } + + public String getOpenApiResponse(String nodeId) { + try { + URL url = new URL(getRequestURL(CHEONAN_CITY_CODE, nodeId)); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-type", "application/json"); + + BufferedReader input; + if (conn.getResponseCode() >= 200 && conn.getResponseCode() <= 300) { + input = new BufferedReader(new InputStreamReader(conn.getInputStream())); + } else { + input = new BufferedReader(new InputStreamReader(conn.getErrorStream())); + } + + StringBuilder response = new StringBuilder(); + String line; + while ((line = input.readLine()) != null) { + response.append(line); + } + input.close(); + conn.disconnect(); + return response.toString(); + } catch (Exception e) { + throw BusOpenApiException.withDetail("nodeId: " + nodeId); + } + } + + private String getRequestURL(String cityCode, String nodeId) throws UnsupportedEncodingException { + String url = "https://apis.data.go.kr/1613000/BusSttnInfoInqireService/getSttnThrghRouteList"; + String contentCount = "50"; + StringBuilder urlBuilder = new StringBuilder(url); + urlBuilder.append("?" + encode("serviceKey", ENCODE_TYPE) + "=" + encode(openApiKey, ENCODE_TYPE)); + urlBuilder.append("&" + encode("numOfRows", ENCODE_TYPE) + "=" + encode(contentCount, ENCODE_TYPE)); + urlBuilder.append("&" + encode("cityCode", ENCODE_TYPE) + "=" + encode(cityCode, ENCODE_TYPE)); + urlBuilder.append("&" + encode("nodeid", ENCODE_TYPE) + "=" + encode(nodeId, ENCODE_TYPE)); + urlBuilder.append("&_type=json"); + return urlBuilder.toString(); + } + + private List extractBusRouteInfo(String jsonResponse) { + List result = new ArrayList<>(); + try { + JsonObject response = JsonParser.parseString(jsonResponse) + .getAsJsonObject() + .get("response") + .getAsJsonObject(); + BusOpenApiResultCode.validateResponse(response); + JsonObject body = response.get("body").getAsJsonObject(); + + if (body.get("totalCount").getAsLong() == 0) { + return result; + } + + JsonElement item = body.get("items").getAsJsonObject().get("item"); + if (item.isJsonArray()) { + return gson.fromJson(item, availableCityBusType); + } + if (item.isJsonObject()) { + result.add(gson.fromJson(item, CityBusRoute.class)); + } + return result; + } catch (JsonSyntaxException e) { + return result; + } + } +} diff --git a/src/test/java/in/koreatech/koin/AcceptanceTest.java b/src/test/java/in/koreatech/koin/AcceptanceTest.java index 940db583b..bd1779dc6 100644 --- a/src/test/java/in/koreatech/koin/AcceptanceTest.java +++ b/src/test/java/in/koreatech/koin/AcceptanceTest.java @@ -22,6 +22,7 @@ import in.koreatech.koin.config.TestJpaConfiguration; import in.koreatech.koin.config.TestTimeConfig; import in.koreatech.koin.domain.bus.util.CityBusClient; +import in.koreatech.koin.domain.bus.util.CityBusRouteClient; import in.koreatech.koin.domain.coop.model.CoopEventListener; import in.koreatech.koin.domain.owner.model.OwnerEventListener; import in.koreatech.koin.domain.shop.model.ShopEventListener; @@ -45,6 +46,9 @@ public abstract class AcceptanceTest { @SpyBean protected CityBusClient cityBusClient; + @SpyBean + protected CityBusRouteClient cityBusRouteClient; + @MockBean protected OwnerEventListener ownerEventListener; diff --git a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java index f853cd7c0..518b7d87a 100644 --- a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java @@ -104,6 +104,104 @@ void setup() { } } """); + + when(cityBusRouteClient.getOpenApiResponse("CAB285000686")).thenReturn(""" + { + "response": { + "header": { + "resultCode": "00", + "resultMsg": "NORMAL SERVICE." + }, + "body": { + "items": { + "item": [ + { + "endnodenm": "황사동", + "routeid": "CAB285000146", + "routeno": 402, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "방아다리공원", + "routeid": "CAB285000331", + "routeno": 9, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "유관순열사유적지", + "routeid": "CAB285000407", + "routeno": 405, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "병천3리", + "routeid": "CAB285000142", + "routeno": 400, + "routetp": "일반버스", + "startnodenm": "종합터미널" + } + ] + }, + "numOfRows": 50, + "pageNo": 1, + "totalCount": 4 + } + } + } + """ + ); + + when(cityBusRouteClient.getOpenApiResponse("CAB285000406")).thenReturn(""" + { + "response": { + "header": { + "resultCode": "00", + "resultMsg": "NORMAL SERVICE." + }, + "body": { + "items": { + "item": [ + { + "endnodenm": "황사동", + "routeid": "CAB285000146", + "routeno": 402, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "병천중고등학교", + "routeid": "CAB285000049", + "routeno": 95, + "routetp": "일반버스", + "startnodenm": "월봉청솔아파트" + }, + { + "endnodenm": "유관순열사유적지", + "routeid": "CAB285000407", + "routeno": 405, + "routetp": "일반버스", + "startnodenm": "종합터미널" + }, + { + "endnodenm": "병천3리", + "routeid": "CAB285000142", + "routeno": 400, + "routetp": "일반버스", + "startnodenm": "종합터미널" + } + ] + }, + "numOfRows": 50, + "pageNo": 1, + "totalCount": 4 + } + } + } + """ + ); } @Test From 102f05c5f2ff5949f9e55ec2dbdb510ba9d9b8a0 Mon Sep 17 00:00:00 2001 From: duehee <149302959+duehee@users.noreply.github.com> Date: Sat, 15 Jun 2024 00:16:11 +0900 Subject: [PATCH 06/37] =?UTF-8?q?feat=20:=20=ED=95=99=EC=83=9D,=20?= =?UTF-8?q?=EC=82=AC=EC=9E=A5=EB=8B=98,=20=EC=98=81=EC=96=91=EC=82=AC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B6=84=EA=B8=B0=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(#563)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 사장님 / 영양사 로그인 분기 처리 추가 * chore : StudentUpdateResponse의 gender 검증 변수 제거 * feat : 테스트 추가 * feat: 가입 신청한 사장님 페이지네이션 조회(어드민계정) (#539) * feat: 응답객체 생성 * feat: controller 생성 * refactor: 접근제어 변경 * feat: 요청 모델앤뷰 dto * feat: 응답 객체 * feat: repository 생성 * feat: shop_id와 shop_name이 있는 owner객체 생성 * feat: service * feat: 테스트 추가 * fix: 필요없는 코드 제거 * refactor: required 관련 수정 * refactor: 컨벤션 적용 * refactor: 로직 수정 * refactor: 로직 수정 * refactor: AdminShopRepository 제거 * refactor: controller api 수정 * refactor: 충돌 해결 * refactor: 충돌 해결 * refactor: 주석 추가 * refactor: 빌더 삭제 및 코드 개선 * refactor: 요청dto requiredMode 전부 적용 * refactor: 빌더 생성 * refactor: Enum위치 변경 및 빌더 삭제 * refactor: controller api implement 추가 * refactor: dto 메서드 스웨거에서 숨기기 * feat: POST /admin/members API 추가 (#541) * feat: POST /admin/members API 추가 * fix: @Auth 추가 및 테스트 수정 * chore: 공백 제거 * refactor: GET /shops api 성능최적화 (#549) * feat: swwagger 인증번호 발송 관련 api명세 추가 * feat: 마크업으로 변경 * feat: 레디스 캐싱 * feat: save메소드 추가 * feat: 캐시가 없으면 캐시에 데이터를 올린다 * refactor: 필요없는 설정 정리 * feat: 리뷰반영 --------- Co-authored-by: HyeonsuLee * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 (#564) * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 * feat: 스웨거 이슈로 길어져버린 dto이름ㅠ * feat: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * 버스 openApi 에러 수정 (#565) * fix: openApi scheduled에서 try catch * fix: Exception catch로 변경 * chore : 사장님 로그인 api 작성 * feat : 로그인 API(학생, 사장님, 영양사) 분리 * chore : UserFixture 원경_사장님 수정 * chore : UserFixture 중복 사항 제거 * chore : UserFixture 오류 수정 * chore : 리뷰 반영(메소드 이름 변경) * chore : 리뷰 반영(전화번호 숨김 및 이메일 형식 삭제) * chore : 리뷰 반영(메소드 위치 변경 및 Response userType 삭제) * chore : 사장님 로그인 테스트 위치 변경 * feat : flyway 추가 * feat : 영양사 테이블 추가 및 로그인 로직 수정 * feat : 사장님 테이블 컬럼 추가 및 로그인 로직 수정 * test : 테스트 코드 수정 * test : 테스트 오류 수정을 위한 기존 request 수정 * chore : 리뷰 반영(이메일 형식 확인 삭제) * chore : flyway 변경(V16 수정 및 V18 추가) * chore : User email @NotNull 제거(email null 허용) * chore : Owner 핸드폰 회원가입 email null 값 허용 * chore : Test 수정 및 User email @Column(nullable = false) 제거 * chore : 전화번호 관련 로직 "01000000000" 로 수정 및 테스트 수정 * test : Admin User 테스트 수정 --------- Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> --- .../koin/domain/coop/controller/CoopApi.java | 17 +++ .../coop/controller/CoopController.java | 14 +++ .../domain/coop/dto/CoopLoginRequest.java | 23 ++++ .../domain/coop/dto/CoopLoginResponse.java | 26 +++++ .../coop/exception/CoopNotFoundException.java | 19 ++++ .../koin/domain/coop/model/Coop.java | 43 +++++++ .../coop/repository/CoopRepository.java | 18 +++ .../koin/domain/coop/service/CoopService.java | 34 ++++++ .../domain/owner/controller/OwnerApi.java | 16 +++ .../owner/controller/OwnerController.java | 13 +++ .../domain/owner/dto/OwnerLoginRequest.java | 25 ++++ .../domain/owner/dto/OwnerLoginResponse.java | 26 +++++ .../dto/OwnerRegisterByPhoneRequest.java | 3 +- .../owner/dto/OwnerRegisterRequest.java | 2 +- .../koin/domain/owner/dto/OwnerResponse.java | 4 + .../koin/domain/owner/model/Owner.java | 8 +- .../owner/repository/OwnerRepository.java | 6 + .../domain/owner/service/OwnerService.java | 29 +++++ .../koin/domain/user/controller/UserApi.java | 18 +++ .../user/controller/UserController.java | 13 +++ .../domain/user/dto/StudentLoginRequest.java | 23 ++++ .../domain/user/dto/StudentLoginResponse.java | 26 +++++ .../user/dto/StudentRegisterRequest.java | 2 +- .../domain/user/dto/StudentUpdateRequest.java | 2 +- .../user/dto/StudentUpdateResponse.java | 5 +- .../domain/user/dto/UserLoginResponse.java | 1 + .../koin/domain/user/model/User.java | 3 +- .../user/repository/UserRepository.java | 7 ++ .../domain/user/service/StudentService.java | 29 +++++ .../koin/domain/user/service/UserService.java | 4 - ...16__add_account_column_to_owners_table.sql | 7 ++ ...V17__create_coop_table_and_insert_data.sql | 10 ++ .../V18__alter_user_email_column_nullable.sql | 2 + .../koin/acceptance/DiningApiTest.java | 2 +- .../koin/acceptance/OwnerApiTest.java | 37 +++++- .../koin/acceptance/UserApiTest.java | 107 +++++++++++++++--- .../admin/acceptance/AdminUserApiTest.java | 14 +-- .../koreatech/koin/fixture/UserFixture.java | 94 +++++++++++---- 38 files changed, 666 insertions(+), 66 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/coop/exception/CoopNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/domain/coop/model/Coop.java create mode 100644 src/main/java/in/koreatech/koin/domain/coop/repository/CoopRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginResponse.java create mode 100644 src/main/resources/db/migration/V16__add_account_column_to_owners_table.sql create mode 100644 src/main/resources/db/migration/V17__create_coop_table_and_insert_data.sql create mode 100644 src/main/resources/db/migration/V18__alter_user_email_column_nullable.sql diff --git a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java index d37084333..a5425a7c5 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java +++ b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopApi.java @@ -4,9 +4,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import in.koreatech.koin.domain.coop.dto.CoopLoginRequest; +import in.koreatech.koin.domain.coop.dto.CoopLoginResponse; import in.koreatech.koin.domain.coop.dto.DiningImageRequest; import in.koreatech.koin.domain.coop.dto.SoldOutRequest; import in.koreatech.koin.global.auth.Auth; @@ -53,4 +56,18 @@ ResponseEntity saveDiningImage( @Auth(permit = {COOP}) Integer userId, @RequestBody @Valid DiningImageRequest imageRequest ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "영양사 로그인") + @PostMapping("/coop/login") + ResponseEntity coopLogin( + @RequestBody @Valid CoopLoginRequest request + ); } diff --git a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java index b673e06c0..6790b9b44 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java +++ b/src/main/java/in/koreatech/koin/domain/coop/controller/CoopController.java @@ -2,12 +2,17 @@ import static in.koreatech.koin.domain.user.model.UserType.COOP; +import java.net.URI; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; +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 in.koreatech.koin.domain.coop.dto.CoopLoginRequest; +import in.koreatech.koin.domain.coop.dto.CoopLoginResponse; import in.koreatech.koin.domain.coop.dto.DiningImageRequest; import in.koreatech.koin.domain.coop.dto.SoldOutRequest; import in.koreatech.koin.domain.coop.service.CoopService; @@ -39,4 +44,13 @@ public ResponseEntity saveDiningImage( coopService.saveDiningImage(imageRequest); return ResponseEntity.ok().build(); } + + @PostMapping("/login") + public ResponseEntity coopLogin( + @RequestBody @Valid CoopLoginRequest request + ) { + CoopLoginResponse response = coopService.coopLogin(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } } diff --git a/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginRequest.java b/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginRequest.java new file mode 100644 index 000000000..717cb6c82 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginRequest.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.coop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record CoopLoginRequest( + @Schema(description = "아이디", example = "koin123", requiredMode = REQUIRED) + @NotBlank(message = "아이디를 입력해주세요.") + String id, + + @Schema( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginResponse.java b/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginResponse.java new file mode 100644 index 000000000..d26976bff --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/dto/CoopLoginResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.coop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CoopLoginResponse( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken + ) { + + public static CoopLoginResponse of(String token, String refreshToken) { + return new CoopLoginResponse(token, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/exception/CoopNotFoundException.java b/src/main/java/in/koreatech/koin/domain/coop/exception/CoopNotFoundException.java new file mode 100644 index 000000000..62135561f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/exception/CoopNotFoundException.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.coop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class CoopNotFoundException extends DataNotFoundException { + private static final String DEFAULT_MESSAGE = "영양사님 계정이 존재하지 않습니다."; + + public CoopNotFoundException(String message) { + super(message); + } + + public CoopNotFoundException(String message, String detail) { + super(message, detail); + } + + public static DiningCacheNotFoundException withDetail(String detail) { + return new DiningCacheNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/model/Coop.java b/src/main/java/in/koreatech/koin/domain/coop/model/Coop.java new file mode 100644 index 000000000..7a917bc85 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/model/Coop.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.domain.coop.model; + +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "coop") +@NoArgsConstructor(access = PROTECTED) +public class Coop { + + @Id + @Column(name = "user_id") + private Integer id; + + @Size(max = 255) + @Column(name = "coop_id") + private String coopId; + + @OneToOne + @MapsId + private User user; + + @Builder + private Coop( + String coopId, + User user + ) { + this.coopId = coopId; + this.user = user; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/repository/CoopRepository.java b/src/main/java/in/koreatech/koin/domain/coop/repository/CoopRepository.java new file mode 100644 index 000000000..2f7c34a51 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/coop/repository/CoopRepository.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.coop.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.coop.exception.CoopNotFoundException; +import in.koreatech.koin.domain.coop.model.Coop; + +public interface CoopRepository extends Repository { + Optional findByCoopId(String coopId); + + default Coop getByCoopId(String coopId){ + return findByCoopId(coopId).orElseThrow(() -> CoopNotFoundException.withDetail("CoopId : " + coopId)); + } + + Coop save(Coop coop); +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java index 4317e914c..b1c57f506 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java +++ b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java @@ -2,17 +2,30 @@ import java.time.Clock; import java.time.LocalDateTime; +import java.util.UUID; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.coop.dto.CoopLoginRequest; +import in.koreatech.koin.domain.coop.dto.CoopLoginResponse; import in.koreatech.koin.domain.coop.dto.DiningImageRequest; import in.koreatech.koin.domain.coop.dto.SoldOutRequest; +import in.koreatech.koin.domain.coop.model.Coop; import in.koreatech.koin.domain.coop.model.DiningSoldOutEvent; +import in.koreatech.koin.domain.coop.repository.CoopRepository; import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; import in.koreatech.koin.domain.dining.model.Dining; import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.repository.UserTokenRepository; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; @Service @@ -24,6 +37,10 @@ public class CoopService { private final ApplicationEventPublisher eventPublisher; private final DiningRepository diningRepository; private final DiningSoldOutCacheRepository diningSoldOutCacheRepository; + private final CoopRepository coopRepository; + private final UserTokenRepository userTokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; @Transactional public void changeSoldOut(SoldOutRequest soldOutRequest) { @@ -48,4 +65,21 @@ public void saveDiningImage(DiningImageRequest imageRequest) { Dining dining = diningRepository.getById(imageRequest.menuId()); dining.setImageUrl(imageRequest.imageUrl()); } + + @Transactional + public CoopLoginResponse coopLogin(CoopLoginRequest request) { + Coop coop = coopRepository.getByCoopId(request.id()); + User user = coop.getUser(); + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedToken = userTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + + return CoopLoginResponse.of(accessToken, savedToken.getRefreshToken()); + } } diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java index 66d18b1d6..a2a74273c 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java @@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.RequestBody; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifySmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateEmailRequest; @@ -50,6 +52,20 @@ ResponseEntity getOwner( @Auth(permit = {OWNER}) Integer userId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 로그인") + @PostMapping("/owner/login") + ResponseEntity ownerLogin( + @RequestBody @Valid OwnerLoginRequest request + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java index 308b085e3..0d9e771fd 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java @@ -2,6 +2,8 @@ import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import java.net.URI; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -10,6 +12,8 @@ import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifySmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateEmailRequest; @@ -66,6 +70,15 @@ public ResponseEntity register( return ResponseEntity.ok().build(); } + @PostMapping("/owner/login") + public ResponseEntity ownerLogin( + @RequestBody @Valid OwnerLoginRequest request + ) { + OwnerLoginResponse response = ownerService.ownerLogin(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } + @PostMapping("/owners/register/phone") public ResponseEntity registerByPhone( @Valid @RequestBody OwnerRegisterByPhoneRequest request diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginRequest.java new file mode 100644 index 000000000..3ba27201a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginRequest.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record OwnerLoginRequest( + @Schema(description = "전화번호", example = "01012345678", requiredMode = REQUIRED) + @NotBlank(message = "전화번호를 입력해주세요.") + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다. 11자리 숫자로 입력해 주세요.") + String account, + + @Schema + ( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginResponse.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginResponse.java new file mode 100644 index 000000000..bd031e926 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerLoginResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.owner.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record OwnerLoginResponse( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static OwnerLoginResponse of(String token, String refreshToken) { + return new OwnerLoginResponse(token, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterByPhoneRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterByPhoneRequest.java index 634c038d3..b31cce063 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterByPhoneRequest.java +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterByPhoneRequest.java @@ -59,7 +59,7 @@ public Owner toOwner(PasswordEncoder passwordEncoder) { User user = User.builder() .password(passwordEncoder.encode(password)) .name(name) - .email(phoneNumber) + .email(null) .phoneNumber(phoneNumber) .userType(OWNER) .isAuthed(false) @@ -71,6 +71,7 @@ public Owner toOwner(PasswordEncoder passwordEncoder) { .attachments(new ArrayList<>()) .grantShop(false) .grantEvent(false) + .account(phoneNumber) .build(); List attachments = attachmentUrls.stream() .map(OwnerRegisterByPhoneInnerAttachmentUrl::fileUrl) diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java index 1555a62ff..2aa1f2830 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java @@ -44,7 +44,7 @@ public record OwnerRegisterRequest( @Schema(description = "비밀번호", example = "password", requiredMode = REQUIRED) String password, - @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}", message = "전화번호 형식이 올바르지 않습니다.") + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다.") @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = REQUIRED) String phoneNumber, diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java index d0d2180c9..ea67d8e45 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerResponse.java @@ -23,6 +23,9 @@ public record OwnerResponse( @Schema(description = "사업자 등록 번호", example = "123-45-67890", requiredMode = REQUIRED) String company_number, + @Schema(description = "저장 된 사장님 전화번호", example = "01012345678", requiredMode = REQUIRED) + String account, + @Schema(description = "첨부 파일 목록", requiredMode = NOT_REQUIRED) List attachments, @@ -35,6 +38,7 @@ public static OwnerResponse of(Owner owner, List attachments, L owner.getUser().getEmail(), owner.getUser().getName(), owner.getCompanyRegistrationNumber(), + owner.getAccount(), attachments.stream() .map(InnerAttachmentResponse::from) .toList(), diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java b/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java index d506da0c5..4ed0a9928 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java +++ b/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java @@ -50,6 +50,10 @@ public class Owner { @Column(name = "grant_event", columnDefinition = "TINYINT") private boolean grantEvent; + @Size(max = 255) + @Column(name = "account") + private String account; + @OneToMany(cascade = {PERSIST, MERGE, REMOVE}, orphanRemoval = true) @JoinColumn(name = "owner_id", updatable = false) private List attachments = new ArrayList<>(); @@ -60,12 +64,14 @@ private Owner( String companyRegistrationNumber, List attachments, Boolean grantShop, - Boolean grantEvent + Boolean grantEvent, + String account ) { this.user = user; this.companyRegistrationNumber = companyRegistrationNumber; this.attachments = attachments; this.grantShop = grantShop; this.grantEvent = grantEvent; + this.account = account; } } diff --git a/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java index fba5c750b..50f346134 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java +++ b/src/main/java/in/koreatech/koin/domain/owner/repository/OwnerRepository.java @@ -15,6 +15,12 @@ default Owner getById(Integer ownerId) { return findById(ownerId).orElseThrow(() -> OwnerNotFoundException.withDetail("ownerId: " + ownerId)); } + Optional findByAccount(String account); + + default Owner getByAccount(String account) { + return findByAccount(account).orElseThrow(() -> OwnerNotFoundException.withDetail("ownerAccount : " + account)); + } + Owner save(Owner owner); Optional findByCompanyRegistrationNumber(String companyRegistrationNumber); diff --git a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java index 5e433dd2e..6612d3432 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java +++ b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java @@ -2,9 +2,11 @@ import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import java.time.LocalDateTime; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; @@ -12,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordResetVerifySmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateEmailRequest; @@ -41,7 +45,10 @@ import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.shop.repository.ShopRepository; import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.domain.user.model.UserType; import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.repository.UserTokenRepository; import in.koreatech.koin.global.auth.JwtProvider; import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; import in.koreatech.koin.global.domain.email.form.OwnerRegistrationData; @@ -67,6 +74,7 @@ public class OwnerService { private final OwnerVerificationStatusRepository ownerVerificationStatusRepository; private final DailyVerificationLimitRepository dailyVerificationLimitRedisRepository; private final NaverSmsService naverSmsService; + private final UserTokenRepository userTokenRepository; public OwnerResponse getOwner(Integer ownerId) { Owner foundOwner = ownerRepository.getById(ownerId); @@ -74,6 +82,27 @@ public OwnerResponse getOwner(Integer ownerId) { return OwnerResponse.of(foundOwner, foundOwner.getAttachments(), shops); } + @Transactional + public OwnerLoginResponse ownerLogin(OwnerLoginRequest request) { + Owner owner = ownerRepository.getByAccount(request.account()); + User user = owner.getUser(); + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + if (!user.isAuthed()) { + throw new KoinIllegalArgumentException("미인증 상태입니다. 인증을 진행해주세요."); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedToken = userTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + + return OwnerLoginResponse.of(accessToken, savedToken.getRefreshToken()); + } + @Transactional public void requestSignUpEmailVerification(VerifyEmailRequest request) { userRepository.findByEmail(request.address()).ifPresent(user -> { diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java index 81784aca4..1b49ab266 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java @@ -17,6 +17,10 @@ import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; import in.koreatech.koin.domain.user.dto.FindPasswordRequest; import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; +import in.koreatech.koin.domain.user.dto.StudentLoginRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginResponse; import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; import in.koreatech.koin.domain.user.dto.StudentResponse; import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; @@ -101,6 +105,20 @@ ResponseEntity login( @RequestBody @Valid UserLoginRequest request ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "학생 로그인") + @PostMapping("/student/login") + ResponseEntity studentLogin( + @RequestBody @Valid StudentLoginRequest request + ); + @ApiResponses( value = { @ApiResponse(responseCode = "201"), diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java index 6c12e3e2a..265cf53c0 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java @@ -24,6 +24,10 @@ import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; import in.koreatech.koin.domain.user.dto.FindPasswordRequest; import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; +import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; +import in.koreatech.koin.domain.user.dto.StudentLoginRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginResponse; import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; import in.koreatech.koin.domain.user.dto.StudentResponse; import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; @@ -83,6 +87,15 @@ public ResponseEntity login( .body(response); } + @PostMapping("/student/login") + public ResponseEntity studentLogin( + @RequestBody @Valid StudentLoginRequest request + ) { + StudentLoginResponse response = studentService.studentLogin(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } + @PostMapping("/user/logout") public ResponseEntity logout( @Auth(permit = {STUDENT, OWNER, COOP}) Integer userId diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java new file mode 100644 index 000000000..0d08a3021 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record StudentLoginRequest( + @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @Schema( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginResponse.java new file mode 100644 index 000000000..232c63753 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record StudentLoginResponse( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static StudentLoginResponse of(String token, String refreshToken) { + return new StudentLoginResponse(token, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java index 3c11dae6a..8c3693ec7 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java @@ -74,7 +74,7 @@ public record StudentRegisterRequest( String studentNumber, @Schema(description = "휴대폰 번호", example = "010-1234-5678 또는 01012345678", requiredMode = NOT_REQUIRED) - @Pattern(regexp = "^(\\d{3}-\\d{3,4}-\\d{4}|\\d{10}|\\d{11})$", message = "전화번호 형식이 올바르지 않습니다.") + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다.") String phoneNumber ) { diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java index b2ce2c7e3..b02904cae 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java @@ -47,7 +47,7 @@ public record StudentUpdateRequest @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) String nickname, - @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + @Schema(description = "휴대폰 번호", example = "01000000000", requiredMode = NOT_REQUIRED) String phoneNumber, @Size(min = 10, max = 10, message = "학번은 10자여야 합니다.") diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java index 12d995f7a..3191d815e 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateResponse.java @@ -42,7 +42,7 @@ public record StudentUpdateResponse( @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) String nickname, - @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + @Schema(description = "휴대폰 번호", example = "01000000000", requiredMode = NOT_REQUIRED) String phoneNumber, @Schema(description = "학번", example = "2029136012", requiredMode = NOT_REQUIRED) @@ -51,11 +51,10 @@ public record StudentUpdateResponse( public static StudentUpdateResponse from(Student student) { User user = student.getUser(); - Integer userGender = user.getGender() != null ? user.getGender().ordinal() : null; return new StudentUpdateResponse( student.getAnonymousNickname(), user.getEmail(), - userGender, + user.getGender() != null ? user.getGender().ordinal() : null, student.getDepartment(), user.getName(), user.getNickname(), diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java index f72752527..ef9c92951 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginResponse.java @@ -24,6 +24,7 @@ public record UserLoginResponse( 로그인한 회원의 신원 - `STUDENT`: 학생 - `OWNER`: 사장님 + - `COOP` : 영양사 """, example = "STUDENT", requiredMode = REQUIRED ) @JsonProperty("user_type") diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index c86c0f513..3af797f91 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -59,8 +59,7 @@ public class User extends BaseEntity { private UserType userType; @Size(max = 100) - @NotNull - @Column(name = "email", nullable = false, length = 100) + @Column(name = "email", length = 100) private String email; @Column(name = "gender", columnDefinition = "INT") diff --git a/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java b/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java index 6cc88bd7b..14997ed60 100644 --- a/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java +++ b/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java @@ -15,6 +15,8 @@ public interface UserRepository extends Repository { Optional findByEmail(String email); + Optional findByEmailAndUserType(String email, UserType userType); + Optional findByPhoneNumberAndUserType(String phoneNumber, UserType userType); Optional findById(Integer id); @@ -40,6 +42,11 @@ default User getById(Integer userId) { .orElseThrow(() -> UserNotFoundException.withDetail("userId: " + userId)); } + default User getById(String id, UserType userType) { + return findByEmailAndUserType(id, userType) + .orElseThrow(() -> UserNotFoundException.withDetail("id: " + id)); + } + default User getByNickname(String nickname) { return findByNickname(nickname) .orElseThrow(() -> UserNotFoundException.withDetail("nickname: " + nickname)); diff --git a/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java b/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java index 230b550cb..900476852 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/StudentService.java @@ -2,6 +2,7 @@ import java.time.Clock; import java.util.Optional; +import java.util.UUID; import org.joda.time.LocalDateTime; import org.springframework.context.ApplicationEventPublisher; @@ -13,6 +14,8 @@ import in.koreatech.koin.domain.user.dto.AuthTokenRequest; import in.koreatech.koin.domain.user.dto.FindPasswordRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginRequest; +import in.koreatech.koin.domain.user.dto.StudentLoginResponse; import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; import in.koreatech.koin.domain.user.dto.StudentResponse; import in.koreatech.koin.domain.user.dto.StudentUpdateRequest; @@ -27,8 +30,12 @@ import in.koreatech.koin.domain.user.model.StudentEmailRequestEvent; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserGender; +import in.koreatech.koin.domain.user.model.UserToken; import in.koreatech.koin.domain.user.repository.StudentRepository; import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.repository.UserTokenRepository; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthorizationException; import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; import in.koreatech.koin.global.domain.email.form.StudentPasswordChangeData; import in.koreatech.koin.global.domain.email.form.StudentRegistrationData; @@ -48,12 +55,34 @@ public class StudentService { private final MailService mailService; private final ApplicationEventPublisher eventPublisher; private final Clock clock; + private final UserTokenRepository userTokenRepository; + private final JwtProvider jwtProvider; public StudentResponse getStudent(Integer userId) { Student student = studentRepository.getById(userId); return StudentResponse.from(student); } + @Transactional + public StudentLoginResponse studentLogin(StudentLoginRequest request) { + User user = userRepository.getByEmail(request.email()); + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + if (!user.isAuthed()) { + throw new AuthorizationException("미인증 상태입니다. 아우누리에서 인증메일을 확인해주세요"); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedToken = userTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(java.time.LocalDateTime.now()); + + return StudentLoginResponse.of(accessToken, savedToken.getRefreshToken()); + } + @Transactional public StudentUpdateResponse updateStudent(Integer userId, StudentUpdateRequest request) { Student student = studentRepository.getById(userId); diff --git a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java index a7f46063a..7558886bc 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java @@ -9,9 +9,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.domain.owner.repository.OwnerAttachmentRepository; import in.koreatech.koin.domain.owner.repository.OwnerRepository; -import in.koreatech.koin.domain.shop.repository.ShopRepository; import in.koreatech.koin.domain.user.dto.AuthResponse; import in.koreatech.koin.domain.user.dto.CoopResponse; import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; @@ -45,8 +43,6 @@ public class UserService { private final UserRepository userRepository; private final StudentRepository studentRepository; private final OwnerRepository ownerRepository; - private final ShopRepository shopRepository; - private final OwnerAttachmentRepository ownerAttachmentRepository; private final PasswordEncoder passwordEncoder; private final UserTokenRepository userTokenRepository; private final ApplicationEventPublisher eventPublisher; diff --git a/src/main/resources/db/migration/V16__add_account_column_to_owners_table.sql b/src/main/resources/db/migration/V16__add_account_column_to_owners_table.sql new file mode 100644 index 000000000..1aa295181 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_account_column_to_owners_table.sql @@ -0,0 +1,7 @@ +ALTER TABLE `koin`.`owners` + ADD COLUMN `account` VARCHAR(255) NULL COMMENT '사장님 전화번호(“-“ 제거, unique)', + ADD UNIQUE INDEX `account_UNIQUE` (`account`); + +UPDATE `koin`.`owners` o + JOIN `koin`.`users` u ON o.user_id = u.id + SET o.account = REPLACE(u.phone_number, '-', '') diff --git a/src/main/resources/db/migration/V17__create_coop_table_and_insert_data.sql b/src/main/resources/db/migration/V17__create_coop_table_and_insert_data.sql new file mode 100644 index 000000000..1641445c1 --- /dev/null +++ b/src/main/resources/db/migration/V17__create_coop_table_and_insert_data.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `coop` +( + `user_id` INT UNSIGNED NOT NULL COMMENT '유저 id, user_type COOP으로 가져옴', + `coop_id` VARCHAR(255) COMMENT '영양사 id, 일반 로그인 형식', + PRIMARY KEY (`user_id`), + CONSTRAINT `FK_COOP_ON_USER` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +); + +INSERT INTO `coop` (`user_id`, `coop_id`) + SELECT `id`, '' FROM `users` WHERE `user_type` = 'COOP'; diff --git a/src/main/resources/db/migration/V18__alter_user_email_column_nullable.sql b/src/main/resources/db/migration/V18__alter_user_email_column_nullable.sql new file mode 100644 index 000000000..822306bdd --- /dev/null +++ b/src/main/resources/db/migration/V18__alter_user_email_column_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE `koin`.`users` + CHANGE COLUMN `email` `email` VARCHAR(100) CHARACTER SET 'utf8mb3' NULL COMMENT '학교 email' ; diff --git a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java index a4d99a3b6..a0d3c7935 100644 --- a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java @@ -49,7 +49,7 @@ class DiningApiTest extends AcceptanceTest { @BeforeEach void setUp() { - coop_준기 = userFixture.준기_영양사(); + coop_준기 = userFixture.준기_영양사().getUser(); token_준기 = userFixture.getToken(coop_준기); owner_현수 = userFixture.현수_사장님().getUser(); token_현수 = userFixture.getToken(owner_현수); diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java index a54f4ce61..8b75d36ea 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java @@ -57,6 +57,29 @@ class OwnerApiTest extends AcceptanceTest { @Autowired private PasswordEncoder passwordEncoder; + @Test + @DisplayName("사장님이 로그인을 진행한다") + void ownerLogin() { + Owner owner = userFixture.원경_사장님(); + String phoneNumber = owner.getAccount(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "account" : "%s", + "password" : "%s" + } + """.formatted(phoneNumber, password)) + .when() + .post("/owner/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + @Test @DisplayName("로그인된 사장님 정보를 조회한다.") void getOwner() { @@ -81,6 +104,7 @@ void getOwner() { "email": "hysoo@naver.com", "name": "테스트용_현수", "company_number": "123-45-67190", + "account" : "01098765432", "attachments": [ { "id": 1, @@ -182,7 +206,7 @@ void register() { "email": "helloworld@koreatech.ac.kr", "name": "최준호", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "010-0000-0000", + "phone_number": "01000000000", "shop_id": null, "shop_name": "기분좋은 뷔짱" } @@ -250,9 +274,10 @@ void registerByPhoneNumber() { softly -> { softly.assertThat(owner).isNotNull(); softly.assertThat(owner.getUser().getName()).isEqualTo("최준호"); - softly.assertThat(owner.getUser().getEmail()).isEqualTo("01012341234"); + softly.assertThat(owner.getUser().getEmail()).isEqualTo(null); softly.assertThat(owner.getUser().getPhoneNumber()).isEqualTo("01012341234"); softly.assertThat(owner.getCompanyRegistrationNumber()).isEqualTo("012-34-56789"); + softly.assertThat(owner.getAccount()).isEqualTo("01012341234"); softly.assertThat(owner.getAttachments().size()).isEqualTo(1); softly.assertThat(owner.getAttachments().get(0).getUrl()) .isEqualTo("https://static.koreatech.in/testimage.png"); @@ -311,7 +336,7 @@ void registerNotAllowedCompanyNumber() { "email": "helloworld@koreatech.ac.kr", "name": "최준호", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "010-0000-0000", + "phone_number": "01000000000", "shop_id": null, "shop_name": "기분좋은 뷔짱" } @@ -340,7 +365,7 @@ void registerWithoutName() { "email": "helloworld@koreatech.ac.kr", "name": "", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "010-0000-0000", + "phone_number": "01000000000", "shop_id": null, "shop_name": "기분좋은 뷔짱" } @@ -370,7 +395,7 @@ void registerWithExistShop() { "email": "helloworld@koreatech.ac.kr", "name": "주노", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "010-0000-0000", + "phone_number": "01000000000", "shop_id": %d, "shop_name": "기분좋은 뷔짱" } @@ -408,7 +433,7 @@ void registerWithNotExistShop() { "email": "helloworld@koreatech.ac.kr", "name": "주노", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "010-0000-0000", + "phone_number": "01000000000", "shop_id": null, "shop_name": "기분좋은 뷔짱" } diff --git a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java index f76ecda03..2a5a5354c 100644 --- a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java @@ -24,7 +24,9 @@ import org.springframework.transaction.support.TransactionTemplate; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.coop.model.Coop; import in.koreatech.koin.domain.dept.model.Dept; +import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserGender; @@ -55,11 +57,80 @@ class UserApiTest extends AcceptanceTest { @Autowired private UserFixture userFixture; + @Test + @DisplayName("학생이 로그인을 진행한다(구 API(/user/login))") + void login() { + Student student = userFixture.성빈_학생(); + String email = student.getUser().getEmail(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + @Test + @DisplayName("학생이 로그인을 진행한다(신규 API(/student/login))") + void studentLogin() { + Student student = userFixture.성빈_학생(); + String email = student.getUser().getEmail(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/student/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + @Test + @DisplayName("영양사가 로그인을 진행한다") + void coopLogin() { + Coop coop = userFixture.준기_영양사(); + String id = coop.getCoopId(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "id" : "%s", + "password" : "%s" + } + """.formatted(id, password)) + .when() + .post("/coop/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + @Test @DisplayName("올바른 영양사 계정인지 확인한다") void coopCheckMe() { - User user = userFixture.준기_영양사(); - String token = userFixture.getToken(user); + Coop coop = userFixture.준기_영양사(); + String token = userFixture.getToken(coop.getUser()); var response = RestAssured .given() @@ -95,7 +166,7 @@ void studentCheckMe() { "major": "컴퓨터공학부", "name": "테스트용_준호", "nickname": "준호", - "phone_number": "010-1234-5678", + "phone_number": "01012345678", "student_number": "2019136135" } """); @@ -153,7 +224,7 @@ void studentUpdateMe() { "name" : "서정빈", "password" : "0c4be6acaba1839d3433c1ccf04e1eec4d1fa841ee37cb019addc269e8bc1b77", "nickname" : "duehee", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -185,7 +256,7 @@ void studentUpdateMe() { "major": "기계공학부", "name": "서정빈", "nickname": "duehee", - "phone_number": "010-2345-6789", + "phone_number": "01023456789", "student_number": "2019136136" } """); @@ -207,7 +278,7 @@ void studentUpdateMeNotValidStudentNumber() { "major" : "메카트로닉스공학부", "name" : "최주노", "nickname" : "juno", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "201913613" } """) @@ -234,7 +305,7 @@ void studentUpdateMeNotValidDepartment() { "major" : "경영학과", "name" : "최주노", "nickname" : "juno", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -261,7 +332,7 @@ void studentUpdateMeUnAuthorized() { "major" : "메카트로닉스공학부", "name" : "최주노", "nickname" : "juno", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -291,7 +362,7 @@ void studentUpdateMeNotFound() { "major" : "메카트로닉스공학부", "name" : "최주노", "nickname" : "juno", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -319,7 +390,7 @@ void studentUpdateMeDuplicationNickname() { "major" : "테스트학과", "name" : "최주노", "nickname" : "%s", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """, 성빈.getUser().getNickname())) @@ -539,7 +610,7 @@ void studentRegister() { "gender": "0", "is_graduated": false, "student_number": "2021136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -559,7 +630,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) { softly.assertThat(student).isNotNull(); softly.assertThat(student.getUser().getNickname()).isEqualTo("koko"); softly.assertThat(student.getUser().getName()).isEqualTo("김철수"); - softly.assertThat(student.getUser().getPhoneNumber()).isEqualTo("010-0000-0000"); + softly.assertThat(student.getUser().getPhoneNumber()).isEqualTo("01000000000"); softly.assertThat(student.getUser().getUserType()).isEqualTo(STUDENT); softly.assertThat(student.getUser().getEmail()).isEqualTo("koko123@koreatech.ac.kr"); softly.assertThat(student.getUser().isAuthed()).isEqualTo(false); @@ -588,7 +659,7 @@ void authenticate() { "gender": "0", "is_graduated": false, "student_number": "2021136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -626,7 +697,7 @@ void studentRegisterBadRequest() { "gender": "0", "is_graduated": false, "student_number": "2021136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -651,7 +722,7 @@ void studentRegisterInvalid() { "gender": "0", "is_graduated": false, "student_number": "2021136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -676,7 +747,7 @@ void studentRegisterStudentNumberInvalid() { "gender": "0", "is_graduated": false, "student_number": "20211360123324231", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -697,7 +768,7 @@ void studentRegisterStudentNumberInvalid() { "gender": "0", "is_graduated": false, "student_number": "19911360123", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) @@ -728,7 +799,7 @@ void concurrencyStudentRegister(CapturedOutput capturedOutput) throws Interrupte "gender": "0", "is_graduated": false, "student_number": "2022136012", - "phone_number": "010-0000-0000" + "phone_number": "01000000000" } """) .contentType(ContentType.JSON) diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java index 13dce5919..089b8259c 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java @@ -102,7 +102,7 @@ void studentGetAdmin() { "major": "컴퓨터공학부", "name": "테스트용_준호", "nickname": "준호", - "phone_number": "010-1234-5678", + "phone_number": "01012345678", "student_number": "2019136135", "updated_at": "2024-01-15 12:00:00", "user_type": "STUDENT" @@ -129,7 +129,7 @@ void studentUpdateAdmin() { "name" : "서정빈", "password" : "0c4be6acaba1839d3433c1ccf04e1eec4d1fa841ee37cb019addc269e8bc1b77", "nickname" : "duehee", - "phone_number" : "010-2345-6789", + "phone_number" : "01023456789", "student_number" : "2019136136" } """) @@ -161,7 +161,7 @@ void studentUpdateAdmin() { "major": "기계공학부", "name": "서정빈", "nickname": "duehee", - "phone_number": "010-2345-6789", + "phone_number": "01023456789", "student_number": "2019136136" } """); @@ -201,7 +201,7 @@ void getOwnerAdmin() { "shops_id": [ %d ], - "phone_number": "010-9876-5432", + "phone_number": "01098765432", "is_authed": true, "user_type": "OWNER", "gender": 0, @@ -249,7 +249,7 @@ void getNewOwnersAdmin() { "id": 1, "email": "testchulsu@gmail.com", "name": "테스트용_철수(인증X)", - "phone_number": "010-9776-5112", + "phone_number": "01097765112", "shop_id": 1, "shop_name": "마슬랜 치킨", "created_at" : "2024-01-15 12:00:00" @@ -258,7 +258,7 @@ void getNewOwnersAdmin() { "id": 1, "email": "testchulsu@gmail.com", "name": "테스트용_철수(인증X)", - "phone_number": "010-9776-5112", + "phone_number": "01097765112", "shop_id": 2, "shop_name": "신전 떡볶이", "created_at" : "2024-01-15 12:00:00" @@ -278,7 +278,7 @@ void getNewOwnersAdminV2() { .password(passwordEncoder.encode("1234")) .nickname("사장님" + i) .name("테스트용(인증X)" + i) - .phoneNumber("010-9776-511" + i) + .phoneNumber("0109776511" + i) .userType(OWNER) .gender(MAN) .email("testchulsu@gmail.com" + i) diff --git a/src/test/java/in/koreatech/koin/fixture/UserFixture.java b/src/test/java/in/koreatech/koin/fixture/UserFixture.java index 9d7126e79..26f80d1d0 100644 --- a/src/test/java/in/koreatech/koin/fixture/UserFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/UserFixture.java @@ -12,6 +12,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import in.koreatech.koin.domain.coop.model.Coop; +import in.koreatech.koin.domain.coop.repository.CoopRepository; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerAttachment; import in.koreatech.koin.domain.owner.repository.OwnerRepository; @@ -31,20 +33,24 @@ public final class UserFixture { private final UserRepository userRepository; private final OwnerRepository ownerRepository; private final StudentRepository studentRepository; + private final CoopRepository coopRepository; private final JwtProvider jwtProvider; + @Autowired public UserFixture( PasswordEncoder passwordEncoder, UserRepository userRepository, OwnerRepository ownerRepository, StudentRepository studentRepository, + CoopRepository coopRepository, JwtProvider jwtProvider ) { this.passwordEncoder = passwordEncoder; this.userRepository = userRepository; this.ownerRepository = ownerRepository; this.studentRepository = studentRepository; + this.coopRepository = coopRepository; this.jwtProvider = jwtProvider; } @@ -54,7 +60,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("코인운영자") .name("테스트용_코인운영자") - .phoneNumber("010-1234-2344") + .phoneNumber("01012342344") .userType(ADMIN) .gender(MAN) .email("juno@koreatech.ac.kr") @@ -77,7 +83,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("준호") .name("테스트용_준호") - .phoneNumber("010-1234-5678") + .phoneNumber("01012345678") .userType(STUDENT) .gender(MAN) .email("juno@koreatech.ac.kr") @@ -102,7 +108,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("성빈") .name("테스트용_성빈") - .phoneNumber("010-9941-1123") + .phoneNumber("01099411123") .userType(STUDENT) .gender(MAN) .email("testsungbeen@koreatech.ac.kr") @@ -119,7 +125,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("현수") .name("테스트용_현수") - .phoneNumber("010-9876-5432") + .phoneNumber("01098765432") .userType(OWNER) .gender(MAN) .email("hysoo@naver.com") @@ -132,6 +138,7 @@ public UserFixture( .companyRegistrationNumber("123-45-67190") .grantShop(true) .grantEvent(true) + .account("01098765432") .attachments(new ArrayList<>()) .build(); @@ -158,7 +165,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("준영") .name("테스트용_준영") - .phoneNumber("010-9776-5112") + .phoneNumber("01097765112") .userType(OWNER) .gender(MAN) .email("testjoonyoung@gmail.com") @@ -171,6 +178,7 @@ public UserFixture( .companyRegistrationNumber("112-80-56789") .grantShop(true) .grantEvent(true) + .account("01097765112") .attachments(new ArrayList<>()) .build(); @@ -198,7 +206,7 @@ public UserFixture( .password(passwordEncoder.encode("1234")) .nickname("철수") .name("테스트용_철수(인증X)") - .phoneNumber("010-9776-5112") + .phoneNumber("01097765112") .userType(OWNER) .gender(MAN) .email("testchulsu@gmail.com") @@ -211,6 +219,7 @@ public UserFixture( .companyRegistrationNumber("118-80-56789") .grantShop(true) .grantEvent(true) + .account("01097765112") .attachments(new ArrayList<>()) .build(); @@ -232,20 +241,65 @@ public UserFixture( return ownerRepository.save(owner); } - public User 준기_영양사() { - return userRepository.save( - User.builder() - .password(passwordEncoder.encode("1234")) - .nickname("준기") - .name("허준기") - .phoneNumber("010-1122-5678") - .userType(COOP) - .gender(MAN) - .email("coop@koreatech.ac.kr") - .isAuthed(true) - .isDeleted(false) - .build() - ); + public Owner 원경_사장님() { + User user = User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("원경") + .name("테스트용_원경(전화번호 - 없음") + .phoneNumber("01024607469") + .userType(OWNER) + .gender(MAN) + .email("wongyeong@naver.com") + .isAuthed(true) + .isDeleted(false) + .build(); + + Owner owner = Owner.builder() + .user(user) + .companyRegistrationNumber("123-45-67890") + .grantShop(true) + .grantEvent(true) + .account("01024607469") + .attachments(new ArrayList<>()) + .build(); + + OwnerAttachment attachment1 = OwnerAttachment.builder() + .url("https://test.com/원경_사장님_인증사진_1.jpg") + .isDeleted(false) + .owner(owner) + .build(); + + OwnerAttachment attachment2 = OwnerAttachment.builder() + .url("https://test.com/원경_사장님_인증사진_2.jpg") + .isDeleted(false) + .owner(owner) + .build(); + + owner.getAttachments().add(attachment1); + owner.getAttachments().add(attachment2); + + return ownerRepository.save(owner); + } + + public Coop 준기_영양사() { + User user = User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("준기") + .name("허준기") + .phoneNumber("01011225678") + .userType(COOP) + .gender(MAN) + .email("coop@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build(); + + Coop coop = Coop.builder() + .user(user) + .coopId("coop") + .build(); + + return coopRepository.save(coop); } public String getToken(User user) { From 3c5118fd8c2e35a606023bc185a700e0c567d4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=9E=AC?= <103095432+seongjae6751@users.noreply.github.com> Date: Tue, 18 Jun 2024 00:30:00 +0900 Subject: [PATCH 07/37] =?UTF-8?q?hotfix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B2=80=EC=A6=9D=20api=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=ED=8B=80=EB=A0=B8=EC=9D=84=EC=8B=9C?= =?UTF-8?q?=EC=97=90=20400=20=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95(develop)=20(#6?= =?UTF-8?q?07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : notification FK 회원 삭제 오류 수정 (#514) * fix : notification/notification_subscribe DELETE CASCADE로 변경 * chore : Front 요청으로 인한 회원가입 에러코드 409 추가 * chore : DB 생략 * fix: 에러 반환값 수정 (#517) * hotfix: 학생 회원 가입 시에 전화번호 형식 추가 허용 (#530) * chore: 회원가입 전화번호 형식 추가 허용 * chore: 전화번호 가운데 세자리도 되게 허용 * chore: 비밀번호 틀렸을시에 400 반환하는 것으로 수정 * chore: swagger 400 추가 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 최준호 Co-authored-by: 송선권 --- .../in/koreatech/koin/domain/user/controller/UserApi.java | 1 + .../in/koreatech/koin/domain/user/service/UserService.java | 2 +- src/test/java/in/koreatech/koin/acceptance/UserApiTest.java | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java index 1b49ab266..fbc9bea3b 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java @@ -244,6 +244,7 @@ ResponseEntity findPassword( @ApiResponses( value = { @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), } diff --git a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java index 7558886bc..98e839f9c 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java @@ -108,7 +108,7 @@ public void withdraw(Integer userId) { public void checkPassword(UserPasswordCheckRequest request, Integer userId) { User user = userRepository.getById(userId); if (!user.isSamePassword(passwordEncoder, request.password())) { - throw new AuthenticationException("올바르지 않은 비밀번호입니다."); + throw new KoinIllegalArgumentException("올바르지 않은 비밀번호입니다."); } } diff --git a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java index 2a5a5354c..08cb07d2b 100644 --- a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java @@ -836,7 +836,7 @@ void userCheckPassword() { } @Test - @DisplayName("사용자가 비밀번호를 통해 자신이 맞는지 인증한다. - 비밀번호가 다르면 401 반환") + @DisplayName("사용자가 비밀번호를 통해 자신이 맞는지 인증한다. - 비밀번호가 다르면 400 반환") void userCheckPasswordInvalid() { Student student = userFixture.준호_학생(); String token = userFixture.getToken(student.getUser()); @@ -853,6 +853,6 @@ void userCheckPasswordInvalid() { .when() .post("/user/check/password") .then() - .statusCode(HttpStatus.UNAUTHORIZED.value()); + .statusCode(HttpStatus.BAD_REQUEST.value()); } } From f3e25aade10fef1c9198fd0ef3eae6a4465ad7a1 Mon Sep 17 00:00:00 2001 From: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Date: Tue, 18 Jun 2024 17:12:21 +0900 Subject: [PATCH 08/37] =?UTF-8?q?feat:=20POST=20/admin/members/{id}/undele?= =?UTF-8?q?te=20API=20=EC=B6=94=EA=B0=80=20(#600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/AdminMemberApi.java | 16 ++++++++++ .../controller/AdminMemberController.java | 13 ++++++-- .../member/service/AdminMemberService.java | 6 ++++ .../koin/domain/member/model/Member.java | 4 +++ .../admin/acceptance/AdminMemberApiTest.java | 31 +++++++++++++++++++ .../koreatech/koin/fixture/MemberFixture.java | 14 +++++++++ 6 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java index d7f792414..b1f80f723 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberApi.java @@ -107,4 +107,20 @@ ResponseEntity updateMember( @RequestBody @Valid AdminMemberRequest request, @Auth(permit = {ADMIN}) Integer adminId ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "BCSDLab 회원 삭제 취소") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/members/{id}/undelete") + ResponseEntity undeleteMember( + @PathVariable("id") Integer memberId, + @Auth(permit = {ADMIN}) Integer adminId + ); } diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java index 78431a2bc..4353fa0db 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminMemberController.java @@ -62,7 +62,7 @@ public ResponseEntity deleteMember( @Auth(permit = {ADMIN}) Integer adminId ) { adminMemberService.deleteMember(memberId); - return ResponseEntity.status(HttpStatus.OK).build(); + return ResponseEntity.ok().build(); } @PutMapping("/admin/members/{id}") @@ -72,6 +72,15 @@ public ResponseEntity updateMember( @Auth(permit = {ADMIN}) Integer adminId ) { adminMemberService.updateMember(memberId, request); - return ResponseEntity.status(HttpStatus.OK).build(); + return ResponseEntity.ok().build(); + } + + @PostMapping("/admin/members/{id}/undelete") + public ResponseEntity undeleteMember( + @PathVariable("id") Integer memberId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminMemberService.undeleteMember(memberId); + return ResponseEntity.ok().build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java b/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java index 7b48aad13..d937fe1fd 100644 --- a/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java +++ b/src/main/java/in/koreatech/koin/admin/member/service/AdminMemberService.java @@ -67,4 +67,10 @@ public void updateMember(Integer memberId, AdminMemberRequest request) { member.update(request.name(), request.studentNumber(), request.position(), request.email(), request.imageUrl()); } + + @Transactional + public void undeleteMember(Integer memberId) { + Member member = adminMemberRepository.getById(memberId); + member.undelete(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/member/model/Member.java b/src/main/java/in/koreatech/koin/domain/member/model/Member.java index cedbfcade..87276b423 100644 --- a/src/main/java/in/koreatech/koin/domain/member/model/Member.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/Member.java @@ -85,6 +85,10 @@ public void delete() { this.isDeleted = true; } + public void undelete() { + this.isDeleted = false; + } + public void update(String name, String studentNumber, String position, String email, String imageUrl) { this.name = name; this.studentNumber = studentNumber; diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java index c52ed4a82..981b28a34 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminMemberApiTest.java @@ -276,4 +276,35 @@ void updateMemberWithTrack() { softly.assertThat(updatedMember.isDeleted()).isEqualTo(false); }); } + + @Test + @DisplayName("BCSDLab 회원 정보를 삭제를 취소한다") + void undeleteMember() { + Member member = memberFixture.최준호_삭제(trackFixture.backend()); + Integer memberId = member.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .post("/admin/members/{id}/undelete", memberId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Member savedMember = adminMemberRepository.getById(memberId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(savedMember.getName()).isEqualTo("최준호"); + softly.assertThat(savedMember.getStudentNumber()).isEqualTo("2019136135"); + softly.assertThat(savedMember.getTrack().getName()).isEqualTo("BackEnd"); + softly.assertThat(savedMember.getPosition()).isEqualTo("Regular"); + softly.assertThat(savedMember.getEmail()).isEqualTo("testjuno@gmail.com"); + softly.assertThat(savedMember.getImageUrl()).isEqualTo("https://imagetest.com/juno.jpg"); + softly.assertThat(savedMember.isDeleted()).isEqualTo(false); + }); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/MemberFixture.java b/src/test/java/in/koreatech/koin/fixture/MemberFixture.java index eff9e3ea6..31f340fe0 100644 --- a/src/test/java/in/koreatech/koin/fixture/MemberFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/MemberFixture.java @@ -63,4 +63,18 @@ public MemberFixture( .build() ); } + + public Member 최준호_삭제(Track track) { + return memberRepository.save( + Member.builder() + .isDeleted(true) + .studentNumber("2019136135") + .imageUrl("https://imagetest.com/juno.jpg") + .name("최준호") + .position("Regular") + .track(track) + .email("testjuno@gmail.com") + .build() + ); + } } From 437886384debb1597bbf7b6e3096fc0b7d0e01d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=A7=84=ED=98=B8?= <72592302+BaeJinho4028@users.noreply.github.com> Date: Tue, 18 Jun 2024 20:30:09 +0900 Subject: [PATCH 09/37] =?UTF-8?q?feat:=20Admin=20BCSDLab=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20API=20=EA=B5=AC=ED=98=84=20(#606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 기술스택 삭제 기능 구현 * test: admin으로 기술스택 제거 테스트 추가 * feat: admin 단일 트랙 조회 기능 추가 * test: admin으로 트랙 정보 단건 조회 테스트 추가 * feat: admin으로 트랙 생성 기능 추가 * test: admin으로 트랙 정보 생성 테스트 추가 * feat: 버스 필터링 (#586) * feat: 노선 정보 캐싱 - 정류장을 지나는 노선 정보들 캐싱 * refactor: 필터링 위치 변경 - 버스 시간 조회시 필터링 * feat: 크롤링 추가 * feat: 테스트 추가 * refactor: 피드백 반영 * chore: 주석 추가 * refactor: 피드백 반영 - save -> saveAll로 변경 * feat : 학생, 사장님, 영양사 로그인 분기처리 (#563) * feat : 사장님 / 영양사 로그인 분기 처리 추가 * chore : StudentUpdateResponse의 gender 검증 변수 제거 * feat : 테스트 추가 * feat: 가입 신청한 사장님 페이지네이션 조회(어드민계정) (#539) * feat: 응답객체 생성 * feat: controller 생성 * refactor: 접근제어 변경 * feat: 요청 모델앤뷰 dto * feat: 응답 객체 * feat: repository 생성 * feat: shop_id와 shop_name이 있는 owner객체 생성 * feat: service * feat: 테스트 추가 * fix: 필요없는 코드 제거 * refactor: required 관련 수정 * refactor: 컨벤션 적용 * refactor: 로직 수정 * refactor: 로직 수정 * refactor: AdminShopRepository 제거 * refactor: controller api 수정 * refactor: 충돌 해결 * refactor: 충돌 해결 * refactor: 주석 추가 * refactor: 빌더 삭제 및 코드 개선 * refactor: 요청dto requiredMode 전부 적용 * refactor: 빌더 생성 * refactor: Enum위치 변경 및 빌더 삭제 * refactor: controller api implement 추가 * refactor: dto 메서드 스웨거에서 숨기기 * feat: POST /admin/members API 추가 (#541) * feat: POST /admin/members API 추가 * fix: @Auth 추가 및 테스트 수정 * chore: 공백 제거 * refactor: GET /shops api 성능최적화 (#549) * feat: swwagger 인증번호 발송 관련 api명세 추가 * feat: 마크업으로 변경 * feat: 레디스 캐싱 * feat: save메소드 추가 * feat: 캐시가 없으면 캐시에 데이터를 올린다 * refactor: 필요없는 설정 정리 * feat: 리뷰반영 --------- Co-authored-by: HyeonsuLee * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 (#564) * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 * feat: 스웨거 이슈로 길어져버린 dto이름ㅠ * feat: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * 버스 openApi 에러 수정 (#565) * fix: openApi scheduled에서 try catch * fix: Exception catch로 변경 * chore : 사장님 로그인 api 작성 * feat : 로그인 API(학생, 사장님, 영양사) 분리 * chore : UserFixture 원경_사장님 수정 * chore : UserFixture 중복 사항 제거 * chore : UserFixture 오류 수정 * chore : 리뷰 반영(메소드 이름 변경) * chore : 리뷰 반영(전화번호 숨김 및 이메일 형식 삭제) * chore : 리뷰 반영(메소드 위치 변경 및 Response userType 삭제) * chore : 사장님 로그인 테스트 위치 변경 * feat : flyway 추가 * feat : 영양사 테이블 추가 및 로그인 로직 수정 * feat : 사장님 테이블 컬럼 추가 및 로그인 로직 수정 * test : 테스트 코드 수정 * test : 테스트 오류 수정을 위한 기존 request 수정 * chore : 리뷰 반영(이메일 형식 확인 삭제) * chore : flyway 변경(V16 수정 및 V18 추가) * chore : User email @NotNull 제거(email null 허용) * chore : Owner 핸드폰 회원가입 email null 값 허용 * chore : Test 수정 및 User email @Column(nullable = false) 제거 * chore : 전화번호 관련 로직 "01000000000" 로 수정 및 테스트 수정 * test : Admin User 테스트 수정 --------- Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> * feat: admin으로 트랙 수정 기능 추가 * fix: legacy request의 불필요한 id 값 제거 * feat: 트랙명 중복 예외처리 추가 * test: admin 트랙 생성, 수정 중복 트랙명 예외 테스트 추가 * test: admin 트랙 수정 테스트 추가 * feat: admin으로 트랙 삭제 기능 추가 * test: admin 트랙 삭제 테스트 추가 * chore: 1차 피드백 반영 --------- Co-authored-by: 박성빈 <46699595+ImTotem@users.noreply.github.com> Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> --- .../member/controller/AdminTrackApi.java | 86 ++++++ .../controller/AdminTrackController.java | 49 +++ .../member/dto/AdminTechStackRequest.java | 8 +- .../admin/member/dto/AdminTrackRequest.java | 34 +++ .../member/dto/AdminTrackSingleResponse.java | 136 +++++++++ .../TrackNameDuplicationException.java | 20 ++ .../repository/AdminMemberRepository.java | 3 + .../repository/AdminTechStackRepository.java | 3 + .../repository/AdminTrackRepository.java | 7 + .../member/service/AdminTrackService.java | 58 +++- .../koin/domain/member/model/TechStack.java | 4 + .../koin/domain/member/model/Track.java | 15 +- .../member/repository/MemberRepository.java | 2 +- .../domain/member/service/TrackService.java | 2 +- .../koin/acceptance/TrackApiTest.java | 2 +- .../admin/acceptance/AdminTrackApiTest.java | 287 +++++++++++++++++- .../koin/fixture/TechStackFixture.java | 12 + .../koreatech/koin/fixture/TrackFixture.java | 9 + 18 files changed, 710 insertions(+), 27 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackSingleResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/member/exception/TrackNameDuplicationException.java diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackApi.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackApi.java index 8afaff638..3a348bf3c 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackApi.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackApi.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -14,7 +15,9 @@ import in.koreatech.koin.admin.member.dto.AdminTechStackRequest; import in.koreatech.koin.admin.member.dto.AdminTechStackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackRequest; import in.koreatech.koin.admin.member.dto.AdminTrackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackSingleResponse; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -43,6 +46,73 @@ ResponseEntity> getTracks( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 생성") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/tracks") + ResponseEntity createTrack( + @RequestBody @Valid AdminTrackRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 단건 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/tracks/{id}") + ResponseEntity getTrack( + @PathVariable("id") Integer trackId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/tracks/{id}") + ResponseEntity updateTrack( + @PathVariable("id") Integer trackId, + @RequestBody @Valid AdminTrackRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "204", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "트랙 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/admin/tracks/{id}") + ResponseEntity deleteTrack( + @PathVariable("id") Integer trackId, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -77,4 +147,20 @@ ResponseEntity updateTechStack( @PathVariable("id") Integer techStackId, @Auth(permit = {ADMIN}) Integer adminId ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "204", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "기술스택 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/admin/techStacks/{id}") + ResponseEntity deleteTechStack( + @PathVariable("id") Integer techStackId, + @Auth(permit = {ADMIN}) Integer adminId + ); } diff --git a/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackController.java b/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackController.java index ebd90de3d..8e5e5132b 100644 --- a/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackController.java +++ b/src/main/java/in/koreatech/koin/admin/member/controller/AdminTrackController.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -15,7 +16,9 @@ import in.koreatech.koin.admin.member.dto.AdminTechStackRequest; import in.koreatech.koin.admin.member.dto.AdminTechStackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackRequest; import in.koreatech.koin.admin.member.dto.AdminTrackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackSingleResponse; import in.koreatech.koin.admin.member.service.AdminTrackService; import in.koreatech.koin.global.auth.Auth; import jakarta.validation.Valid; @@ -35,6 +38,43 @@ public ResponseEntity> getTracks( return ResponseEntity.ok(response); } + @PostMapping("/admin/tracks") + public ResponseEntity createTrack( + @RequestBody @Valid AdminTrackRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + var response = adminTrackService.createTrack(request); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/tracks/{id}") + public ResponseEntity getTrack( + @PathVariable("id") Integer trackId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + var response = adminTrackService.getTrack(trackId); + return ResponseEntity.ok(response); + } + + @PutMapping("/admin/tracks/{id}") + public ResponseEntity updateTrack( + @PathVariable("id") Integer trackId, + @RequestBody @Valid AdminTrackRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + var response = adminTrackService.updateTrack(trackId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/admin/tracks/{id}") + public ResponseEntity deleteTrack( + @PathVariable("id") Integer trackId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminTrackService.deleteTrack(trackId); + return ResponseEntity.ok().build(); + } + @PostMapping("/admin/techStacks") public ResponseEntity createTechStack( @RequestBody @Valid AdminTechStackRequest request, @@ -55,4 +95,13 @@ public ResponseEntity updateTechStack( var response = adminTrackService.updateTechStack(request, trackName, techStackId); return ResponseEntity.ok(response); } + + @DeleteMapping("/admin/techStacks/{id}") + public ResponseEntity deleteTechStack( + @PathVariable("id") Integer techStackId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminTrackService.deleteTechStack(techStackId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/in/koreatech/koin/admin/member/dto/AdminTechStackRequest.java b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTechStackRequest.java index 954cc107c..1643ef943 100644 --- a/src/main/java/in/koreatech/koin/admin/member/dto/AdminTechStackRequest.java +++ b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTechStackRequest.java @@ -11,18 +11,14 @@ @JsonNaming(SnakeCaseStrategy.class) public record AdminTechStackRequest( - - @Schema(description = "기술스택 고유 ID", example = "1", requiredMode = REQUIRED) - Integer id, - - @Schema(description = "이미지 링크", example = "http://url.com", requiredMode = REQUIRED) + @Schema(description = "이미지 링크", example = "http://url.com") String imageUrl, @Schema(description = "기술 스택명", example = "Spring", requiredMode = REQUIRED) @NotBlank(message = "기술 스택명은 비워둘 수 없습니다.") String name, - @Schema(description = "기술 스택 설명", example = "스프링은 웹 프레임워크이다", requiredMode = REQUIRED) + @Schema(description = "기술 스택 설명", example = "스프링은 웹 프레임워크이다") String description, @Schema(description = "삭제 여부", example = "false") diff --git a/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackRequest.java b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackRequest.java new file mode 100644 index 000000000..cbe9b6e1a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackRequest.java @@ -0,0 +1,34 @@ +package in.koreatech.koin.admin.member.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.member.model.Track; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminTrackRequest( + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + @NotBlank(message = "트랙명은 비워둘 수 없습니다.") + String name, + + @Schema(description = "인원 수", example = "15") + @NotNull(message = "인원 수는 비워둘 수 없습니다.") + Integer headcount, + + @Schema(description = "삭제 여부", example = "false") + boolean isDeleted +) { + + public Track toEntity() { + return Track.builder() + .name(name) + .headcount(headcount) + .isDeleted(isDeleted) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackSingleResponse.java b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackSingleResponse.java new file mode 100644 index 000000000..13329280e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/member/dto/AdminTrackSingleResponse.java @@ -0,0 +1,136 @@ +package in.koreatech.koin.admin.member.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.member.model.Member; +import in.koreatech.koin.domain.member.model.TechStack; +import in.koreatech.koin.domain.member.model.Track; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminTrackSingleResponse( + @JsonProperty("TrackName") + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + String trackName, + + @JsonProperty("Members") + List innerMemberResponses, + + @JsonProperty("TechStacks") + List innerTechStackResponses +) { + + public static AdminTrackSingleResponse of(Track track, List members, List techStacks) { + return new AdminTrackSingleResponse( + track.getName(), + members.stream() + .map(member -> InnerMemberResponse.from(member, track.getName())) + .toList(), + techStacks.stream() + .map(InnerTechStackResponse::from) + .toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerMemberResponse( + @Schema(description = "BCSD 회원 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "최준호", requiredMode = REQUIRED) + String name, + + @Schema(description = "학번", example = "2019136135", requiredMode = NOT_REQUIRED) + String studentNumber, + + @Schema(description = "동아리 포지션 `Beginner`, `Regular`, `Mentor`", example = "Regular", requiredMode = REQUIRED) + String position, + + @Schema(description = "트랙 명", example = "Backend", requiredMode = REQUIRED) + String track, + + @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = NOT_REQUIRED) + String email, + + @Schema(description = "이미지 Url", example = "https://static.koreatech.in/example/image.png", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + LocalDateTime createdAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + LocalDateTime updatedAt + ) { + + public static InnerMemberResponse from(Member member, String trackName) { + return new InnerMemberResponse( + member.getId(), + member.getName(), + member.getStudentNumber(), + member.getPosition(), + trackName, + member.getEmail(), + member.getImageUrl(), + member.isDeleted(), + member.getCreatedAt(), + member.getUpdatedAt() + ); + } + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerTechStackResponse( + @Schema(description = "기술 스택 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "기술 이름", example = "Backend") + String name, + + @Schema(description = "기술 설명", example = "15") + String description, + + @Schema(description = "이미지 Url", example = "https://static.koreatech.in/example/image.png") + String imageUrl, + + @Schema(description = "트랙 ID", example = "1") + Integer trackId, + + @Schema(description = "삭제 여부", example = "false") + Boolean isDeleted, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt + ) { + + public static InnerTechStackResponse from(TechStack techStack) { + return new InnerTechStackResponse( + techStack.getId(), + techStack.getName(), + techStack.getDescription(), + techStack.getImageUrl(), + techStack.getTrackId(), + techStack.isDeleted(), + techStack.getCreatedAt(), + techStack.getUpdatedAt() + ); + } + } +} + diff --git a/src/main/java/in/koreatech/koin/admin/member/exception/TrackNameDuplicationException.java b/src/main/java/in/koreatech/koin/admin/member/exception/TrackNameDuplicationException.java new file mode 100644 index 000000000..a880c2f7c --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/member/exception/TrackNameDuplicationException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.member.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class TrackNameDuplicationException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "트랙명이 이미 존재합니다"; + + protected TrackNameDuplicationException(String message) { + super(message); + } + + protected TrackNameDuplicationException(String message, String detail) { + super(message, detail); + } + + public static TrackNameDuplicationException withDetail(String name) { + return new TrackNameDuplicationException(DEFAULT_MESSAGE, "name: " + name); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java b/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java index b0dc1f157..2a23b719c 100644 --- a/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java +++ b/src/main/java/in/koreatech/koin/admin/member/repository/AdminMemberRepository.java @@ -1,5 +1,6 @@ package in.koreatech.koin.admin.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -23,6 +24,8 @@ public interface AdminMemberRepository extends Repository { Member save(Member member); + List findByTrackId(Integer id); + @EntityGraph(attributePaths = {"track"}) Optional findByName(String name); diff --git a/src/main/java/in/koreatech/koin/admin/member/repository/AdminTechStackRepository.java b/src/main/java/in/koreatech/koin/admin/member/repository/AdminTechStackRepository.java index be9dae634..9ae814317 100644 --- a/src/main/java/in/koreatech/koin/admin/member/repository/AdminTechStackRepository.java +++ b/src/main/java/in/koreatech/koin/admin/member/repository/AdminTechStackRepository.java @@ -1,5 +1,6 @@ package in.koreatech.koin.admin.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.repository.Repository; @@ -13,6 +14,8 @@ public interface AdminTechStackRepository extends Repository Optional findById(Integer id); + List findAllByTrackId(Integer id); + default TechStack getById(Integer id) { return findById(id) .orElseThrow(() -> TechStackNotFoundException.withDetail("id : " + id)); diff --git a/src/main/java/in/koreatech/koin/admin/member/repository/AdminTrackRepository.java b/src/main/java/in/koreatech/koin/admin/member/repository/AdminTrackRepository.java index cb993dda6..4477a2bbb 100644 --- a/src/main/java/in/koreatech/koin/admin/member/repository/AdminTrackRepository.java +++ b/src/main/java/in/koreatech/koin/admin/member/repository/AdminTrackRepository.java @@ -14,8 +14,15 @@ public interface AdminTrackRepository extends Repository { List findAll(); + Optional findById(Integer trackId); + Optional findByName(String trackName); + default Track getById(Integer trackId) { + return findById(trackId) + .orElseThrow(() -> TrackNotFoundException.withDetail("trackId: " + trackId)); + } + default Track getByName(String trackName) { return findByName(trackName) .orElseThrow(() -> TrackNotFoundException.withDetail("name: " + trackName)); diff --git a/src/main/java/in/koreatech/koin/admin/member/service/AdminTrackService.java b/src/main/java/in/koreatech/koin/admin/member/service/AdminTrackService.java index 81b244463..57af6b991 100644 --- a/src/main/java/in/koreatech/koin/admin/member/service/AdminTrackService.java +++ b/src/main/java/in/koreatech/koin/admin/member/service/AdminTrackService.java @@ -7,9 +7,14 @@ import in.koreatech.koin.admin.member.dto.AdminTechStackRequest; import in.koreatech.koin.admin.member.dto.AdminTechStackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackRequest; import in.koreatech.koin.admin.member.dto.AdminTrackResponse; +import in.koreatech.koin.admin.member.dto.AdminTrackSingleResponse; +import in.koreatech.koin.admin.member.exception.TrackNameDuplicationException; +import in.koreatech.koin.admin.member.repository.AdminMemberRepository; import in.koreatech.koin.admin.member.repository.AdminTechStackRepository; import in.koreatech.koin.admin.member.repository.AdminTrackRepository; +import in.koreatech.koin.domain.member.model.Member; import in.koreatech.koin.domain.member.model.TechStack; import in.koreatech.koin.domain.member.model.Track; import lombok.RequiredArgsConstructor; @@ -20,6 +25,7 @@ public class AdminTrackService { private final AdminTrackRepository adminTrackRepository; + private final AdminMemberRepository adminMemberRepository; private final AdminTechStackRepository adminTechStackRepository; public List getTracks() { @@ -28,6 +34,39 @@ public List getTracks() { .toList(); } + @Transactional + public AdminTrackResponse createTrack(AdminTrackRequest request) { + if (adminTrackRepository.findByName(request.name()).isPresent()) { + throw TrackNameDuplicationException.withDetail("name: " + request.name()); + } + Track track = request.toEntity(); + Track savedTrack = adminTrackRepository.save(track); + return AdminTrackResponse.from(savedTrack); + } + + public AdminTrackSingleResponse getTrack(Integer trackId) { + Track track = adminTrackRepository.getById(trackId); + List members = adminMemberRepository.findByTrackId(trackId); + List techStacks = adminTechStackRepository.findAllByTrackId(trackId); + return AdminTrackSingleResponse.of(track, members, techStacks); + } + + @Transactional + public AdminTrackResponse updateTrack(Integer trackId, AdminTrackRequest request) { + if (adminTrackRepository.findByName(request.name()).isPresent()) { + throw TrackNameDuplicationException.withDetail("name: " + request.name()); + } + Track track = adminTrackRepository.getById(trackId); + track.update(request.name(), request.headcount(), request.isDeleted()); + return AdminTrackResponse.from(track); + } + + @Transactional + public void deleteTrack(Integer trackId) { + Track track = adminTrackRepository.getById(trackId); + track.delete(); + } + @Transactional public AdminTechStackResponse createTechStack(AdminTechStackRequest request, String trackName) { Track track = adminTrackRepository.getByName(trackName); @@ -36,18 +75,25 @@ public AdminTechStackResponse createTechStack(AdminTechStackRequest request, Str return AdminTechStackResponse.from(savedTechStack); } - public AdminTechStackResponse updateTechStack(AdminTechStackRequest request, String trackName, - Integer techStackId) { + @Transactional + public AdminTechStackResponse updateTechStack( + AdminTechStackRequest request, + String trackName, + Integer techStackId + ) { TechStack techStack = adminTechStackRepository.getById(techStackId); - Integer id = techStack.getTrackId(); if (trackName != null) { Track track = adminTrackRepository.getByName(trackName); id = track.getId(); } - techStack.update(id, request.imageUrl(), request.name(), request.description(), request.isDeleted()); - TechStack updatedTechStack = adminTechStackRepository.save(techStack); - return AdminTechStackResponse.from(updatedTechStack); + return AdminTechStackResponse.from(techStack); + } + + @Transactional + public void deleteTechStack(Integer techStackId) { + TechStack techStack = adminTechStackRepository.getById(techStackId); + techStack.delete(); } } diff --git a/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java b/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java index a3035bd22..facea5698 100644 --- a/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/TechStack.java @@ -67,4 +67,8 @@ public void update(Integer trackId, String imageUrl, String name, String descrip this.description = description; this.isDeleted = isDeleted; } + + public void delete() { + this.isDeleted = true; + } } diff --git a/src/main/java/in/koreatech/koin/domain/member/model/Track.java b/src/main/java/in/koreatech/koin/domain/member/model/Track.java index 423d9bed4..e545cd372 100644 --- a/src/main/java/in/koreatech/koin/domain/member/model/Track.java +++ b/src/main/java/in/koreatech/koin/domain/member/model/Track.java @@ -39,8 +39,19 @@ public class Track extends BaseEntity { private boolean isDeleted = false; @Builder - private Track(String name) { + private Track(String name, Integer headcount, boolean isDeleted) { this.name = name; - this.headcount = 0; + this.headcount = headcount != null ? headcount : 0; + this.isDeleted = isDeleted; + } + + public void update(String name, Integer headcount, boolean isDeleted) { + this.name = name; + this.headcount = headcount; + this.isDeleted = isDeleted; + } + + public void delete() { + this.isDeleted = true; } } diff --git a/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java b/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java index 07ecc1f9a..cde93f1cf 100644 --- a/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java +++ b/src/main/java/in/koreatech/koin/domain/member/repository/MemberRepository.java @@ -16,7 +16,7 @@ public interface MemberRepository extends Repository { Member save(Member member); - List findByTrackIdAndIsDeletedFalse(Integer id); + List findAllByTrackIdAndIsDeletedFalse(Integer id); List findAll(); diff --git a/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java b/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java index 6872505da..fe23ba50a 100644 --- a/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java +++ b/src/main/java/in/koreatech/koin/domain/member/service/TrackService.java @@ -30,7 +30,7 @@ public List getTracks() { public TrackSingleResponse getTrack(Integer id) { Track track = trackRepository.getById(id); - List member = memberRepository.findByTrackIdAndIsDeletedFalse(id); + List member = memberRepository.findAllByTrackIdAndIsDeletedFalse(id); List techStacks = techStackRepository.findAllByTrackId(id); return TrackSingleResponse.of(track, member, techStacks); diff --git a/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java index 5a01cee9f..bb724fa27 100644 --- a/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/TrackApiTest.java @@ -73,7 +73,7 @@ void findTracks() { @Test @DisplayName("BCSDLab 트랙 정보 단건 조회 - 삭제된 멤버는 조회하지 않는다.") - void findingTracksExcludingDeletedMember() { + void findTrackWithoutDeletedMember() { Track track = trackFixture.backend(); memberFixture.배진호(track); // 삭제된 멤버 memberFixture.최준호(track); diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java index c525bc61e..c9ea7ecc9 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminTrackApiTest.java @@ -1,15 +1,19 @@ package in.koreatech.koin.admin.acceptance; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.member.repository.AdminTechStackRepository; +import in.koreatech.koin.admin.member.repository.AdminTrackRepository; import in.koreatech.koin.domain.member.model.TechStack; import in.koreatech.koin.domain.member.model.Track; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.MemberFixture; import in.koreatech.koin.fixture.TechStackFixture; import in.koreatech.koin.fixture.TrackFixture; import in.koreatech.koin.fixture.UserFixture; @@ -22,19 +26,28 @@ public class AdminTrackApiTest extends AcceptanceTest { @Autowired private TrackFixture trackFixture; + @Autowired + private MemberFixture memberFixture; + @Autowired private TechStackFixture techStackFixture; @Autowired private UserFixture userFixture; + @Autowired + private AdminTrackRepository adminTrackRepository; + + @Autowired + private AdminTechStackRepository adminTechStackRepository; + @Test - @DisplayName("관리자가 BCSDLab 트랙 정보를 조회한다 - 관리자가 아니면 403 반환") + @DisplayName("관리자가 BCSDLab 트랙 정보를 조회한다. - 관리자가 아니면 403 반환") void findTracksAdminNoAuth() { Student student = userFixture.준호_학생(); String token = userFixture.getToken(student.getUser()); - var response = RestAssured + RestAssured .given() .header("Authorization", "Bearer " + token) .when() @@ -45,7 +58,7 @@ void findTracksAdminNoAuth() { } @Test - @DisplayName("관리자가 BCSDLab 트랙 정보를 조회한다") + @DisplayName("관리자가 BCSDLab 트랙 정보를 조회한다.") void findTracks() { User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); @@ -95,7 +108,233 @@ void findTracks() { } @Test - @DisplayName("관리자가 BCSDLab 기술스택 정보를 생성한다") + @DisplayName("관리자가 BCSDLab 트랙 정보를 생성한다.") + void createTrack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "name": "BackEnd", + "headcount": 20 + } + """) + .when() + .post("/admin/tracks") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "name": "BackEnd", + "headcount": 20, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + """); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 정보를 생성한다. - 이미 있는 트랙명이면 409반환") + void createTrackDuplication() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + trackFixture.backend(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "name": "BackEnd", + "headcount": 20 + } + """) + .when() + .post("/admin/tracks") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 단건 정보를 조회한다.") + void findTrack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backend = trackFixture.backend(); + trackFixture.ai(); // 삭제된 트랙 + memberFixture.배진호(backend); // 삭제된 멤버 + memberFixture.최준호(backend); + techStackFixture.java(backend); + techStackFixture.adobeFlash(backend); //삭제된 기술스택 + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/admin/tracks/{id}", backend.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "TrackName": "BackEnd", + "Members": [ + { + "id": 1, + "name": "배진호", + "student_number": "2020136061", + "position": "Regular", + "track": "BackEnd", + "email": "testjhb@gmail.com", + "image_url": "https://imagetest.com/jino.jpg", + "is_deleted": true, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + { + "id": 2, + "name": "최준호", + "student_number": "2019136135", + "position": "Regular", + "track": "BackEnd", + "email": "testjuno@gmail.com", + "image_url": "https://imagetest.com/juno.jpg", + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ], + "TechStacks": [ + { + "id": 1, + "name": "Java", + "description": "Language", + "image_url": "https://testimageurl.com", + "track_id": 1, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + }, + { + "id": 2, + "name": "AdobeFlash", + "description": "deleted", + "image_url": "https://testimageurl.com", + "track_id": 1, + "is_deleted": true, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + ] + } + """); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 정보를 수정한다.") + void updateTrack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backEnd = trackFixture.backend(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "name": "frontEnd", + "headcount": 20 + } + """) + .when() + .put("/admin/tracks/{id}", backEnd.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "name": "frontEnd", + "headcount": 20, + "is_deleted": false, + "created_at": "2024-01-15 12:00:00", + "updated_at": "2024-01-15 12:00:00" + } + """); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 정보를 수정한다. - 이미 있는 트랙명이면 409반환") + void updateTrackDuplication() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backEnd = trackFixture.backend(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "name": "BackEnd", + "headcount": 20 + } + """) + .when() + .put("/admin/tracks/{id}", backEnd.getId()) + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 BCSDLab 트랙 정보를 삭제한다.") + void deleteTrack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backEnd = trackFixture.backend(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/admin/tracks/{id}", backEnd.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Track updatedTrack = adminTrackRepository.getById(backEnd.getId()); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(updatedTrack.getName()).isEqualTo(backEnd.getName()); + softly.assertThat(updatedTrack.getHeadcount()).isEqualTo(backEnd.getHeadcount()); + softly.assertThat(updatedTrack.isDeleted()).isEqualTo(true); + }); + } + + @Test + @DisplayName("관리자가 BCSDLab 기술스택 정보를 생성한다.") void createTechStack() { User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); @@ -109,8 +348,7 @@ void createTechStack() { .contentType("application/json") .body(""" { - "id": 3, - "image_url": "http://url.com", + "image_url": "https://url.com", "name": "Spring", "description": "스프링은 웹 프레임워크이다" } @@ -126,7 +364,7 @@ void createTechStack() { .isEqualTo(""" { "id": 1, - "image_url": "http://url.com", + "image_url": "https://url.com", "name": "Spring", "description": "스프링은 웹 프레임워크이다", "track_id": 2, @@ -138,7 +376,7 @@ void createTechStack() { } @Test - @DisplayName("관리자가 BCSDLab 기술스택 정보를 수정한다") + @DisplayName("관리자가 BCSDLab 기술스택 정보를 수정한다.") void updateTechStack() { User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); @@ -152,7 +390,7 @@ void updateTechStack() { .contentType("application/json") .body(""" { - "image_url": "http://java.com", + "image_url": "https://java.com", "name": "JAVA", "description": "java의 TrackID를 BackEnd로 수정한다.", "is_deleted": true @@ -169,7 +407,7 @@ void updateTechStack() { .isEqualTo(""" { "id": 1, - "image_url": "http://java.com", + "image_url": "https://java.com", "name": "JAVA", "description": "java의 TrackID를 BackEnd로 수정한다.", "track_id": 2, @@ -179,4 +417,33 @@ void updateTechStack() { } """); } + + @Test + @DisplayName("관리자가 기술스택 정보를 삭제한다.") + void deleteTechStack() { + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + Track backEnd = trackFixture.backend(); + TechStack java = techStackFixture.java(backEnd); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/admin/techStacks/{id}", java.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + TechStack updatedtechStack = adminTechStackRepository.getById(java.getId()); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(updatedtechStack.getImageUrl()).isEqualTo(java.getImageUrl()); + softly.assertThat(updatedtechStack.getName()).isEqualTo(java.getName()); + softly.assertThat(updatedtechStack.getDescription()).isEqualTo(java.getDescription()); + softly.assertThat(updatedtechStack.getTrackId()).isEqualTo(backEnd.getId()); + softly.assertThat(updatedtechStack.isDeleted()).isEqualTo(true); + }); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java b/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java index a8b833531..fcf49b5b8 100644 --- a/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/TechStackFixture.java @@ -25,4 +25,16 @@ public TechStack java(Track track) { .build() ); } + + public TechStack adobeFlash(Track track) { + return techStackRepository.save( + TechStack.builder() + .imageUrl("https://testimageurl.com") + .trackId(track.getId()) + .name("AdobeFlash") + .description("deleted") + .isDeleted(true) + .build() + ); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/TrackFixture.java b/src/test/java/in/koreatech/koin/fixture/TrackFixture.java index 5b669dcd9..14e2234ae 100644 --- a/src/test/java/in/koreatech/koin/fixture/TrackFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/TrackFixture.java @@ -37,4 +37,13 @@ public Track ios() { .build() ); } + + public Track ai() { + return trackRepository.save( + Track.builder() + .name("AI") + .isDeleted(true) + .build() + ); + } } From ba97fe3dcb5de6a87d4b0597782678c878ccab5a Mon Sep 17 00:00:00 2001 From: Jang-JunYoung <79901434+johnny19991006@users.noreply.github.com> Date: Wed, 19 Jun 2024 00:32:53 +0900 Subject: [PATCH 10/37] =?UTF-8?q?Feature:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=B3=B5=EB=8D=95=EB=B0=A9=20=EC=82=AD=EC=A0=9C=20(#612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: AdminLandApi 구현 * feat: AdminLandController * feat: AdminLandRepository * feat: AdminLandService * feat: Land * refactor: Admin권한 추가 * feat: 복덕방 삭제 테스트 코드 추가 * refactor: 복덕방 삭제 테스트 공백 추가 --------- Co-authored-by: Jang Jun Young --- .../admin/land/controller/AdminLandApi.java | 18 +++++++++ .../land/controller/AdminLandController.java | 11 ++++++ .../land/repository/AdminLandRepository.java | 7 ++++ .../admin/land/service/AdminLandService.java | 6 +++ .../koin/domain/land/model/Land.java | 4 ++ .../admin/acceptance/AdminLandApiTest.java | 37 +++++++++++++++++++ 6 files changed, 83 insertions(+) diff --git a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java index 77bb055fe..7f93a12a2 100644 --- a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java +++ b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java @@ -3,7 +3,9 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; 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.RequestParam; @@ -57,4 +59,20 @@ ResponseEntity postLands( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/admin/lands/{id}") + ResponseEntity deleteLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + } diff --git a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java index b50fccaec..2fda9c5dc 100644 --- a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java +++ b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java @@ -4,7 +4,9 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; 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.RequestParam; @@ -42,4 +44,13 @@ public ResponseEntity postLands( return ResponseEntity.status(HttpStatus.CREATED).build(); } + @DeleteMapping("/admin/lands/{id}") + public ResponseEntity deleteLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminLandService.deleteLand(id); + return null; + } + } diff --git a/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java b/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java index 9d3a6dc38..fd85268dd 100644 --- a/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java +++ b/src/main/java/in/koreatech/koin/admin/land/repository/AdminLandRepository.java @@ -23,4 +23,11 @@ default Land getByName(String name) { return findByName(name).orElseThrow(() -> LandNotFoundException.withDetail("name: " + name)); } + Optional findById(Integer id); + + default Land getById(Integer id) { + return findById(id) + .orElseThrow(() -> LandNotFoundException.withDetail("id: " + id)); + } + } diff --git a/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java b/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java index 4e765e8d9..d719f13d5 100644 --- a/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java +++ b/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java @@ -43,4 +43,10 @@ public void createLands(AdminLandsRequest adminLandsRequest) { Land land = adminLandsRequest.toLand(); adminLandRepository.save(land); } + + @Transactional + public void deleteLand(Integer id) { + Land land = adminLandRepository.getById(id); + land.delete(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/land/model/Land.java b/src/main/java/in/koreatech/koin/domain/land/model/Land.java index ffc9f2a73..ae187c3aa 100644 --- a/src/main/java/in/koreatech/koin/domain/land/model/Land.java +++ b/src/main/java/in/koreatech/koin/domain/land/model/Land.java @@ -243,4 +243,8 @@ private String convertToSting(List imageUrls) { .map(url -> "\"" + url + "\"") .collect(Collectors.joining(","))); } + + public void delete() { + this.isDeleted = true; + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java index ac5ab4687..12a5df1ce 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java @@ -130,4 +130,41 @@ void postLands() { softly.assertThat(savedLand.isOptRefrigerator()).as("opt_refrigerator가 누락될 경우 false 반환여부").isEqualTo(false); }); } + + @Test + @DisplayName("관리자 권한으로 복덕방을 삭제한다.") + void deleteLand() { + // 복덕방 생성 + Land request = Land.builder() + .internalName("금실타운") + .name("금실타운") + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .monthlyFee("100") + .charterFee("1000") + .build(); + + Land savedLand = adminLandRepository.save(request); + Integer landId = savedLand.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/admin/lands/{id}", landId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Land deletedLand = adminLandRepository.getById(landId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(deletedLand.getName()).isEqualTo("금실타운"); + softly.assertThat(deletedLand.isDeleted()).isEqualTo(true); + }); + } } From 0d3e554adb7457d26a3fceb2c581ccca57434071 Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Thu, 20 Jun 2024 13:45:15 +0900 Subject: [PATCH 11/37] =?UTF-8?q?refactor:=20=ED=98=B8=ED=99=98=EC=84=B1?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=98=A4=EB=84=88=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=ED=9C=B4=EB=8C=80=ED=8F=B0=EB=B2=88=ED=98=B8=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=EB=A1=A4=EB=B0=B1=20(#614)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 회원가입 휴대폰번호 양식 롤백 * refactor: 오너 회원가입 휴대폰번호 양식 테스트코드 롤백 * refactor: 리뷰 반영 --------- Co-authored-by: HyeonsuLee --- .../koin/domain/owner/controller/OwnerController.java | 2 +- .../koin/domain/owner/dto/OwnerRegisterRequest.java | 1 - .../java/in/koreatech/koin/acceptance/OwnerApiTest.java | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java index 0d9e771fd..038a90b3e 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java @@ -19,11 +19,11 @@ import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateSmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerRegisterByPhoneRequest; -import in.koreatech.koin.domain.owner.dto.OwnerSmsVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerRegisterRequest; import in.koreatech.koin.domain.owner.dto.OwnerResponse; import in.koreatech.koin.domain.owner.dto.OwnerSendEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerSendSmsRequest; +import in.koreatech.koin.domain.owner.dto.OwnerSmsVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerVerifyResponse; import in.koreatech.koin.domain.owner.dto.VerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.VerifySmsRequest; diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java index 2aa1f2830..3595abb03 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java @@ -44,7 +44,6 @@ public record OwnerRegisterRequest( @Schema(description = "비밀번호", example = "password", requiredMode = REQUIRED) String password, - @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다.") @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = REQUIRED) String phoneNumber, diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java index 8b75d36ea..28220afdf 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java @@ -206,7 +206,7 @@ void register() { "email": "helloworld@koreatech.ac.kr", "name": "최준호", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "01000000000", + "phone_number": "010-0000-0000", "shop_id": null, "shop_name": "기분좋은 뷔짱" } @@ -395,7 +395,7 @@ void registerWithExistShop() { "email": "helloworld@koreatech.ac.kr", "name": "주노", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "01000000000", + "phone_number": "010-0000-0000", "shop_id": %d, "shop_name": "기분좋은 뷔짱" } @@ -433,7 +433,7 @@ void registerWithNotExistShop() { "email": "helloworld@koreatech.ac.kr", "name": "주노", "password": "a0240120305812krlakdsflsa;1235", - "phone_number": "01000000000", + "phone_number": "010-0000-0000", "shop_id": null, "shop_name": "기분좋은 뷔짱" } From 114c1ed0ebf34011057642c986bba834075d5bbf Mon Sep 17 00:00:00 2001 From: duehee <149302959+duehee@users.noreply.github.com> Date: Sat, 22 Jun 2024 13:36:33 +0900 Subject: [PATCH 12/37] =?UTF-8?q?feat=20:=20coop=5Fid=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20flyway=20=EC=B6=94=EA=B0=80=20(#618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : coop_id 관련 flyway 추가 * chore : 리뷰 반영(flyway 분리) --- .../db/migration/V19__update_coop_id_with_user_email.sql | 3 +++ .../db/migration/V20__alter_coop_id_column_not_null.sql | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 src/main/resources/db/migration/V19__update_coop_id_with_user_email.sql create mode 100644 src/main/resources/db/migration/V20__alter_coop_id_column_not_null.sql diff --git a/src/main/resources/db/migration/V19__update_coop_id_with_user_email.sql b/src/main/resources/db/migration/V19__update_coop_id_with_user_email.sql new file mode 100644 index 000000000..cd00a55af --- /dev/null +++ b/src/main/resources/db/migration/V19__update_coop_id_with_user_email.sql @@ -0,0 +1,3 @@ +UPDATE coop + JOIN users ON coop.user_id = users.id + SET coop.coop_id = users.email; diff --git a/src/main/resources/db/migration/V20__alter_coop_id_column_not_null.sql b/src/main/resources/db/migration/V20__alter_coop_id_column_not_null.sql new file mode 100644 index 000000000..5534b212f --- /dev/null +++ b/src/main/resources/db/migration/V20__alter_coop_id_column_not_null.sql @@ -0,0 +1,2 @@ +ALTER TABLE `koin`.`coop` + CHANGE COLUMN `coop_id` `coop_id` VARCHAR(255) NOT NULL COMMENT '영양사 id, 일반 로그인 형식' ; From e4d528c1858ad5c5843833001d26d2df468e35f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= <46699595+ImTotem@users.noreply.github.com> Date: Sat, 22 Jun 2024 21:32:40 +0900 Subject: [PATCH 13/37] =?UTF-8?q?refactor:=20=EC=8B=9C=EB=82=B4=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=95=EB=A5=98=EC=9E=A5=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=ED=95=B4=EA=B2=B0=20(#601)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 시내버스 노선 캐시 저장 로직 수정 * refactor: 정류장 추가 * refactor: 정류장 조회 로직 수정 * refactor: 테스트 수정 --- .../domain/bus/model/enums/BusStation.java | 2 +- .../bus/model/enums/BusStationNode.java | 14 ++++---- .../CityBusRouteCacheRepository.java | 2 -- .../koin/domain/bus/util/CityBusClient.java | 13 +++++--- .../domain/bus/util/CityBusRouteClient.java | 32 +++++++++++-------- .../koreatech/koin/acceptance/BusApiTest.java | 2 +- 6 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java index 616ceac1b..684b286f0 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStation.java @@ -41,7 +41,7 @@ public static BusDirection getDirection(BusStation depart, BusStation arrival) { return BusDirection.NORTH; } - public String getNodeId(BusDirection direction) { + public List getNodeId(BusDirection direction) { return node.getId(direction); } diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java index 4edb945fe..cd84ec1df 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/BusStationNode.java @@ -15,24 +15,24 @@ */ @Getter public enum BusStationNode { - TERMINAL(Map.of(NORTH, "CAB285000686", SOUTH, "CAB285000685")), // 종합터미널 - KOREATECH(Map.of(NORTH, "CAB285000406", SOUTH, "CAB285000405")), // 코리아텍 - STATION(Map.of(NORTH, "CAB285000655", SOUTH, "CAB285000656")), // 천안역 동부광장 + TERMINAL(Map.of(NORTH, List.of("CAB285000686"), SOUTH, List.of("CAB285000685", "CAB285010125"))), // 종합터미널 + KOREATECH(Map.of(NORTH, List.of("CAB285000406"), SOUTH, List.of("CAB285000405"))), // 코리아텍 + STATION(Map.of(NORTH, List.of("CAB285000655"), SOUTH, List.of("CAB285000656"))), // 천안역 동부광장 ; - private final Map node; + private final Map> node; - BusStationNode(Map node) { + BusStationNode(Map> node) { this.node = node; } - public String getId(BusDirection direction) { + public List getId(BusDirection direction) { return node.get(direction); } public static List getNodeIds() { return Arrays.stream(values()) - .flatMap(station -> station.node.values().stream()) + .flatMap(station -> station.node.values().stream().flatMap(List::stream)) .toList(); } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java index d3e055d9c..38c11e51e 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusRouteCacheRepository.java @@ -12,8 +12,6 @@ public interface CityBusRouteCacheRepository extends Repository saveAll(List cityBusRouteCaches); - List findAll(); Optional findById(String nodeId); diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java index 8de709982..69fa7b492 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java +++ b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusClient.java @@ -69,10 +69,15 @@ public CityBusClient( this.cityBusCacheRepository = cityBusCacheRepository; } - public List getBusRemainTime(String nodeId) { - Optional cityBusCache = cityBusCacheRepository.findById(nodeId); - return cityBusCache.map(busCache -> busCache.getBusInfos().stream().map(CityBusRemainTime::from).toList()) - .orElseGet(ArrayList::new); + public List getBusRemainTime(List nodeIds) { + List result = new ArrayList<>(); + nodeIds.forEach(nodeId -> { + Optional cityBusCache = cityBusCacheRepository.findById(nodeId); + if (cityBusCache.isPresent()) { + result.addAll(cityBusCache.map(busCache -> busCache.getBusInfos().stream().map(CityBusRemainTime::from).toList()).get()); + } + }); + return result; } @Transactional diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java index 9b906310e..c7cf25468 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java +++ b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java @@ -61,26 +61,32 @@ public CityBusRouteClient( this.cityBusRouteCacheRepository = cityBusRouteCacheRepository; } - public Set getAvailableCityBus(String nodeId) { - Optional routeCache = cityBusRouteCacheRepository.findById(nodeId); - if (routeCache.isEmpty()) { + public Set getAvailableCityBus(List nodeIds) { + Set busNumbers = new HashSet<>(); + nodeIds.forEach(nodeId -> { + Optional routeCache = cityBusRouteCacheRepository.findById(nodeId); + routeCache.ifPresent(cityBusRouteCache -> busNumbers.addAll(cityBusRouteCache.getBusNumbers())); + }); + + if (busNumbers.isEmpty()) { return new HashSet<>(AVAILABLE_CITY_BUS); } - return routeCache.get().getBusNumbers(); + return busNumbers; } @Transactional public void storeCityBusRoute() { - cityBusRouteCacheRepository.saveAll( - BusStationNode.getNodeIds().stream() - .map(node -> - CityBusRouteCache.of( - node, - Set.copyOf(extractBusRouteInfo(getOpenApiResponse(node))) - ) - ).toList() - ); + List nodeIds = BusStationNode.getNodeIds(); + + for (String node : nodeIds) { + cityBusRouteCacheRepository.save( + CityBusRouteCache.of( + node, + Set.copyOf(extractBusRouteInfo(getOpenApiResponse(node))) + ) + ); + } } public String getOpenApiResponse(String nodeId) { diff --git a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java index 518b7d87a..570561d5e 100644 --- a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java @@ -250,7 +250,7 @@ void getNextCityBusRemainTimeRedis() { cityBusCacheRepository.save( CityBusCache.of( - depart.getNodeId(direction), + depart.getNodeId(direction).get(0), List.of(CityBusCacheInfo.of( CityBusArrival.builder() .routeno(busNumber) From 82e75ad2c717170bc8a7eb7a3feb4ad2b058578f Mon Sep 17 00:00:00 2001 From: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Date: Sat, 22 Jun 2024 22:00:33 +0900 Subject: [PATCH 14/37] =?UTF-8?q?fix=20:=20=ED=92=88=EC=A0=88=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=B2=98=EB=A6=AC=20=EC=88=9C=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : 품절 캐시 저장, 알림 발송 순서 수정 * fix : dining 응답 0 -> null 반환으로 수정 --- .../koreatech/koin/domain/coop/model/CoopEventListener.java | 3 +-- .../in/koreatech/koin/domain/dining/dto/DiningResponse.java | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java b/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java index 64cfcf37c..7efaebf87 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/coop/model/CoopEventListener.java @@ -28,6 +28,7 @@ public class CoopEventListener { @TransactionalEventListener(phase = AFTER_COMMIT) public void onDiningSoldOutRequest(DiningSoldOutEvent event) { + diningSoldOutCacheRepository.save(DiningSoldOutCache.from(event.place())); NotificationDetailSubscribeType detailType = NotificationDetailSubscribeType.from(event.diningType()); var notifications = notificationSubscribeRepository .findAllBySubscribeTypeAndDetailType(DINING_SOLD_OUT, null).stream() @@ -40,8 +41,6 @@ public void onDiningSoldOutRequest(DiningSoldOutEvent event) { event.place(), subscribe.getUser() )).toList(); - notificationService.push(notifications); - diningSoldOutCacheRepository.save(DiningSoldOutCache.from(event.place())); } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java index 81fbc3f24..5c01fb1fb 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java @@ -70,9 +70,9 @@ public static DiningResponse from(Dining dining) { dining.getDate(), dining.getType().name(), dining.getPlace(), - dining.getPriceCard() != null ? dining.getPriceCard() : 0, - dining.getPriceCash() != null ? dining.getPriceCash() : 0, - dining.getKcal() != null ? dining.getKcal() : 0, + dining.getPriceCard(), + dining.getPriceCash(), + dining.getKcal(), dining.getMenu(), dining.getImageUrl(), dining.getCreatedAt(), From e65964b005c2761e22be35c85b2876e2614581a0 Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Sun, 23 Jun 2024 12:33:58 +0900 Subject: [PATCH 15/37] =?UTF-8?q?refactor:=20sms=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20redis=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20(#622)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: sms회원가입 redi초기화 로직 변경 * refactor: eventListener 메소드 이름 변경 * refactor: 테스트 코드 변경 --------- Co-authored-by: HyeonsuLee --- .../koin/domain/owner/model/OwnerEventListener.java | 13 +++++++++++++ .../domain/owner/model/OwnerRegisterBySmsEvent.java | 7 +++++++ .../koin/domain/owner/service/OwnerService.java | 4 ++-- .../in/koreatech/koin/acceptance/OwnerApiTest.java | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterBySmsEvent.java diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java index eb3fff055..f96d90279 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerEventListener.java @@ -56,4 +56,17 @@ public void onOwnerRegister(OwnerRegisterEvent event) { ); slackClient.sendMessage(notification); } + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onOwnerRegisterBySms(OwnerRegisterBySmsEvent event) { + Owner owner = event.owner(); + ownerInVerificationRedisRepository.deleteByVerify(owner.getAccount()); + String shopsName = shopRepository.findAllByOwnerId(owner.getId()) + .stream().map(Shop::getName).collect(Collectors.joining(", ")); + var notification = slackNotificationFactory.generateOwnerRegisterRequestNotification( + owner.getUser().getName(), + shopsName + ); + slackClient.sendMessage(notification); + } } diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterBySmsEvent.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterBySmsEvent.java new file mode 100644 index 000000000..5e1a7d87b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerRegisterBySmsEvent.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.owner.model; + +public record OwnerRegisterBySmsEvent( + Owner owner +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java index 6612d3432..99b938ccf 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java +++ b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java @@ -33,6 +33,7 @@ import in.koreatech.koin.domain.owner.exception.DuplicationPhoneNumberException; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerEmailRequestEvent; +import in.koreatech.koin.domain.owner.model.OwnerRegisterBySmsEvent; import in.koreatech.koin.domain.owner.model.OwnerRegisterEvent; import in.koreatech.koin.domain.owner.model.OwnerShop; import in.koreatech.koin.domain.owner.model.OwnerSmsRequestEvent; @@ -46,7 +47,6 @@ import in.koreatech.koin.domain.shop.repository.ShopRepository; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserToken; -import in.koreatech.koin.domain.user.model.UserType; import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.domain.user.repository.UserTokenRepository; import in.koreatech.koin.global.auth.JwtProvider; @@ -168,7 +168,7 @@ public void registerByPhone(OwnerRegisterByPhoneRequest request) { ownerShopBuilder.shopId(shop.getId()); } ownerShopRedisRepository.save(ownerShopBuilder.build()); - eventPublisher.publishEvent(new OwnerRegisterEvent(saved)); + eventPublisher.publishEvent(new OwnerRegisterBySmsEvent(saved)); } @Transactional diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java index 28220afdf..8de388546 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java @@ -283,7 +283,7 @@ void registerByPhoneNumber() { .isEqualTo("https://static.koreatech.in/testimage.png"); softly.assertThat(owner.getUser().isAuthed()).isFalse(); softly.assertThat(owner.getUser().isDeleted()).isFalse(); - verify(ownerEventListener).onOwnerRegister(any()); + verify(ownerEventListener).onOwnerRegisterBySms(any()); } ); } From 479c3950f158f64353af9066fcdacfb8669fc8ff Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Sun, 23 Jun 2024 17:31:25 +0900 Subject: [PATCH 16/37] =?UTF-8?q?fix:=20=EC=83=81=EC=A0=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=82=AD=EC=A0=9C=EC=8B=9C=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=20=EC=9A=94=EC=9D=BC=20=ED=98=95=EC=8B=9D=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20(#624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 상점 운영시간 요일 형식 검증 추가 * feat: Inner Record에 @Valid추가 * refactor: 필요없는 어노테이션 삭제 --------- Co-authored-by: HyeonsuLee --- .../controller/OwnerShopController.java | 2 +- .../ownershop/dto/OwnerShopsRequest.java | 4 +++ .../domain/shop/dto/CreateMenuRequest.java | 2 ++ .../domain/shop/dto/ModifyMenuRequest.java | 2 ++ .../domain/shop/dto/ModifyShopRequest.java | 4 +++ .../global/validation/DayOfWeekValidator.java | 25 +++++++++++++++++++ .../global/validation/ValidDayOfWeek.java | 25 +++++++++++++++++++ 7 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/main/java/in/koreatech/koin/global/validation/DayOfWeekValidator.java create mode 100644 src/main/java/in/koreatech/koin/global/validation/ValidDayOfWeek.java diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java b/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java index dcbe1696e..c7423a914 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/controller/OwnerShopController.java @@ -153,7 +153,7 @@ public ResponseEntity modifyMenuCategory( public ResponseEntity modifyOwnerShop( @Auth(permit = {OWNER}) Integer ownerId, @PathVariable("id") Integer shopId, - @RequestBody @Valid ModifyShopRequest modifyShopRequest + @Valid @RequestBody ModifyShopRequest modifyShopRequest ) { ownerShopService.modifyShop(ownerId, shopId, modifyShopRequest); return ResponseEntity.status(HttpStatus.CREATED).build(); diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java index 4a954dd32..2664c5ad1 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/dto/OwnerShopsRequest.java @@ -12,7 +12,9 @@ import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; +import in.koreatech.koin.global.validation.ValidDayOfWeek; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -57,6 +59,7 @@ public record OwnerShopsRequest( @Schema(description = "요일별 운영 시간과 휴무 여부", requiredMode = REQUIRED) @Size(min = 7, max = 7, message = "The list must contain exactly 7 elements.") @NotNull + @Valid List open, @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) @@ -103,6 +106,7 @@ public record InnerOpenRequest( @Schema(description = "요일", example = "MONDAY", requiredMode = REQUIRED) @NotBlank(message = "영업 요일을 입력해주세요.") + @ValidDayOfWeek String dayOfWeek, @Schema(description = "여는 시간", example = "10:00", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java index 39f6d96e3..59bf3e9a8 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/CreateMenuRequest.java @@ -12,6 +12,7 @@ import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; @@ -45,6 +46,7 @@ public record CreateMenuRequest( String name, @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + @Valid List optionPrices, @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java index dd760a907..8f64856e1 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyMenuRequest.java @@ -11,6 +11,7 @@ import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; @@ -44,6 +45,7 @@ public record ModifyMenuRequest( String name, @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + @Valid List optionPrices, @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java index 746dc5ce0..78933ba2d 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java @@ -14,7 +14,9 @@ import in.koreatech.koin.domain.shop.model.ShopOpen; import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; +import in.koreatech.koin.global.validation.ValidDayOfWeek; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; @@ -55,6 +57,7 @@ public record ModifyShopRequest( String name, @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + @Valid List open, @Schema(example = "true", description = "계좌 이체 가능 여부", requiredMode = REQUIRED) @@ -75,6 +78,7 @@ public record InnerShopOpen( @Schema(example = "MONDAY", description = """ 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] """, requiredMode = REQUIRED) + @ValidDayOfWeek String dayOfWeek, @Schema(example = "false", description = "휴무 여부", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/global/validation/DayOfWeekValidator.java b/src/main/java/in/koreatech/koin/global/validation/DayOfWeekValidator.java new file mode 100644 index 000000000..06e2a4f5a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/DayOfWeekValidator.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.global.validation; + +import java.util.Set; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class DayOfWeekValidator implements ConstraintValidator { + private final Set validDays = Set.of( + "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY" + ); + + @Override + public void initialize(ValidDayOfWeek constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String dayOfWeek, ConstraintValidatorContext context) { + if (dayOfWeek == null) { + return false; + } + return validDays.contains(dayOfWeek.toUpperCase()); + } +} diff --git a/src/main/java/in/koreatech/koin/global/validation/ValidDayOfWeek.java b/src/main/java/in/koreatech/koin/global/validation/ValidDayOfWeek.java new file mode 100644 index 000000000..88611850a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/ValidDayOfWeek.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.global.validation; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = DayOfWeekValidator.class) +@Target({FIELD, ANNOTATION_TYPE}) +@Retention(RUNTIME) +public @interface ValidDayOfWeek { + + String message() default "요일 형식을 확인해주세요."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} From 29cef11208f5d2093363cae1c3136883702c584a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A4=80=ED=98=B8?= Date: Mon, 24 Jun 2024 14:38:04 +0900 Subject: [PATCH 17/37] =?UTF-8?q?feat:=20=EC=82=AC=EC=97=85=EC=9E=90?= =?UTF-8?q?=EB=B2=88=ED=98=B8,=20=EC=95=84=EC=9D=B4=EB=94=94=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(#610)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 사업자등록번호 검증 추가 * feat: 전화번호 중복 검증 추가 * refactor: 전화번호 중복 사장님으로 이관 * refactor: phone_number -> account * test: 테스트 수정 * refactor: check -> exists --- .../domain/owner/controller/OwnerApi.java | 34 +++++- .../owner/controller/OwnerController.java | 21 ++++ .../owner/dto/CompanyNumberCheckRequest.java | 20 +++ .../dto/OwnerAccountCheckExistsRequest.java | 18 +++ .../owner/dto/OwnerRegisterRequest.java | 1 + .../domain/owner/service/OwnerService.java | 21 +++- .../koin/domain/user/controller/UserApi.java | 2 - .../user/controller/UserController.java | 2 - .../user/repository/UserRepository.java | 4 +- .../koin/domain/user/service/UserService.java | 1 - .../koin/acceptance/OwnerApiTest.java | 114 +++++++++++++++++- .../koin/acceptance/UserApiTest.java | 1 - .../koreatech/koin/fixture/UserFixture.java | 7 +- 13 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/owner/dto/CompanyNumberCheckRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/owner/dto/OwnerAccountCheckExistsRequest.java diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java index a2a74273c..c2d685d80 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerApi.java @@ -4,10 +4,13 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import in.koreatech.koin.domain.owner.dto.CompanyNumberCheckRequest; +import in.koreatech.koin.domain.owner.dto.OwnerAccountCheckExistsRequest; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; @@ -16,11 +19,11 @@ import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerPasswordUpdateSmsRequest; import in.koreatech.koin.domain.owner.dto.OwnerRegisterByPhoneRequest; -import in.koreatech.koin.domain.owner.dto.OwnerSmsVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerRegisterRequest; import in.koreatech.koin.domain.owner.dto.OwnerResponse; import in.koreatech.koin.domain.owner.dto.OwnerSendEmailRequest; import in.koreatech.koin.domain.owner.dto.OwnerSendSmsRequest; +import in.koreatech.koin.domain.owner.dto.OwnerSmsVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerVerifyResponse; import in.koreatech.koin.domain.owner.dto.VerifyEmailRequest; import in.koreatech.koin.domain.owner.dto.VerifySmsRequest; @@ -279,4 +282,33 @@ ResponseEntity updatePasswordByEmail( ResponseEntity updatePasswordBySms( @Valid @RequestBody OwnerPasswordUpdateSmsRequest request ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사업자 등록번호 중복 검증") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/owners/exists/company-number") + ResponseEntity checkCompanyNumber( + @ModelAttribute("company_number") + @Valid CompanyNumberCheckRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "전화번호 중복 체크") + @GetMapping("/owners/exists/account") + ResponseEntity checkDuplicationOfPhoneNumber( + @ModelAttribute("account") + @Valid OwnerAccountCheckExistsRequest request + ); } diff --git a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java index 038a90b3e..82504e901 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java +++ b/src/main/java/in/koreatech/koin/domain/owner/controller/OwnerController.java @@ -6,11 +6,14 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.domain.owner.dto.CompanyNumberCheckRequest; +import in.koreatech.koin.domain.owner.dto.OwnerAccountCheckExistsRequest; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; @@ -150,4 +153,22 @@ public ResponseEntity updatePasswordBySms( ownerService.updatePasswordBySms(request); return ResponseEntity.ok().build(); } + + @GetMapping("/owners/exists/company-number") + public ResponseEntity checkCompanyNumber( + @ModelAttribute("company_number") + @Valid CompanyNumberCheckRequest request + ) { + ownerService.checkCompanyNumber(request); + return ResponseEntity.ok().build(); + } + + @GetMapping("/owners/exists/account") + public ResponseEntity checkDuplicationOfPhoneNumber( + @ModelAttribute("account") + @Valid OwnerAccountCheckExistsRequest request + ) { + ownerService.checkExistsAccount(request); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/CompanyNumberCheckRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/CompanyNumberCheckRequest.java new file mode 100644 index 000000000..e663cec58 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/CompanyNumberCheckRequest.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.owner.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +@JsonNaming(SnakeCaseStrategy.class) +public record CompanyNumberCheckRequest( + @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}", message = "사업자 등록 번호 형식이 올바르지 않습니다. ${validatedValue}") + @Schema(description = "사업자 등록 번호", example = "012-34-56789", requiredMode = REQUIRED) + @NotBlank(message = "사업자 등록 번호를 입력해주세요.") + String companyNumber +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerAccountCheckExistsRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerAccountCheckExistsRequest.java new file mode 100644 index 000000000..bac5c369f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerAccountCheckExistsRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.owner.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +@JsonNaming(SnakeCaseStrategy.class) +public record OwnerAccountCheckExistsRequest( + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다. 11자리 숫자로 입력해 주세요.") + @NotBlank(message = "아이디를 입력해주세요.") + @Schema(description = "아이디(전화번호)", example = "01012345678") + String account +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java index 3595abb03..8464d9971 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java +++ b/src/main/java/in/koreatech/koin/domain/owner/dto/OwnerRegisterRequest.java @@ -71,6 +71,7 @@ public Owner toOwner(PasswordEncoder passwordEncoder) { .build(); Owner owner = Owner.builder() .user(user) + .account(phoneNumber) .companyRegistrationNumber(companyNumber) .attachments(new ArrayList<>()) .grantShop(false) diff --git a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java index 99b938ccf..07998b12a 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java +++ b/src/main/java/in/koreatech/koin/domain/owner/service/OwnerService.java @@ -13,6 +13,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.owner.dto.CompanyNumberCheckRequest; +import in.koreatech.koin.domain.owner.dto.OwnerAccountCheckExistsRequest; import in.koreatech.koin.domain.owner.dto.OwnerEmailVerifyRequest; import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; @@ -114,7 +116,7 @@ public void requestSignUpEmailVerification(VerifyEmailRequest request) { @Transactional public void requestSignUpSmsVerification(VerifySmsRequest request) { userRepository.findByPhoneNumberAndUserType(request.phoneNumber(), OWNER).ifPresent(user -> { - throw DuplicationPhoneNumberException.withDetail("phoneNumber: " + request.phoneNumber()); + throw DuplicationPhoneNumberException.withDetail("account: " + request.phoneNumber()); }); sendCertificationSms(request.phoneNumber()); } @@ -141,6 +143,9 @@ public void register(OwnerRegisterRequest request) { if (ownerRepository.findByCompanyRegistrationNumber(request.companyNumber()).isPresent()) { throw DuplicationCompanyNumberException.withDetail("companyNumber: " + request.companyNumber()); } + if (ownerRepository.findByAccount(request.phoneNumber()).isPresent()) { + throw DuplicationPhoneNumberException.withDetail("account: " + request.phoneNumber()); + } Owner owner = request.toOwner(passwordEncoder); Owner saved = ownerRepository.save(owner); OwnerShop.OwnerShopBuilder ownerShopBuilder = OwnerShop.builder().ownerId(owner.getId()); @@ -155,7 +160,7 @@ public void register(OwnerRegisterRequest request) { @Transactional public void registerByPhone(OwnerRegisterByPhoneRequest request) { if (userRepository.findByPhoneNumberAndUserType(request.phoneNumber(), OWNER).isPresent()) { - throw DuplicationPhoneNumberException.withDetail("phoneNumber: " + request.phoneNumber()); + throw DuplicationPhoneNumberException.withDetail("account: " + request.phoneNumber()); } if (ownerRepository.findByCompanyRegistrationNumber(request.companyNumber()).isPresent()) { throw DuplicationCompanyNumberException.withDetail("companyNumber: " + request.companyNumber()); @@ -247,4 +252,16 @@ private void verifyCode(String key, String code) { } ownerVerificationStatusRepository.deleteById(key); } + + public void checkCompanyNumber(CompanyNumberCheckRequest request) { + if (ownerRepository.findByCompanyRegistrationNumber(request.companyNumber()).isPresent()) { + throw DuplicationCompanyNumberException.withDetail("companyNumber: " + request.companyNumber()); + } + } + + public void checkExistsAccount(OwnerAccountCheckExistsRequest request) { + ownerRepository.findByAccount(request.account()).ifPresent(user -> { + throw DuplicationPhoneNumberException.withDetail("account: " + request.account()); + }); + } } diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java index fbc9bea3b..ebd6e3bba 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java @@ -17,8 +17,6 @@ import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; import in.koreatech.koin.domain.user.dto.FindPasswordRequest; import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; -import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; -import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; import in.koreatech.koin.domain.user.dto.StudentLoginRequest; import in.koreatech.koin.domain.user.dto.StudentLoginResponse; import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java index 265cf53c0..53c4e14ce 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java @@ -24,8 +24,6 @@ import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; import in.koreatech.koin.domain.user.dto.FindPasswordRequest; import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest; -import in.koreatech.koin.domain.owner.dto.OwnerLoginRequest; -import in.koreatech.koin.domain.owner.dto.OwnerLoginResponse; import in.koreatech.koin.domain.user.dto.StudentLoginRequest; import in.koreatech.koin.domain.user.dto.StudentLoginResponse; import in.koreatech.koin.domain.user.dto.StudentRegisterRequest; diff --git a/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java b/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java index 14997ed60..0b858b179 100644 --- a/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java +++ b/src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java @@ -34,7 +34,7 @@ default User getByEmail(String email) { default User getByPhoneNumber(String phoneNumber, UserType userType) { return findByPhoneNumberAndUserType(phoneNumber, userType) - .orElseThrow(() -> UserNotFoundException.withDetail("phoneNumber: " + phoneNumber)); + .orElseThrow(() -> UserNotFoundException.withDetail("account: " + phoneNumber)); } default User getById(Integer userId) { @@ -62,4 +62,6 @@ default User getByResetToken(String resetToken) { void delete(User user); List findAllByDeviceTokenIsNotNull(); + + Optional findByPhoneNumber(String phoneNumber); } diff --git a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java index 98e839f9c..93843f8de 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java @@ -28,7 +28,6 @@ import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.domain.user.repository.UserTokenRepository; import in.koreatech.koin.global.auth.JwtProvider; -import in.koreatech.koin.global.auth.exception.AuthenticationException; import in.koreatech.koin.global.auth.exception.AuthorizationException; import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java index 8de388546..608962a86 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerApiTest.java @@ -18,9 +18,9 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.redis.OwnerVerificationStatus; -import in.koreatech.koin.domain.owner.repository.redis.OwnerVerificationStatusRepository; import in.koreatech.koin.domain.owner.repository.OwnerRepository; import in.koreatech.koin.domain.owner.repository.OwnerShopRedisRepository; +import in.koreatech.koin.domain.owner.repository.redis.OwnerVerificationStatusRepository; import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.repository.UserRepository; @@ -704,4 +704,116 @@ void ownerDelete() { // then assertThat(userRepository.findById(owner.getId())).isNotPresent(); } + + @Test + @DisplayName("사업자 등록번호 중복 검증 - 존재하지 않으면 200") + void checkDuplicateCompanyNumber() { + // when & then + RestAssured + .given() + .queryParam("company_number", "123-45-67190") + .when() + .get("/owners/exists/company-number") + .then() + .statusCode(HttpStatus.OK.value()); + } + + @Test + @DisplayName("사업자 등록번호 중복 검증 - 이미 존재하면 409") + void checkDuplicateCompanyNumberExists() { + // given + Owner owner = userFixture.현수_사장님(); + // when & then + var response = RestAssured + .given() + .queryParam("company_number", owner.getCompanyRegistrationNumber()) + .when() + .get("/owners/exists/company-number") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + + assertThat(response.body().jsonPath().getString("message")) + .isEqualTo("이미 존재하는 사업자 등록번호입니다."); + } + + @Test + @DisplayName("사업자 등록번호 중복 검증 - 값이 존재하지 않으면 400") + void checkDuplicateCompanyNumberNotAccept() { + // when & then + RestAssured + .given() + .when() + .get("/owners/exists/company-number") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사업자 등록번호 중복 검증 - 값이 올바르지 않으면 400") + void checkDuplicateCompanyNumberNotMatchedPattern() { + // when & then + RestAssured + .given() + .queryParam("company_number", "1234567890") + .when() + .get("/owners/exists/company-number") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("사장님 아이디(전화번호) 중복 검증 - 존재하지 않으면 200") + void checkExistsPhoneNumber() { + RestAssured + .given() + .param("account", "01012345678") + .when() + .get("/owners/exists/account") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + } + + @Test + @DisplayName("사장님 아이디(전화번호) 중복 검증 - 이미 존재하면 409") + void checkExistsPhoneNumberConflict() { + Owner owner = userFixture.현수_사장님(); + var response = RestAssured + .given() + .param("account", owner.getAccount()) + .when() + .get("/owners/exists/account") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + + assertThat(response.body().jsonPath().getString("message")) + .contains("이미 존재하는 휴대폰번호입니다."); + } + + @Test + @DisplayName("사장님 아이디(전화번호) 중복 검증 - 파라미터에 전화번호를 포함하지 않으면 400") + void checkExistsPhoneNumberNull() { + RestAssured + .when() + .get("/owners/exists/account") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("사장님 아이디(전화번호) 중복 검증 - 잘못된 전화번호 형식이면 400") + void checkExistsPhoneNumberWrongFormat() { + String phoneNumber = "123123123123"; + RestAssured + .given() + .param("phone_number", phoneNumber) + .when() + .get("/owners/exists/account") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } } diff --git a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java index 08cb07d2b..bda450b83 100644 --- a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java @@ -26,7 +26,6 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.domain.coop.model.Coop; import in.koreatech.koin.domain.dept.model.Dept; -import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserGender; diff --git a/src/test/java/in/koreatech/koin/fixture/UserFixture.java b/src/test/java/in/koreatech/koin/fixture/UserFixture.java index 26f80d1d0..c71e83ecc 100644 --- a/src/test/java/in/koreatech/koin/fixture/UserFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/UserFixture.java @@ -2,11 +2,13 @@ import static in.koreatech.koin.domain.user.model.UserGender.MAN; import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; -import static in.koreatech.koin.domain.user.model.UserType.*; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static in.koreatech.koin.domain.user.model.UserType.COOP; +import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; @@ -134,6 +136,7 @@ public UserFixture( .build(); Owner owner = Owner.builder() + .account("01098987979") .user(user) .companyRegistrationNumber("123-45-67190") .grantShop(true) From 8468ffa581c20ded164f86017e5f977bcd2f3528 Mon Sep 17 00:00:00 2001 From: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Date: Tue, 25 Jun 2024 13:14:02 +0900 Subject: [PATCH 18/37] =?UTF-8?q?fix=20:=20Kcal=EA=B0=80=20null=20?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=BD=EC=9A=B0=200=EC=9D=84=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A1=A4=EB=B0=B1=20(#6?= =?UTF-8?q?26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/koreatech/koin/domain/dining/dto/DiningResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java index 5c01fb1fb..7174503bf 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java @@ -72,7 +72,7 @@ public static DiningResponse from(Dining dining) { dining.getPlace(), dining.getPriceCard(), dining.getPriceCash(), - dining.getKcal(), + dining.getKcal() != null ? dining.getKcal() : 0, dining.getMenu(), dining.getImageUrl(), dining.getCreatedAt(), From dfe19d71d63963a10660ff853099f9ce4fec67a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= <46699595+ImTotem@users.noreply.github.com> Date: Wed, 26 Jun 2024 09:13:00 +0900 Subject: [PATCH 19/37] =?UTF-8?q?fix:=20CityBusRoute=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=8B=9C=20null=20=EC=A0=9C=EC=99=B8=20=EC=B6=94=EA=B0=80=20(#?= =?UTF-8?q?629)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/domain/bus/util/CityBusRouteClient.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java index c7cf25468..2f24a71ca 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java +++ b/src/main/java/in/koreatech/koin/domain/bus/util/CityBusRouteClient.java @@ -80,12 +80,10 @@ public void storeCityBusRoute() { List nodeIds = BusStationNode.getNodeIds(); for (String node : nodeIds) { - cityBusRouteCacheRepository.save( - CityBusRouteCache.of( - node, - Set.copyOf(extractBusRouteInfo(getOpenApiResponse(node))) - ) - ); + Set routes = Set.copyOf(extractBusRouteInfo(getOpenApiResponse(node))); + if (routes.isEmpty()) { continue; } + + cityBusRouteCacheRepository.save(CityBusRouteCache.of(node, routes)); } } From c5c111840980864d8da6fbf0fbde0a08eb04b6ba Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Wed, 26 Jun 2024 16:32:58 +0900 Subject: [PATCH 20/37] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=83=81=EC=A0=90=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?api=EC=9E=91=EC=84=B1=20(#619)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 컨트롤러 작성 * feat: 상점 메뉴관련 조회 api 구현 * feat: 상점 메뉴관련 조회 api 구현 2 * feat: 특정 상점 메뉴 조회 테스트코드 작성 * feat: 테스트코드 작성 * feat: 테스트코드 작성 * refactor: 불필요한 공백 제거 * refactor: 리뷰 반영 --------- Co-authored-by: HyeonsuLee --- .../admin/shop/controller/AdminShopApi.java | 190 ++++++ .../shop/controller/AdminShopController.java | 132 +++++ .../dto/AdminCreateMenuCategoryRequest.java | 16 + .../shop/dto/AdminCreateMenuRequest.java | 74 +++ .../shop/dto/AdminMenuCategoriesResponse.java | 42 ++ .../shop/dto/AdminMenuDetailResponse.java | 112 ++++ .../dto/AdminModifyMenuCategoryRequest.java | 18 + .../shop/dto/AdminModifyMenuRequest.java | 68 +++ .../admin/shop/dto/AdminShopMenuResponse.java | 141 +++++ .../AdminMenuCategoryMapRepository.java | 10 + .../AdminMenuCategoryRepository.java | 26 + .../repository/AdminMenuDetailRepository.java | 10 + .../repository/AdminMenuImageRepository.java | 10 + .../shop/repository/AdminMenuRepository.java | 24 + .../AdminShopCategoryMapRepository.java | 14 + .../AdminShopCategoryRepository.java | 25 + .../repository/AdminShopImageRepository.java | 14 + .../repository/AdminShopOpenRepository.java | 14 + .../shop/repository/AdminShopRepository.java | 42 ++ .../admin/shop/service/AdminShopService.java | 171 ++++++ .../user/repository/AdminShopRepository.java | 12 - .../admin/user/service/AdminUserService.java | 11 +- .../koin/domain/shop/model/Menu.java | 24 + .../koin/domain/shop/model/Shop.java | 4 + .../admin/acceptance/AdmimShopApiTest.java | 556 ++++++++++++++++++ 25 files changed, 1742 insertions(+), 18 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuCategoryRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuCategoriesResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuCategoryRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopMenuResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryMapRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuDetailRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuImageRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopImageRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopOpenRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java delete mode 100644 src/main/java/in/koreatech/koin/admin/user/repository/AdminShopRepository.java create mode 100644 src/test/java/in/koreatech/koin/admin/acceptance/AdmimShopApiTest.java diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java new file mode 100644 index 000000000..fb326fb7d --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java @@ -0,0 +1,190 @@ +package in.koreatech.koin.admin.shop.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Shop: 상점", description = "상점 정보를 관리한다") +public interface AdminShopApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 모든 메뉴 조회") + @GetMapping("/admin/shops/{id}/menus") + ResponseEntity getAllMenus( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 모든 메뉴 카테고리 조회") + @GetMapping("/admin/shops/{id}/menus/categories") + ResponseEntity getAllMenuCategories( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 조회") + @GetMapping("/admin/shops/{shopId}/menus/{menuId}") + ResponseEntity getMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 생성") + @PostMapping("/admin/shops/{id}/menus") + ResponseEntity createMenu( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @RequestBody @Valid AdminCreateMenuRequest adminCreateMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 카테고리 생성") + @PostMapping("/admin/shops/{id}/menus/categories") + ResponseEntity createMenuCategory( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @RequestBody @Valid AdminCreateMenuCategoryRequest adminCreateMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 삭제 해제") + @PostMapping("/admin/shops/{id}/undelete") + ResponseEntity cancelShopDelete( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 카테고리 수정") + @PutMapping("/admin/shops/{shopId}/menus/categories") + ResponseEntity modifyMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @RequestBody @Valid AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 수정") + @PutMapping("/admin/shops/{shopId}/menus/{menuId}") + ResponseEntity modifyMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @RequestBody @Valid AdminModifyMenuRequest adminModifyMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴카테고리 삭제") + @DeleteMapping("/admin/shops/{shopId}/menus/categories/{categoryId}") + ResponseEntity deleteMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("categoryId") Integer categoryId, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점의 메뉴 삭제") + @DeleteMapping("/admin/shops/{shopId}/menus/{menuId}") + ResponseEntity deleteMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @Auth(permit = {ADMIN}) Integer adminId + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java new file mode 100644 index 000000000..38520980b --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java @@ -0,0 +1,132 @@ +package in.koreatech.koin.admin.shop.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.service.AdminShopService; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AdminShopController implements AdminShopApi { + + private final AdminShopService adminShopService; + + @GetMapping("/admin/shops/{id}/menus") + public ResponseEntity getAllMenus( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopMenuResponse adminShopMenuResponse = adminShopService.getAllMenus(shopId); + return ResponseEntity.ok(adminShopMenuResponse); + } + + @GetMapping("/admin/shops/{id}/menus/categories") + public ResponseEntity getAllMenuCategories( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminMenuCategoriesResponse adminMenuCategoriesResponse = adminShopService.getAllMenuCategories(shopId); + return ResponseEntity.ok(adminMenuCategoriesResponse); + } + + @GetMapping("/admin/shops/{shopId}/menus/{menuId}") + public ResponseEntity getMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminMenuDetailResponse adminMenuDetailResponse = adminShopService.getMenu(shopId, menuId); + return ResponseEntity.ok(adminMenuDetailResponse); + } + + @PostMapping("/admin/shops/{id}/menus") + public ResponseEntity createMenu( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @RequestBody @Valid AdminCreateMenuRequest adminCreateMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.createMenu(shopId, adminCreateMenuRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/admin/shops/{id}/menus/categories") + public ResponseEntity createMenuCategory( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @RequestBody @Valid AdminCreateMenuCategoryRequest adminCreateMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.createMenuCategory(shopId, adminCreateMenuCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/admin/shops/{id}/undelete") + public ResponseEntity cancelShopDelete( + @Parameter(in = PATH) @PathVariable("id") Integer shopId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.cancelShopDelete(shopId); + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @PutMapping("/admin/shops/{shopId}/menus/categories") + public ResponseEntity modifyMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @RequestBody @Valid AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyMenuCategory(shopId, adminModifyMenuCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/admin/shops/{shopId}/menus/{menuId}") + public ResponseEntity modifyMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @RequestBody @Valid AdminModifyMenuRequest adminModifyMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyMenu(shopId, menuId, adminModifyMenuRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/admin/shops/{shopId}/menus/categories/{categoryId}") + public ResponseEntity deleteMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("categoryId") Integer categoryId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.deleteMenuCategory(shopId, categoryId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @DeleteMapping("/admin/shops/{shopId}/menus/{menuId}") + public ResponseEntity deleteMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.deleteMenu(shopId, menuId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuCategoryRequest.java new file mode 100644 index 000000000..c7ee0f8c0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuCategoryRequest.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AdminCreateMenuCategoryRequest( + @Schema(example = "사이드 메뉴", description = "카테고리명", requiredMode = REQUIRED) + @NotBlank(message = "카테고리명은 필수입니다.") + @Size(min = 1, max = 20, message = "카테고리명은 1자 이상 20자 이하로 입력해주세요.") + String name +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java new file mode 100644 index 000000000..02de34e20 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java @@ -0,0 +1,74 @@ +package in.koreatech.koin.admin.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminCreateMenuRequest( + @Schema(example = "[1, 2, 3]", description = "선택된 카테고리 고유 id 리스트", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + List categoryIds, + + @Schema(example = "저희 가게의 대표 메뉴 짜장면입니다.", description = "메뉴 구성 설명", requiredMode = REQUIRED) + @Size(max = 80, message = "메뉴 구성 설명은 80자 이하로 입력해주세요.") + String description, + + @Schema(example = """ + [ "https://static.koreatech.in/example.png" ] + """, description = "이미지 URL 리스트", requiredMode = REQUIRED) + @Size(max = 3, message = "이미지는 최대 3개까지 입력 가능합니다.") + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(example = "true", description = "단일 메뉴 여부", requiredMode = REQUIRED) + @NotNull(message = "단일 메뉴 여부는 필수입니다.") + boolean isSingle, + + @Schema(example = "짜장면", description = "메뉴명") + @NotNull(message = "메뉴명은 필수입니다.") + @Size(min = 1, max = 25, message = "메뉴명은 1자 이상 25자 이하로 입력해주세요.") + String name, + + @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer singlePrice +) { + + public Menu toEntity(Integer shopId) { + return Menu.builder() + .name(name) + .shopId(shopId) + .description(description) + .build(); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + @NotNull @Size(min = 1, max = 50) String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + @NotNull Integer price + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuCategoriesResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuCategoriesResponse.java new file mode 100644 index 000000000..b1a0508a9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuCategoriesResponse.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.MenuCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminMenuCategoriesResponse( + @Schema(description = "카테고리 수", example = "3") + Long count, + + @Schema(description = "카테고리 목록") + List menuCategories +) { + + public static AdminMenuCategoriesResponse from(List menuCategories) { + List categories = menuCategories.stream() + .map(menuCategory -> MenuCategoryResponse.of(menuCategory.getId(), menuCategory.getName())) + .toList(); + + return new AdminMenuCategoriesResponse((long)categories.size(), categories); + } + + private record MenuCategoryResponse( + @Schema(description = "카테고리 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "카테고리 이름", example = "치킨", requiredMode = REQUIRED) + String name + ) { + + public static MenuCategoryResponse of(Integer id, String name) { + return new MenuCategoryResponse(id, name); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java new file mode 100644 index 000000000..8942d7d91 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminMenuDetailResponse.java @@ -0,0 +1,112 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.global.exception.KoinIllegalStateException; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminMenuDetailResponse( + @Schema(example = "1", description = "고유id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "1", description = "메뉴가 소속된 상점의 고유 id", requiredMode = REQUIRED) + Integer shopId, + + @Schema(example = "탕수육", description = "이름", requiredMode = REQUIRED) + String name, + + @Schema(example = "false", description = "숨김 여부", requiredMode = REQUIRED) + Boolean isHidden, + + @Schema(example = "false", description = "단일 메뉴 여부", requiredMode = REQUIRED) + Boolean isSingle, + + @Schema(example = "7000", description = "단일 메뉴일때(is_single이 true일때)의 가격", requiredMode = REQUIRED) + Integer singlePrice, + + @Schema(description = "옵션이 있는 메뉴일때(is_single이 false일때)의 가격", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(example = "돼지고기 + 튀김", description = "구성 설명", requiredMode = REQUIRED) + String description, + + @Schema(description = "소속되어 있는 메뉴 카테고리 고유 id 리스트", requiredMode = REQUIRED) + List categoryIds, + + @Schema(description = "이미지 URL 리스트", requiredMode = NOT_REQUIRED) + List imageUrls +) { + + public static AdminMenuDetailResponse createForSingleOption(Menu menu, List shopMenuCategories) { + if (menu.hasMultipleOption()) { + log.warn("{}는 옵션이 하나 이상인 메뉴입니다. createForMultipleOption 메서드를 이용해야 합니다.", menu); + throw new KoinIllegalStateException("서버에 에러가 발생했습니다."); + } + + return new AdminMenuDetailResponse( + menu.getId(), + menu.getShopId(), + menu.getName(), + menu.isHidden(), + true, + menu.getMenuOptions().get(0).getPrice(), + null, + menu.getDescription(), + shopMenuCategories.stream().map(MenuCategory::getId).toList(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + public static AdminMenuDetailResponse createMenuDetailResponse(Menu menu, List menuCategories) { + if (menu.hasMultipleOption()) { + return AdminMenuDetailResponse.createForMultipleOption(menu, menuCategories); + } + return AdminMenuDetailResponse.createForSingleOption(menu, menuCategories); + } + + public static AdminMenuDetailResponse createForMultipleOption(Menu menu, List shopMenuCategories) { + if (!menu.hasMultipleOption()) { + log.error("{}는 옵션이 하나인 메뉴입니다. createForSingleOption 메서드를 이용해야 합니다.", menu); + throw new KoinIllegalStateException("서버에 에러가 발생했습니다."); + } + + return new AdminMenuDetailResponse( + menu.getId(), + menu.getShopId(), + menu.getName(), + menu.isHidden(), + false, + null, + menu.getMenuOptions().stream().map(InnerOptionPriceResponse::of).toList(), + menu.getDescription(), + shopMenuCategories.stream().map(MenuCategory::getId).toList(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + private record InnerOptionPriceResponse( + @Schema(example = "소", description = "옵션명", requiredMode = REQUIRED) + String option, + + @Schema(example = "10000", description = "옵션에 대한 가격", requiredMode = REQUIRED) + Integer price + ) { + + public static InnerOptionPriceResponse of(MenuOption menuOption) { + return new InnerOptionPriceResponse(menuOption.getOption(), menuOption.getPrice()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuCategoryRequest.java new file mode 100644 index 000000000..0415769d9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuCategoryRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record AdminModifyMenuCategoryRequest( + @Schema(example = "1", description = "상점 카테고리 고유 id", requiredMode = REQUIRED) + @NotNull(message = "카테고리 ID는 필수입니다.") + Integer id, + + @Schema(example = "사이드 메뉴", description = "카테고리 명", requiredMode = REQUIRED) + @NotNull(message = "카테고리 명은 필수입니다.") + String name +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java new file mode 100644 index 000000000..e5617f5d6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java @@ -0,0 +1,68 @@ +package in.koreatech.koin.admin.shop.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminModifyMenuRequest( + @Schema(example = "[1, 2, 3]", description = "선택된 카테고리 고유 id 리스트", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + List categoryIds, + + @Schema(example = "저희 가게의 대표 메뉴 짜장면입니다.", description = "메뉴 구성 설명", requiredMode = REQUIRED) + @Size(max = 80, message = "메뉴 구성 설명은 80자 이하로 입력해주세요.") + String description, + + @Schema(example = """ + [ "https://static.koreatech.in/example.png" ] + """, description = "이미지 URL 리스트", requiredMode = NOT_REQUIRED) + @Size(max = 3, message = "이미지는 최대 3개까지 입력 가능합니다.") + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(example = "true", description = "단일 메뉴 여부", requiredMode = REQUIRED) + @NotNull(message = "단일 메뉴 여부는 필수입니다.") + boolean isSingle, + + @Schema(example = "짜장면", description = "메뉴명", requiredMode = REQUIRED) + @NotNull(message = "메뉴명은 필수입니다.") + @Size(min = 1, max = 25, message = "메뉴명은 1자 이상 25자 이하로 입력해주세요.") + String name, + + @Schema(description = "단일 메뉴가 아닐때의 옵션에 따른 가격 리스트 / 단일 메뉴일 경우 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(description = "단일 메뉴일때의 가격 / 단일 메뉴가 아닐 경우 null", requiredMode = NOT_REQUIRED) + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer singlePrice +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + @NotNull(message = "옵션명은 필수입니다.") + @Size(min = 1, max = 50, message = "옵션명은 1자 이상 50자 이하로 입력해주세요.") + String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + @NotNull(message = "가격은 필수입니다.") + @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") + Integer price + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopMenuResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopMenuResponse.java new file mode 100644 index 000000000..cc8ff0d30 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopMenuResponse.java @@ -0,0 +1,141 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopMenuResponse( + @Schema(example = "20", description = "개수", requiredMode = REQUIRED) + Integer count, + + @Schema(description = "카테고리 별로 분류된 소속 메뉴 리스트") + List menuCategories, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(example = "2024-03-16", description = "해당 상점 마지막 메뉴 업데이트 날짜", requiredMode = REQUIRED) + LocalDateTime updatedAt +) { + + public static AdminShopMenuResponse from(List menuCategories) { + List filteredMenuCategories = menuCategories.stream() + .filter(menuCategory -> !menuCategory.getMenuCategoryMaps().isEmpty()) + .toList(); + + int totalMapsCount = filteredMenuCategories.stream() + .mapToInt(menuCategory -> menuCategory.getMenuCategoryMaps().size()) + .sum(); + + LocalDateTime lastUpdatedAt = filteredMenuCategories.stream() + .flatMap(menuCategory -> menuCategory.getMenuCategoryMaps().stream()) + .map(menuCategoryMap -> menuCategoryMap.getMenu().getUpdatedAt()) + .max(LocalDateTime::compareTo) + .orElse(LocalDateTime.MIN); + + List responses = filteredMenuCategories.stream() + .map(InnerMenuCategoriesResponse::from) + .toList(); + + return new AdminShopMenuResponse( + totalMapsCount, + responses, + lastUpdatedAt + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerMenuCategoriesResponse( + @Schema(example = "1", description = "카테고리 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "중식", description = "카테고리 이름", requiredMode = REQUIRED) + String name, + + @Schema(description = "해당 상점의 모든 메뉴 리스트") + List menus + ) { + + public static InnerMenuCategoriesResponse from(MenuCategory menuCategory) { + return new InnerMenuCategoriesResponse( + menuCategory.getId(), + menuCategory.getName(), + menuCategory.getMenuCategoryMaps().stream().map(InnerMenuResponse::from).toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerMenuResponse( + @Schema(example = "1", description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(example = "탕수육", description = "이름", requiredMode = NOT_REQUIRED) + String name, + + @Schema(example = "false", description = "숨김 여부", requiredMode = REQUIRED) + Boolean isHidden, + + @Schema(example = "false", description = "단일 메뉴 여부", requiredMode = REQUIRED) + Boolean isSingle, + + @Schema(example = "10000", description = "단일 메뉴일때(is_single이 true일때)의 가격 / 단일 메뉴가 아니라면 null", requiredMode = NOT_REQUIRED) + Integer singlePrice, + + @Schema(description = "옵션이 있는 메뉴일때(is_single이 false일때)의 옵션에 따른 가격 리스트 / 단일 메뉴 라면 null", requiredMode = NOT_REQUIRED) + List optionPrices, + + @Schema(example = "저희 식당의 대표 메뉴 탕수육입니다.", description = "설명", requiredMode = NOT_REQUIRED) + String description, + + @Schema(description = "이미지 URL리스트", example = """ + [ "https://static.koreatech.in/example.png", "https://static.koreatech.in/example2.png" ] + """, requiredMode = NOT_REQUIRED) + List imageUrls + ) { + + public static InnerMenuResponse from(MenuCategoryMap menuCategoryMap) { + Menu menu = menuCategoryMap.getMenu(); + boolean isSingle = !menu.hasMultipleOption(); + return new InnerMenuResponse( + menu.getId(), + menu.getName(), + menu.isHidden(), + isSingle, + isSingle ? menu.getMenuOptions().get(0).getPrice() : null, + isSingle ? null : menu.getMenuOptions().stream().map(InnerOptionPrice::from).toList(), + menu.getDescription(), + menu.getMenuImages().stream().map(MenuImage::getImageUrl).toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + private record InnerOptionPrice( + @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) + String option, + + @Schema(example = "26000", description = "가격", requiredMode = REQUIRED) + Integer price + ) { + + public static InnerOptionPrice from(MenuOption menuOption) { + return new InnerOptionPrice( + menuOption.getOption(), + menuOption.getPrice() + ); + } + } + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryMapRepository.java new file mode 100644 index 000000000..d0f9c352f --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryMapRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.admin.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; + +public interface AdminMenuCategoryMapRepository extends Repository { + + MenuCategoryMap save(MenuCategoryMap menuCategoryMap); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java new file mode 100644 index 000000000..21e3868cb --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.MenuCategoryNotFoundException; +import in.koreatech.koin.domain.shop.model.MenuCategory; + +public interface AdminMenuCategoryRepository extends Repository { + + List findAllByShopId(Integer shopId); + + MenuCategory save(MenuCategory menuCategory); + + Optional findById(Integer id); + + List findAllByIdIn(List ids); + + default MenuCategory getById(Integer id) { + return findById(id).orElseThrow(() -> MenuCategoryNotFoundException.withDetail("categoryId: " + id)); + } + + Void deleteById(Integer id); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuDetailRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuDetailRepository.java new file mode 100644 index 000000000..09c910a08 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuDetailRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.admin.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuOption; + +public interface AdminMenuDetailRepository extends Repository { + + MenuOption save(MenuOption menuOption); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuImageRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuImageRepository.java new file mode 100644 index 000000000..8f9066321 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuImageRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.admin.shop.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.MenuImage; + +public interface AdminMenuImageRepository extends Repository { + + MenuImage save(MenuImage menuImage); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuRepository.java new file mode 100644 index 000000000..61d773248 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuRepository.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.MenuNotFoundException; +import in.koreatech.koin.domain.shop.model.Menu; + +public interface AdminMenuRepository extends Repository { + + Optional findById(Integer menuId); + + Menu save(Menu menu); + + void deleteById(Integer id); + + default Menu getById(Integer menuId) { + return findById(menuId).orElseThrow(() -> MenuNotFoundException.withDetail("menuId: " + menuId)); + } + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java new file mode 100644 index 000000000..ba378ed9e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryMapRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; + +public interface AdminShopCategoryMapRepository extends Repository { + + ShopCategoryMap save(ShopCategoryMap shopCategoryMap); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java new file mode 100644 index 000000000..52ae86ebc --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.exception.ShopCategoryNotFoundException; +import in.koreatech.koin.domain.shop.model.ShopCategory; + +public interface AdminShopCategoryRepository extends Repository { + + Optional findById(Integer shopCategoryId); + + ShopCategory save(ShopCategory shopCategory); + + List findAllByIdIn(List ids); + + default ShopCategory getById(Integer shopCategoryId) { + return findById(shopCategoryId) + .orElseThrow(() -> ShopCategoryNotFoundException.withDetail("shopCategoryId: " + shopCategoryId)); + } + + List findAll(); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopImageRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopImageRepository.java new file mode 100644 index 000000000..1749d489b --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopImageRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopImage; + +public interface AdminShopImageRepository extends Repository { + + ShopImage save(ShopImage shopImage); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopOpenRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopOpenRepository.java new file mode 100644 index 000000000..8b1fa5db0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopOpenRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.shop.model.ShopOpen; + +public interface AdminShopOpenRepository extends Repository { + + ShopOpen save(ShopOpen shopOpen); + + List findAllByShopId(Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java new file mode 100644 index 000000000..3bba83f33 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.shop.exception.ShopNotFoundException; +import in.koreatech.koin.domain.shop.model.Shop; + +public interface AdminShopRepository extends Repository { + + Shop save(Shop shop); + + List findAllByOwnerId(Integer ownerId); + + Optional findById(Integer shopId); + + Optional findByOwnerId(Integer ownerId); + + default Shop getById(Integer shopId) { + return findById(shopId) + .orElseThrow(() -> ShopNotFoundException.withDetail("shopId: " + shopId)); + } + + default Shop getByOwnerId(Integer ownerId) { + return findByOwnerId(ownerId) + .orElseThrow(() -> ShopNotFoundException.withDetail("ownerId: " + ownerId)); + } + + List findAll(); + + @Query(value = "SELECT * FROM shops WHERE id = :shopId AND is_deleted = true", nativeQuery = true) + Optional findDeletedShopById(@Param("shopId") Integer shopId); + + @Modifying + @Query(value = "UPDATE shops SET is_deleted = true WHERE id = :shopId", nativeQuery = true) + int deleteById(@Param("shopId") Integer shopId); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java new file mode 100644 index 000000000..d0f3bc7dc --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -0,0 +1,171 @@ +package in.koreatech.koin.admin.shop.service; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest.InnerOptionPrice; +import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryMapRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuDetailRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuImageRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopRepository; +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminShopService { + + private final EntityManager entityManager; + private final AdminShopRepository adminShopRepository; + private final AdminMenuRepository adminMenuRepository; + private final AdminMenuCategoryRepository adminMenuCategoryRepository; + private final AdminMenuCategoryMapRepository adminMenuCategoryMapRepository; + private final AdminMenuImageRepository adminMenuImageRepository; + private final AdminMenuDetailRepository adminMenuDetailRepository; + + public AdminShopMenuResponse getAllMenus(Integer shopId) { + Shop shop = adminShopRepository.getById(shopId); + List menuCategories = adminMenuCategoryRepository.findAllByShopId(shop.getId()); + Collections.sort(menuCategories); + return AdminShopMenuResponse.from(menuCategories); + } + + public AdminMenuCategoriesResponse getAllMenuCategories(Integer shopId) { + Shop shop = adminShopRepository.getById(shopId); + List menuCategories = adminMenuCategoryRepository.findAllByShopId(shop.getId()); + Collections.sort(menuCategories); + return AdminMenuCategoriesResponse.from(menuCategories); + } + + public AdminMenuDetailResponse getMenu(Integer shopId, Integer menuId) { + adminShopRepository.getById(shopId); + Menu menu = adminMenuRepository.getById(menuId); + List menuCategories = menu.getMenuCategoryMaps() + .stream() + .map(MenuCategoryMap::getMenuCategory) + .toList(); + return AdminMenuDetailResponse.createMenuDetailResponse(menu, menuCategories); + } + + @Transactional + public void createMenu(Integer shopId, AdminCreateMenuRequest adminCreateMenuRequest) { + adminShopRepository.getById(shopId); + Menu menu = adminCreateMenuRequest.toEntity(shopId); + Menu savedMenu = adminMenuRepository.save(menu); + for (Integer categoryId : adminCreateMenuRequest.categoryIds()) { + MenuCategory menuCategory = adminMenuCategoryRepository.getById(categoryId); + MenuCategoryMap menuCategoryMap = MenuCategoryMap.builder() + .menuCategory(menuCategory) + .menu(savedMenu) + .build(); + adminMenuCategoryMapRepository.save(menuCategoryMap); + } + for (String imageUrl : adminCreateMenuRequest.imageUrls()) { + MenuImage menuImage = MenuImage.builder() + .imageUrl(imageUrl) + .menu(savedMenu) + .build(); + adminMenuImageRepository.save(menuImage); + } + if (adminCreateMenuRequest.optionPrices() == null) { + MenuOption menuOption = MenuOption.builder() + .option(savedMenu.getName()) + .price(adminCreateMenuRequest.singlePrice()) + .menu(menu) + .build(); + adminMenuDetailRepository.save(menuOption); + } else { + for (var option : adminCreateMenuRequest.optionPrices()) { + MenuOption menuOption = MenuOption.builder() + .option(option.option()) + .price(option.price()) + .menu(menu) + .build(); + adminMenuDetailRepository.save(menuOption); + } + } + } + + @Transactional + public void createMenuCategory(Integer shopId, AdminCreateMenuCategoryRequest adminCreateMenuCategoryRequest) { + Shop shop = adminShopRepository.getById(shopId); + MenuCategory menuCategory = MenuCategory.builder() + .shop(shop) + .name(adminCreateMenuCategoryRequest.name()) + .build(); + adminMenuCategoryRepository.save(menuCategory); + } + + @Transactional + public void cancelShopDelete(Integer shopId) { + Optional shop = adminShopRepository.findDeletedShopById(shopId); + if(shop.isPresent()) { + shop.get().cancelDelete(); + } + } + + @Transactional + public void modifyMenuCategory(Integer shopId, AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest) { + adminShopRepository.getById(shopId); + MenuCategory menuCategory = adminMenuCategoryRepository.getById(adminModifyMenuCategoryRequest.id()); + menuCategory.modifyName(adminModifyMenuCategoryRequest.name()); + } + + @Transactional + public void modifyMenu(Integer shopId, Integer menuId, AdminModifyMenuRequest adminModifyMenuRequest) { + Menu menu = adminMenuRepository.getById(menuId); + adminShopRepository.getById(shopId); + menu.modifyMenu( + adminModifyMenuRequest.name(), + adminModifyMenuRequest.description() + ); + menu.modifyMenuImages(adminModifyMenuRequest.imageUrls(), entityManager); + menu.modifyMenuCategories(adminMenuCategoryRepository.findAllByIdIn(adminModifyMenuRequest.categoryIds()), entityManager); + if (adminModifyMenuRequest.isSingle()) { + menu.adminModifyMenuSingleOptions(adminModifyMenuRequest, entityManager); + } else { + List optionPrices = adminModifyMenuRequest.optionPrices(); + menu.adminModifyMenuMultipleOptions(optionPrices, entityManager); + } + } + + @Transactional + public void deleteMenuCategory(Integer shopId, Integer categoryId) { + MenuCategory menuCategory = adminMenuCategoryRepository.getById(categoryId); + if (!Objects.equals(menuCategory.getShop().getId(), shopId)) { + throw new KoinIllegalArgumentException("해당 상점의 카테고리가 아닙니다."); + } + adminMenuCategoryRepository.deleteById(categoryId); + } + + @Transactional + public void deleteMenu(Integer shopId, Integer menuId) { + Menu menu = adminMenuRepository.getById(menuId); + if (!Objects.equals(menu.getShopId(), shopId)) { + throw new KoinIllegalArgumentException("해당 상점의 카테고리가 아닙니다."); + } + adminMenuRepository.deleteById(menuId); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminShopRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminShopRepository.java deleted file mode 100644 index 98b6823af..000000000 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminShopRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package in.koreatech.koin.admin.user.repository; - -import java.util.List; - -import org.springframework.data.repository.Repository; - -import in.koreatech.koin.domain.shop.model.Shop; - -public interface AdminShopRepository extends Repository { - - List findAllByOwnerId(Integer ownerId); -} diff --git a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java index 272c0fd22..92ed7a8ba 100644 --- a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java +++ b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java @@ -1,15 +1,15 @@ package in.koreatech.koin.admin.user.service; +import java.util.List; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; -import java.util.List; -import java.util.stream.Collectors; - import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.admin.shop.repository.AdminShopRepository; import in.koreatech.koin.admin.user.dto.AdminNewOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminOwnerResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; @@ -17,11 +17,10 @@ import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; import in.koreatech.koin.admin.user.dto.NewOwnersCondition; import in.koreatech.koin.admin.user.repository.AdminOwnerRepository; -import in.koreatech.koin.admin.user.repository.AdminShopRepository; import in.koreatech.koin.admin.user.repository.AdminStudentRepository; import in.koreatech.koin.admin.user.repository.AdminUserRepository; -import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.user.exception.DuplicationNicknameException; import in.koreatech.koin.domain.user.exception.StudentDepartmentNotValidException; @@ -106,7 +105,7 @@ public AdminOwnerResponse getOwner(Integer ownerId) { List shopsId = adminShopRepository.findAllByOwnerId(ownerId) .stream() .map(Shop::getId) - .collect(Collectors.toList()); + .toList(); return AdminOwnerResponse.of(owner, shopsId); } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java b/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java index 481990e50..110488e34 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.List; +import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest; import in.koreatech.koin.domain.shop.dto.ModifyMenuRequest.InnerOptionPrice; import in.koreatech.koin.global.domain.BaseEntity; @@ -127,6 +128,16 @@ public void modifyMenuSingleOptions(ModifyMenuRequest modifyMenuRequest, EntityM this.menuOptions.add(menuOption); } + public void adminModifyMenuSingleOptions(AdminModifyMenuRequest adminModifyMenuRequest, EntityManager entityManager) { + this.menuOptions.clear(); + entityManager.flush(); + MenuOption menuOption = MenuOption.builder() + .price(adminModifyMenuRequest.singlePrice()) + .menu(this) + .build(); + this.menuOptions.add(menuOption); + } + public void modifyMenuMultipleOptions(List innerOptionPrice, EntityManager entityManager) { this.menuOptions.clear(); entityManager.flush(); @@ -139,4 +150,17 @@ public void modifyMenuMultipleOptions(List innerOptionPrice, E this.menuOptions.add(menuOption); } } + + public void adminModifyMenuMultipleOptions(List innerOptionPrice, EntityManager entityManager) { + this.menuOptions.clear(); + entityManager.flush(); + for (var option : innerOptionPrice) { + MenuOption menuOption = MenuOption.builder() + .option(option.option()) + .price(option.price()) + .menu(this) + .build(); + this.menuOptions.add(menuOption); + } + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java index 22e94d826..dacf4bde5 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java @@ -231,4 +231,8 @@ private boolean isBetweenDate(LocalDateTime now, ShopOpen shopOpen, LocalDate cr } return !start.isAfter(now) && !end.isBefore(now); } + + public void cancelDelete() { + this.isDeleted = false; + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdmimShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdmimShopApiTest.java new file mode 100644 index 000000000..ec4d47abc --- /dev/null +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdmimShopApiTest.java @@ -0,0 +1,556 @@ +package in.koreatech.koin.admin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminMenuRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopRepository; +import in.koreatech.koin.domain.owner.model.Owner; +import in.koreatech.koin.domain.shop.model.Menu; +import in.koreatech.koin.domain.shop.model.MenuCategory; +import in.koreatech.koin.domain.shop.model.MenuCategoryMap; +import in.koreatech.koin.domain.shop.model.MenuImage; +import in.koreatech.koin.domain.shop.model.MenuOption; +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.repository.EventArticleRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.EventArticleFixture; +import in.koreatech.koin.fixture.MenuCategoryFixture; +import in.koreatech.koin.fixture.MenuFixture; +import in.koreatech.koin.fixture.ShopCategoryFixture; +import in.koreatech.koin.fixture.ShopFixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +public class AdmimShopApiTest extends AcceptanceTest { + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private AdminMenuRepository menuRepository; + + @Autowired + private AdminShopRepository shopRepository; + + @Autowired + private AdminMenuCategoryRepository menuCategoryRepository; + + @Autowired + private EventArticleRepository eventArticleRepository; + + @Autowired + private MenuFixture menuFixture; + + @Autowired + private UserFixture userFixture; + + @Autowired + private ShopFixture shopFixture; + + @Autowired + private ShopCategoryFixture shopCategoryFixture; + + @Autowired + private MenuCategoryFixture menuCategoryFixture; + + @Autowired + private EventArticleFixture eventArticleFixture; + + private Owner owner_현수; + private String token_현수; + private Owner owner_준영; + private String token_준영; + private Shop shop_마슬랜; + private User admin; + private String token_admin; + private ShopCategory shopCategory_치킨; + private ShopCategory shopCategory_일반; + private MenuCategory menuCategory_메인; + private MenuCategory menuCategory_사이드; + + @BeforeEach + void setUp() { + admin = userFixture.코인_운영자(); + token_admin = userFixture.getToken(admin); + owner_현수 = userFixture.현수_사장님(); + token_현수 = userFixture.getToken(owner_현수.getUser()); + owner_준영 = userFixture.준영_사장님(); + token_준영 = userFixture.getToken(owner_준영.getUser()); + shop_마슬랜 = shopFixture.마슬랜(owner_현수); + shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(); + shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(); + menuCategory_메인 = menuCategoryFixture.메인메뉴(shop_마슬랜); + menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); + } + + @Test + @DisplayName("어드민이 특정 상점의 모든 메뉴를 조회한다.") + void findShopMenus() { + // given + menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("id", shop_마슬랜.getId()) + .when() + .get("/admin/shops/{id}/menus") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 1, + "menu_categories": [ + { + "id": 1, + "name": "메인 메뉴", + "menus": [ + { + "id": 1, + "name": "짜장면", + "is_hidden": false, + "is_single": false, + "single_price": null, + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "description": "맛있는 짜장면", + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ] + } + ] + } + ], + "updated_at": "2024-01-15" + } + """); + } + + @Test + @DisplayName("어드민이 특정 상점의 메뉴 카테고리들을 조회한다.") + void findShopMenuCategories() { + // given + menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + var response = RestAssured + .given() + .pathParam("id", shop_마슬랜.getId()) + .header("Authorization", "Bearer " + token_admin) + .when() + .get("/admin/shops/{id}/menus/categories") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "count": 2, + "menu_categories": [ + { + "id": 1, + "name": "메인 메뉴" + }, + { + "id": 2, + "name": "사이드 메뉴" + } + ] + } + """); + } + + @Test + @DisplayName("어드민이 특정 상점의 특정 메뉴를 조회한다.") + void findShopMenu() { + // given + Menu menu = menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("menuId", menu.getId()) + .when() + .get("/admin/shops/{shopId}/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + System.out.println(JsonAssertions.assertThat(response.asPrettyString())); + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "category_ids": [1], + "description": "맛있는 짜장면", + "id": 1, + "image_urls": [ + "https://test.com/짜장면.jpg", + "https://test.com/짜장면22.jpg" + ], + "is_hidden": false, + "is_single": false, + "name": "짜장면", + "option_prices": [ + { + "option": "곱빼기", + "price": 7500 + }, + { + "option": "일반", + "price": 7000 + } + ], + "shop_id": 1, + "single_price": null + } + """); + } + + @Test + @DisplayName("어드민이 옵션이 여러개인 메뉴를 추가한다.") + void createManyOptionMenu() { + // given + MenuCategory menuCategory = menuCategory_메인; + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %s + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://test-image.com/짜장면.jpg" + ], + "is_single": false, + "name": "짜장면", + "option_prices": [ + { + "option": "중", + "price": 10000 + }, + { + "option": "소", + "price": 5000 + } + ] + } + """, menuCategory.getId())) + .when() + .post("/admin/shops/{id}/menus", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu menu = menuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = menu.getMenuCategoryMaps(); + List menuOptions = menu.getMenuOptions(); + List menuImages = menu.getMenuImages(); + softly.assertThat(menu.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(menu.getName()).isEqualTo("짜장면"); + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.com/짜장면.jpg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(1); + softly.assertThat(menuOptions).hasSize(2); + } + ); + }); + } + + @Test + @DisplayName("어드민이 옵션이 한개인 메뉴를 추가한다.") + void createOneOptionMenu() { + // given + MenuCategory menuCategory = menuCategory_메인; + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %s + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://test-image.com/짜장면.jpg" + ], + "is_single": true, + "name": "짜장면", + "option_prices": null, + "single_price": 10000 + } + """, menuCategory.getId())) + .when() + .post("/admin/shops/{id}/menus", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu menu = menuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = menu.getMenuCategoryMaps(); + List menuOptions = menu.getMenuOptions(); + List menuImages = menu.getMenuImages(); + softly.assertThat(menu.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(menu.getName()).isEqualTo("짜장면"); + + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.com/짜장면.jpg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(1); + + softly.assertThat(menuOptions.get(0).getPrice()).isEqualTo(10000); + } + ); + }); + } + + @Test + @DisplayName("어드민이 메뉴 카테고리를 추가한다.") + void createMenuCategory() { + // given + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("id", shop_마슬랜.getId()) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "name": "대박메뉴" + } + """)) + .when() + .post("/admin/shops/{id}/menus/categories") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + var menuCategories = menuCategoryRepository.findAllByShopId(shop_마슬랜.getId()); + + assertThat(menuCategories).anyMatch(menuCategory -> "대박메뉴".equals(menuCategory.getName())); + } + + @Test + @DisplayName("어드민이 상점 삭제를 해제한다.") + void cancelShopDeleted() { + // given + System.out.println("qwe"); + shopRepository.deleteById(shop_마슬랜.getId()); + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("id", shop_마슬랜.getId()) + .contentType(ContentType.JSON) + .when() + .post("/admin/shops/{id}/undelete") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + var shop = shopRepository.getById(shop_마슬랜.getId()); + assertSoftly(softly -> softly.assertThat(shop.isDeleted()).isFalse()); + } + + @Test + @DisplayName("어드민이 특점 상점의 메뉴 카테고리를 수정한다.") + void modifyMenuCategory() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .pathParam("shopId", shop_마슬랜.getId()) + .body(String.format(""" + { + "id": %s, + "name": "사이드 메뉴" + } + """, menuCategory_메인.getId())) + .when() + .put("/admin/shops/{shopId}/menus/categories") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + MenuCategory menuCategory = menuCategoryRepository.getById(menuCategory_메인.getId()); + assertSoftly(softly -> softly.assertThat(menuCategory.getName()).isEqualTo("사이드 메뉴")); + } + + @Test + @DisplayName("어드민이 특정 삼점의 메뉴를 단일 메뉴로 수정한다.") + void modifyOneMenu() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("menuId", menu.getId()) + .body(String.format(""" + { + "category_ids": [ + %d + ], + "description": "테스트메뉴수정", + "image_urls": [ + "https://test-image.net/테스트메뉴.jpeg" + ], + "is_single": true, + "name": "짜장면2", + "single_price": 10000 + } + """, shopCategory_일반.getId())) + .when() + .put("/admin/shops/{shopId}/menus/{menuId}") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu result = menuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = result.getMenuCategoryMaps(); + List menuOptions = result.getMenuOptions(); + List menuImages = result.getMenuImages(); + softly.assertThat(result.getDescription()).isEqualTo("테스트메뉴수정"); + softly.assertThat(result.getName()).isEqualTo("짜장면2"); + + softly.assertThat(menuImages.get(0).getImageUrl()).isEqualTo("https://test-image.net/테스트메뉴.jpeg"); + softly.assertThat(menuCategoryMaps.get(0).getMenuCategory().getId()).isEqualTo(2); + + softly.assertThat(menuOptions.get(0).getPrice()).isEqualTo(10000); + + } + ); + }); + } + + @Test + @DisplayName("어드민이 특정 상점의 메뉴를 여러옵션을 가진 메뉴로 수정한다.") + void modifyManyOptionMenu() { + // given + Menu menu = menuFixture.짜장면_옵션메뉴(shop_마슬랜, menuCategory_메인); + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("menuId", menu.getId()) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "category_ids": [ + %d, %d + ], + "description": "테스트메뉴입니다.", + "image_urls": [ + "https://fixed-testimage.com/수정된짜장면.png" + ], + "is_single": false, + "name": "짜장면", + "option_prices": [ + { + "option": "중", + "price": 10000 + }, + { + "option": "소", + "price": 5000 + } + ] + } + """, menuCategory_메인.getId(), menuCategory_사이드.getId()) + ) + .when() + .put("/admin/shops/{shopId}/menus/{menuId}") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Menu result = menuRepository.getById(1); + assertSoftly( + softly -> { + List menuCategoryMaps = result.getMenuCategoryMaps(); + List menuOptions = result.getMenuOptions(); + List menuImages = result.getMenuImages(); + softly.assertThat(result.getDescription()).isEqualTo("테스트메뉴입니다."); + softly.assertThat(result.getName()).isEqualTo("짜장면"); + softly.assertThat(menuImages.get(0).getImageUrl()) + .isEqualTo("https://fixed-testimage.com/수정된짜장면.png"); + softly.assertThat(menuCategoryMaps).hasSize(2); + softly.assertThat(menuOptions).hasSize(2); + } + ); + }); + } + + @Test + @DisplayName("어드민이 특정 상점의 메뉴 카테고리를 삭제한다.") + void deleteMenuCategory() { + // when & then + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("categoryId", menuCategory_메인.getId()) + .when() + .delete("/admin/shops/{shopId}/menus/categories/{categoryId}") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + assertThat(menuCategoryRepository.findById(menuCategory_메인.getId())).isNotPresent(); + } + + @Test + @DisplayName("어드민이 메뉴를 삭제한다.") + void deleteMenu() { + // given + Menu menu = menuFixture.짜장면_단일메뉴(shop_마슬랜, menuCategory_메인); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("shopId", shop_마슬랜.getId()) + .pathParam("menuId", menu.getId()) + .when() + .delete("/admin/shops/{shopId}/menus/{menuId}", menu.getId()) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + .extract(); + + assertThat(menuRepository.findById(menu.getId())).isNotPresent(); + } +} From 390246767692e5c12547e0936d54be8b447911b6 Mon Sep 17 00:00:00 2001 From: Jang-JunYoung <79901434+johnny19991006@users.noreply.github.com> Date: Wed, 26 Jun 2024 16:45:46 +0900 Subject: [PATCH 21/37] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=B3=B5=EB=8D=95=EB=B0=A9=20=EC=A1=B0=ED=9A=8C,=EC=88=98?= =?UTF-8?q?=EC=A0=95,=EC=82=AD=EC=A0=9C=EC=B7=A8=EC=86=8C=20(#631)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 복덕방 조회 컨트롤러 구현 * feat: 복덕방 조회 서비스 구현 * feat: 복덕방 조회 테스트 구현 * feat: 복덕방 수정,삭제취소 구현 * feat: 복덕방 수정,삭제취소 서비스 구현 * refactor: land 모델 수정 * refactor: land 테스트 케이스 추가 * refactor: 어드민 dto 수정 * feat: 어드민 수정, 삭제취소 테스트 구현 * refactor: 라인 포맷팅 * refactor: 테스트 코드 수정 * refactor: dto반환형식 변경 * refactor: 라인포맷팅 --------- Co-authored-by: Jang Jun Young --- .../admin/land/controller/AdminLandApi.java | 54 +++- .../land/controller/AdminLandController.java | 35 ++- ...andsRequest.java => AdminLandRequest.java} | 45 +++- .../admin/land/dto/AdminLandResponse.java | 124 ++++++++- .../admin/land/dto/AdminLandsResponse.java | 39 ++- .../admin/land/service/AdminLandService.java | 61 ++++- .../koin/domain/land/dto/LandResponse.java | 2 +- .../koin/domain/land/model/Land.java | 52 ++++ .../admin/acceptance/AdminLandApiTest.java | 240 +++++++++++++++--- .../koreatech/koin/fixture/LandFixture.java | 20 ++ 10 files changed, 614 insertions(+), 58 deletions(-) rename src/main/java/in/koreatech/koin/admin/land/dto/{AdminLandsRequest.java => AdminLandRequest.java} (81%) diff --git a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java index 7f93a12a2..5e0664ade 100644 --- a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java +++ b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandApi.java @@ -7,10 +7,12 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; -import in.koreatech.koin.admin.land.dto.AdminLandsRequest; +import in.koreatech.koin.admin.land.dto.AdminLandResponse; +import in.koreatech.koin.admin.land.dto.AdminLandRequest; import in.koreatech.koin.admin.land.dto.AdminLandsResponse; import in.koreatech.koin.global.auth.Auth; @@ -55,7 +57,7 @@ ResponseEntity getLands( @SecurityRequirement(name = "Jwt Authentication") @PostMapping("/admin/lands") ResponseEntity postLands( - @RequestBody @Valid AdminLandsRequest adminLandsRequest, + @RequestBody @Valid AdminLandRequest adminLandRequest, @Auth(permit = {ADMIN}) Integer adminId ); @@ -75,4 +77,52 @@ ResponseEntity deleteLand( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/lands/{id}") + ResponseEntity getLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/lands/{id}") + ResponseEntity updateLand( + @PathVariable("id") Integer id, + @RequestBody @Valid AdminLandRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "복덕방 삭제 취소") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/lands/{id}/undelete") + ResponseEntity undeleteLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); } diff --git a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java index 2fda9c5dc..8d67fdc7f 100644 --- a/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java +++ b/src/main/java/in/koreatech/koin/admin/land/controller/AdminLandController.java @@ -8,11 +8,13 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import in.koreatech.koin.admin.land.dto.AdminLandsRequest; +import in.koreatech.koin.admin.land.dto.AdminLandResponse; +import in.koreatech.koin.admin.land.dto.AdminLandRequest; import in.koreatech.koin.admin.land.dto.AdminLandsResponse; import in.koreatech.koin.admin.land.service.AdminLandService; import in.koreatech.koin.global.auth.Auth; @@ -37,10 +39,10 @@ public ResponseEntity getLands( @PostMapping("/admin/lands") public ResponseEntity postLands( - @RequestBody @Valid AdminLandsRequest adminLandsRequest, + @RequestBody @Valid AdminLandRequest adminLandRequest, @Auth(permit = {ADMIN}) Integer adminId ) { - adminLandService.createLands(adminLandsRequest); + adminLandService.createLands(adminLandRequest); return ResponseEntity.status(HttpStatus.CREATED).build(); } @@ -53,4 +55,31 @@ public ResponseEntity deleteLand( return null; } + @GetMapping("/admin/lands/{id}") + public ResponseEntity getLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok().body(adminLandService.getLand(id)); + } + + @PutMapping("/admin/lands/{id}") + public ResponseEntity updateLand( + @PathVariable("id") Integer id, + @RequestBody @Valid AdminLandRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminLandService.updateLand(id, request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/admin/lands/{id}/undelete") + public ResponseEntity undeleteLand( + @PathVariable("id") Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminLandService.undeleteLand(id); + return ResponseEntity.ok().build(); + } + } diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsRequest.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java similarity index 81% rename from src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsRequest.java rename to src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java index 376952656..1bab535fc 100644 --- a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsRequest.java +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java @@ -1,6 +1,5 @@ package in.koreatech.koin.admin.land.dto; - import java.util.List; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; @@ -17,7 +16,7 @@ import jakarta.validation.constraints.Size; @JsonNaming(SnakeCaseStrategy.class) -public record AdminLandsRequest( +public record AdminLandRequest( @Schema(description = "이름 - not null - 최대 255자", example = "금실타운", requiredMode = REQUIRED) @NotNull(message = "방이름은 필수입니다.") @Size(max = 255, message = "방이름의 최대 길이는 255자입니다.") @@ -29,17 +28,17 @@ public record AdminLandsRequest( String internalName, @Schema(description = "크기", example = "9.0") - String size, + double size, @Schema(description = "종류 - 최대 20자", example = "원룸") @Size(max = 20, message = "방종류의 최대 길이는 20자입니다.") String roomType, @Schema(description = "위도", example = "36.766205") - String latitude, + double latitude, @Schema(description = "경도", example = "127.284638") - String longitude, + double longitude, @Schema(description = "전화번호 - 정규식 `^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$` 을 만족해야함", example = "041-111-1111") @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호의 형식이 올바르지 않습니다.") @@ -101,16 +100,37 @@ public record AdminLandsRequest( boolean optAirConditioner, @Schema(description = "샤워기 보유 여부 - null일경우 false로 요청됨", example = "true") - boolean optWasher + boolean optWasher, + + @Schema(description = "침대 보유 여부", example = "false") + boolean optBed, + + @Schema(description = "책상 보유 여부", example = "true") + boolean optDesk, + + @Schema(description = "신발장 보유 여부", example = "true") + boolean optShoeCloset, + + @Schema(description = "전자 도어락 보유 여부", example = "true") + boolean optElectronicDoorLocks, + + @Schema(description = "비데 보유 여부", example = "false") + boolean optBidet, + + @Schema(description = "베란다 보유 여부", example = "false") + boolean optVeranda, + + @Schema(description = "엘리베이터 보유 여부", example = "true") + boolean optElevator ) { public Land toLand() { return Land.builder() .name(name) .internalName(internalName) - .size(size) + .size(String.valueOf(size)) .roomType(roomType) - .latitude(latitude) - .longitude(longitude) + .latitude(String.valueOf(latitude)) + .longitude(String.valueOf(longitude)) .phone(phone) .imageUrls(imageUrls) .address(address) @@ -129,6 +149,13 @@ public Land toLand() { .optWaterPurifier(optWaterPurifier) .optAirConditioner(optAirConditioner) .optWasher(optWasher) + .optBed(optBed) + .optDesk(optDesk) + .optShoeCloset(optShoeCloset) + .optElectronicDoorLocks(optElectronicDoorLocks) + .optBidet(optBidet) + .optVeranda(optVeranda) + .optElevator(optElevator) .build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java index 0c5e1d1cc..5958f27ad 100644 --- a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java @@ -1,7 +1,8 @@ package in.koreatech.koin.admin.land.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -10,32 +11,147 @@ @JsonNaming(value = SnakeCaseStrategy.class) public record AdminLandResponse( - @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + @Schema(description = "고유 id", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) Integer id, - @Schema(description = "이름", example = "금실타운", requiredMode = REQUIRED) + @Schema(description = "이름", example = "금실타운", requiredMode = Schema.RequiredMode.REQUIRED) String name, + @Schema(description = "내부 이름", example = "금실타운", requiredMode = Schema.RequiredMode.REQUIRED) + String internalName, + + @Schema(description = "크기", example = "9.0") + double size, + @Schema(description = "종류", example = "원룸") String roomType, + @Schema(description = "위도", example = "36.766205") + double latitude, + + @Schema(description = "경도", example = "127.284638") + double longitude, + + @Schema(description = "전화번호", example = "041-111-1111") + String phone, + + @Schema(description = "이미지 URL 리스트") + List imageUrls, + + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면") + String address, + + @Schema(description = "설명", example = "1년 계약시 20만원 할인") + String description, + + @Schema(description = "층수", example = "4") + Integer floor, + + @Schema(description = "보증금", example = "30") + String deposit, + @Schema(description = "월세", example = "200만원 (6개월)") String monthlyFee, @Schema(description = "전세", example = "3500") String charterFee, - @Schema(description = "삭제(soft delete) 여부", example = "false", requiredMode = REQUIRED) + @Schema(description = "관리비", example = "21(1인 기준)") + String managementFee, + + @Schema(description = "냉장고 보유 여부", example = "true") + boolean optRefrigerator, + + @Schema(description = "옷장 보유 여부", example = "true") + boolean optCloset, + + @Schema(description = "TV 보유 여부", example = "true") + boolean optTv, + + @Schema(description = "전자레인지 보유 여부", example = "true") + boolean optMicrowave, + + @Schema(description = "가스레인지 보유 여부", example = "false") + boolean optGasRange, + + @Schema(description = "인덕션 보유 여부", example = "true") + boolean optInduction, + + @Schema(description = "정수기 보유 여부", example = "true") + boolean optWaterPurifier, + + @Schema(description = "에어컨 보유 여부", example = "true") + boolean optAirConditioner, + + @Schema(description = "세탁기 보유 여부", example = "true") + boolean optWasher, + + @Schema(description = "침대 보유 여부", example = "false") + boolean optBed, + + @Schema(description = "책상 보유 여부", example = "true") + boolean optDesk, + + @Schema(description = "신발장 보유 여부", example = "true") + boolean optShoeCloset, + + @Schema(description = "전자 도어락 보유 여부", example = "true") + boolean optElectronicDoorLocks, + + @Schema(description = "비데 보유 여부", example = "false") + boolean optBidet, + + @Schema(description = "베란다 보유 여부", example = "false") + boolean optVeranda, + + @Schema(description = "엘리베이터 보유 여부", example = "true") + boolean optElevator, + + @Schema(description = "삭제(soft delete) 여부", example = "false", requiredMode = Schema.RequiredMode.REQUIRED) Boolean isDeleted ) { public static AdminLandResponse from(Land land) { return new AdminLandResponse( land.getId(), land.getName(), + land.getInternalName(), + land.getSize() == null ? null : land.getSize(), land.getRoomType(), + land.getLatitude() == null ? null : land.getLatitude(), + land.getLongitude() == null ? null : land.getLongitude(), + land.getPhone(), + convertToList(land.getImageUrls()), + land.getAddress(), + land.getDescription(), + land.getFloor(), + land.getDeposit(), land.getMonthlyFee(), land.getCharterFee(), + land.getManagementFee(), + land.isOptRefrigerator(), + land.isOptCloset(), + land.isOptTv(), + land.isOptMicrowave(), + land.isOptGasRange(), + land.isOptInduction(), + land.isOptWaterPurifier(), + land.isOptAirConditioner(), + land.isOptWasher(), + land.isOptBed(), + land.isOptDesk(), + land.isOptShoeCloset(), + land.isOptElectronicDoorLocks(), + land.isOptBidet(), + land.isOptVeranda(), + land.isOptElevator(), land.isDeleted() ); } + + private static List convertToList(String imageUrls) { + if (imageUrls == null || imageUrls.isEmpty()) { + return List.of(); + } + return List.of(imageUrls.replace("[", "").replace("]", "").replace("\"", "").split(",")); + } } diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java index 5d2676a99..63d6e6836 100644 --- a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandsResponse.java @@ -29,7 +29,7 @@ public record AdminLandsResponse( Integer currentPage, @Schema(description = "집 정보 리스트", requiredMode = REQUIRED) - List lands + List lands ) { public static AdminLandsResponse of(Page pagedResult, Criteria criteria) { return new AdminLandsResponse( @@ -39,8 +39,41 @@ public static AdminLandsResponse of(Page pagedResult, Criteria criteria) { criteria.getPage() + 1, pagedResult.getContent() .stream() - .map(AdminLandResponse::from) + .map(SimpleLandInformation::from) .toList() ); } -} + + @JsonNaming(value = SnakeCaseStrategy.class) + private record SimpleLandInformation( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "금실타운", requiredMode = REQUIRED) + String name, + + @Schema(description = "종류", example = "원룸") + String roomType, + + @Schema(description = "월세", example = "200만원 (6개월)") + String monthlyFee, + + @Schema(description = "전세", example = "3500") + String charterFee, + + @Schema(description = "삭제(soft delete) 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted + ) { + public static SimpleLandInformation from(Land land) { + return new SimpleLandInformation( + land.getId(), + land.getName(), + land.getRoomType(), + land.getMonthlyFee(), + land.getCharterFee(), + land.isDeleted() + ); + } + } + +} \ No newline at end of file diff --git a/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java b/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java index d719f13d5..0f82166ad 100644 --- a/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java +++ b/src/main/java/in/koreatech/koin/admin/land/service/AdminLandService.java @@ -6,7 +6,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.admin.land.dto.AdminLandsRequest; +import in.koreatech.koin.admin.land.dto.AdminLandResponse; +import in.koreatech.koin.admin.land.dto.AdminLandRequest; import in.koreatech.koin.admin.land.dto.AdminLandsResponse; import in.koreatech.koin.admin.land.execption.LandNameDuplicationException; import in.koreatech.koin.admin.land.repository.AdminLandRepository; @@ -36,11 +37,11 @@ public AdminLandsResponse getLands(Integer page, Integer limit, Boolean isDelete } @Transactional - public void createLands(AdminLandsRequest adminLandsRequest) { - if (adminLandRepository.findByName(adminLandsRequest.name()).isPresent()) { - throw LandNameDuplicationException.withDetail("name: " + adminLandsRequest.name()); + public void createLands(AdminLandRequest adminLandRequest) { + if (adminLandRepository.findByName(adminLandRequest.name()).isPresent()) { + throw LandNameDuplicationException.withDetail("name: " + adminLandRequest.name()); } - Land land = adminLandsRequest.toLand(); + Land land = adminLandRequest.toLand(); adminLandRepository.save(land); } @@ -49,4 +50,54 @@ public void deleteLand(Integer id) { Land land = adminLandRepository.getById(id); land.delete(); } + + public AdminLandResponse getLand(Integer id) { + Land land = adminLandRepository.getById(id); + return AdminLandResponse.from(land); + } + + @Transactional + public void updateLand(Integer id, AdminLandRequest request) { + Land land = adminLandRepository.getById(id); + land.update( + request.internalName(), + request.name(), + request.size(), + request.roomType(), + request.latitude(), + request.longitude(), + request.phone(), + request.imageUrls(), + request.address(), + request.description(), + request.floor(), + request.deposit(), + request.monthlyFee(), + request.charterFee(), + request.managementFee(), + request.optRefrigerator(), + request.optCloset(), + request.optTv(), + request.optMicrowave(), + request.optGasRange(), + request.optInduction(), + request.optWaterPurifier(), + request.optAirConditioner(), + request.optWasher(), + request.optBed(), + request.optDesk(), + request.optShoeCloset(), + request.optElectronicDoorLocks(), + request.optBidet(), + request.optVeranda(), + request.optElevator() + ); + + } + + @Transactional + public void undeleteLand(Integer id) { + Land land = adminLandRepository.getById(id); + land.undelete(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java b/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java index e296cbdcf..0735ec7ed 100644 --- a/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java +++ b/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java @@ -158,7 +158,7 @@ public static LandResponse of(Land land, List imageUrls, String permalin land.getLongitude(), land.getAddress(), land.isOptBed(), - land.getSize(), + String.valueOf(land.getSize()), land.getPhone(), land.isOptAirConditioner(), land.getName(), diff --git a/src/main/java/in/koreatech/koin/domain/land/model/Land.java b/src/main/java/in/koreatech/koin/domain/land/model/Land.java index ae187c3aa..31f1fa325 100644 --- a/src/main/java/in/koreatech/koin/domain/land/model/Land.java +++ b/src/main/java/in/koreatech/koin/domain/land/model/Land.java @@ -235,6 +235,13 @@ public Double getLongitude() { return Double.parseDouble(longitude); } + public Double getSize() { + if (this.size == null) { + return null; + } + return Double.parseDouble(size); + } + private String convertToSting(List imageUrls) { if (imageUrls == null || imageUrls.isEmpty()) { return null; @@ -247,4 +254,49 @@ private String convertToSting(List imageUrls) { public void delete() { this.isDeleted = true; } + + public void undelete() { + this.isDeleted = false; + } + + public void update(String internalName, String name, double size, String roomType, double latitude, + double longitude, + String phone, List imageUrls, String address, String description, Integer floor, + String deposit, String monthlyFee, String charterFee, String managementFee, boolean optRefrigerator, + boolean optCloset, boolean optTv, boolean optMicrowave, boolean optGasRange, boolean optInduction, + boolean optWaterPurifier, boolean optAirConditioner, boolean optWasher, boolean optBed, boolean optDesk, + boolean optShoeCloset, boolean optElectronicDoorLocks, boolean optBidet, boolean optVeranda, + boolean optElevator) { + this.internalName = internalName; + this.name = name; + this.size = String.valueOf(size); + this.roomType = roomType; + this.latitude = String.valueOf(latitude); + this.longitude = String.valueOf(longitude); + this.phone = phone; + this.imageUrls = convertToSting(imageUrls); + this.address = address; + this.description = description; + this.floor = floor; + this.deposit = deposit; + this.monthlyFee = monthlyFee; + this.charterFee = charterFee; + this.managementFee = managementFee; + this.optRefrigerator = optRefrigerator; + this.optCloset = optCloset; + this.optTv = optTv; + this.optMicrowave = optMicrowave; + this.optGasRange = optGasRange; + this.optInduction = optInduction; + this.optWaterPurifier = optWaterPurifier; + this.optAirConditioner = optAirConditioner; + this.optWasher = optWasher; + this.optBed = optBed; + this.optDesk = optDesk; + this.optShoeCloset = optShoeCloset; + this.optElectronicDoorLocks = optElectronicDoorLocks; + this.optBidet = optBidet; + this.optVeranda = optVeranda; + this.optElevator = optElevator; + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java index 12a5df1ce..cad868d9d 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminLandApiTest.java @@ -1,14 +1,10 @@ package in.koreatech.koin.admin.acceptance; -import static in.koreatech.koin.support.JsonAssertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.hibernate.validator.internal.util.Contracts.assertNotNull; import static org.junit.Assert.assertEquals; import static org.junit.jupiter.api.Assertions.assertAll; -import java.util.List; -import java.util.Optional; - import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,11 +12,12 @@ import org.springframework.http.HttpStatus; import in.koreatech.koin.AcceptanceTest; -import in.koreatech.koin.admin.land.dto.AdminLandsRequest; import in.koreatech.koin.admin.land.repository.AdminLandRepository; import in.koreatech.koin.domain.land.model.Land; import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.LandFixture; import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; import io.restassured.RestAssured; @SuppressWarnings("NonAsciiCharacters") @@ -29,6 +26,9 @@ class AdminLandApiTest extends AcceptanceTest { @Autowired private AdminLandRepository adminLandRepository; + @Autowired + private LandFixture landFixture; + @Autowired private UserFixture userFixture; @@ -78,32 +78,32 @@ void getLands() { @DisplayName("관리자 권한으로 복덕방을 추가한다.") void postLands() { String jsonBody = """ - { - "name": "금실타운", - "internal_name": "금실타운", - "size": "9.0", - "room_type": "원룸", - "latitude": "37.555", - "longitude": "126.555", - "phone": "041-111-1111", - "image_urls": ["http://image1.com", "http://image2.com"], - "address": "충청남도 천안시 동남구 병천면", - "description": "1년 계약시 20만원 할인", - "floor": 4, - "deposit": "30", - "monthly_fee": "200만원 (6개월)", - "charter_fee": "3500", - "management_fee": "21(1인 기준)", - "opt_closet": true, - "opt_tv": true, - "opt_microwave": true, - "opt_gas_range": false, - "opt_induction": true, - "opt_water_purifier": true, - "opt_air_conditioner": true, - "opt_washer": true - } - """; + { + "name": "금실타운", + "internal_name": "금실타운", + "size": "9.0", + "room_type": "원룸", + "latitude": "37.555", + "longitude": "126.555", + "phone": "041-111-1111", + "image_urls": ["http://image1.com", "http://image2.com"], + "address": "충청남도 천안시 동남구 병천면", + "description": "1년 계약시 20만원 할인", + "floor": 4, + "deposit": "30", + "monthly_fee": "200만원 (6개월)", + "charter_fee": "3500", + "management_fee": "21(1인 기준)", + "opt_closet": true, + "opt_tv": true, + "opt_microwave": true, + "opt_gas_range": false, + "opt_induction": true, + "opt_water_purifier": true, + "opt_air_conditioner": true, + "opt_washer": true + } + """; User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); @@ -167,4 +167,182 @@ void deleteLand() { softly.assertThat(deletedLand.isDeleted()).isEqualTo(true); }); } + + @Test + @DisplayName("관리자의 권한으로 특정 복덕방 정보를 조회한다.") + void getLand() { + // 복덕방 생성 + Land request = Land.builder() + .internalName("금실타운") + .name("금실타운") + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .size("9.0") + .monthlyFee("100") + .charterFee("1000") + .address("가전리 123") + .description("테스트용 복덕방") + .build(); + + Land savedLand = adminLandRepository.save(request); + Integer landId = savedLand.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/admin/lands/{id}", landId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(String.format(""" + { + "id": %d, + "name": "금실타운", + "internal_name": "금실타운", + "size": 9.0, + "room_type": "원룸", + "latitude": 37.555, + "longitude": 126.555, + "phone": null, + "image_urls": [], + "address": "가전리 123", + "description": "테스트용 복덕방", + "floor": null, + "deposit": null, + "monthly_fee": "100", + "charter_fee": "1000", + "management_fee": null, + "opt_closet": false, + "opt_tv": false, + "opt_microwave": false, + "opt_gas_range": false, + "opt_induction": false, + "opt_water_purifier": false, + "opt_air_conditioner": false, + "opt_washer": false, + "opt_bed": false, + "opt_bidet": false, + "opt_desk": false, + "opt_electronic_door_locks": false, + "opt_elevator": false, + "opt_refrigerator": false, + "opt_shoe_closet": false, + "opt_veranda": false, + "is_deleted": false + } + """, landId)); + } + + @Test + @DisplayName("관리자 권한으로 복덕방 정보를 수정한다.") + void updateLand() { + Land land = landFixture.신안빌(); + Integer landId = land.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + String jsonBody = """ + { + "name": "신안빌 수정", + "internal_name": "신안빌", + "size": "110.0", + "room_type": "투룸", + "latitude": "37.556", + "longitude": "126.556", + "phone": "010-1234-5679", + "image_urls": ["http://newimage1.com", "http://newimage2.com"], + "address": "서울시 강남구 신사동", + "description": "신안빌 수정 설명", + "floor": 5, + "deposit": "50", + "monthly_fee": "150만원", + "charter_fee": "5000", + "management_fee": "150", + "opt_closet": true, + "opt_tv": false, + "opt_microwave": true, + "opt_gas_range": false, + "opt_induction": true, + "opt_water_purifier": false, + "opt_air_conditioner": true, + "opt_washer": true + } + """; + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(jsonBody) + .when() + .put("/admin/lands/{id}", landId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Land updatedLand = adminLandRepository.getById(landId); + + assertSoftly(softly -> { + softly.assertThat(updatedLand.getName()).isEqualTo("신안빌 수정"); + softly.assertThat(updatedLand.getInternalName()).isEqualTo("신안빌"); + softly.assertThat(updatedLand.getSize()).isEqualTo(110.0); + softly.assertThat(updatedLand.getRoomType()).isEqualTo("투룸"); + softly.assertThat(updatedLand.getLatitude()).isEqualTo(37.556); + softly.assertThat(updatedLand.getLongitude()).isEqualTo(126.556); + softly.assertThat(updatedLand.getPhone()).isEqualTo("010-1234-5679"); + softly.assertThat(updatedLand.getImageUrls()).containsAnyOf("http://newimage1.com", "http://newimage2.com"); + softly.assertThat(updatedLand.getAddress()).isEqualTo("서울시 강남구 신사동"); + softly.assertThat(updatedLand.getDescription()).isEqualTo("신안빌 수정 설명"); + softly.assertThat(updatedLand.getFloor()).isEqualTo(5); + softly.assertThat(updatedLand.getDeposit()).isEqualTo("50"); + softly.assertThat(updatedLand.getMonthlyFee()).isEqualTo("150만원"); + softly.assertThat(updatedLand.getCharterFee()).isEqualTo("5000"); + softly.assertThat(updatedLand.getManagementFee()).isEqualTo("150"); + softly.assertThat(updatedLand.isOptCloset()).isTrue(); + softly.assertThat(updatedLand.isOptTv()).isFalse(); + softly.assertThat(updatedLand.isOptMicrowave()).isTrue(); + softly.assertThat(updatedLand.isOptGasRange()).isFalse(); + softly.assertThat(updatedLand.isOptInduction()).isTrue(); + softly.assertThat(updatedLand.isOptWaterPurifier()).isFalse(); + softly.assertThat(updatedLand.isOptAirConditioner()).isTrue(); + softly.assertThat(updatedLand.isOptWasher()).isTrue(); + softly.assertThat(updatedLand.isDeleted()).isEqualTo(false); + }); + } + + @Test + @DisplayName("관리자 권한으로 복덕방 삭제를 취소한다.") + void undeleteLand() { + Land deletedLand = landFixture.삭제된_복덕방(); + Integer landId = deletedLand.getId(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .post("/admin/lands/{id}/undelete", landId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + Land undeletedLand = adminLandRepository.getById(landId); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(undeletedLand).isNotNull(); + softly.assertThat(undeletedLand.getName()).isEqualTo("삭제된 복덕방"); + softly.assertThat(undeletedLand.isDeleted()).isFalse(); + }); + } + } diff --git a/src/test/java/in/koreatech/koin/fixture/LandFixture.java b/src/test/java/in/koreatech/koin/fixture/LandFixture.java index a6cea411a..3e7aa6c8a 100644 --- a/src/test/java/in/koreatech/koin/fixture/LandFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/LandFixture.java @@ -66,4 +66,24 @@ public LandFixture(LandRepository landRepository) { .build() ); } + + public Land 삭제된_복덕방() { + List imageUrls = List.of( + "https://example1.test.com/image.jpeg", + "https://example2.test.com/image.jpeg" + ); + return landRepository.save( + Land.builder() + .internalName("삭제된 복덕방") + .name("삭제된 복덕방") + .roomType("원룸") + .latitude("37.555") + .longitude("126.555") + .monthlyFee("100") + .charterFee("1000") + .isDeleted(true) + .imageUrls(imageUrls) + .build() + ); + } } From 4cca20424cb64c2fd4a7528565f199e01a951acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9B=90=EA=B2=BD?= <148550522+kwoo28@users.noreply.github.com> Date: Wed, 26 Jun 2024 20:00:31 +0900 Subject: [PATCH 22/37] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=2082~?= =?UTF-8?q?86,89,90,93~95=20(#621)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 컨트롤러 구현 * feat: 사장님 인증권한 허용 * feat: 사장님 인증권한 허용 * feat: 테스트 추가 * feat: 테스트 import문 사용 * feat: 학생 리스트 조회 구현 * feat: 어드민 특정 사장님 수정 * feat: 어드민 사장님 페이지네이션 * feat: 어드민 회원 삭제 * feat: 어드민 로그인 구현 * feat: 어드민 로그아웃 구현 * feat: 어드민 리프레쉬 구현 * feat: 어드민 회원 조회 * fix: 페이지네이션 오류 수정 * test: 테스트 구현 * test: 나머지 모든 테스트 구현 * chore: 개행 처리 * chore: 개행 처리 * chore: 개행 처리 * chore: 이상한 설명 수정 * feat: ModelAttribute 파라미터로 전달되게 변경 * refactor: 사장님 인증 shop에 owner_id 할당되게 변경 * feat: 회원 탈퇴 취소 API 구현 * refactor: 83~86 충돌 수정 * refactor: 사장님인증 후 OwnerShop redis삭제 추가 * refactor: 사장님인증 테스트 수정 * refactor: AdminStudentResponse 메서드 of -> from 변경 * refactor: shopId null체크 * refactor: GrantShop 로직 수정, 로그인 save()제거 * refactor: 응답객체 수정 * refactor: 충돌 수정 * refactor: 충돌 수정 * refactor: 테스트 수정 * refactor: 응답반환수정 --------- Co-authored-by: seongjae6751 --- .../admin/user/controller/AdminUserApi.java | 177 ++++++- .../user/controller/AdminUserController.java | 121 ++++- .../admin/user/dto/AdminLoginRequest.java | 22 + .../admin/user/dto/AdminLoginResponse.java | 26 + .../admin/user/dto/AdminOwnerResponse.java | 4 + .../user/dto/AdminOwnerUpdateRequest.java | 46 ++ .../user/dto/AdminOwnerUpdateResponse.java | 49 ++ .../admin/user/dto/AdminOwnersResponse.java | 79 +++ .../user/dto/AdminStudentUpdateRequest.java | 3 +- .../user/dto/AdminStudentUpdateResponse.java | 1 + .../admin/user/dto/AdminStudentsResponse.java | 53 ++ .../user/dto/AdminTokenRefreshRequest.java | 20 + .../user/dto/AdminTokenRefreshResponse.java | 26 + ...ersCondition.java => OwnersCondition.java} | 4 +- .../admin/user/dto/StudentsCondition.java | 39 ++ .../user/repository/AdminOwnerRepository.java | 5 + .../AdminOwnerShopRedisRepository.java | 14 + .../repository/AdminStudentRepository.java | 19 + .../user/repository/AdminTokenRepository.java | 22 + .../user/repository/AdminUserRepository.java | 9 + .../admin/user/service/AdminUserService.java | 170 ++++++- .../koin/domain/owner/model/Owner.java | 11 + .../koin/domain/shop/model/Shop.java | 4 + .../koin/domain/user/model/User.java | 4 + .../admin/acceptance/AdminUserApiTest.java | 466 +++++++++++++++++- .../koreatech/koin/fixture/UserFixture.java | 4 +- 26 files changed, 1373 insertions(+), 25 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentsResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshResponse.java rename src/main/java/in/koreatech/koin/admin/user/dto/{NewOwnersCondition.java => OwnersCondition.java} (97%) create mode 100644 src/main/java/in/koreatech/koin/admin/user/dto/StudentsCondition.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerShopRedisRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/user/repository/AdminTokenRepository.java diff --git a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java index 94f125d3f..faaa39b66 100644 --- a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java +++ b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserApi.java @@ -2,19 +2,32 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; +import in.koreatech.koin.admin.user.dto.AdminLoginRequest; +import in.koreatech.koin.admin.user.dto.AdminLoginResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminNewOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminOwnerResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.NewOwnersCondition; +import in.koreatech.koin.admin.user.dto.OwnersCondition; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -27,6 +40,85 @@ @Tag(name = "(Admin) User: 회원", description = "관리자 권한으로 회원 정보를 관리한다") public interface AdminUserApi { + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "학생 리스트 조회(페이지네이션)") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/students") + public ResponseEntity getStudents( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Boolean isAuthed, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 로그인") + @PostMapping("/admin/user/login") + ResponseEntity adminLogin( + @RequestBody @Valid AdminLoginRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 로그아웃") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("admin/user/logout") + ResponseEntity logout( + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "어드민 액세스 토큰 재발급") + @PostMapping("/admin/user/refresh") + public ResponseEntity refresh( + @RequestBody @Valid AdminTokenRefreshRequest request + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "사장님 권한 요청 허용") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/owner/{id}/authed") + ResponseEntity allowOwnerPermission( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -76,6 +168,23 @@ ResponseEntity getOwner( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 사장님 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/admin/users/owner/{id}") + ResponseEntity updateOwner( + @PathVariable Integer id, + @RequestBody @Valid AdminOwnerUpdateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -88,7 +197,71 @@ ResponseEntity getOwner( @SecurityRequirement(name = "Jwt Authentication") @GetMapping("/admin/users/new-owners") ResponseEntity getNewOwners( - @ModelAttribute NewOwnersCondition newOwnersCondition, + @ParameterObject @ModelAttribute OwnersCondition ownersCondition, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "사장 리스트 조회 (페이지네이션)") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/users/owners") + ResponseEntity getOwners( + @ParameterObject @ModelAttribute OwnersCondition ownersCondition, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원 정보 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/admin/users/{id}") + ResponseEntity getUser( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원 삭제 (탈퇴 처리)") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/admin/users/{id}") + ResponseEntity deleteUser( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "회원 삭제 해제 (탈퇴 상태를 해제 처리)") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/users/{id}/undelete") + ResponseEntity undeleteUser( + @PathVariable Integer id, @Auth(permit = {ADMIN}) Integer adminId ); } diff --git a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java index 90870be29..600756488 100644 --- a/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java +++ b/src/main/java/in/koreatech/koin/admin/user/controller/AdminUserController.java @@ -2,21 +2,37 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import org.springdoc.core.annotations.ParameterObject; +import java.net.URI; + import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.admin.user.dto.AdminLoginRequest; +import in.koreatech.koin.admin.user.dto.AdminLoginResponse; import in.koreatech.koin.admin.user.dto.AdminNewOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminOwnerResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.NewOwnersCondition; +import in.koreatech.koin.admin.user.dto.OwnersCondition; +import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; +import in.koreatech.koin.admin.user.dto.StudentsCondition; import in.koreatech.koin.admin.user.service.AdminUserService; +import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.auth.Auth; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -27,13 +43,62 @@ public class AdminUserController implements AdminUserApi{ private final AdminUserService adminUserService; + @PutMapping("/admin/owner/{id}/authed") + public ResponseEntity allowOwnerPermission( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId) { + adminUserService.allowOwnerPermission(id); + return ResponseEntity.ok().build(); + } + + @GetMapping("/admin/students") + public ResponseEntity getStudents( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Boolean isAuthed, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email, + @Auth(permit = {ADMIN}) Integer adminId + ) { + StudentsCondition studentsCondition = new StudentsCondition(page, limit, isAuthed, nickname, email); + AdminStudentsResponse adminStudentsResponse = adminUserService.getStudents(studentsCondition); + return ResponseEntity.ok().body(adminStudentsResponse); + } + + @PostMapping("/admin/user/login") + public ResponseEntity adminLogin( + @RequestBody @Valid AdminLoginRequest request + ) { + AdminLoginResponse response = adminUserService.adminLogin(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } + + @PostMapping("admin/user/logout") + public ResponseEntity logout( + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminUserService.adminLogout(adminId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/admin/user/refresh") + public ResponseEntity refresh( + @RequestBody @Valid AdminTokenRefreshRequest request + ) { + AdminTokenRefreshResponse tokenGroupResponse = adminUserService.adminRefresh(request); + return ResponseEntity.created(URI.create("/")) + .body(tokenGroupResponse); + } + + @GetMapping("/admin/users/student/{id}") public ResponseEntity getStudent( @PathVariable Integer id, @Auth(permit = {ADMIN}) Integer adminId ) { AdminStudentResponse adminStudentResponse = adminUserService.getStudent(id); - return ResponseEntity.ok(adminStudentResponse); + return ResponseEntity.ok().body(adminStudentResponse); } @PutMapping("/admin/users/student/{id}") @@ -43,7 +108,7 @@ public ResponseEntity updateStudent( @Auth(permit = {ADMIN}) Integer adminId ) { AdminStudentUpdateResponse adminStudentUpdateResponse = adminUserService.updateStudent(id, adminRequest); - return ResponseEntity.ok(adminStudentUpdateResponse); + return ResponseEntity.ok().body(adminStudentUpdateResponse); } @GetMapping("/admin/users/owner/{id}") @@ -52,14 +117,58 @@ public ResponseEntity getOwner( @Auth(permit = {ADMIN}) Integer adminId ) { AdminOwnerResponse adminOwnerResponse = adminUserService.getOwner(id); - return ResponseEntity.ok(adminOwnerResponse); + return ResponseEntity.ok().body(adminOwnerResponse); + } + + @PutMapping("/admin/users/owner/{id}") + public ResponseEntity updateOwner( + @PathVariable Integer id, + @RequestBody @Valid AdminOwnerUpdateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminOwnerUpdateResponse adminOwnerUpdateResponse = adminUserService.updateOwner(id, request); + return ResponseEntity.ok().body(adminOwnerUpdateResponse); } @GetMapping("/admin/users/new-owners") public ResponseEntity getNewOwners( - @ModelAttribute NewOwnersCondition newOwnersCondition, + @ParameterObject @ModelAttribute OwnersCondition ownersCondition, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok().body(adminUserService.getNewOwners(ownersCondition)); + } + + @GetMapping("/admin/users/owners") + public ResponseEntity getOwners( + @ParameterObject @ModelAttribute OwnersCondition ownersCondition, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok().body(adminUserService.getOwners(ownersCondition)); + } + + @GetMapping("/admin/users/{id}") + public ResponseEntity getUser( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok().body(adminUserService.getUser(id)); + } + + @DeleteMapping("/admin/users/{id}") + public ResponseEntity deleteUser( + @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminUserService.deleteUser(id); + return ResponseEntity.ok().build(); + } + + @PostMapping("/admin/users/{id}/undelete") + public ResponseEntity undeleteUser( + @PathVariable Integer id, @Auth(permit = {ADMIN}) Integer adminId ) { - return ResponseEntity.ok().body(adminUserService.getNewOwners(newOwnersCondition)); + adminUserService.undeleteUser(id); + return ResponseEntity.ok().build(); } } diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginRequest.java new file mode 100644 index 000000000..cd1e38b84 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginRequest.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record AdminLoginRequest( + @Schema(description = "이메일", example = "abc@koreatech.ac.kr", requiredMode = REQUIRED) + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @Schema( + description = "SHA 256 해시 알고리즘으로 암호화된 비밀번호", + example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", + requiredMode = REQUIRED + ) + @NotBlank(message = "비밀번호를 입력해주세요") + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginResponse.java new file mode 100644 index 000000000..3cfd0ad15 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminLoginResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminLoginResponse ( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refresh token", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static AdminLoginResponse of(String token, String refreshToken) { + return new AdminLoginResponse(token, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerResponse.java index 17eb9e3e0..75c3003a1 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerResponse.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerResponse.java @@ -7,12 +7,16 @@ import java.util.List; import java.util.stream.Collectors; +import org.springframework.data.domain.Page; + import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerAttachment; +import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; +import in.koreatech.koin.global.model.Criteria; import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateRequest.java new file mode 100644 index 000000000..237ddb8d7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateRequest.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminOwnerUpdateRequest ( + @Schema(description = "사업자 등록 번호", example = "012-34-56789", requiredMode = NOT_REQUIRED) + @Size(max = 12, message = "사업자 등록 번호는 12자 이하로 입력해주세요.") + String companyRegistrationNumber, + + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr", requiredMode = NOT_REQUIRED) + @Size(max = 100, message = "이메일의 길이는 최대 100자입니다") + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1", requiredMode = NOT_REQUIRED) + Integer gender, + + @Schema(description = "상점 수정 권한", example = "false", requiredMode = NOT_REQUIRED) + Boolean grantShop, + + @Schema(description = "이벤트 수정 권한", example = "false", requiredMode = NOT_REQUIRED) + Boolean grantEvent, + + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + @Size(max = 50, message = "이름의 길이는 최대 50자 입니다.") + String name, + + @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) + @Size(max = 10, message = "닉네임은 10자 이내여야 합니다.") + String nickname, + + @Schema(description = "사장님 전화번호", example = "01012345678", requiredMode = NOT_REQUIRED) + @Size(max = 20, message = "휴대전화의 길이는 최대 20자 입니다") + String phoneNumber, + + @Schema(description = "비밀번호", example = "a0240120305812krlakdsflsa;1235", requiredMode = NOT_REQUIRED) + String password +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateResponse.java new file mode 100644 index 000000000..f288e6fa3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnerUpdateResponse.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.owner.model.Owner; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminOwnerUpdateResponse ( + @Schema(description = "사업자 등록 번호", example = "012-34-56789", requiredMode = NOT_REQUIRED) + String companyRegistrationNumber, + + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr", requiredMode = NOT_REQUIRED) + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1", requiredMode = NOT_REQUIRED) + Integer gender, + + @Schema(description = "상점 수정 권한", example = "false", requiredMode = NOT_REQUIRED) + Boolean grantShop, + + @Schema(description = "이벤트 수정 권한", example = "false", requiredMode = NOT_REQUIRED) + Boolean grantEvent, + + @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) + String nickname, + + @Schema(description = "사장님 전화번호", example = "01012345678", requiredMode = NOT_REQUIRED) + String phoneNumber +) { + public static AdminOwnerUpdateResponse from(Owner owner) { + return new AdminOwnerUpdateResponse( + owner.getCompanyRegistrationNumber(), + owner.getUser().getEmail(), + owner.getUser().getGender().ordinal(), + owner.isGrantShop(), + owner.isGrantEvent(), + owner.getUser().getName(), + owner.getUser().getNickname(), + owner.getAccount() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java new file mode 100644 index 000000000..8d2f112bc --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java @@ -0,0 +1,79 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public record AdminOwnersResponse( + @Schema(description = "조건에 해당하는 총 사장님의 수", example = "57", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "조건에 해당하는 사장님중에 현재 페이지에서 조회된 수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "조건에 해당하는 사장님들을 조회할 수 있는 최대 페이지", example = "6", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "사장님 리스트", requiredMode = REQUIRED) + List owners +) { + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + public record InnerOwnersResponse( + @Schema(description = "고유 id", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이메일", requiredMode = REQUIRED) + String email, + + @Schema(description = "이름", requiredMode = NOT_REQUIRED) + String name, + + @Schema(description = "전화번호", requiredMode = NOT_REQUIRED) + String phoneNumber, + + @Schema(description = "가입 신청 일자", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt + ) { + public static InnerOwnersResponse from(OwnerIncludingShop ownerIncludingShop) { + User user = ownerIncludingShop.getOwner().getUser(); + return new InnerOwnersResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getPhoneNumber(), + user.getCreatedAt() + ); + } + } + + public static AdminOwnersResponse of(Page pagedResult, Criteria criteria) { + return new AdminOwnersResponse( + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1, + pagedResult.getContent().stream() + .map(InnerOwnersResponse::from) + .collect(Collectors.toList()) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateRequest.java index 238f47cba..e21faf23f 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateRequest.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateRequest.java @@ -14,7 +14,7 @@ public record AdminStudentUpdateRequest( Integer gender, @Schema(description = "[NOT UPDATE]신원(학생, 사장님)", example = "학생", requiredMode = NOT_REQUIRED) - Integer userIdentity, + Integer identity, @Schema(description = "[NOT UPDATE]졸업 여부(true, false)", example = "false", requiredMode = NOT_REQUIRED) Boolean isGraduated, @@ -47,6 +47,7 @@ public record AdminStudentUpdateRequest( String nickname, @Schema(description = "휴대폰 번호", example = "010-0000-0000", requiredMode = NOT_REQUIRED) + @Size(max = 20, message = "휴대전화의 길이는 최대 20자 입니다") String phoneNumber, @Size(min = 10, max = 10, message = "학번은 10자여야 합니다.") diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateResponse.java index 50de2fc79..a1fc6a3b1 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateResponse.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentUpdateResponse.java @@ -48,6 +48,7 @@ public record AdminStudentUpdateResponse( @Schema(description = "학번", example = "2029136012", requiredMode = NOT_REQUIRED) String studentNumber ) { + public static AdminStudentUpdateResponse from(Student student) { User user = student.getUser(); diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentsResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentsResponse.java new file mode 100644 index 000000000..3dbb2734e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminStudentsResponse.java @@ -0,0 +1,53 @@ +package in.koreatech.koin.admin.user.dto; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.user.model.Student; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminStudentsResponse( + Integer currentCount, + Integer currentPage, + List students, + Long totalCount, + Integer totalPage +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record StudentInfo( + String email, + Integer id, + String major, + String name, + String nickname, + String studentNumber + ) { + + public static StudentInfo fromStudent(Student student) { + return new StudentInfo( + student.getUser().getEmail(), + student.getUser().getId(), + student.getDepartment(), + student.getUser().getName(), + student.getUser().getNickname(), + student.getStudentNumber() + ); + } + } + + public static AdminStudentsResponse from(Page studentsPage) { + return new AdminStudentsResponse( + studentsPage.getNumberOfElements(), + studentsPage.getNumber() + 1, + studentsPage.getContent().stream().map(StudentInfo::fromStudent).collect(Collectors.toList()), + studentsPage.getTotalElements(), + studentsPage.getTotalPages() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshRequest.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshRequest.java new file mode 100644 index 000000000..aac0790f4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshRequest.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminTokenRefreshRequest ( + @Schema(description = "refresh_token", example = "eyJhbGciOiJIUzI1NiJ9", requiredMode = REQUIRED) + @NotNull(message = "refresh_token을 입력해주세요.") + @JsonProperty("refresh_token") + String refreshToken +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshResponse.java new file mode 100644 index 000000000..41a8ad45a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminTokenRefreshResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminTokenRefreshResponse ( + @Schema( + description = "Jwt accessToken", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + requiredMode = REQUIRED + ) + @JsonProperty("token") + String accessToken, + + @Schema(description = "Random UUID refreshToken", example = "RANDOM-KEY-VALUE", requiredMode = REQUIRED) + @JsonProperty("refresh_token") + String refreshToken +) { + + public static AdminTokenRefreshResponse of(String accessToken, String refreshToken) { + return new AdminTokenRefreshResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/NewOwnersCondition.java b/src/main/java/in/koreatech/koin/admin/user/dto/OwnersCondition.java similarity index 97% rename from src/main/java/in/koreatech/koin/admin/user/dto/NewOwnersCondition.java rename to src/main/java/in/koreatech/koin/admin/user/dto/OwnersCondition.java index d11bdb2c5..12a3ebace 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/NewOwnersCondition.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/OwnersCondition.java @@ -17,7 +17,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) -public record NewOwnersCondition( +public record OwnersCondition( @Schema(description = "페이지", example = "1", defaultValue = "1", requiredMode = NOT_REQUIRED) Integer page, @@ -33,7 +33,7 @@ public record NewOwnersCondition( @Schema(description = "정렬 기준['CREATED_AT_ASC` (오래된순), 'CREATED_AT_DESC` (최신순)]", example = "CREATED_AT_ASC", defaultValue = "CREATED_AT_ASC", requiredMode = NOT_REQUIRED) Sort sort ) { - public NewOwnersCondition { + public OwnersCondition { if (Objects.isNull(page)) { page = Criteria.DEFAULT_PAGE; } diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/StudentsCondition.java b/src/main/java/in/koreatech/koin/admin/user/dto/StudentsCondition.java new file mode 100644 index 000000000..9da9fd9e4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/dto/StudentsCondition.java @@ -0,0 +1,39 @@ +package in.koreatech.koin.admin.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record StudentsCondition ( + @Schema(description = "페이지", example = "1", defaultValue = "1", requiredMode = NOT_REQUIRED) + Integer page, + + @Schema(description = "페이지당 조회할 최대 개수", example = "10", defaultValue = "10", requiredMode = NOT_REQUIRED) + Integer limit, + + @Schema(description = "인증 되었는지 여부", requiredMode = NOT_REQUIRED) + Boolean isAuthed, + + @Schema(description = "닉네임", requiredMode = NOT_REQUIRED) + String nickname, + + @Schema(description = "이메일", requiredMode = NOT_REQUIRED) + String email +) { + + public StudentsCondition { + if (Objects.isNull(page)) { + page = Criteria.DEFAULT_PAGE; + } + if (Objects.isNull(limit)) { + limit = Criteria.DEFAULT_LIMIT; + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java index 2d7ae918e..d42c6d94b 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java @@ -10,6 +10,7 @@ import in.koreatech.koin.domain.owner.exception.OwnerNotFoundException; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; +import in.koreatech.koin.domain.user.model.UserType; import io.lettuce.core.dynamic.annotation.Param; public interface AdminOwnerRepository extends Repository { @@ -18,6 +19,8 @@ public interface AdminOwnerRepository extends Repository { Owner save(Owner owner); + void deleteById(Integer ownerId); + @Query(""" SELECT COUNT(o) FROM Owner o WHERE o.user.userType = 'OWNER' @@ -25,6 +28,8 @@ SELECT COUNT(o) FROM Owner o """) Integer findUnauthenticatedOwnersCount(); + Integer countByUserUserType(UserType userType); + @Query(""" SELECT new in.koreatech.koin.domain.owner.model.OwnerIncludingShop(o, s.id, s.name) FROM Owner o diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerShopRedisRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerShopRedisRepository.java new file mode 100644 index 000000000..7b95e3371 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerShopRedisRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.user.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.owner.model.OwnerShop; + +public interface AdminOwnerShopRedisRepository extends Repository { + + Optional findById(Integer ownerId); + + void deleteById(Integer ownerId); +} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminStudentRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminStudentRepository.java index 30b108fd2..cfeaf8657 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminStudentRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminStudentRepository.java @@ -2,8 +2,13 @@ import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import in.koreatech.koin.admin.user.dto.StudentsCondition; import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.domain.user.model.Student; @@ -11,10 +16,24 @@ public interface AdminStudentRepository extends Repository { Student save(Student student); + void deleteById(Integer userId); + Optional findById(Integer userId); default Student getById(Integer userId) { return findById(userId) .orElseThrow(() -> UserNotFoundException.withDetail("userId: " + userId)); } + + @Query(" SELECT COUNT(s) FROM Student s ") + Integer findAllStudentCount(); + + @Query( + """ + SELECT s FROM Student s WHERE\s + (:#{#condition.isAuthed} IS NULL OR s.user.isAuthed = :#{#condition.isAuthed}) AND + (:#{#condition.nickname} IS NULL OR s.user.nickname LIKE CONCAT('%', :#{#condition.nickname}, '%')) AND + (:#{#condition.email} IS NULL OR s.user.email LIKE CONCAT('%', :#{#condition.email}, '%')) + """) + Page findByConditions(@Param("condition") StudentsCondition condition, Pageable pageable); } diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminTokenRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminTokenRepository.java new file mode 100644 index 000000000..e1b961d24 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminTokenRepository.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.admin.user.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; + +public interface AdminTokenRepository extends Repository { + + UserToken save(UserToken userToken); + + Optional findById(Integer userId); + + void deleteById(Integer id); + + default UserToken getById(Integer userId) { + return findById(userId) + .orElseThrow(() -> new KoinIllegalArgumentException("refresh token이 존재하지 않습니다. ", "userId: " + userId)); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java index 7718b52f3..83796bcb0 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java @@ -11,8 +11,17 @@ public interface AdminUserRepository extends Repository { User save(User user); + Optional findByEmail(String Email); + Optional findById(Integer id); + default User getByEmail(String email) { + return findByEmail(email) + .orElseThrow(() -> UserNotFoundException.withDetail("email: " + email)); + } + + void delete(User user); + default User getById(Integer userId) { return findById(userId) .orElseThrow(() -> UserNotFoundException.withDetail("userId: " + userId)); diff --git a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java index 92ed7a8ba..636fce11f 100644 --- a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java +++ b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java @@ -1,33 +1,59 @@ package in.koreatech.koin.admin.user.service; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Objects; +import java.util.UUID; + import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.admin.shop.repository.AdminShopRepository; +import in.koreatech.koin.admin.user.dto.AdminLoginRequest; +import in.koreatech.koin.admin.user.dto.AdminLoginResponse; import in.koreatech.koin.admin.user.dto.AdminNewOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminOwnerResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateResponse; +import in.koreatech.koin.admin.user.dto.AdminOwnersResponse; import in.koreatech.koin.admin.user.dto.AdminStudentResponse; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateRequest; import in.koreatech.koin.admin.user.dto.AdminStudentUpdateResponse; -import in.koreatech.koin.admin.user.dto.NewOwnersCondition; +import in.koreatech.koin.admin.user.dto.OwnersCondition; +import in.koreatech.koin.admin.user.dto.AdminStudentsResponse; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshRequest; +import in.koreatech.koin.admin.user.dto.AdminTokenRefreshResponse; +import in.koreatech.koin.admin.user.dto.StudentsCondition; import in.koreatech.koin.admin.user.repository.AdminOwnerRepository; +import in.koreatech.koin.admin.user.repository.AdminOwnerShopRedisRepository; import in.koreatech.koin.admin.user.repository.AdminStudentRepository; +import in.koreatech.koin.admin.user.repository.AdminTokenRepository; import in.koreatech.koin.admin.user.repository.AdminUserRepository; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; +import in.koreatech.koin.domain.owner.model.OwnerShop; import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.user.exception.DuplicationNicknameException; import in.koreatech.koin.domain.user.exception.StudentDepartmentNotValidException; +import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.StudentDepartment; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.model.UserGender; +import in.koreatech.koin.domain.user.model.UserToken; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.global.auth.JwtProvider; +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import lombok.RequiredArgsConstructor; @@ -36,11 +62,86 @@ @Transactional(readOnly = true) public class AdminUserService { + private final JwtProvider jwtProvider; private final AdminStudentRepository adminStudentRepository; private final AdminOwnerRepository adminOwnerRepository; + private final AdminOwnerShopRedisRepository adminOwnerShopRedisRepository; private final AdminUserRepository adminUserRepository; private final AdminShopRepository adminShopRepository; private final PasswordEncoder passwordEncoder; + private final AdminTokenRepository adminTokenRepository; + + public AdminStudentsResponse getStudents(StudentsCondition studentsCondition) { + Integer totalStudents = adminStudentRepository.findAllStudentCount(); + Criteria criteria = Criteria.of(studentsCondition.page(), studentsCondition.limit(), totalStudents); + + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit()); + Page studentsPage = adminStudentRepository.findByConditions(studentsCondition, pageRequest); + + return AdminStudentsResponse.from(studentsPage); + } + + @Transactional + public AdminLoginResponse adminLogin(AdminLoginRequest request) { + User user = adminUserRepository.getByEmail(request.email()); + + /* 어드민 권한이 없으면 없는 회원으로 간주 */ + if(user.getUserType() != ADMIN) { + throw UserNotFoundException.withDetail("email" + request.email()); + } + + if (!user.isSamePassword(passwordEncoder, request.password())) { + throw new KoinIllegalArgumentException("비밀번호가 틀렸습니다."); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s-%d", UUID.randomUUID(), user.getId()); + UserToken savedtoken = adminTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + + return AdminLoginResponse.of(accessToken, savedtoken.getRefreshToken()); + } + + @Transactional + public void adminLogout(Integer adminId) { + adminTokenRepository.deleteById(adminId); + } + + public AdminTokenRefreshResponse adminRefresh(AdminTokenRefreshRequest request) { + String adminId = getAdminId(request.refreshToken()); + UserToken userToken = adminTokenRepository.getById(Integer.parseInt(adminId)); + if (!Objects.equals(userToken.getRefreshToken(), request.refreshToken())) { + throw new KoinIllegalArgumentException("refresh token이 일치하지 않습니다.", "request: " + request); + } + User user = adminUserRepository.getById(userToken.getId()); + + String accessToken = jwtProvider.createToken(user); + return AdminTokenRefreshResponse.of(accessToken, userToken.getRefreshToken()); + } + + private String getAdminId(String refreshToken) { + String[] split = refreshToken.split("-"); + if (split.length == 0) { + throw new AuthorizationException("올바르지 않은 인증 토큰입니다. refreshToken: " + refreshToken); + } + return split[split.length - 1]; + } + + @Transactional + public void allowOwnerPermission(Integer id) { + Owner owner = adminOwnerRepository.getById(id); + owner.getUser().auth(); + Optional ownerShop = adminOwnerShopRedisRepository.findById(id); + if (ownerShop.isPresent()) { + Integer shopId = ownerShop.get().getShopId(); + if (shopId != null) { + Shop shop = adminShopRepository.getById(shopId); + shop.updateOwner(owner); + owner.setGrantShop(true); + } + adminOwnerShopRedisRepository.deleteById(id); + } + } public AdminStudentResponse getStudent(Integer userId) { Student student = adminStudentRepository.getById(userId); @@ -62,30 +163,48 @@ public AdminStudentUpdateResponse updateStudent(Integer id, AdminStudentUpdateRe return AdminStudentUpdateResponse.from(student); } - public AdminNewOwnersResponse getNewOwners(NewOwnersCondition newOwnersCondition) { - newOwnersCondition.checkDataConstraintViolation(); + public AdminNewOwnersResponse getNewOwners(OwnersCondition ownersCondition) { + ownersCondition.checkDataConstraintViolation(); - // page > totalPage인 경우 totalPage로 조회하기 위해 Integer totalOwners = adminOwnerRepository.findUnauthenticatedOwnersCount(); - Criteria criteria = Criteria.of(newOwnersCondition.page(), newOwnersCondition.limit(), totalOwners); - Sort.Direction direction = newOwnersCondition.getDirection(); + Criteria criteria = Criteria.of(ownersCondition.page(), ownersCondition.limit(), totalOwners); + Sort.Direction direction = ownersCondition.getDirection(); + + Page result = getResultPage(ownersCondition, criteria, direction); + + return AdminNewOwnersResponse.of(result, criteria); + } + + public AdminOwnersResponse getOwners(OwnersCondition ownersCondition) { + ownersCondition.checkDataConstraintViolation(); + Integer totalOwners = adminOwnerRepository.countByUserUserType(UserType.OWNER); + Criteria criteria = Criteria.of(ownersCondition.page(), ownersCondition.limit(), totalOwners); + Sort.Direction direction = ownersCondition.getDirection(); + + Page result = getResultPage(ownersCondition, criteria, direction); + + return AdminOwnersResponse.of(result, criteria); + } + + private Page getResultPage(OwnersCondition ownersCondition, Criteria criteria, Sort.Direction direction) { PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), Sort.by(direction, "user.createdAt")); Page result; - if (newOwnersCondition.searchType() == NewOwnersCondition.SearchType.EMAIL) { - result = adminOwnerRepository.findPageUnauthenticatedOwnersByEmail(newOwnersCondition.query(), pageRequest); - } else if (newOwnersCondition.searchType() == NewOwnersCondition.SearchType.NAME) { - result = adminOwnerRepository.findPageUnauthenticatedOwnersByName(newOwnersCondition.query(), pageRequest); + if (ownersCondition.searchType() == OwnersCondition.SearchType.EMAIL) { + result = adminOwnerRepository.findPageUnauthenticatedOwnersByEmail(ownersCondition.query(), pageRequest); + } else if (ownersCondition.searchType() == OwnersCondition.SearchType.NAME) { + result = adminOwnerRepository.findPageUnauthenticatedOwnersByName(ownersCondition.query(), pageRequest); } else { result = adminOwnerRepository.findPageUnauthenticatedOwners(pageRequest); } - return AdminNewOwnersResponse.of(result, criteria); + return result; } + private void validateNicknameDuplication(String nickname, Integer userId) { if (nickname != null && adminUserRepository.existsByNicknameAndIdNot(nickname, userId)) { @@ -109,4 +228,33 @@ public AdminOwnerResponse getOwner(Integer ownerId) { return AdminOwnerResponse.of(owner, shopsId); } + + @Transactional + public AdminOwnerUpdateResponse updateOwner(Integer ownerId, AdminOwnerUpdateRequest request) { + Owner owner = adminOwnerRepository.getById(ownerId); + owner.update(request); + return AdminOwnerUpdateResponse.from(owner); + } + + @Transactional + public User getUser(Integer userId) { + return adminUserRepository.getById(userId); + } + + @Transactional + public void deleteUser(Integer userId) { + User user = adminUserRepository.getById(userId); + if (user.getUserType() == UserType.STUDENT) { + adminStudentRepository.deleteById(userId); + } else if (user.getUserType() == UserType.OWNER) { + adminOwnerRepository.deleteById(userId); + } + adminUserRepository.delete(user); + } + + @Transactional + public void undeleteUser(Integer id) { + User user = adminUserRepository.getById(id); + user.undelete(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java b/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java index 4ed0a9928..22c380241 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java +++ b/src/main/java/in/koreatech/koin/domain/owner/model/Owner.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; +import in.koreatech.koin.admin.user.dto.AdminOwnerUpdateRequest; import in.koreatech.koin.domain.user.model.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -74,4 +75,14 @@ private Owner( this.grantEvent = grantEvent; this.account = account; } + + public void setGrantShop(boolean grantShop) { + this.grantShop = grantShop; + } + + public void update(AdminOwnerUpdateRequest request) { + this.companyRegistrationNumber = request.companyRegistrationNumber(); + this.grantShop = request.grantShop(); + this.grantEvent = request.grantEvent(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java index dacf4bde5..28f245dfd 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java @@ -232,6 +232,10 @@ private boolean isBetweenDate(LocalDateTime now, ShopOpen shopOpen, LocalDate cr return !start.isAfter(now) && !end.isBefore(now); } + public void updateOwner(Owner owner) { + this.owner = owner; + } + public void cancelDelete() { this.isDeleted = false; } diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index 3af797f91..54ca3ab5c 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -183,4 +183,8 @@ public void validateResetToken() { throw UserResetTokenExpiredException.withDetail("resetToken: " + resetToken); } } + + public void undelete() { + this.isDeleted = false; + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java index 089b8259c..d82f37a68 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java @@ -1,11 +1,13 @@ package in.koreatech.koin.admin.acceptance; import static in.koreatech.koin.domain.user.model.UserGender.MAN; +import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; import static in.koreatech.koin.domain.user.model.UserType.OWNER; +import static org.assertj.core.api.Assertions.assertThat; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; import static org.assertj.core.api.SoftAssertions.assertSoftly; import java.util.ArrayList; -import java.util.List; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayName; @@ -17,9 +19,13 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.user.repository.AdminOwnerRepository; +import in.koreatech.koin.admin.user.repository.AdminOwnerShopRedisRepository; import in.koreatech.koin.admin.user.repository.AdminStudentRepository; +import in.koreatech.koin.admin.user.repository.AdminUserRepository; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.owner.model.OwnerAttachment; +import in.koreatech.koin.domain.owner.model.OwnerShop; +import in.koreatech.koin.domain.owner.repository.OwnerShopRedisRepository; import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.user.model.Student; import in.koreatech.koin.domain.user.model.User; @@ -39,6 +45,15 @@ public class AdminUserApiTest extends AcceptanceTest { @Autowired private AdminOwnerRepository adminOwnerRepository; + @Autowired + private AdminUserRepository adminUserRepository; + + @Autowired + private AdminOwnerShopRedisRepository adminOwnerShopRedisRepository; + + @Autowired + private OwnerShopRedisRepository ownerShopRedisRepository; + @Autowired private TransactionTemplate transactionTemplate; @@ -51,6 +66,298 @@ public class AdminUserApiTest extends AcceptanceTest { @Autowired private PasswordEncoder passwordEncoder; + @Test + @DisplayName("관리자가 학생 리스트를 파라미터가 없이 조회한다.(페이지네이션)") + void getStudentsWithoutParameterAdmin() { + Student student = userFixture.준호_학생(); + User adminUser = userFixture.코인_운영자(); + + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .when() + .get("/admin/students") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "current_count": 1, + "current_page": 1, + "students": [ + { + "email": "juno@koreatech.ac.kr", + "id": 1, + "major": "컴퓨터공학부", + "name": "테스트용_준호", + "nickname": "준호", + "student_number": "2019136135" + } + ], + "total_count": 1, + "total_page": 1 + } + """); + } + + @Test + @DisplayName("관리자가 학생 리스트를 페이지 수와 limit으로 조회한다.(페이지네이션)") + void getStudentsWithPageAndLimitAdmin() { + for (int i = 0; i < 11; i++) { + Student student = Student.builder() + .studentNumber("2019136135") + .anonymousNickname("익명" + i) + .department("컴퓨터공학부") + .userIdentity(UNDERGRADUATE) + .isGraduated(false) + .user( + User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("성재" + i) + .name("테스트용_성재" + i) + .phoneNumber("01012345670") + .userType(STUDENT) + .gender(MAN) + .email("seongjae@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build() + ) + .build(); + + adminStudentRepository.save(student); + } + + User adminUser = userFixture.코인_운영자(); + + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .queryParam("page", 2) + .queryParam("limit", 10) + .when() + .get("/admin/students") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "current_count": 1, + "current_page": 2, + "students": [ + { + "email": "seongjae@koreatech.ac.kr", + "id": 11, + "major": "컴퓨터공학부", + "name": "테스트용_성재10", + "nickname": "성재10", + "student_number": "2019136135" + } + ], + "total_count": 11, + "total_page": 2 + } + """); + } + + @Test + @DisplayName("관리자가 학생 리스트를 닉네임으로 조회한다.(페이지네이션)") + void getStudentsWithNicknameAdmin() { + Student student1 = userFixture.성빈_학생(); + Student student2 = userFixture.준호_학생(); + User adminUser = userFixture.코인_운영자(); + + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .when() + .queryParam("nickname", "준호") + .get("/admin/students") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "current_count": 1, + "current_page": 1, + "students": [ + { + "email": "juno@koreatech.ac.kr", + "id": 2, + "major": "컴퓨터공학부", + "name": "테스트용_준호", + "nickname": "준호", + "student_number": "2019136135" + } + ], + "total_count": 1, + "total_page": 1 + } + """); + } + + @Test + @DisplayName("관리자가 로그인 한다.") + void adminLogin() { + User adminUser = userFixture.코인_운영자(); + String email = adminUser.getEmail(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/admin/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 로그인 한다. - 관리자가 아니면 404 반환") + void adminLoginNoAuth() { + Student student = userFixture.준호_학생(); + String email = student.getUser().getEmail(); + String password = "1234"; + + var response = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/admin/user/login") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 로그아웃한다") + void adminLogout() { + User adminUser = userFixture.코인_운영자(); + + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .when() + .post("/admin/user/logout") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + } + + @Test + @DisplayName("관리자가 액세스 토큰 재발급 한다") + void adminRefresh() { + User adminUser = userFixture.코인_운영자(); + String email = adminUser.getEmail(); + String password = "1234"; + + String token = userFixture.getToken(adminUser); + + var loginResponse = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "email" : "%s", + "password" : "%s" + } + """.formatted(email, password)) + .when() + .post("/admin/user/login") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract() + .response(); + + String refreshToken = loginResponse.jsonPath().getString("refresh_token"); + + var refreshResponse = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "refresh_token" : "%s" + } + """.formatted(refreshToken)) + .when() + .post("/admin/user/refresh") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + + @Test + @DisplayName("관리자가 사장님 권한 요청을 허용한다.") + void allowOwnerPermission() { + Owner owner = userFixture.철수_사장님(); + Shop shop = shopFixture.마슬랜(null); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + OwnerShop ownerShop = OwnerShop.builder() + .ownerId(owner.getId()) + .shopId(shop.getId()) + .build(); + + ownerShopRedisRepository.save(ownerShop); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .pathParam("id", owner.getUser().getId()) + .put("/admin/owner/{id}/authed") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + //영속성 컨테스트 동기화 + Owner updatedOwner = adminOwnerRepository.getById(owner.getId()); + var resultOwnerShop = adminOwnerShopRedisRepository.findById(owner.getId()); + + assertSoftly( + softly -> { + softly.assertThat(updatedOwner.getUser().isAuthed()).isEqualTo(true); + softly.assertThat(updatedOwner.isGrantShop()).isEqualTo(true); + softly.assertThat(resultOwnerShop).isEmpty(); + } + ); + } + @Test @DisplayName("관리자가 특정 학생 정보를 조회한다. - 관리자가 아니면 403 반환") void studentUpdateAdminNoAuth() { @@ -213,6 +520,48 @@ void getOwnerAdmin() { )); } + @Test + @DisplayName("관리자가 특정 사장을 수정한다.") + void updateOwner() { + Owner owner = userFixture.현수_사장님(); + Shop shop = shopFixture.마슬랜(owner); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(""" + { + "company_registration_number" : "123-45-67190", + "grant_shop" : "false", + "grant_event" : "false" + } + """) + .when() + .pathParam("id", owner.getUser().getId()) + .put("/admin/users/owner/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "company_registration_number" : "123-45-67190", + "email" : "hysoo@naver.com", + "gender" : 0, + "grant_shop" : false, + "grant_event" : false, + "name" : "테스트용_현수", + "nickname" : "현수", + "phone_number" : "01098765432" + } + """); + } + @Test @DisplayName("관리자가 가입 신청한 사장님 리스트 조회한다.") void getNewOwnersAdmin() { @@ -334,4 +683,119 @@ void getNewOwnersAdminV2() { } ); } + + @Test + @DisplayName("관리자가 가입 사장님 리스트 조회한다") + void getOwnersAdmin() { + for (int i = 0; i < 11; i++) { + User user = User.builder() + .password(passwordEncoder.encode("1234")) + .nickname("사장님" + i) + .name("테스트용(인증X)" + i) + .phoneNumber("0109776511" + i) + .userType(OWNER) + .gender(MAN) + .email("testchulsu@gmail.com" + i) + .isAuthed(true) + .isDeleted(false) + .build(); + + Owner owner = Owner.builder() + .user(user) + .companyRegistrationNumber("118-80-567" + i) + .grantShop(true) + .grantEvent(true) + .attachments(new ArrayList<>()) + .build(); + + OwnerAttachment attachment1 = OwnerAttachment.builder() + .url("https://test.com/사장님_인증사진_1" + i + ".jpg") + .isDeleted(false) + .owner(owner) + .build(); + + OwnerAttachment attachment2 = OwnerAttachment.builder() + .url("https://test.com/사장님_인증사진_2" + i + ".jpg") + .isDeleted(false) + .owner(owner) + .build(); + + owner.getAttachments().add(attachment1); + owner.getAttachments().add(attachment2); + + adminOwnerRepository.save(owner); + } + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .get("/admin/users/owners") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getInt("total_count")).isEqualTo(11); + softly.assertThat(response.body().jsonPath().getInt("current_count")).isEqualTo(10); + softly.assertThat(response.body().jsonPath().getInt("total_page")).isEqualTo(2); + softly.assertThat(response.body().jsonPath().getInt("current_page")).isEqualTo(1); + softly.assertThat(response.body().jsonPath().getList("owners").size()).isEqualTo(10); + } + ); + } + + @Test + @DisplayName("관리자가 회원을 조회한다.") + void getUser() { + Student student = userFixture.준호_학생(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .pathParam("id", student.getUser().getId()) + .get("/admin/users/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getString("nickname")).isEqualTo("준호"); + softly.assertThat(response.body().jsonPath().getString("name")).isEqualTo("테스트용_준호"); + softly.assertThat(response.body().jsonPath().getString("phoneNumber")).isEqualTo("01012345678"); + softly.assertThat(response.body().jsonPath().getString("email")).isEqualTo("juno@koreatech.ac.kr"); + } + ); + } + + @Test + @DisplayName("관리자가 회원을 삭제한다.") + void deleteUser() { + Student student = userFixture.준호_학생(); + + User adminUser = userFixture.코인_운영자(); + String token = userFixture.getToken(adminUser); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .when() + .pathParam("id", student.getUser().getId()) + .delete("/admin/users/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertThat(adminUserRepository.findById(student.getId())).isNotPresent(); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/UserFixture.java b/src/test/java/in/koreatech/koin/fixture/UserFixture.java index c71e83ecc..4d4521c72 100644 --- a/src/test/java/in/koreatech/koin/fixture/UserFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/UserFixture.java @@ -220,8 +220,8 @@ public UserFixture( Owner owner = Owner.builder() .user(user) .companyRegistrationNumber("118-80-56789") - .grantShop(true) - .grantEvent(true) + .grantShop(false) + .grantEvent(false) .account("01097765112") .attachments(new ArrayList<>()) .build(); From 4a3e4555632e6b4d4a2da9471d854b0bc2e59b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=A7=84=ED=98=B8?= <72592302+BaeJinho4028@users.noreply.github.com> Date: Wed, 26 Jun 2024 20:16:20 +0900 Subject: [PATCH 23/37] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=83=81=EC=A0=90,=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20api=EC=9E=91=EC=84=B1=20(#627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : notification FK 회원 삭제 오류 수정 (#514) * fix : notification/notification_subscribe DELETE CASCADE로 변경 * chore : Front 요청으로 인한 회원가입 에러코드 409 추가 * chore : DB 생략 * fix: 에러 반환값 수정 (#517) * hotfix: 학생 회원 가입 시에 전화번호 형식 추가 허용 (#530) * chore: 회원가입 전화번호 형식 추가 허용 * chore: 전화번호 가운데 세자리도 되게 허용 * chore: 비밀번호 틀렸을시에 400 반환하는 것으로 수정 (#605) * feat: controller 작성 * feat: service, repository 작성 * test: 어드민 shop, category 테스트 작성 * refactor: 1차 피드백 반영 * fix: 버그 수정 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 최준호 Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: 송선권 --- .../admin/shop/controller/AdminShopApi.java | 168 +++++- .../shop/controller/AdminShopController.java | 131 ++++- .../dto/AdminCreateShopCategoryRequest.java | 33 ++ .../shop/dto/AdminCreateShopRequest.java | 133 +++++ .../dto/AdminModifyShopCategoryRequest.java | 24 + .../shop/dto/AdminModifyShopRequest.java | 114 ++++ .../shop/dto/AdminShopCategoriesResponse.java | 68 +++ .../shop/dto/AdminShopCategoryResponse.java | 28 + .../admin/shop/dto/AdminShopResponse.java | 163 ++++++ .../admin/shop/dto/AdminShopsResponse.java | 78 +++ .../ShopCategoryDuplicationException.java | 20 + .../AdminEventArticleRepository.java | 19 + .../AdminMenuCategoryRepository.java | 8 +- .../AdminShopCategoryRepository.java | 17 +- .../shop/repository/AdminShopRepository.java | 23 +- .../admin/shop/service/AdminShopService.java | 155 +++++- .../koin/domain/shop/model/Menu.java | 6 +- .../koin/domain/shop/model/Shop.java | 25 +- .../koin/domain/shop/model/ShopCategory.java | 9 + ...ShopApiTest.java => AdminShopApiTest.java} | 488 +++++++++++++++++- 20 files changed, 1648 insertions(+), 62 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryDuplicationException.java create mode 100644 src/main/java/in/koreatech/koin/admin/shop/repository/AdminEventArticleRepository.java rename src/test/java/in/koreatech/koin/admin/acceptance/{AdmimShopApiTest.java => AdminShopApiTest.java} (51%) diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java index fb326fb7d..377009135 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopApi.java @@ -10,14 +10,23 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest; import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoryResponse; import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsResponse; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -28,9 +37,73 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -@Tag(name = "(Admin) Shop: 상점", description = "상점 정보를 관리한다") +@Tag(name = "(Admin) Shop: 상점", description = "관리자 권한으로 상점 정보를 관리한다") public interface AdminShopApi { + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "모든 상점 조회") + @GetMapping("/admin/shops") + ResponseEntity getShops( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "특정 상점 조회") + @GetMapping("/admin/shops/{id}") + ResponseEntity getShop( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "모든 상점 카테고리 조회") + @GetMapping("/admin/shops/categories") + ResponseEntity getShopCategories( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 조회") + @GetMapping("/admin/shops/categories/{id}") + ResponseEntity getShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -92,6 +165,7 @@ ResponseEntity createMenu( @RequestBody @Valid AdminCreateMenuRequest adminCreateMenuRequest, @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( value = { @ApiResponse(responseCode = "201"), @@ -108,6 +182,36 @@ ResponseEntity createMenuCategory( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 생성") + @PostMapping("/admin/shops") + ResponseEntity createShop( + @RequestBody @Valid AdminCreateShopRequest adminCreateShopRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 생성") + @PostMapping("/admin/shops/categories") + ResponseEntity createShopCategory( + @RequestBody @Valid AdminCreateShopCategoryRequest adminCreateShopCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "201"), @@ -123,6 +227,38 @@ ResponseEntity cancelShopDelete( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 수정") + @PutMapping("/admin/shops/{id}") + ResponseEntity modifyShop( + @Parameter(in = PATH) @PathVariable Integer id, + @RequestBody @Valid AdminModifyShopRequest adminModifyShopRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 수정") + @PutMapping("/admin/shops/categories/{id}") + ResponseEntity modifyShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @RequestBody @Valid AdminModifyShopCategoryRequest adminModifyShopCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), @@ -156,6 +292,36 @@ ResponseEntity modifyMenu( @Auth(permit = {ADMIN}) Integer adminId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 삭제") + @DeleteMapping("/admin/shops/{id}") + ResponseEntity deleteShop( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "상점 카테고리 삭제") + @DeleteMapping("/admin/shops/categories/{id}") + ResponseEntity deleteShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java index 38520980b..8a2d71a4a 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java +++ b/src/main/java/in/koreatech/koin/admin/shop/controller/AdminShopController.java @@ -11,15 +11,24 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest; import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoryResponse; import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsResponse; import in.koreatech.koin.admin.shop.service.AdminShopService; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Parameter; @@ -32,6 +41,46 @@ public class AdminShopController implements AdminShopApi { private final AdminShopService adminShopService; + @GetMapping("/admin/shops") + public ResponseEntity getShops( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopsResponse response = adminShopService.getShops(page, limit, isDeleted); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/shops/{id}") + public ResponseEntity getShop( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopResponse response = adminShopService.getShop(id); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/shops/categories") + public ResponseEntity getShopCategories( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "is_deleted", defaultValue = "false") Boolean isDeleted, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopCategoriesResponse response = adminShopService.getShopCategories(page, limit, isDeleted); + return ResponseEntity.ok(response); + } + + @GetMapping("/admin/shops/categories/{id}") + public ResponseEntity getShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @Auth(permit = {ADMIN}) Integer adminId + ) { + AdminShopCategoryResponse response = adminShopService.getShopCategory(id); + return ResponseEntity.ok(response); + } + @GetMapping("/admin/shops/{id}/menus") public ResponseEntity getAllMenus( @Parameter(in = PATH) @PathVariable("id") Integer shopId, @@ -60,6 +109,65 @@ public ResponseEntity getMenu( return ResponseEntity.ok(adminMenuDetailResponse); } + @PostMapping("/admin/shops") + public ResponseEntity createShop( + @RequestBody @Valid AdminCreateShopRequest adminCreateShopRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.createShop(adminCreateShopRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/admin/shops/categories") + public ResponseEntity createShopCategory( + @RequestBody @Valid AdminCreateShopCategoryRequest adminCreateShopCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.createShopCategory(adminCreateShopCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/admin/shops/{id}") + public ResponseEntity modifyShop( + @Parameter(in = PATH) @PathVariable Integer id, + @RequestBody @Valid AdminModifyShopRequest adminModifyShopRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyShop(id, adminModifyShopRequest); + return ResponseEntity.ok().build(); + } + + @PutMapping("/admin/shops/categories/{id}") + public ResponseEntity modifyShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, + @RequestBody @Valid AdminModifyShopCategoryRequest adminModifyShopCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyShopCategory(id, adminModifyShopCategoryRequest); + return ResponseEntity.ok().build(); + } + + @PutMapping("/admin/shops/{shopId}/menus/categories") + public ResponseEntity modifyMenuCategory( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @RequestBody @Valid AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyMenuCategory(shopId, adminModifyMenuCategoryRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/admin/shops/{shopId}/menus/{menuId}") + public ResponseEntity modifyMenu( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, + @RequestBody @Valid AdminModifyMenuRequest adminModifyMenuRequest, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminShopService.modifyMenu(shopId, menuId, adminModifyMenuRequest); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + @PostMapping("/admin/shops/{id}/menus") public ResponseEntity createMenu( @Parameter(in = PATH) @PathVariable("id") Integer shopId, @@ -89,25 +197,22 @@ public ResponseEntity cancelShopDelete( return ResponseEntity.status(HttpStatus.OK).build(); } - @PutMapping("/admin/shops/{shopId}/menus/categories") - public ResponseEntity modifyMenuCategory( - @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, - @RequestBody @Valid AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest, + @DeleteMapping("/admin/shops/{id}") + public ResponseEntity deleteShop( + @Parameter(in = PATH) @PathVariable Integer id, @Auth(permit = {ADMIN}) Integer adminId ) { - adminShopService.modifyMenuCategory(shopId, adminModifyMenuCategoryRequest); - return ResponseEntity.status(HttpStatus.CREATED).build(); + adminShopService.deleteShop(id); + return ResponseEntity.ok().build(); } - @PutMapping("/admin/shops/{shopId}/menus/{menuId}") - public ResponseEntity modifyMenu( - @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, - @Parameter(in = PATH) @PathVariable("menuId") Integer menuId, - @RequestBody @Valid AdminModifyMenuRequest adminModifyMenuRequest, + @DeleteMapping("/admin/shops/categories/{id}") + public ResponseEntity deleteShopCategory( + @Parameter(in = PATH) @PathVariable Integer id, @Auth(permit = {ADMIN}) Integer adminId ) { - adminShopService.modifyMenu(shopId, menuId, adminModifyMenuRequest); - return ResponseEntity.status(HttpStatus.CREATED).build(); + adminShopService.deleteShopCategory(id); + return ResponseEntity.ok().build(); } @DeleteMapping("/admin/shops/{shopId}/menus/categories/{categoryId}") diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java new file mode 100644 index 000000000..196d7146c --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopCategoryRequest.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.admin.shop.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.ShopCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminCreateShopCategoryRequest( + @Schema(description = "이미지 URL", example = "https://static.koreatech.in/test.png", requiredMode = RequiredMode.REQUIRED) + @NotBlank(message = "이미지 URL은 필수입니다.") + @Size(max = 100, message = "이미지 URL은 255자 이하로 입력해주세요.") + String imageUrl, + + @Schema(description = "이름", example = "햄버거", requiredMode = RequiredMode.REQUIRED) + @NotBlank(message = "카테고리명은 필수입니다.") + @Size(min = 1, max = 25, message = "이름은 1자 이상, 25자 이하로 입력해주세요.") + String name +) { + + public ShopCategory toShopCategory() { + return ShopCategory.builder() + .imageUrl(imageUrl) + .name(name) + .isDeleted(false) + .build(); + } +} + diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java new file mode 100644 index 000000000..9e3cb4559 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java @@ -0,0 +1,133 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminCreateShopRequest( + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면", requiredMode = NOT_REQUIRED) + @NotNull(message = "주소는 필수입니다.") + @Size(min = 1, max = 100, message = "주소는 1자 이상, 100자 이하로 입력해주세요.") + String address, + + @Schema(description = "상점 카테고리 고유 id 리스트", example = "[1, 2]", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + List categoryIds, + + @Schema(description = "배달 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "배달 가능 여부는 필수입니다.") + Boolean delivery, + + @Schema(description = "배달비", example = "1000", requiredMode = REQUIRED) + @NotNull(message = "배달비는 필수입니다.") + @PositiveOrZero(message = "배달비는 0원 이상이어야 합니다.") + Integer deliveryPrice, + + @Schema(description = "설명", example = "string", requiredMode = NOT_REQUIRED) + @NotNull(message = "상점 설명은 null일 수 없습니다.") + @Size(max = 200, message = "설명은 200자 이하로 입력해주세요.") + String description, + + @Schema(description = "이미지 URL 리스트", example = """ + [ "https://static.koreatech.in/example.png" ] + """, requiredMode = NOT_REQUIRED) + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(description = "이름", example = "수신반점", requiredMode = REQUIRED) + @NotBlank(message = "이름은 필수입니다.") + @Size(min = 1, max = 100, message = "가게명은 1자 이상, 15자 이하로 입력해주세요.") + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + List open, + + @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "계좌 이체 가능 여부는 필수입니다.") + Boolean payBank, + + @Schema(description = "카드 계산 가능 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "카드 계산 가능 여부는 필수입니다.") + Boolean payCard, + + @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호 형식이 유효하지 않습니다.") + String phone +) { + + public Shop toShop() { + return Shop.builder() + .address(address) + .delivery(delivery) + .deliveryPrice(deliveryPrice) + .description(description) + .name(name) + .payBank(payBank) + .payCard(payCard) + .phone(phone) + .internalName(name) + .chosung(name.substring(0, 1)) + .isDeleted(false) + .isEvent(false) + .remarks("") + .hit(0) + .build(); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + @Valid + public record InnerShopOpen( + @Schema(description = """ + 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] + """, example = "MONDAY", requiredMode = REQUIRED) + @NotNull(message = "요일은 필수입니다.") + String dayOfWeek, + + @Schema(description = "휴무 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "휴무 여부는 필수입니다.") + boolean closed, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "오픈 시간", example = "02:00", requiredMode = NOT_REQUIRED) + @NotNull(message = "오픈 시간은 필수입니다.") + LocalTime openTime, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "마감 시간", example = "16:00", requiredMode = NOT_REQUIRED) + @NotNull(message = "닫는 시간은 필수입니다.") + LocalTime closeTime + ) { + + public ShopOpen toEntity(Shop shop) { + return ShopOpen.builder() + .shop(shop) + .closed(closed) + .openTime(openTime) + .closeTime(closeTime) + .dayOfWeek(dayOfWeek) + .build(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java new file mode 100644 index 000000000..0ff53f5ce --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopCategoryRequest.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.admin.shop.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminModifyShopCategoryRequest( + @Schema(description = "이미지 URL", example = "https://static.koreatech.in/test.png", requiredMode = RequiredMode.REQUIRED) + @NotBlank(message = "이미지 URL은 필수입니다.") + @Size(max = 100, message = "이미지 URL은 255자 이하로 입력해주세요.") + String imageUrl, + + @Schema(description = "이름", example = "햄버거", requiredMode = RequiredMode.REQUIRED) + @NotBlank(message = "카테고리명은 필수입니다.") + @Size(min = 1, max = 25, message = "이름은 1자 이상, 25자 이하로 입력해주세요.") + String name +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java new file mode 100644 index 000000000..8a3652100 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java @@ -0,0 +1,114 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import in.koreatech.koin.global.validation.UniqueId; +import in.koreatech.koin.global.validation.UniqueUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminModifyShopRequest( + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면", requiredMode = NOT_REQUIRED) + @NotNull(message = "주소는 필수입니다.") + @Size(min = 1, max = 100, message = "주소는 1자 이상, 100자 이하로 입력해주세요.") + String address, + + @Schema(description = "상점 카테고리 고유 id 리스트", example = "[1, 2]", requiredMode = REQUIRED) + @NotNull(message = "카테고리는 필수입니다.") + @UniqueId(message = "카테고리 ID는 중복될 수 없습니다.") + @Size(min = 1, message = "최소 한 개의 카테고리가 필요합니다.") + List categoryIds, + + @Schema(description = "배달 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "배달 가능 여부는 필수입니다.") + Boolean delivery, + + @Schema(description = "배달비", example = "1000", requiredMode = REQUIRED) + @NotNull(message = "배달비는 필수입니다.") + @PositiveOrZero(message = "배달비는 0원 이상이어야 합니다.") + Integer deliveryPrice, + + @Schema(description = "설명", example = "string", requiredMode = NOT_REQUIRED) + @NotNull(message = "상점 설명은 null일 수 없습니다.") + @Size(max = 200, message = "설명은 200자 이하로 입력해주세요.") + String description, + + @Schema(description = "이미지 URL 리스트", example = """ + [ "https://static.koreatech.in/example.png" ] + """, requiredMode = NOT_REQUIRED) + @UniqueUrl(message = "이미지 URL은 중복될 수 없습니다.") + List imageUrls, + + @Schema(description = "이름", example = "수신반점", requiredMode = REQUIRED) + @NotBlank(message = "이름은 필수입니다.") + @Size(min = 1, max = 100, message = "가게명은 1자 이상, 15자 이하로 입력해주세요.") + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + List open, + + @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "계좌 이체 가능 여부는 필수입니다.") + Boolean payBank, + + @Schema(description = "카드 계산 가능 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "카드 계산 가능 여부는 필수입니다.") + Boolean payCard, + + @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호 형식이 유효하지 않습니다.") + String phone +) { + + @JsonNaming(value = SnakeCaseStrategy.class) + @Valid + public record InnerShopOpen( + @Schema(description = """ + 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] + """, example = "MONDAY", requiredMode = REQUIRED) + @NotNull(message = "요일은 필수입니다.") + String dayOfWeek, + + @Schema(description = "휴무 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "휴무 여부는 필수입니다.") + boolean closed, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "오픈 시간", example = "02:00", requiredMode = NOT_REQUIRED) + @NotNull(message = "오픈 시간은 필수입니다.") + LocalTime openTime, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "마감 시간", example = "16:00", requiredMode = NOT_REQUIRED) + @NotNull(message = "닫는 시간은 필수입니다.") + LocalTime closeTime + ) { + + public ShopOpen toEntity(Shop shop) { + return ShopOpen.builder() + .shop(shop) + .closed(closed) + .openTime(openTime) + .closeTime(closeTime) + .dayOfWeek(dayOfWeek) + .build(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java new file mode 100644 index 000000000..965e79cc8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoriesResponse.java @@ -0,0 +1,68 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopCategoriesResponse( + @Schema(description = "총 상점 카테고리 수", example = "57", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 상점 카테고리 수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "전체 페이지 수", example = "6", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "모든 상점 카테고리 리스트", requiredMode = NOT_REQUIRED) + List categories +) { + + public static AdminShopCategoriesResponse of(Page pagedResult, Criteria criteria) { + return new AdminShopCategoriesResponse( + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1, + pagedResult.getContent() + .stream() + .map(InnerShopCategory::from) + .toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopCategory( + @Schema(description = "고유 id", example = "0", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이미지 URL", example = "https://static.koreatech.in/test.png", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(description = "이름", example = "치킨", requiredMode = REQUIRED) + String name + ) { + + public static InnerShopCategory from(ShopCategory shopCategory) { + return new InnerShopCategory( + shopCategory.getId(), + shopCategory.getImageUrl(), + shopCategory.getName() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java new file mode 100644 index 000000000..c3b628a19 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopCategoryResponse.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.admin.shop.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.ShopCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopCategoryResponse( + @Schema(description = "카테고리 고유 ID", example = "0") + int id, + + @Schema(description = "카테고리 이미지 URL", example = "string") + String imageUrl, + + @Schema(description = "카테고리 이름", example = "string") + String name +) { + + public static AdminShopCategoryResponse from(ShopCategory shopCategory) { + return new AdminShopCategoryResponse( + shopCategory.getId(), + shopCategory.getImageUrl(), + shopCategory.getName() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java new file mode 100644 index 000000000..b292e9231 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java @@ -0,0 +1,163 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopResponse( + @Schema(description = "주소", example = "충청남도 천안시 동남구 병천면") + String address, + + @Schema(description = "배달 가능 여부", example = "true") + Boolean delivery, + + @Schema(description = "배달비", example = "1000", requiredMode = REQUIRED) + Integer deliveryPrice, + + @Schema(description = "설명", example = "string") + String description, + + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이미지 URL 리스트") + List imageUrls, + + @Schema(description = "상점에 있는 메뉴 카테고리 리스트") + List menuCategories, + + @Schema(description = "이름", example = "수신반점") + String name, + + @Schema(description = "요일별 휴무 여부 및 장사 시간") + List open, + + @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) + Boolean payBank, + + @Schema(description = "카드 계산 가능 여부", example = "false", requiredMode = REQUIRED) + Boolean payCard, + + @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) + String phone, + + @Schema(description = "소속된 상점 카테고리 리스트") + List shopCategories, + + @JsonFormat(pattern = "yyyy-MM-dd") + @Schema(description = "업데이트 날짜", example = "2024-03-01", requiredMode = REQUIRED) + LocalDateTime updatedAt, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + Boolean isDeleted, + + @Schema(description = "상점 이벤트 진행 여부", example = "true", requiredMode = REQUIRED) + Boolean isEvent +) { + + public static AdminShopResponse from(Shop shop, Boolean isEvent) { + Collections.sort(shop.getMenuCategories()); + return new AdminShopResponse( + shop.getAddress(), + shop.isDelivery(), + shop.getDeliveryPrice(), + (shop.getDescription() == null || shop.getDescription().isBlank()) ? "-" : shop.getDescription(), + shop.getId(), + shop.getShopImages().stream() + .map(ShopImage::getImageUrl) + .toList(), + shop.getMenuCategories().stream().map(menuCategory -> + new InnerMenuCategory( + menuCategory.getId(), + menuCategory.getName() + ) + ).toList(), + shop.getName(), + shop.getShopOpens().stream().map(shopOpen -> + new InnerShopOpen( + shopOpen.getDayOfWeek(), + shopOpen.isClosed(), + shopOpen.getOpenTime(), + shopOpen.getCloseTime() + ) + ).toList(), + shop.isPayBank(), + shop.isPayCard(), + shop.getPhone(), + shop.getShopCategories().stream().map(shopCategoryMap -> { + ShopCategory shopCategory = shopCategoryMap.getShopCategory(); + return new InnerShopCategory( + shopCategory.getId(), + shopCategory.getName() + ); + }).toList(), + shop.getUpdatedAt(), + shop.isDeleted(), + isEvent + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopOpen( + @Schema(description = """ + 요일 = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] + """, example = "MONDAY", requiredMode = REQUIRED) + String dayOfWeek, + + @Schema(description = "휴무 여부", example = "false", requiredMode = REQUIRED) + Boolean closed, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "오픈 시간", example = "02:00", requiredMode = NOT_REQUIRED) + LocalTime openTime, + + @JsonFormat(pattern = "HH:mm") + @Schema(description = "마감 시간", example = "16:00", requiredMode = NOT_REQUIRED) + LocalTime closeTime + ) { + + public static InnerShopOpen from(ShopOpen shopOpen) { + return new InnerShopOpen( + shopOpen.getDayOfWeek(), + shopOpen.isClosed(), + shopOpen.getOpenTime(), + shopOpen.getCloseTime() + ); + } + } + + private record InnerShopCategory( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "중국집", requiredMode = REQUIRED) + String name + ) { + + } + + private record InnerMenuCategory( + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "대표 메뉴", requiredMode = REQUIRED) + String name + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsResponse.java new file mode 100644 index 000000000..57bee7f33 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopsResponse.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.admin.shop.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.global.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record AdminShopsResponse( + @Schema(description = "총 상점의 수", example = "57", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "조회할 수 있는 최대 페이지", example = "6", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "상점 정보") + List shops +) { + + public static AdminShopsResponse of(Page pagedResult, Criteria criteria) { + return new AdminShopsResponse( + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1, + pagedResult.getContent() + .stream() + .map(InnerShopResponse::from) + .toList() + ); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerShopResponse( + @Schema(description = "속해있는 상점 카테고리 이름 리스트", example = "[\"string\"]", requiredMode = NOT_REQUIRED) + List categoryNames, + + @Schema(description = "고유 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "이름", example = "홉스", requiredMode = REQUIRED) + String name, + + @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) + String phone, + + @Schema(description = "삭제 여부", example = "false", requiredMode = REQUIRED) + boolean isDeleted + ) { + + public static InnerShopResponse from(Shop shop) { + return new InnerShopResponse( + shop.getShopCategories().stream() + .map(shopCategoryMap -> shopCategoryMap.getShopCategory().getName()) + .toList(), + shop.getId(), + shop.getName(), + shop.getPhone(), + shop.isDeleted() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryDuplicationException.java b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryDuplicationException.java new file mode 100644 index 000000000..57f64f0b8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/exception/ShopCategoryDuplicationException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.admin.shop.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class ShopCategoryDuplicationException extends DuplicationException { + + private static final String DEFAULT_MESSAGE = "카테고리명이 이미 존재합니다"; + + protected ShopCategoryDuplicationException(String message) { + super(message); + } + + protected ShopCategoryDuplicationException(String message, String detail) { + super(message, detail); + } + + public static ShopCategoryDuplicationException withDetail(String name) { + return new ShopCategoryDuplicationException(DEFAULT_MESSAGE, "name: " + name); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminEventArticleRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminEventArticleRepository.java new file mode 100644 index 000000000..400a59962 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminEventArticleRepository.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.admin.shop.repository; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.shop.model.EventArticle; + +public interface AdminEventArticleRepository extends Repository { + + @Query(""" + SELECT COUNT(e) > 0 FROM EventArticle e + WHERE :now BETWEEN e.startDate AND e.endDate + AND e.shop.id = :shopId + """) + boolean isDurationEvent(@Param("shopId") Integer shopId, @Param("now") LocalDate now); +} diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java index 21e3868cb..ebc1b4abe 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminMenuCategoryRepository.java @@ -10,17 +10,17 @@ public interface AdminMenuCategoryRepository extends Repository { - List findAllByShopId(Integer shopId); - MenuCategory save(MenuCategory menuCategory); + List findAllByShopId(Integer shopId); + Optional findById(Integer id); List findAllByIdIn(List ids); + Void deleteById(Integer id); + default MenuCategory getById(Integer id) { return findById(id).orElseThrow(() -> MenuCategoryNotFoundException.withDetail("categoryId: " + id)); } - - Void deleteById(Integer id); } diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java index 52ae86ebc..53db5202e 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopCategoryRepository.java @@ -3,23 +3,34 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.shop.exception.ShopCategoryNotFoundException; import in.koreatech.koin.domain.shop.model.ShopCategory; public interface AdminShopCategoryRepository extends Repository { - Optional findById(Integer shopCategoryId); + Page findAllByIsDeleted(boolean isDeleted, Pageable pageable); + + Integer countAllByIsDeleted(boolean isDeleted); + + @Query(value = "SELECT * FROM shop_categories WHERE id = :shopCategoryId", nativeQuery = true) + Optional findById(@Param("shopCategoryId") Integer shopCategoryId); ShopCategory save(ShopCategory shopCategory); List findAllByIdIn(List ids); + Optional findByName(String name); + + List findAll(); + default ShopCategory getById(Integer shopCategoryId) { return findById(shopCategoryId) .orElseThrow(() -> ShopCategoryNotFoundException.withDetail("shopCategoryId: " + shopCategoryId)); } - - List findAll(); } diff --git a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java index 3bba83f33..4eda89f19 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java +++ b/src/main/java/in/koreatech/koin/admin/shop/repository/AdminShopRepository.java @@ -3,6 +3,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -13,24 +15,27 @@ public interface AdminShopRepository extends Repository { - Shop save(Shop shop); + @Query(value = "SELECT * FROM shops WHERE is_deleted = :isDeleted", + countQuery = "SELECT count(*) FROM shops WHERE is_deleted = :isDeleted", + nativeQuery = true) + Page findAllByIsDeleted(@Param("isDeleted") boolean isDeleted, Pageable pageable); + + @Query(value = "SELECT COUNT(*) FROM shops WHERE is_deleted = :isDeleted", nativeQuery = true) + Integer countAllByIsDeleted(@Param("isDeleted") boolean isDeleted); - List findAllByOwnerId(Integer ownerId); + Shop save(Shop shop); - Optional findById(Integer shopId); + @Query(value = "SELECT * FROM shops WHERE id = :shopId", nativeQuery = true) + Optional findById(@Param("shopId") Integer shopId); - Optional findByOwnerId(Integer ownerId); + @Query(value = "SELECT * FROM shops WHERE owner_id = :ownerId AND is_deleted = false", nativeQuery = true) + List findAllByOwnerId(@Param("ownerId") Integer ownerId); default Shop getById(Integer shopId) { return findById(shopId) .orElseThrow(() -> ShopNotFoundException.withDetail("shopId: " + shopId)); } - default Shop getByOwnerId(Integer ownerId) { - return findByOwnerId(ownerId) - .orElseThrow(() -> ShopNotFoundException.withDetail("ownerId: " + ownerId)); - } - List findAll(); @Query(value = "SELECT * FROM shops WHERE id = :shopId AND is_deleted = true", nativeQuery = true) diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index d0f3bc7dc..4cb4189ac 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -1,26 +1,46 @@ package in.koreatech.koin.admin.shop.service; +import java.time.Clock; +import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.admin.shop.dto.AdminCreateMenuCategoryRequest; import in.koreatech.koin.admin.shop.dto.AdminCreateMenuRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminCreateShopRequest.InnerShopOpen; import in.koreatech.koin.admin.shop.dto.AdminMenuCategoriesResponse; import in.koreatech.koin.admin.shop.dto.AdminMenuDetailResponse; import in.koreatech.koin.admin.shop.dto.AdminModifyMenuCategoryRequest; import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest; import in.koreatech.koin.admin.shop.dto.AdminModifyMenuRequest.InnerOptionPrice; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopCategoryRequest; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoriesResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopCategoryResponse; import in.koreatech.koin.admin.shop.dto.AdminShopMenuResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopResponse; +import in.koreatech.koin.admin.shop.dto.AdminShopsResponse; +import in.koreatech.koin.admin.shop.exception.ShopCategoryDuplicationException; +import in.koreatech.koin.admin.shop.repository.AdminEventArticleRepository; import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryMapRepository; import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryRepository; import in.koreatech.koin.admin.shop.repository.AdminMenuDetailRepository; import in.koreatech.koin.admin.shop.repository.AdminMenuImageRepository; import in.koreatech.koin.admin.shop.repository.AdminMenuRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopCategoryMapRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopCategoryRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopImageRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopOpenRepository; import in.koreatech.koin.admin.shop.repository.AdminShopRepository; import in.koreatech.koin.domain.shop.model.Menu; import in.koreatech.koin.domain.shop.model.MenuCategory; @@ -28,7 +48,12 @@ import in.koreatech.koin.domain.shop.model.MenuImage; import in.koreatech.koin.domain.shop.model.MenuOption; import in.koreatech.koin.domain.shop.model.Shop; +import in.koreatech.koin.domain.shop.model.ShopCategory; +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; +import in.koreatech.koin.global.model.Criteria; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @@ -37,14 +62,49 @@ @RequiredArgsConstructor public class AdminShopService { + private final Clock clock; private final EntityManager entityManager; + private final AdminEventArticleRepository adminEventArticleRepository; + private final AdminMenuCategoryRepository adminMenuCategoryRepository; + private final AdminShopCategoryMapRepository adminShopCategoryMapRepository; + private final AdminShopCategoryRepository adminShopCategoryRepository; + private final AdminShopImageRepository adminShopImageRepository; + private final AdminShopOpenRepository adminShopOpenRepository; private final AdminShopRepository adminShopRepository; private final AdminMenuRepository adminMenuRepository; - private final AdminMenuCategoryRepository adminMenuCategoryRepository; private final AdminMenuCategoryMapRepository adminMenuCategoryMapRepository; private final AdminMenuImageRepository adminMenuImageRepository; private final AdminMenuDetailRepository adminMenuDetailRepository; + public AdminShopsResponse getShops(Integer page, Integer limit, Boolean isDeleted) { + Integer total = adminShopRepository.countAllByIsDeleted(isDeleted); + Criteria criteria = Criteria.of(page, limit, total); + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), + Sort.by(Sort.Direction.ASC, "id")); + Page result = adminShopRepository.findAllByIsDeleted(isDeleted, pageRequest); + return AdminShopsResponse.of(result, criteria); + } + + public AdminShopResponse getShop(Integer shopId) { + Shop shop = adminShopRepository.getById(shopId); + boolean eventDuration = adminEventArticleRepository.isDurationEvent(shopId, LocalDate.now(clock)); + return AdminShopResponse.from(shop, eventDuration); + } + + public AdminShopCategoriesResponse getShopCategories(Integer page, Integer limit, Boolean isDeleted) { + Integer total = adminShopCategoryRepository.countAllByIsDeleted(isDeleted); + Criteria criteria = Criteria.of(page, limit, total); + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), + Sort.by(Sort.Direction.ASC, "id")); + Page result = adminShopCategoryRepository.findAllByIsDeleted(isDeleted, pageRequest); + return AdminShopCategoriesResponse.of(result, criteria); + } + + public AdminShopCategoryResponse getShopCategory(Integer categoryId) { + ShopCategory shopCategory = adminShopCategoryRepository.getById(categoryId); + return AdminShopCategoryResponse.from(shopCategory); + } + public AdminShopMenuResponse getAllMenus(Integer shopId) { Shop shop = adminShopRepository.getById(shopId); List menuCategories = adminMenuCategoryRepository.findAllByShopId(shop.getId()); @@ -69,6 +129,54 @@ public AdminMenuDetailResponse getMenu(Integer shopId, Integer menuId) { return AdminMenuDetailResponse.createMenuDetailResponse(menu, menuCategories); } + @Transactional + public void createShop(AdminCreateShopRequest adminCreateShopRequest) { + Shop shop = adminCreateShopRequest.toShop(); + Shop savedShop = adminShopRepository.save(shop); + List categoryNames = List.of("추천 메뉴", "메인 메뉴", "세트 메뉴", "사이드 메뉴"); + for (String categoryName : categoryNames) { + MenuCategory menuCategory = MenuCategory.builder() + .shop(savedShop) + .name(categoryName) + .build(); + adminMenuCategoryRepository.save(menuCategory); + } + for (String imageUrl : adminCreateShopRequest.imageUrls()) { + ShopImage shopImage = ShopImage.builder() + .shop(savedShop) + .imageUrl(imageUrl) + .build(); + adminShopImageRepository.save(shopImage); + } + for (InnerShopOpen open : adminCreateShopRequest.open()) { + ShopOpen shopOpen = ShopOpen.builder() + .shop(savedShop) + .openTime(open.openTime()) + .closeTime(open.closeTime()) + .dayOfWeek(open.dayOfWeek()) + .closed(open.closed()) + .build(); + adminShopOpenRepository.save(shopOpen); + } + List categories = adminShopCategoryRepository.findAllByIdIn(adminCreateShopRequest.categoryIds()); + for (ShopCategory shopCategory : categories) { + ShopCategoryMap shopCategoryMap = ShopCategoryMap.builder() + .shopCategory(shopCategory) + .shop(savedShop) + .build(); + adminShopCategoryMapRepository.save(shopCategoryMap); + } + } + + @Transactional + public void createShopCategory(AdminCreateShopCategoryRequest adminCreateShopCategoryRequest) { + if (adminShopCategoryRepository.findByName(adminCreateShopCategoryRequest.name()).isPresent()) { + throw ShopCategoryDuplicationException.withDetail("name: " + adminCreateShopCategoryRequest.name()); + } + ShopCategory shopCategory = adminCreateShopCategoryRequest.toShopCategory(); + adminShopCategoryRepository.save(shopCategory); + } + @Transactional public void createMenu(Integer shopId, AdminCreateMenuRequest adminCreateMenuRequest) { adminShopRepository.getById(shopId); @@ -126,6 +234,39 @@ public void cancelShopDelete(Integer shopId) { } } + @Transactional + public void modifyShop(Integer shopId, AdminModifyShopRequest adminModifyShopRequest) { + Shop shop = adminShopRepository.getById(shopId); + shop.modifyShop( + adminModifyShopRequest.name(), + adminModifyShopRequest.phone(), + adminModifyShopRequest.address(), + adminModifyShopRequest.description(), + adminModifyShopRequest.delivery(), + adminModifyShopRequest.deliveryPrice(), + adminModifyShopRequest.payCard(), + adminModifyShopRequest.payBank() + ); + shop.modifyShopCategories( + adminShopCategoryRepository.findAllByIdIn(adminModifyShopRequest.categoryIds()), + entityManager + ); + shop.modifyShopImages(adminModifyShopRequest.imageUrls(), entityManager); + shop.modifyAdminShopOpens(adminModifyShopRequest.open(), entityManager); + } + + @Transactional + public void modifyShopCategory(Integer categoryId, AdminModifyShopCategoryRequest adminModifyShopCategoryRequest) { + if (adminShopCategoryRepository.findByName(adminModifyShopCategoryRequest.name()).isPresent()) { + throw ShopCategoryDuplicationException.withDetail("name: " + adminModifyShopCategoryRequest.name()); + } + ShopCategory shopCategory = adminShopCategoryRepository.getById(categoryId); + shopCategory.modifyShopCategory( + adminModifyShopCategoryRequest.name(), + adminModifyShopCategoryRequest.imageUrl() + ); + } + @Transactional public void modifyMenuCategory(Integer shopId, AdminModifyMenuCategoryRequest adminModifyMenuCategoryRequest) { adminShopRepository.getById(shopId); @@ -151,6 +292,18 @@ public void modifyMenu(Integer shopId, Integer menuId, AdminModifyMenuRequest ad } } + @Transactional + public void deleteShop(Integer shopId) { + Shop shop = adminShopRepository.getById(shopId); + shop.delete(); + } + + @Transactional + public void deleteShopCategory(Integer categoryId) { + ShopCategory shopCategory = adminShopCategoryRepository.getById(categoryId); + shopCategory.delete(); + } + @Transactional public void deleteMenuCategory(Integer shopId, Integer categoryId) { MenuCategory menuCategory = adminMenuCategoryRepository.getById(categoryId); diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java b/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java index 110488e34..666be8bd1 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Menu.java @@ -128,7 +128,8 @@ public void modifyMenuSingleOptions(ModifyMenuRequest modifyMenuRequest, EntityM this.menuOptions.add(menuOption); } - public void adminModifyMenuSingleOptions(AdminModifyMenuRequest adminModifyMenuRequest, EntityManager entityManager) { + public void adminModifyMenuSingleOptions(AdminModifyMenuRequest adminModifyMenuRequest, + EntityManager entityManager) { this.menuOptions.clear(); entityManager.flush(); MenuOption menuOption = MenuOption.builder() @@ -151,7 +152,8 @@ public void modifyMenuMultipleOptions(List innerOptionPrice, E } } - public void adminModifyMenuMultipleOptions(List innerOptionPrice, EntityManager entityManager) { + public void adminModifyMenuMultipleOptions(List innerOptionPrice, + EntityManager entityManager) { this.menuOptions.clear(); entityManager.flush(); for (var option : innerOptionPrice) { diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java index 28f245dfd..87549b328 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java @@ -1,9 +1,6 @@ package in.koreatech.koin.domain.shop.model; -import static jakarta.persistence.CascadeType.MERGE; -import static jakarta.persistence.CascadeType.PERSIST; -import static jakarta.persistence.CascadeType.REFRESH; -import static jakarta.persistence.CascadeType.REMOVE; +import static jakarta.persistence.CascadeType.*; import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; @@ -16,6 +13,7 @@ import org.hibernate.annotations.Where; +import in.koreatech.koin.admin.shop.dto.AdminModifyShopRequest; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.dto.ModifyShopRequest.InnerShopOpen; import in.koreatech.koin.global.domain.BaseEntity; @@ -195,6 +193,18 @@ public void modifyShopOpens(List innerShopOpens, EntityManager en } } + public void modifyAdminShopOpens( + List innerShopOpens, + EntityManager entityManager + ) { + this.shopOpens.clear(); + entityManager.flush(); + for (var open : innerShopOpens) { + ShopOpen shopOpen = open.toEntity(this); + this.shopOpens.add(shopOpen); + } + } + public void modifyShopCategories(List shopCategories, EntityManager entityManager) { this.shopCategories.clear(); entityManager.flush(); @@ -215,7 +225,8 @@ public boolean isOpen(LocalDateTime now) { return true; } if ( - shopOpen.getDayOfWeek().equals(prevDayOfWeek) && isBetweenDate(now, shopOpen, now.minusDays(1).toLocalDate()) + shopOpen.getDayOfWeek().equals(prevDayOfWeek) && isBetweenDate(now, shopOpen, + now.minusDays(1).toLocalDate()) ) { return true; } @@ -236,6 +247,10 @@ public void updateOwner(Owner owner) { this.owner = owner; } + public void delete() { + this.isDeleted = true; + } + public void cancelDelete() { this.isDeleted = false; } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java index a290eed14..2375d5dc6 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/ShopCategory.java @@ -55,4 +55,13 @@ private ShopCategory(String name, String imageUrl, Boolean isDeleted) { this.imageUrl = imageUrl; this.isDeleted = isDeleted; } + + public void modifyShopCategory(String name, String imageUrl) { + this.name = name; + this.imageUrl = imageUrl; + } + + public void delete() { + this.isDeleted = true; + } } diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdmimShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java similarity index 51% rename from src/test/java/in/koreatech/koin/admin/acceptance/AdmimShopApiTest.java rename to src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java index ec4d47abc..75219c54d 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdmimShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -15,6 +15,7 @@ import in.koreatech.koin.AcceptanceTest; import in.koreatech.koin.admin.shop.repository.AdminMenuCategoryRepository; import in.koreatech.koin.admin.shop.repository.AdminMenuRepository; +import in.koreatech.koin.admin.shop.repository.AdminShopCategoryRepository; import in.koreatech.koin.admin.shop.repository.AdminShopRepository; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.Menu; @@ -24,9 +25,10 @@ import in.koreatech.koin.domain.shop.model.MenuOption; import in.koreatech.koin.domain.shop.model.Shop; import in.koreatech.koin.domain.shop.model.ShopCategory; -import in.koreatech.koin.domain.shop.repository.EventArticleRepository; +import in.koreatech.koin.domain.shop.model.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.ShopImage; +import in.koreatech.koin.domain.shop.model.ShopOpen; import in.koreatech.koin.domain.user.model.User; -import in.koreatech.koin.fixture.EventArticleFixture; import in.koreatech.koin.fixture.MenuCategoryFixture; import in.koreatech.koin.fixture.MenuFixture; import in.koreatech.koin.fixture.ShopCategoryFixture; @@ -37,22 +39,22 @@ import io.restassured.http.ContentType; @SuppressWarnings("NonAsciiCharacters") -public class AdmimShopApiTest extends AcceptanceTest { +class AdminShopApiTest extends AcceptanceTest { @Autowired private TransactionTemplate transactionTemplate; @Autowired - private AdminMenuRepository menuRepository; + private AdminShopCategoryRepository adminShopCategoryRepository; @Autowired - private AdminShopRepository shopRepository; + private AdminShopRepository adminShopRepository; @Autowired - private AdminMenuCategoryRepository menuCategoryRepository; + private AdminMenuRepository adminMenuRepository; @Autowired - private EventArticleRepository eventArticleRepository; + private AdminMenuCategoryRepository adminMenuCategoryRepository; @Autowired private MenuFixture menuFixture; @@ -69,13 +71,8 @@ public class AdmimShopApiTest extends AcceptanceTest { @Autowired private MenuCategoryFixture menuCategoryFixture; - @Autowired - private EventArticleFixture eventArticleFixture; - private Owner owner_현수; - private String token_현수; private Owner owner_준영; - private String token_준영; private Shop shop_마슬랜; private User admin; private String token_admin; @@ -89,9 +86,7 @@ void setUp() { admin = userFixture.코인_운영자(); token_admin = userFixture.getToken(admin); owner_현수 = userFixture.현수_사장님(); - token_현수 = userFixture.getToken(owner_현수.getUser()); owner_준영 = userFixture.준영_사장님(); - token_준영 = userFixture.getToken(owner_준영.getUser()); shop_마슬랜 = shopFixture.마슬랜(owner_현수); shopCategory_치킨 = shopCategoryFixture.카테고리_치킨(); shopCategory_일반 = shopCategoryFixture.카테고리_일반음식(); @@ -99,6 +94,172 @@ void setUp() { menuCategory_사이드 = menuCategoryFixture.사이드메뉴(shop_마슬랜); } + @Test + @DisplayName("어드민이 모든 상점을 조회한다.") + void findAllShops() { + for (int i = 0; i < 12; i++) { + Shop request = shopFixture.builder() + .owner(owner_현수) + .name("상점" + i) + .internalName("상점" + i) + .phone("010-1234-567" + i) + .address("주소" + i) + .description("설명" + i) + .delivery(true) + .deliveryPrice(1000 + i) + .payCard(true) + .payBank(true) + .isDeleted(false) + .isEvent(false) + .remarks("비고" + i) + .hit(0) + .build(); + adminShopRepository.save(request); + } + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .param("page", 1) + .param("is_deleted", false) + .get("/admin/shops") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getInt("total_count")).isEqualTo(13); + softly.assertThat(response.body().jsonPath().getInt("current_count")).isEqualTo(10); + softly.assertThat(response.body().jsonPath().getInt("total_page")).isEqualTo(2); + softly.assertThat(response.body().jsonPath().getInt("current_page")).isEqualTo(1); + softly.assertThat(response.body().jsonPath().getList("shops").size()).isEqualTo(10); + } + ); + } + + @Test + @DisplayName("어드민이 특정 상점을 조회한다.") + void findShop() { + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .get("/admin/shops/{shopId}", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "address": "천안시 동남구 병천면 1600", + "delivery": true, + "delivery_price": 3000, + "description": "마슬랜 치킨입니다.", + "id": 1, + "image_urls": [ + "https://test-image.com/마슬랜.png", + "https://test-image.com/마슬랜2.png" + ], + "menu_categories": [ + { + "id": 1, + "name": "메인 메뉴" + }, + { + "id": 2, + "name": "사이드 메뉴" + } + ], + "name": "마슬랜 치킨", + "open": [ + { + "day_of_week": "MONDAY", + "closed": false, + "open_time": "00:00", + "close_time": "21:00" + }, + { + "day_of_week": "FRIDAY", + "closed": false, + "open_time": "00:00", + "close_time": "00:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-7574-1212", + "shop_categories": [ + + ], + "updated_at": "2024-01-15", + "is_deleted": false, + "is_event": false + } + """); + } + + @Test + @DisplayName("어드민이 상점의 모든 카테고리를 조회한다.") + void findShopCategories() { + for (int i = 0; i < 12; i++) { + ShopCategory request = ShopCategory.builder() + .name("카테고리" + i) + .isDeleted(false) + .build(); + adminShopCategoryRepository.save(request); + System.out.println(i); + } + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .param("page", 1) + .param("is_deleted", false) + .get("/admin/shops/categories") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getInt("total_count")).isEqualTo(14); + softly.assertThat(response.body().jsonPath().getInt("current_count")).isEqualTo(10); + softly.assertThat(response.body().jsonPath().getInt("total_page")).isEqualTo(2); + softly.assertThat(response.body().jsonPath().getInt("current_page")).isEqualTo(1); + softly.assertThat(response.body().jsonPath().getList("categories").size()).isEqualTo(10); + } + ); + } + + @Test + @DisplayName("어드민이 상점의 특정 카테고리를 조회한다.") + void findShopCategory() { + ShopCategory shopCategory = shopCategoryFixture.카테고리_치킨(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .pathParam("id", shopCategory.getId()) + .when() + .get("/admin/shops/categories/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 3, + "image_url": "https://test-image.com/ckicken.jpg", + "name": "치킨" + } + """); + } + @Test @DisplayName("어드민이 특정 상점의 모든 메뉴를 조회한다.") void findShopMenus() { @@ -230,6 +391,133 @@ void findShopMenu() { """); } + @Test + @DisplayName("어드민이 상점을 생성한다.") + void createShop() { + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "address": "대전광역시 유성구 대학로 291", + "category_ids": [ + %d + ], + "delivery": true, + "delivery_price": 4000, + "description": "테스트 상점2입니다.", + "image_urls": [ + "https://test.com/test1.jpg", + "https://test.com/test2.jpg", + "https://test.com/test3.jpg" + ], + + "name": "테스트 상점2", + "open": [ + { + "close_time": "21:00", + "closed": false, + "day_of_week": "MONDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "TUESDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "WEDNESDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "THURSDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "FRIDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "SATURDAY", + "open_time": "09:00" + }, + { + "close_time": "21:00", + "closed": false, + "day_of_week": "SUNDAY", + "open_time": "09:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "010-1234-5678" + } + """, shopCategory_치킨.getId()) + ) + .when() + .post("/admin/shops") + .then() + .log().all() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Shop result = adminShopRepository.getById(2); + assertSoftly( + softly -> { + softly.assertThat(result.getAddress()).isEqualTo("대전광역시 유성구 대학로 291"); + softly.assertThat(result.getDeliveryPrice()).isEqualTo(4000); + softly.assertThat(result.getDescription()).isEqualTo("테스트 상점2입니다."); + softly.assertThat(result.getName()).isEqualTo("테스트 상점2"); + softly.assertThat(result.getShopImages()).hasSize(3); + softly.assertThat(result.getShopOpens()).hasSize(7); + softly.assertThat(result.getShopCategories()).hasSize(1); + } + ); + }); + } + + @Test + @DisplayName("어드민이 상점 카테고리를 생성한다.") + void createShopCategory() { + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(""" + { + "image_url": "https://image.png", + "name": "새로운 카테고리" + } + """) + .when() + .post("/admin/shops/categories") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + ShopCategory result = adminShopCategoryRepository.getById(3); + assertSoftly( + softly -> { + softly.assertThat(result.getImageUrl()).isEqualTo("https://image.png"); + softly.assertThat(result.getName()).isEqualTo("새로운 카테고리"); + softly.assertThat(result.isDeleted()).isEqualTo(false); + } + ); + }); + } + @Test @DisplayName("어드민이 옵션이 여러개인 메뉴를 추가한다.") void createManyOptionMenu() { @@ -269,7 +557,7 @@ void createManyOptionMenu() { .extract(); transactionTemplate.executeWithoutResult(status -> { - Menu menu = menuRepository.getById(1); + Menu menu = adminMenuRepository.getById(1); assertSoftly( softly -> { List menuCategoryMaps = menu.getMenuCategoryMaps(); @@ -316,7 +604,7 @@ void createOneOptionMenu() { .extract(); transactionTemplate.executeWithoutResult(status -> { - Menu menu = menuRepository.getById(1); + Menu menu = adminMenuRepository.getById(1); assertSoftly( softly -> { List menuCategoryMaps = menu.getMenuCategoryMaps(); @@ -354,7 +642,7 @@ void createMenuCategory() { .statusCode(HttpStatus.CREATED.value()) .extract(); - var menuCategories = menuCategoryRepository.findAllByShopId(shop_마슬랜.getId()); + var menuCategories = adminMenuCategoryRepository.findAllByShopId(shop_마슬랜.getId()); assertThat(menuCategories).anyMatch(menuCategory -> "대박메뉴".equals(menuCategory.getName())); } @@ -364,7 +652,7 @@ void createMenuCategory() { void cancelShopDeleted() { // given System.out.println("qwe"); - shopRepository.deleteById(shop_마슬랜.getId()); + adminShopRepository.deleteById(shop_마슬랜.getId()); RestAssured .given() .header("Authorization", "Bearer " + token_admin) @@ -375,10 +663,128 @@ void cancelShopDeleted() { .then() .statusCode(HttpStatus.OK.value()) .extract(); - var shop = shopRepository.getById(shop_마슬랜.getId()); + var shop = adminShopRepository.getById(shop_마슬랜.getId()); assertSoftly(softly -> softly.assertThat(shop.isDeleted()).isFalse()); } + @Test + @DisplayName("어드민이 상점을 수정한다.") + void modifyShop() { + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "address": "충청남도 천안시 동남구 병천면 충절로 1600", + "category_ids": [ + %d, %d + ], + "delivery": false, + "delivery_price": 1000, + "description": "이번주 전 메뉴 10%% 할인 이벤트합니다.", + "image_urls": [ + "https://fixed-shopimage.com/수정된_상점_이미지.png" + ], + "name": "써니 숯불 도시락", + "open": [ + { + "close_time": "22:30", + "closed": false, + "day_of_week": "MONDAY", + "open_time": "10:00" + }, + { + "close_time": "23:30", + "closed": true, + "day_of_week": "SUNDAY", + "open_time": "11:00" + } + ], + "pay_bank": true, + "pay_card": true, + "phone": "041-123-4567" + } + """, shopCategory_일반.getId(), shopCategory_치킨.getId() + )) + .when() + .put("/admin/shops/{id}", shop_마슬랜.getId()) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + Shop result = adminShopRepository.getById(1); + List shopImages = result.getShopImages(); + List shopOpens = result.getShopOpens(); + List shopCategoryMaps = result.getShopCategories(); + assertSoftly( + softly -> { + softly.assertThat(result.getAddress()).isEqualTo("충청남도 천안시 동남구 병천면 충절로 1600"); + softly.assertThat(result.isDeleted()).isFalse(); + softly.assertThat(result.getDeliveryPrice()).isEqualTo(1000); + softly.assertThat(result.getDescription()).isEqualTo("이번주 전 메뉴 10% 할인 이벤트합니다."); + softly.assertThat(result.getName()).isEqualTo("써니 숯불 도시락"); + softly.assertThat(result.isPayBank()).isTrue(); + softly.assertThat(result.isPayCard()).isTrue(); + softly.assertThat(result.getPhone()).isEqualTo("041-123-4567"); + + softly.assertThat(shopCategoryMaps.get(0).getShopCategory().getId()).isEqualTo(1); + softly.assertThat(shopCategoryMaps.get(1).getShopCategory().getId()).isEqualTo(2); + + softly.assertThat(shopImages.get(0).getImageUrl()) + .isEqualTo("https://fixed-shopimage.com/수정된_상점_이미지.png"); + + softly.assertThat(shopOpens.get(0).getCloseTime()).isEqualTo("22:30"); + softly.assertThat(shopOpens.get(0).getOpenTime()).isEqualTo("10:00"); + + softly.assertThat(shopOpens.get(0).getDayOfWeek()).isEqualTo("MONDAY"); + softly.assertThat(shopOpens.get(0).isClosed()).isFalse(); + + softly.assertThat(shopOpens.get(1).getCloseTime()).isEqualTo("23:30"); + softly.assertThat(shopOpens.get(1).getOpenTime()).isEqualTo("11:00"); + + softly.assertThat(shopOpens.get(1).getDayOfWeek()).isEqualTo("SUNDAY"); + softly.assertThat(shopOpens.get(1).isClosed()).isTrue(); + } + ); + }); + } + + @Test + @DisplayName("어드민이 상점 카테고리를 수정한다.") + void modifyShopCategory() { + ShopCategory shopCategory = shopCategoryFixture.카테고리_일반음식(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .contentType(ContentType.JSON) + .pathParam("id", shopCategory.getId()) + .body(""" + { + "image_url": "http://image.png", + "name": "수정된 카테고리 이름" + } + """) + .when() + .put("/admin/shops/categories/{id}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + transactionTemplate.executeWithoutResult(status -> { + ShopCategory updatedCategory = adminShopCategoryRepository.getById(shopCategory.getId()); + assertSoftly( + softly -> { + softly.assertThat(updatedCategory.getId()).isEqualTo(shopCategory.getId()); + softly.assertThat(updatedCategory.getImageUrl()).isEqualTo("http://image.png"); + softly.assertThat(updatedCategory.getName()).isEqualTo("수정된 카테고리 이름"); + } + ); + }); + } + @Test @DisplayName("어드민이 특점 상점의 메뉴 카테고리를 수정한다.") void modifyMenuCategory() { @@ -401,7 +807,7 @@ void modifyMenuCategory() { .statusCode(HttpStatus.CREATED.value()) .extract(); - MenuCategory menuCategory = menuCategoryRepository.getById(menuCategory_메인.getId()); + MenuCategory menuCategory = adminMenuCategoryRepository.getById(menuCategory_메인.getId()); assertSoftly(softly -> softly.assertThat(menuCategory.getName()).isEqualTo("사이드 메뉴")); } @@ -438,7 +844,7 @@ void modifyOneMenu() { .extract(); transactionTemplate.executeWithoutResult(status -> { - Menu result = menuRepository.getById(1); + Menu result = adminMenuRepository.getById(1); assertSoftly( softly -> { List menuCategoryMaps = result.getMenuCategoryMaps(); @@ -499,7 +905,7 @@ void modifyManyOptionMenu() { .extract(); transactionTemplate.executeWithoutResult(status -> { - Menu result = menuRepository.getById(1); + Menu result = adminMenuRepository.getById(1); assertSoftly( softly -> { List menuCategoryMaps = result.getMenuCategoryMaps(); @@ -516,6 +922,40 @@ void modifyManyOptionMenu() { }); } + @Test + @DisplayName("어드민이 상점을 삭제한다.") + void deleteShop() { + Shop shop = shopFixture.신전_떡볶이(owner_현수); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .delete("/admin/shops/{id}", shop.getId()) + .then() + .statusCode(HttpStatus.OK.value()); + + Shop deletedShop = adminShopRepository.getById(shop.getId()); + assertSoftly(softly -> softly.assertThat(deletedShop.isDeleted()).isTrue()); + } + + @Test + @DisplayName("어드민이 상점 카테고리를 삭제한다.") + void deleteShopCategory() { + ShopCategory shopCategory = shopCategoryFixture.카테고리_일반음식(); + + RestAssured + .given() + .header("Authorization", "Bearer " + token_admin) + .when() + .delete("/admin/shops/categories/{id}", shopCategory.getId()) + .then() + .statusCode(HttpStatus.OK.value()); + + ShopCategory deletedCategory = adminShopCategoryRepository.getById(shopCategory.getId()); + assertSoftly(softly -> softly.assertThat(deletedCategory.isDeleted()).isTrue()); + } + @Test @DisplayName("어드민이 특정 상점의 메뉴 카테고리를 삭제한다.") void deleteMenuCategory() { @@ -531,7 +971,7 @@ void deleteMenuCategory() { .statusCode(HttpStatus.NO_CONTENT.value()) .extract(); - assertThat(menuCategoryRepository.findById(menuCategory_메인.getId())).isNotPresent(); + assertThat(adminMenuCategoryRepository.findById(menuCategory_메인.getId())).isNotPresent(); } @Test @@ -551,6 +991,6 @@ void deleteMenu() { .statusCode(HttpStatus.NO_CONTENT.value()) .extract(); - assertThat(menuRepository.findById(menu.getId())).isNotPresent(); + assertThat(adminMenuRepository.findById(menu.getId())).isNotPresent(); } } From 20ddf2f385c629ac1ee7ef1dbaf695c494e11067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A4=80=ED=98=B8?= Date: Thu, 27 Jun 2024 00:08:16 +0900 Subject: [PATCH 24/37] =?UTF-8?q?refactor:=20ADMIN=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#638)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/global/domain/upload/controller/UploadApi.java | 7 ++++--- .../global/domain/upload/controller/UploadController.java | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java index 050d73a4c..6d45a6724 100644 --- a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java +++ b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadApi.java @@ -1,5 +1,6 @@ package in.koreatech.koin.global.domain.upload.controller; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import static in.koreatech.koin.domain.user.model.UserType.COOP; import static in.koreatech.koin.domain.user.model.UserType.OWNER; import static in.koreatech.koin.domain.user.model.UserType.STUDENT; @@ -58,7 +59,7 @@ public interface UploadApi { ResponseEntity getPresignedUrl( @PathVariable ImageUploadDomain domain, @RequestBody @Valid UploadUrlRequest request, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ); @ApiResponses( @@ -89,7 +90,7 @@ ResponseEntity getPresignedUrl( ResponseEntity uploadFile( @Parameter(in = PATH) @PathVariable ImageUploadDomain domain, @RequestPart MultipartFile multipartFile, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ); @ApiResponses( @@ -120,6 +121,6 @@ ResponseEntity uploadFile( ResponseEntity uploadFiles( @Parameter(in = PATH) @PathVariable ImageUploadDomain domain, @RequestPart List files, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ); } diff --git a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java index 09e6749c0..6ddfcf870 100644 --- a/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java +++ b/src/main/java/in/koreatech/koin/global/domain/upload/controller/UploadController.java @@ -1,5 +1,6 @@ package in.koreatech.koin.global.domain.upload.controller; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import static in.koreatech.koin.domain.user.model.UserType.COOP; import static in.koreatech.koin.domain.user.model.UserType.OWNER; import static in.koreatech.koin.domain.user.model.UserType.STUDENT; @@ -36,7 +37,7 @@ public class UploadController implements UploadApi { public ResponseEntity getPresignedUrl( @PathVariable ImageUploadDomain domain, @RequestBody @Valid UploadUrlRequest request, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ) { var response = uploadService.getPresignedUrl(domain, request); return ResponseEntity.ok(response); @@ -50,7 +51,7 @@ public ResponseEntity getPresignedUrl( public ResponseEntity uploadFile( @PathVariable ImageUploadDomain domain, @RequestPart MultipartFile multipartFile, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ) { var response = uploadService.uploadFile(domain, multipartFile); return new ResponseEntity<>(response, HttpStatus.CREATED); @@ -64,7 +65,7 @@ public ResponseEntity uploadFile( public ResponseEntity uploadFiles( @PathVariable ImageUploadDomain domain, @RequestPart List files, - @Auth(permit = {OWNER, STUDENT, COOP}, anonymous = true) Integer memberId + @Auth(permit = {OWNER, STUDENT, COOP, ADMIN}, anonymous = true) Integer memberId ) { var response = uploadService.uploadFiles(domain, files); return new ResponseEntity<>(response, HttpStatus.CREATED); From 701e9e649b37b2ae28b7253f3c70ff8e2aec6f5d Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Thu, 27 Jun 2024 20:54:54 +0900 Subject: [PATCH 25/37] =?UTF-8?q?fix:=20=EC=83=81=EC=A0=90=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=EC=88=98=EC=A0=95=20/=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=EC=88=98=EC=A0=95=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#644)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: DTO수정 * feat: imageUrl이 null인경우를 위한 DTO 생성자 추가 * feat: 상점 운영시간 DTO validation 문구 추가 * feat: 상점 운영시간 DTO 개수 제한 수정 --------- Co-authored-by: HyeonsuLee --- .../koin/admin/shop/dto/AdminCreateMenuRequest.java | 8 +++++++- .../koin/admin/shop/dto/AdminCreateShopRequest.java | 6 ++++++ .../koin/admin/shop/dto/AdminModifyMenuRequest.java | 6 +++++- .../koin/admin/shop/dto/AdminModifyShopRequest.java | 7 ++++++- .../koin/global/validation/UniqueIdValidator.java | 3 +++ .../koin/global/validation/UniqueUrlsValidator.java | 3 +++ 6 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java index 02de34e20..cc084c620 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateMenuRequest.java @@ -12,6 +12,7 @@ import in.koreatech.koin.global.validation.UniqueId; import in.koreatech.koin.global.validation.UniqueUrl; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; @@ -51,7 +52,11 @@ public record AdminCreateMenuRequest( @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") Integer singlePrice ) { - + public AdminCreateMenuRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } + } public Menu toEntity(Integer shopId) { return Menu.builder() .name(name) @@ -61,6 +66,7 @@ public Menu toEntity(Integer shopId) { } @JsonNaming(value = SnakeCaseStrategy.class) + @Valid public record InnerOptionPrice( @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) @NotNull @Size(min = 1, max = 50) String option, diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java index 9e3cb4559..cba9936df 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminCreateShopRequest.java @@ -61,6 +61,7 @@ public record AdminCreateShopRequest( String name, @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + @NotNull List open, @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) @@ -76,6 +77,11 @@ public record AdminCreateShopRequest( @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호 형식이 유효하지 않습니다.") String phone ) { + public AdminCreateShopRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } + } public Shop toShop() { return Shop.builder() diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java index e5617f5d6..70f04dd30 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyMenuRequest.java @@ -50,7 +50,11 @@ public record AdminModifyMenuRequest( @PositiveOrZero(message = "가격은 0원 이상이어야 합니다.") Integer singlePrice ) { - + public AdminModifyMenuRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } + } @JsonNaming(value = SnakeCaseStrategy.class) public record InnerOptionPrice( @Schema(example = "대", description = "옵션명", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java index 8a3652100..69a7ce730 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java @@ -61,6 +61,7 @@ public record AdminModifyShopRequest( String name, @Schema(description = "요일별 휴무 여부 및 장사 시간", requiredMode = NOT_REQUIRED) + @NotNull List open, @Schema(description = "계좌 이체 가능 여부", example = "true", requiredMode = REQUIRED) @@ -76,7 +77,11 @@ public record AdminModifyShopRequest( @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호 형식이 유효하지 않습니다.") String phone ) { - + public AdminModifyShopRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } + } @JsonNaming(value = SnakeCaseStrategy.class) @Valid public record InnerShopOpen( diff --git a/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java b/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java index 3ba338312..3553fa74f 100644 --- a/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java +++ b/src/main/java/in/koreatech/koin/global/validation/UniqueIdValidator.java @@ -14,6 +14,9 @@ public void initialize(UniqueId constraintAnnotation) { @Override public boolean isValid(List elements, ConstraintValidatorContext context) { + if (elements == null) { + elements = List.of(); + } return elements.stream().distinct().count() == elements.size(); } } diff --git a/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java b/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java index 1bd00639b..669684eaa 100644 --- a/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java +++ b/src/main/java/in/koreatech/koin/global/validation/UniqueUrlsValidator.java @@ -14,6 +14,9 @@ public void initialize(UniqueUrl constraintAnnotation) { @Override public boolean isValid(List elements, ConstraintValidatorContext context) { + if (elements == null) { + elements = List.of(); + } return elements.stream().distinct().count() == elements.size(); } } From 095add25b0078d8a86a137178aee3c8f573bd985 Mon Sep 17 00:00:00 2001 From: Dahee Park <106418303+daheeParkk@users.noreply.github.com> Date: Sat, 29 Jun 2024 12:55:20 +0900 Subject: [PATCH 26/37] =?UTF-8?q?feat:=20new=20timetable=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#615)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: flyway 추가 * refactor: flyway 수정 * feat: timetable frame api 중 post, get, delete 구현 (#592) * feat : 기본 Model 및 Repository 생성 * chore : isMain 추가 * feat: timetablesFrame post, get delete 기능 구현 * chore: 겹치는 로직 수정 및 일부 버그 수정 * feat: 회원 탈퇴시 timetableframe 및 timetable 수동 삭제 * feat: 삭제 전 검증 로직 추가 * chore: api 오타 수정 * chore: frame 빌더 파라미터 추가 * chore: snakecase로 dto 변경 * feat: lecture repo 메서드 추가 * test: timetable v2 test 코드 작성 * chore: 오타 수정 * feat: 삭제되는 frame이 main일때 다른 frame을 main으로 설정 * chore: 개행 제거 * chore: 개행 추가 * chore: 어색한 말 수정 * chore: 오타 수정 * chore: 변수명 및 개행 수정 * chore: 피드백 반영 * chore: 메서드 명 변경 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * feat: timetableFrame put, timetableLecture delete 구현 & reafactor: 기존 timetable delete, get 수정 (#594) * feat : 기본 Model 및 Repository 생성 * refactor: 기존 delete timetable 새로운 db와 연결 & v2 api 추가 * feat: tiemtable frame 수정 api 구현 * refactor: tiemtable frame 수정 api 반환값 dto 추가 * refactor: PUT timetable/frame에서 main으로 업데이트할 시 기존 main 테이블의 is_main을 false로 수정 * refactor: Get /timetables api 변경된 테이블 구조에 맞게 수정 * refactor: timeTable -> timetable로 변경 * refactor: deleteTimetableLecture() 유저 본인의 시간표에서 삭제하는지 검증 추가 * refactor: repository에서 main timetable을 가져오게 수정 * refactor: url 앞에 / 추가 & 기존에 없던 api url에 v2제거 * refactor: TimetableFrameUpdateRequest, TimetableFrameUpdateResponse에서 name Schema 수정 * refactor: TimetableFrameUpdateResponse에서 semester 대신 id를 반환하도록 수정 * refactor: timeTable -> timetable 이름 변경 * refactor: TimetableFrame에서 필요 없는 isMain()함수 제거 * refactor: LectureRepository에서 findByIdIn를 사용해 Lecture List를 가져오도록 수정 * refactor: TimetableLecture와 TimetableFrame에서 is_deleted=0으로 찾는 where 제거 * refactor: timetableFrame 수정 시 semester 변경 못하도록 수정 * fix: is_main 타입변경, snakecase적용되도록 수정 & test: timetableframe 수정 api, timetablelecture 삭제 api 테스트 작성 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * feat: GET/semester/check수정 & POST/timetables수정 & POST/v2/timetables/lecture 추가 (#596) * feat : 기본 Model 및 Repository 생성 * chore : isMain 추가 * feat: 시간표 생성 수정 및 추가 * feat: 강의시간표 응답 수정 * feat: 코드 오류 수정 * feat: API 수정 * feat: pr 수정 * feat: repository semester타입 수정 * fear : flyway 추가 * fear : flyway 추가 * fear : Lecture id 반환 추가 * fear : PUT /timetables 수정 및 /v2/timetables 추가 * feat: getTimetables API 수정 * refactor: PR 리뷰 반영 * fear : 리뷰 반영 * refactor : 코드 리팩터링 * refactor: 충돌 수정 * refactor: 충돌 수정 * refactor: 로직 수정 및 학점추가 * refactor: 학점 계산 로직 수정 * refactor: 오류 수정 * refactor: 코드 수정 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * refactor: flyway timetable_id -> frame_id 변경 * refactor: TimetableFrame에 @Where(clause = is_deleted=0) 추가 * refactor: frame api의 url에 v2추가 * refactor: TimetableLectures create,update 문에 user 인증 추가 * refactor : TimetableUpdate에 id가 null인 경우 삭제 * refactor: swagger 수정 * refactor: v2 패키지로 이동 * fix: v2 api 이름에 v2 추가 * chore : 리뷰 반영(Repo 미사용 메소드 삭제) * chore : 리뷰 반영(isMain boolean으로 수정) * chore : 리뷰 반영(변수 추가 및 getIsMain() -> isMain() 수정) * chore : 리뷰 반영 * refactor: createTimetablesFrame에서 main 시간표 count 시 0부터 세도록 수정 * fix: createTimetablesFrame에서 이름 생성할 때 카운트 +1하도록 수정 * fix: createTimetablesFrame에서 카운트 +1할 때 괄호 추가 --------- Co-authored-by: kwoo28 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> --- .../timetable/controller/TimetableApi.java | 21 +- .../controller/TimetableController.java | 33 +- .../domain/timetable/dto/LectureResponse.java | 4 + ...quest.java => TimetableCreateRequest.java} | 30 +- ...leResponse.java => TimetableResponse.java} | 56 +-- ...quest.java => TimetableUpdateRequest.java} | 8 +- .../exception/LectureNotFoundException.java | 20 + ...n.java => TimetableNotFoundException.java} | 10 +- .../model/{TimeTable.java => Timetable.java} | 8 +- .../repository/LectureRepository.java | 19 + .../repository/TimeTableRepository.java | 27 -- .../repository/TimetableRepository.java | 27 ++ .../timetable/service/SemesterService.java | 12 +- .../timetable/service/TimetableService.java | 124 +++-- .../controller/TimetableApiV2.java | 163 +++++++ .../controller/TimetableControllerV2.java | 109 +++++ .../dto/TimetableFrameCreateRequest.java | 24 + .../dto/TimetableFrameResponse.java | 29 ++ .../dto/TimetableFrameUpdateRequest.java | 25 + .../dto/TimetableFrameUpdateResponse.java | 29 ++ .../dto/TimetableLectureCreateRequest.java | 87 ++++ .../dto/TimetableLectureResponse.java | 138 ++++++ .../dto/TimetableLectureUpdateRequest.java | 56 +++ .../TimetableFrameNotFoundException.java | 20 + .../TimetableLectureNotFoundException.java | 20 + .../timetableV2/model/TimetableFrame.java | 100 ++++ .../timetableV2/model/TimetableLecture.java | 106 ++++ .../repository/LectureRepositoryV2.java | 22 + .../repository/SemesterRepositoryV2.java | 21 + .../TimetableFrameRepositoryV2.java | 65 +++ .../TimetableLectureRepositoryV2.java | 24 + .../service/TimetableServiceV2.java | 188 ++++++++ .../koin/domain/user/service/UserService.java | 3 + .../db/migration/V19__add_timetable_frame.sql | 12 + .../migration/V20__insert_timetable_frame.sql | 3 + .../migration/V21__add_timetable_lecture.sql | 17 + .../V22__insert_timetable_lecture.sql | 3 + ...3__alter_timetable_lecture_lectures_id.sql | 6 + ...__alter_timetable_lecture_timetable_id.sql | 3 + ...etable_lecture_user_id_and_semester_id.sql | 2 + .../koin/acceptance/TimetableApiTest.java | 451 +++++------------- .../koin/acceptance/TimetableV2ApiTest.java | 449 +++++++++++++++++ .../koin/fixture/TimeTableV2Fixture.java | 174 +++++++ ...ableFixture.java => TimetableFixture.java} | 26 +- 44 files changed, 2270 insertions(+), 504 deletions(-) rename src/main/java/in/koreatech/koin/domain/timetable/dto/{TimeTableCreateRequest.java => TimetableCreateRequest.java} (76%) rename src/main/java/in/koreatech/koin/domain/timetable/dto/{TimeTableResponse.java => TimetableResponse.java} (62%) rename src/main/java/in/koreatech/koin/domain/timetable/dto/{TimeTableUpdateRequest.java => TimetableUpdateRequest.java} (95%) create mode 100644 src/main/java/in/koreatech/koin/domain/timetable/exception/LectureNotFoundException.java rename src/main/java/in/koreatech/koin/domain/timetable/exception/{TimeTableNotFoundException.java => TimetableNotFoundException.java} (51%) rename src/main/java/in/koreatech/koin/domain/timetable/model/{TimeTable.java => Timetable.java} (94%) delete mode 100644 src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetable/repository/TimetableRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameCreateRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureUpdateRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableFrameNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableLectureNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableFrame.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableLecture.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/repository/LectureRepositoryV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/repository/SemesterRepositoryV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java create mode 100644 src/main/resources/db/migration/V19__add_timetable_frame.sql create mode 100644 src/main/resources/db/migration/V20__insert_timetable_frame.sql create mode 100644 src/main/resources/db/migration/V21__add_timetable_lecture.sql create mode 100644 src/main/resources/db/migration/V22__insert_timetable_lecture.sql create mode 100644 src/main/resources/db/migration/V23__alter_timetable_lecture_lectures_id.sql create mode 100644 src/main/resources/db/migration/V24__alter_timetable_lecture_timetable_id.sql create mode 100644 src/main/resources/db/migration/V25__delete_timetable_lecture_user_id_and_semester_id.sql create mode 100644 src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java create mode 100644 src/test/java/in/koreatech/koin/fixture/TimeTableV2Fixture.java rename src/test/java/in/koreatech/koin/fixture/{TimeTableFixture.java => TimetableFixture.java} (81%) diff --git a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java index d821e1133..0994b0140 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableApi.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -14,9 +15,9 @@ import in.koreatech.koin.domain.timetable.dto.LectureResponse; import in.koreatech.koin.domain.timetable.dto.SemesterCheckResponse; import in.koreatech.koin.domain.timetable.dto.SemesterResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; -import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableResponse; +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -79,7 +80,7 @@ ResponseEntity getStudentSemesters( @Operation(summary = "시간표 정보 조회") @SecurityRequirement(name = "Jwt Authentication") @GetMapping("/timetables") - ResponseEntity getTimeTables( + ResponseEntity getTimetables( @RequestParam(value = "semester") String semester, @Auth(permit = {STUDENT}) Integer userId ); @@ -95,8 +96,8 @@ ResponseEntity getTimeTables( @Operation(summary = "시간표 정보 생성") @SecurityRequirement(name = "Jwt Authentication") @PostMapping("/timetables") - ResponseEntity createTimeTables( - @RequestBody TimeTableCreateRequest timeTableCreateRequest, + ResponseEntity createTimetables( + @RequestBody TimetableCreateRequest request, @Auth(permit = {STUDENT}) Integer userId ); @@ -111,8 +112,8 @@ ResponseEntity createTimeTables( @Operation(summary = "시간표 정보 수정") @SecurityRequirement(name = "Jwt Authentication") @PutMapping("/timetables") - ResponseEntity updateTimeTable( - @RequestBody TimeTableUpdateRequest request, + ResponseEntity updateTimetable( + @RequestBody TimetableUpdateRequest request, @Auth(permit = {STUDENT}) Integer userId ); @@ -126,8 +127,8 @@ ResponseEntity updateTimeTable( ) @Operation(summary = "시간표 삭제") @SecurityRequirement(name = "Jwt Authentication") - @PutMapping("/timetables") - ResponseEntity deleteTimeTableById( + @DeleteMapping("/timetable") + ResponseEntity deleteTimetableById( @RequestParam(value = "id") Integer id, @Auth(permit = {STUDENT}) Integer userId ); diff --git a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java index db9225872..e9ebdf4ec 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/controller/TimetableController.java @@ -16,9 +16,9 @@ import in.koreatech.koin.domain.timetable.dto.LectureResponse; import in.koreatech.koin.domain.timetable.dto.SemesterCheckResponse; import in.koreatech.koin.domain.timetable.dto.SemesterResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; -import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableResponse; +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; import in.koreatech.koin.domain.timetable.service.SemesterService; import in.koreatech.koin.domain.timetable.service.TimetableService; import in.koreatech.koin.global.auth.Auth; @@ -55,38 +55,39 @@ public ResponseEntity getStudentSemesters( } @GetMapping("/timetables") - public ResponseEntity getTimeTables( + public ResponseEntity getTimetables( @RequestParam(name = "semester") String semester, @Auth(permit = {STUDENT}) Integer userId ) { - TimeTableResponse timeTableResponse = timetableService.getTimeTables(userId, semester); - return ResponseEntity.ok(timeTableResponse); + TimetableResponse timetableResponse = timetableService.getTimetables(userId, semester); + return ResponseEntity.ok(timetableResponse); } @PostMapping("/timetables") - public ResponseEntity createTimeTables( - @Valid @RequestBody TimeTableCreateRequest request, + public ResponseEntity createTimetables( + @Valid @RequestBody TimetableCreateRequest request, @Auth(permit = {STUDENT}) Integer userId ) { - TimeTableResponse timeTableResponse = timetableService.createTimeTables(userId, request); + TimetableResponse timeTableResponse = timetableService.createTimetables(userId, request); return ResponseEntity.ok(timeTableResponse); } @PutMapping("/timetables") - public ResponseEntity updateTimeTable( - @Valid @RequestBody TimeTableUpdateRequest request, + public ResponseEntity updateTimetable( + @Valid @RequestBody TimetableUpdateRequest request, @Auth(permit = {STUDENT}) Integer userId ) { - TimeTableResponse timeTableResponse = timetableService.updateTimeTables(userId, request); - return ResponseEntity.ok(timeTableResponse); + TimetableResponse timetableResponse = timetableService.updateTimetables(userId, request); + return ResponseEntity.ok(timetableResponse); } @DeleteMapping("/timetable") - public ResponseEntity deleteTimeTableById( - @RequestParam(name = "id") Integer id, + public ResponseEntity deleteTimetableById( + @RequestParam(name = "id") Integer timetableId, @Auth(permit = {STUDENT}) Integer userId ) { - timetableService.deleteTimeTable(id); + timetableService.deleteTimetableLecture(userId, timetableId); return ResponseEntity.ok().build(); } + } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java index 3d2136a18..1c0f8b586 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/LectureResponse.java @@ -15,6 +15,9 @@ @JsonNaming(value = SnakeCaseStrategy.class) public record LectureResponse( + @Schema(name = "과목 id", example = "1", requiredMode = REQUIRED) + Integer id, + @Schema(name = "과목 코드", example = "ARB244", requiredMode = REQUIRED) String code, @@ -54,6 +57,7 @@ public record LectureResponse( public static LectureResponse from(Lecture lecture) { return new LectureResponse( + lecture.getId(), lecture.getCode(), lecture.getName(), lecture.getGrades(), diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java similarity index 76% rename from src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java rename to src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java index b61fc7dfc..8471c9a9e 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableCreateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableCreateRequest.java @@ -11,7 +11,7 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming; import in.koreatech.koin.domain.timetable.model.Semester; -import in.koreatech.koin.domain.timetable.model.TimeTable; +import in.koreatech.koin.domain.timetable.model.Timetable; import in.koreatech.koin.domain.user.model.User; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -20,11 +20,11 @@ import jakarta.validation.constraints.Size; @JsonNaming(value = SnakeCaseStrategy.class) -public record TimeTableCreateRequest( +public record TimetableCreateRequest( @Valid @Schema(description = "시간표 정보", requiredMode = REQUIRED) @NotNull(message = "시간표 정보를 입력해주세요.") - List timetable, + List timetable, @Schema(description = "학기 정보", example = "20192", requiredMode = REQUIRED) @NotBlank(message = "학기 정보를 입력해주세요.") @@ -32,7 +32,7 @@ public record TimeTableCreateRequest( ) { @JsonNaming(value = SnakeCaseStrategy.class) - public record InnerTimeTableRequest( + public record InnerTimetableRequest( @Schema(description = "과목 코드", example = "CPC490", requiredMode = NOT_REQUIRED) String code, @@ -77,30 +77,10 @@ public record InnerTimeTableRequest( @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") String memo ) { - public InnerTimeTableRequest { + public InnerTimetableRequest { if (Objects.isNull(grades)) { grades = "0"; } } - - public TimeTable toTimeTable(User user, Semester semester) { - return TimeTable.builder() - .user(user) - .semester(semester) - .code(this.code) - .classTitle(this.classTitle()) - .classTime(Arrays.toString(this.classTime().stream().toArray())) - .classPlace(this.classPlace()) - .professor(this.professor()) - .grades(this.grades()) - .lectureClass(this.lectureClass()) - .target(this.target()) - .regularNumber(this.regularNumber()) - .designScore(this.designScore()) - .department(this.department()) - .memo(this.memo()) - .isDeleted(false) - .build(); - } } } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java similarity index 62% rename from src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java rename to src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java index 9a9969b81..5befc62c5 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableResponse.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableResponse.java @@ -10,16 +10,17 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.domain.timetable.model.TimeTable; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) -public record TimeTableResponse( +public record TimetableResponse( @Schema(name = "학기", example = "20241", requiredMode = REQUIRED) String semester, @Schema(name = "시간표 상세정보") - List timetable, + List timetable, @Schema(name = "해당 학기 학점", example = "21") Integer grades, @@ -29,11 +30,11 @@ public record TimeTableResponse( ) { @JsonNaming(value = SnakeCaseStrategy.class) - public record InnerTimeTableResponse( - @Schema(name = "시간표 ID", example = "1", requiredMode = REQUIRED) + public record InnerTimetableResponse( + @Schema(name = "시간표 id", example = "1", requiredMode = REQUIRED) Integer id, - @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) + @Schema(name = "수강 정원", example = "40", requiredMode = NOT_REQUIRED) String regularNumber, @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) @@ -70,34 +71,33 @@ public record InnerTimeTableResponse( String department ) { - public static List from(List timeTables) { - return timeTables.stream() - .map(it -> new InnerTimeTableResponse( - it.getId(), - it.getRegularNumber(), - it.getCode(), - it.getDesignScore(), - parseIntegerClassTimesFromString(it.getClassTime()), - it.getClassPlace(), - it.getMemo(), - it.getGrades(), - it.getClassTitle(), - it.getLectureClass(), - it.getTarget(), - it.getProfessor(), - it.getDepartment() + public static List from(List timetableLectures) { + return timetableLectures.stream() + .map(timeTableLecture -> new InnerTimetableResponse( + timeTableLecture.getId(), + timeTableLecture.getLecture().getRegularNumber(), + timeTableLecture.getLecture().getCode(), + timeTableLecture.getLecture().getDesignScore(), + parseIntegerClassTimesFromString(timeTableLecture.getLecture().getClassTime()), + timeTableLecture.getClassPlace(), + timeTableLecture.getMemo(), + timeTableLecture.getLecture().getGrades(), + timeTableLecture.getLecture().getName(), + timeTableLecture.getLecture().getLectureClass(), + timeTableLecture.getLecture().getTarget(), + timeTableLecture.getLecture().getProfessor(), + timeTableLecture.getLecture().getDepartment() ) ) .toList(); } - } - public static TimeTableResponse of(String semester, List timeTables, Integer grades, - Integer totalGrades) { - return new TimeTableResponse( - semester, - InnerTimeTableResponse.from(timeTables), + public static TimetableResponse of(List timetableLectures, TimetableFrame timetableFrame, + Integer grades, Integer totalGrades) { + return new TimetableResponse( + timetableFrame.getSemester().getSemester(), + InnerTimetableResponse.from(timetableLectures), grades, totalGrades ); diff --git a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java similarity index 95% rename from src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java rename to src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java index dffc70dc1..39e75178a 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/dto/TimeTableUpdateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/dto/TimetableUpdateRequest.java @@ -17,11 +17,11 @@ import lombok.Builder; @JsonNaming(value = SnakeCaseStrategy.class) -public record TimeTableUpdateRequest( +public record TimetableUpdateRequest( @Valid @Schema(description = "시간표 정보", requiredMode = NOT_REQUIRED) @NotNull(message = "시간표 정보를 입력해주세요.") - List timetable, + List timetable, @Schema(description = "학기 정보", example = "20192", requiredMode = NOT_REQUIRED) @NotBlank(message = "학기 정보를 입력해주세요.") @@ -29,7 +29,7 @@ public record TimeTableUpdateRequest( ) { @JsonNaming(value = SnakeCaseStrategy.class) - public record InnerTimeTableRequest( + public record InnerTimetableRequest( @Schema(description = "시간표 식별 번호", example = "1", requiredMode = REQUIRED) @NotNull(message = "시간표 식별 번호를 입력해주세요.") Integer id, @@ -79,7 +79,7 @@ public record InnerTimeTableRequest( String memo ) { @Builder - public InnerTimeTableRequest { + public InnerTimetableRequest { if (Objects.isNull(grades)) { grades = "0"; } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/exception/LectureNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetable/exception/LectureNotFoundException.java new file mode 100644 index 000000000..fd2cdc65a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/exception/LectureNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.timetable.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class LectureNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 강의입니다."; + + public LectureNotFoundException(String message) { + super(message); + } + + public LectureNotFoundException(String message, String detail) { + super(message, detail); + } + + public static LectureNotFoundException withDetail(String detail) { + return new LectureNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetable/exception/TimetableNotFoundException.java similarity index 51% rename from src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java rename to src/main/java/in/koreatech/koin/domain/timetable/exception/TimetableNotFoundException.java index e76c9a3e8..5c50ce84c 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/exception/TimeTableNotFoundException.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/exception/TimetableNotFoundException.java @@ -2,19 +2,19 @@ import in.koreatech.koin.global.exception.DataNotFoundException; -public class TimeTableNotFoundException extends DataNotFoundException { +public class TimetableNotFoundException extends DataNotFoundException { private static final String DEFAULT_MESSAGE = "존재하지 않는 시간표입니다."; - public TimeTableNotFoundException(String message) { + public TimetableNotFoundException(String message) { super(message); } - public TimeTableNotFoundException(String message, String detail) { + public TimetableNotFoundException(String message, String detail) { super(message, detail); } - public static TimeTableNotFoundException withDetail(String detail) { - return new TimeTableNotFoundException(DEFAULT_MESSAGE, detail); + public static TimetableNotFoundException withDetail(String detail) { + return new TimetableNotFoundException(DEFAULT_MESSAGE, detail); } } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java b/src/main/java/in/koreatech/koin/domain/timetable/model/Timetable.java similarity index 94% rename from src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java rename to src/main/java/in/koreatech/koin/domain/timetable/model/Timetable.java index 6f929fcb4..3576f5a36 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/model/TimeTable.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/model/Timetable.java @@ -5,7 +5,7 @@ import org.hibernate.annotations.Where; -import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; @@ -27,7 +27,7 @@ @Table(name = "timetables") @Where(clause = "is_deleted=0") @NoArgsConstructor(access = PROTECTED) -public class TimeTable extends BaseEntity { +public class Timetable extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) @@ -97,7 +97,7 @@ public class TimeTable extends BaseEntity { private boolean isDeleted = false; @Builder - private TimeTable(User user, Semester semester, String code, String classTitle, String classTime, + private Timetable(User user, Semester semester, String code, String classTitle, String classTime, String classPlace, String professor, String grades, String lectureClass, String target, String regularNumber, String designScore, String department, String memo, boolean isDeleted) { @@ -118,7 +118,7 @@ private TimeTable(User user, Semester semester, String code, String classTitle, this.isDeleted = isDeleted; } - public void update(TimeTableUpdateRequest.InnerTimeTableRequest timeTableRequest) { + public void update(TimetableUpdateRequest.InnerTimetableRequest timeTableRequest) { this.code = timeTableRequest.code(); this.classTitle = timeTableRequest.classTitle(); this.classTime = timeTableRequest.classTime().toString(); diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java index 65c0b708b..02a163dec 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java @@ -1,9 +1,12 @@ package in.koreatech.koin.domain.timetable.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.repository.Repository; +import in.koreatech.koin.domain.timetable.exception.LectureNotFoundException; +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; import in.koreatech.koin.domain.timetable.model.Lecture; public interface LectureRepository extends Repository { @@ -11,4 +14,20 @@ public interface LectureRepository extends Repository { List findBySemester(String semesterDate); Lecture save(Lecture lecture); + + Optional findById(Integer id); + + Optional findBySemesterAndNameAndLectureClass(String semesterDate, String name, String classLecture); + + default Lecture getBySemesterAndNameAndLectureClass(String semesterDate, String name, String classLecture) { + return findBySemesterAndNameAndLectureClass(semesterDate, name, classLecture) + .orElseThrow(() -> SemesterNotFoundException.withDetail("semester: " + semesterDate + " name: " + name + " classLecture: " + classLecture)); + } + + default Lecture getLectureById(Integer id) { + return findById(id) + .orElseThrow(() -> LectureNotFoundException.withDetail("lecture_id: " + id)); + } + + List findByIdIn(List lectureIds); } diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java deleted file mode 100644 index 22e3e2e8c..000000000 --- a/src/main/java/in/koreatech/koin/domain/timetable/repository/TimeTableRepository.java +++ /dev/null @@ -1,27 +0,0 @@ -package in.koreatech.koin.domain.timetable.repository; - -import java.util.List; -import java.util.Optional; - -import org.springframework.data.repository.Repository; - -import in.koreatech.koin.domain.timetable.exception.TimeTableNotFoundException; -import in.koreatech.koin.domain.timetable.model.TimeTable; - -public interface TimeTableRepository extends Repository { - - TimeTable save(TimeTable timeTable); - - List findAllByUserId(Integer id); - - List findAllByUserIdAndSemesterId(Integer userId, Integer semesterId); - - Optional findById(Integer id); - - void deleteByUserIdAndSemesterId(Integer userId, Integer semesterId); - - default TimeTable getById(Integer id) { - return findById(id) - .orElseThrow(() -> TimeTableNotFoundException.withDetail("id: " + id)); - } -} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/TimetableRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/TimetableRepository.java new file mode 100644 index 000000000..1daf16aa5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetable/repository/TimetableRepository.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.domain.timetable.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.exception.TimetableNotFoundException; +import in.koreatech.koin.domain.timetable.model.Timetable; + +public interface TimetableRepository extends Repository { + + Timetable save(Timetable timeTable); + + List findAllByUserId(Integer id); + + List findAllByUserIdAndSemesterId(Integer userId, Integer semesterId); + + Optional findById(Integer id); + + void deleteByUserIdAndSemesterId(Integer userId, Integer semesterId); + + default Timetable getById(Integer id) { + return findById(id) + .orElseThrow(() -> TimetableNotFoundException.withDetail("id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java b/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java index b1891794a..047fb9aaf 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/service/SemesterService.java @@ -7,9 +7,9 @@ import in.koreatech.koin.domain.timetable.dto.SemesterCheckResponse; import in.koreatech.koin.domain.timetable.dto.SemesterResponse; -import in.koreatech.koin.domain.timetable.model.TimeTable; import in.koreatech.koin.domain.timetable.repository.SemesterRepository; -import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; import lombok.RequiredArgsConstructor; @Service @@ -18,7 +18,7 @@ public class SemesterService { private final SemesterRepository semesterRepository; - private final TimeTableRepository timetableRepository; + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; public List getSemesters() { return semesterRepository.findAllByOrderBySemesterDesc().stream() @@ -27,9 +27,9 @@ public List getSemesters() { } public SemesterCheckResponse getStudentSemesters(Integer userId) { - List timetables = timetableRepository.findAllByUserId(userId); - List semesters = timetables.stream() - .map(timetable -> timetable.getSemester().getSemester()) + List timeTableFrames = timetableFrameRepositoryV2.findByUserIdAndIsMainTrue(userId); + List semesters = timeTableFrames.stream() + .map(timeTableFrame -> timeTableFrame.getSemester().getSemester()) .distinct() .toList(); return SemesterCheckResponse.of(userId, semesters); diff --git a/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java b/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java index bc222c4a4..41aacf4bb 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java @@ -1,23 +1,26 @@ package in.koreatech.koin.domain.timetable.service; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.domain.timetable.dto.LectureResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableCreateRequest; -import in.koreatech.koin.domain.timetable.dto.TimeTableResponse; -import in.koreatech.koin.domain.timetable.dto.TimeTableUpdateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableCreateRequest; +import in.koreatech.koin.domain.timetable.dto.TimetableResponse; +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; import in.koreatech.koin.domain.timetable.model.Lecture; import in.koreatech.koin.domain.timetable.model.Semester; -import in.koreatech.koin.domain.timetable.model.TimeTable; import in.koreatech.koin.domain.timetable.repository.LectureRepository; import in.koreatech.koin.domain.timetable.repository.SemesterRepository; -import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; -import in.koreatech.koin.domain.user.model.User; -import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; +import in.koreatech.koin.global.auth.exception.AuthorizationException; import lombok.RequiredArgsConstructor; @Service @@ -26,9 +29,9 @@ public class TimetableService { private final LectureRepository lectureRepository; + private final TimetableLectureRepositoryV2 timetableLectureRepositoryV2; + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; private final SemesterRepository semesterRepository; - private final TimeTableRepository timeTableRepository; - private final UserRepository userRepository; public List getLecturesBySemester(String semester) { List lectures = lectureRepository.findBySemester(semester); @@ -40,58 +43,99 @@ public List getLecturesBySemester(String semester) { .toList(); } - public TimeTableResponse getTimeTables(Integer userId, String semesterRequest) { - Semester semester = semesterRepository.getBySemester(semesterRequest); - return getTimeTableResponse(userId, semester); - } - @Transactional - public TimeTableResponse createTimeTables(Integer userId, TimeTableCreateRequest request) { - User user = userRepository.getById(userId); + public TimetableResponse createTimetables(Integer userId, TimetableCreateRequest request) { Semester semester = semesterRepository.getBySemester(request.semester()); - for (TimeTableCreateRequest.InnerTimeTableRequest timeTableRequest : request.timetable()) { - TimeTable timeTable = timeTableRequest.toTimeTable(user, semester); - timeTableRepository.save(timeTable); + List timetableLectures = new ArrayList<>(); + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getMainTimetableByUserIdAndSemesterId(userId, + semester.getId()); + + for (TimetableCreateRequest.InnerTimetableRequest timeTable : request.timetable()) { + Lecture lecture = lectureRepository.getBySemesterAndNameAndLectureClass(request.semester(), + timeTable.classTitle(), timeTable.lectureClass()); + TimetableLecture timetableLecture = TimetableLecture.builder() + .classPlace(timeTable.classPlace()) + .grades("0") + .memo(timeTable.memo()) + .lecture(lecture) + .timetableFrame(timetableFrame) + .build(); + + timetableLectures.add(timetableLectureRepositoryV2.save(timetableLecture)); } - return getTimeTableResponse(userId, semester); + + return getTimetableResponse(userId, timetableFrame, timetableLectures); } @Transactional - public TimeTableResponse updateTimeTables(Integer userId, TimeTableUpdateRequest request) { + public TimetableResponse updateTimetables(Integer userId, TimetableUpdateRequest request) { Semester semester = semesterRepository.getBySemester(request.semester()); - for (TimeTableUpdateRequest.InnerTimeTableRequest timeTableRequest : request.timetable()) { - TimeTable timeTable = timeTableRepository.getById(timeTableRequest.id()); - timeTable.update(timeTableRequest); + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getMainTimetableByUserIdAndSemesterId(userId, + semester.getId()); + for (TimetableUpdateRequest.InnerTimetableRequest timetableRequest : request.timetable()) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetableRequest.id()); + timetableLecture.update(timetableRequest); + timetableLectureRepositoryV2.save(timetableLecture); } - return getTimeTableResponse(userId, semester); + return getTimetableResponse(userId, timetableFrame); + } + + public TimetableResponse getTimetables(Integer userId, String semesterRequest) { + Semester semester = semesterRepository.getBySemester(semesterRequest); + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getMainTimetableByUserIdAndSemesterId(userId, + semester.getId()); + return getTimetableResponse(userId, timetableFrame); } @Transactional - public void deleteTimeTable(Integer id) { - TimeTable timeTable = timeTableRepository.getById(id); - timeTable.updateIsDeleted(true); + public void deleteTimetableLecture(Integer userId, Integer timetableLectureId) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetableLectureId); + TimetableFrame frame = timetableFrameRepositoryV2.getById(timetableLecture.getTimetableFrame().getId()); + if (!Objects.equals(frame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + + timetableLectureRepositoryV2.deleteById(timetableLectureId); } - private TimeTableResponse getTimeTableResponse(Integer userId, Semester semester) { - List timeTables = timeTableRepository.findAllByUserIdAndSemesterId(userId, semester.getId()); - Integer grades = timeTables.stream() - .mapToInt(timeTable -> Integer.parseInt(timeTable.getGrades())) + private TimetableResponse getTimetableResponse(Integer userId, TimetableFrame timetableFrame) { + int grades = 0; + int totalGrades = 0; + + List timetableLectures = timetableLectureRepositoryV2.findAllByTimetableFrameId( + timetableFrame.getId()); + grades = timetableLectures.stream() + .mapToInt(lecture -> Integer.parseInt(lecture.getLecture().getGrades())) .sum(); - Integer totalGrades = calculateTotalGrades(userId); - return TimeTableResponse.of(semester.getSemester(), timeTables, grades, totalGrades); + for (TimetableFrame timetableFrames : timetableFrameRepositoryV2.findByUserIdAndIsMainTrue(userId)) { + totalGrades += timetableLectureRepositoryV2.findAllByTimetableFrameId(timetableFrames.getId()).stream() + .filter(lecture -> lecture.getLecture() != null) + .mapToInt(lecture -> Integer.parseInt(lecture.getLecture().getGrades())) + .sum(); + } + + return TimetableResponse.of(timetableLectures, timetableFrame, grades, totalGrades); } - private int calculateTotalGrades(Integer userId) { + private TimetableResponse getTimetableResponse(Integer userId, TimetableFrame timetableFrame, + List timetableLectures) { + int grades = 0; int totalGrades = 0; - List semesters = semesterRepository.findAllByOrderBySemesterDesc(); - for (Semester semester : semesters) { - totalGrades += timeTableRepository.findAllByUserIdAndSemesterId(userId, semester.getId()).stream() - .mapToInt(timeTable -> Integer.parseInt(timeTable.getGrades())) + if (timetableFrame.isMain()) { + grades = timetableLectures.stream() + .filter(lecture -> lecture.getLecture() != null) + .mapToInt(lecture -> Integer.parseInt(lecture.getLecture().getGrades())) + .sum(); + } + for (TimetableFrame timetableFrames : timetableFrameRepositoryV2.findByUserIdAndIsMainTrue(userId)) { + totalGrades += timetableLectureRepositoryV2.findAllByTimetableFrameId(timetableFrames.getId()).stream() + .filter(lecture -> lecture.getLecture() != null) + .mapToInt(lecture -> Integer.parseInt(lecture.getLecture().getGrades())) .sum(); } - return totalGrades; + return TimetableResponse.of(timetableLectures, timetableFrame, grades, totalGrades); } } diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java new file mode 100644 index 000000000..ea0f7a352 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableApiV2.java @@ -0,0 +1,163 @@ +package in.koreatech.koin.domain.timetableV2.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Timetable: V2-시간표", description = "시간표 정보를 관리한다") +public interface TimetableApiV2 { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 생성") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/v2/timetables/frame") + ResponseEntity createTimetablesFrame( + @Valid @RequestBody TimetableFrameCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/v2/timetables/frame/{id}") + ResponseEntity updateTimetableFrame( + @PathVariable(value = "id") Integer timetableFrameId, + @Valid @RequestBody TimetableFrameUpdateRequest timetableFrameUpdateRequest, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/v2/timetables/frame") + ResponseEntity> getTimetablesFrame( + @RequestParam(name = "semester") String semester, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표 프레임 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/v2/timetables/frame") + ResponseEntity deleteTimetablesFrame( + @RequestParam(name = "id") Integer frameId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에 강의 정보 추가") + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/v2/timetables/lecture") + ResponseEntity createTimetableLecture( + @RequestBody TimetableLectureCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에서 강의 정보 수정") + @SecurityRequirement(name = "Jwt Authentication") + @PutMapping("/v2/timetables/lecture") + ResponseEntity updateTimetableLecture( + @RequestBody TimetableLectureUpdateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에서 강의 정보 조회") + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/v2/timetables/lecture") + ResponseEntity getTimetableLecture( + @RequestParam(value = "timetable_frame_id") Integer timetableFrameId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation(summary = "시간표에서 강의 정보 삭제") + @SecurityRequirement(name = "Jwt Authentication") + @DeleteMapping("/v2/timetables/lecture/{id}") + ResponseEntity deleteTimetableLecture( + @PathVariable(value = "id") Integer timetableLectureId, + @Auth(permit = {STUDENT}) Integer userId + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java new file mode 100644 index 000000000..8d35b1536 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/controller/TimetableControllerV2.java @@ -0,0 +1,109 @@ +package in.koreatech.koin.domain.timetableV2.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest; +import in.koreatech.koin.domain.timetableV2.service.TimetableServiceV2; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class TimetableControllerV2 implements TimetableApiV2 { + + private final TimetableServiceV2 timetableServiceV2; + + @PostMapping("/v2/timetables/frame") + public ResponseEntity createTimetablesFrame( + @Valid @RequestBody TimetableFrameCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableFrameResponse response = timetableServiceV2.createTimetablesFrame(userId, request); + return ResponseEntity.ok(response); + } + + @PutMapping("/v2/timetables/frame/{id}") + public ResponseEntity updateTimetableFrame( + @PathVariable(value = "id") Integer timetableFrameId, + @Valid @RequestBody TimetableFrameUpdateRequest timetableFrameUpdateRequest, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableFrameUpdateResponse timetableFrameUpdateResponse = + timetableServiceV2.updateTimetableFrame(timetableFrameId, timetableFrameUpdateRequest, userId); + return ResponseEntity.ok(timetableFrameUpdateResponse); + } + + @GetMapping("/v2/timetables/frames") + public ResponseEntity> getTimetablesFrame( + @RequestParam(name = "semester") String semester, + @Auth(permit = {STUDENT}) Integer userId + ) { + List timeTableFrameResponse = timetableServiceV2.getTimetablesFrame(userId, semester); + return ResponseEntity.ok(timeTableFrameResponse); + } + + @DeleteMapping("/v2/timetables/frame") + public ResponseEntity deleteTimetablesFrame( + @RequestParam(name = "id") Integer frameId, + @Auth(permit = {STUDENT}) Integer userId + ) { + timetableServiceV2.deleteTimetablesFrame(userId, frameId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/v2/timetables/lecture") + public ResponseEntity createTimetableLecture( + @Valid @RequestBody TimetableLectureCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableLectureResponse timeTableLectureResponse = timetableServiceV2.createTimetableLectures(userId, request); + return ResponseEntity.ok(timeTableLectureResponse); + } + + @PutMapping("/v2/timetables/lecture") + public ResponseEntity updateTimetableLecture( + @Valid @RequestBody TimetableLectureUpdateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableLectureResponse timetableLectureResponse = timetableServiceV2.updateTimetablesLectures(userId, request); + return ResponseEntity.ok(timetableLectureResponse); + } + + @GetMapping("/v2/timetables/lecture") + public ResponseEntity getTimetableLecture( + @RequestParam(name = "timetable_frame_id") Integer timetableFrameId, + @Auth(permit = {STUDENT}) Integer userId + ) { + TimetableLectureResponse timetableLectureResponse = timetableServiceV2.getTimetableLectures(userId, + timetableFrameId); + return ResponseEntity.ok(timetableLectureResponse); + } + + @DeleteMapping("/v2/timetables/lecture/{id}") + public ResponseEntity deleteTimetableLecture( + @PathVariable(value = "id") Integer timetableLectureId, + @Auth(permit = {STUDENT}) Integer userId + ) { + timetableServiceV2.deleteTimetableLecture(userId, timetableLectureId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameCreateRequest.java new file mode 100644 index 000000000..cb23713c1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameCreateRequest.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record TimetableFrameCreateRequest( + @Schema(description = "학기 정보", example = "20192", requiredMode = REQUIRED) + @NotBlank(message = "학기 정보를 입력해주세요") + String semester +) { + public TimetableFrame toTimetablesFrame(User user, Semester semester, String name, boolean isMain) { + return TimetableFrame.builder() + .user(user) + .semester(semester) + .name(name) + .isMain(isMain) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameResponse.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameResponse.java new file mode 100644 index 000000000..1a2403f78 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameResponse.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableFrameResponse( + @Schema(description = "id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "시간표 이름", example = "시간표1", requiredMode = REQUIRED) + String timetableName, + + @Schema(description = "메인 시간표 여부", example = "false", requiredMode = REQUIRED) + Boolean isMain +) { + public static TimetableFrameResponse from(TimetableFrame timetableFrame) { + return new TimetableFrameResponse( + timetableFrame.getId(), + timetableFrame.getName(), + timetableFrame.isMain() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateRequest.java new file mode 100644 index 000000000..12ece7a7f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateRequest.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableFrameUpdateRequest( + @Schema(description = "시간표 이름", example = "시간표1", requiredMode = REQUIRED) + @Size(max = 255, message = "시간표 이름의 최대 길이는 255자입니다.") + @NotBlank(message = "시간표 이름을 입력해주세요.") + String name, + + @Schema(description = "메인 시간표 여부", example = "false", requiredMode = REQUIRED) + @NotNull(message = "시간표 메인 여부를 입력해주세요.") + Boolean isMain +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateResponse.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateResponse.java new file mode 100644 index 000000000..17245cad4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableFrameUpdateResponse.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableFrameUpdateResponse( + @Schema(description = "id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "시간표 이름", example = "시간표1", requiredMode = REQUIRED) + String name, + + @Schema(description = "메인 시간표 여부", example = "false", requiredMode = REQUIRED) + Boolean isMain +) { + + public static TimetableFrameUpdateResponse from(TimetableFrame timetableFrame) { + return new TimetableFrameUpdateResponse( + timetableFrame.getId(), + timetableFrame.getName(), + timetableFrame.isMain()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java new file mode 100644 index 000000000..97d3f11b4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureCreateRequest.java @@ -0,0 +1,87 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableLectureCreateRequest( + @Schema(description = "시간표 프레임 id", example = "1", requiredMode = REQUIRED) + Integer timetableFrameId, + + @Valid + @Schema(description = "강의 시간표 정보", requiredMode = REQUIRED) + @NotNull(message = "시간표 정보를 입력해주세요.") + List timetableLecture +) { + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimeTableLectureRequest( + @Schema(description = "강의 이름", example = "기상분석", requiredMode = REQUIRED) + String classTitle, + + @Schema(description = "강의 시간", example = "[210, 211]", requiredMode = REQUIRED) + List classTime, + + @Schema(description = "강의 장소", example = "도서관", requiredMode = NOT_REQUIRED) + String classPlace, + + @Schema(description = "교수명", example = "이강환", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(description = "학점", example = "3", requiredMode = NOT_REQUIRED) + String grades, + + @Schema(description = "메모", example = "메모메모", requiredMode = NOT_REQUIRED) + @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") + String memo, + + @Schema(description = "강의 고유 번호", example = "1", requiredMode = NOT_REQUIRED) + Integer lectureId + ){ + public InnerTimeTableLectureRequest { + if (grades == null) { + grades = "0"; + } + } + public TimetableLecture toTimetableLecture(TimetableFrame timetableFrame) { + return new TimetableLecture( + classTitle, + Arrays.toString(classTime().stream().toArray()), + classPlace, + professor, + grades, + memo, + false, + null, + timetableFrame + ); + } + + public TimetableLecture toTimetableLecture(TimetableFrame timetableFrame, Lecture lecture) { + return new TimetableLecture( + classTitle, + Arrays.toString(classTime().stream().toArray()), + classPlace, + professor, + grades, + memo, + false, + lecture, + timetableFrame + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureResponse.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureResponse.java new file mode 100644 index 000000000..332acc9dd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureResponse.java @@ -0,0 +1,138 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableLectureResponse( + @Schema(name = "시간표 프레임 id", example = "1") + Integer timetableFrameId, + + @Schema(name = "시간표 상세정보") + List timetable, + + @Schema(name = "해당 학기 학점", example = "21") + Integer grades, + + @Schema(name = "전체 학기 학점", example = "121") + Integer totalGrades +) { + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimetableLectureResponse( + @Schema(name = "시간표 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(name = "수강 정원", example = "38", requiredMode = NOT_REQUIRED) + String regularNumber, + + @Schema(name = "과목 코드", example = "ARB244", requiredMode = NOT_REQUIRED) + String code, + + @Schema(description = "설계 학점", example = "0", requiredMode = NOT_REQUIRED) + String designScore, + + @Schema(description = "강의(커스텀) 시간", example = "[204, 205, 206, 207, 302, 303]", requiredMode = REQUIRED) + List classTime, + + @Schema(description = "강의 장소", example = "2 공학관", requiredMode = NOT_REQUIRED) + String classPlace, + + @Schema(description = "메모", example = "null", requiredMode = NOT_REQUIRED) + String memo, + + @Schema(name = "학점", example = "3", requiredMode = REQUIRED) + String grades, + + @Schema(name = "강의(커스텀) 이름", example = "한국사", requiredMode = REQUIRED) + String classTitle, + + @Schema(name = "분반", example = "01", requiredMode = NOT_REQUIRED) + String lectureClass, + + @Schema(name = "대상", example = "디자 1 건축", requiredMode = NOT_REQUIRED) + String target, + + @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(name = "학부", example = "디자인ㆍ건축공학부", requiredMode = NOT_REQUIRED) + String department + ) { + + public static List from(List timetableLectures) { + List timetableLectureList = new ArrayList<>(); + + for (TimetableLecture timetableLecture : timetableLectures) { + InnerTimetableLectureResponse response; + if (timetableLecture.getLecture() == null) { + response = new InnerTimetableLectureResponse( + timetableLecture.getId(), + null, + null, + null, + parseIntegerClassTimesFromString(timetableLecture.getClassTime()), + timetableLecture.getClassPlace(), + timetableLecture.getMemo(), + timetableLecture.getGrades(), + timetableLecture.getClassTitle(), + null, + null, + timetableLecture.getProfessor(), + null + ); + } else { + response = new InnerTimetableLectureResponse( + timetableLecture.getId(), + timetableLecture.getLecture().getRegularNumber(), + timetableLecture.getLecture().getCode(), + timetableLecture.getLecture().getDesignScore(), + parseIntegerClassTimesFromString(timetableLecture.getLecture().getClassTime()), + timetableLecture.getClassPlace(), + timetableLecture.getMemo(), + timetableLecture.getLecture().getGrades(), + timetableLecture.getLecture().getName(), + timetableLecture.getLecture().getLectureClass(), + timetableLecture.getLecture().getTarget(), + timetableLecture.getLecture().getProfessor(), + timetableLecture.getLecture().getDepartment() + ); + } + timetableLectureList.add(response); + } + return timetableLectureList; + } + } + + public static TimetableLectureResponse of(Integer timetableFrameId, List timetableLectures, + Integer grades, Integer totalGrades) { + return new TimetableLectureResponse(timetableFrameId, InnerTimetableLectureResponse.from(timetableLectures), + grades, totalGrades); + } + + private static final int INITIAL_BRACE_INDEX = 1; + + private static List parseIntegerClassTimesFromString(String classTime) { + String classTimeWithoutBrackets = classTime.substring(INITIAL_BRACE_INDEX, classTime.length() - 1); + + if (!classTimeWithoutBrackets.isEmpty()) { + return Arrays.stream(classTimeWithoutBrackets.split(",")) + .map(String::strip) + .map(Integer::parseInt) + .toList(); + } else { + return Collections.emptyList(); + } + } +} + diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureUpdateRequest.java new file mode 100644 index 000000000..41f66e6d4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/dto/TimetableLectureUpdateRequest.java @@ -0,0 +1,56 @@ +package in.koreatech.koin.domain.timetableV2.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TimetableLectureUpdateRequest( + @Schema(description = "시간표 프레임 id", example = "1", requiredMode = REQUIRED) + @NotNull(message = "시간표 식별 번호를 입력해주세요.") + Integer timetableFrameId, + + @Valid + @Schema(description = "시간표 정보", requiredMode = NOT_REQUIRED) + @NotNull(message = "시간표 정보를 입력해주세요.") + List timetableLecture +) { + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerTimetableLectureRequest( + @Schema(description = "시간표 강의 id", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "강의 id", example = "1", requiredMode = NOT_REQUIRED) + Integer lectureId, + + @Schema(description = "강의 이름", example = "운영체제", requiredMode = NOT_REQUIRED) + String classTitle, + + @Schema(description = "강의 시간", example = "[210, 211]", requiredMode = NOT_REQUIRED) + List classTime, + + @Schema(description = "강의 장소", example = "null", requiredMode = NOT_REQUIRED) + String classPlace, + + @Schema(name = "강의 교수", example = "이돈우", requiredMode = NOT_REQUIRED) + String professor, + + @Schema(description = "학점", example = "3", requiredMode = NOT_REQUIRED) + String grades, + + @Schema(name = "memo", example = "메모메모", requiredMode = NOT_REQUIRED) + @Size(max = 200, message = "메모는 200자 이하로 입력해주세요.") + String memo + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableFrameNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableFrameNotFoundException.java new file mode 100644 index 000000000..45caab340 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableFrameNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.timetableV2.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class TimetableFrameNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 시간표 프레임입니다."; + + public TimetableFrameNotFoundException(String message) { + super(message); + } + + public TimetableFrameNotFoundException(String message, String detail) { + super(message, detail); + } + + public static TimetableFrameNotFoundException withDetail(String detail) { + return new TimetableFrameNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableLectureNotFoundException.java b/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableLectureNotFoundException.java new file mode 100644 index 000000000..e450c3890 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/exception/TimetableLectureNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.timetableV2.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class TimetableLectureNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 수업입니다."; + + public TimetableLectureNotFoundException(String message) { + super(message); + } + + public TimetableLectureNotFoundException(String message, String detail) { + super(message, detail); + } + + public static TimetableLectureNotFoundException withDetail(String detail) { + return new TimetableLectureNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableFrame.java b/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableFrame.java new file mode 100644 index 000000000..fc5b2fce7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableFrame.java @@ -0,0 +1,100 @@ +package in.koreatech.koin.domain.timetableV2.model; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.util.List; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "timetable_frame", + indexes = @Index(name = "timetable_frame_INDEX", columnList = "user_id, semester_id") +) +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class TimetableFrame extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @NotNull + @Size(max = 255) + @Column(name = "name", length = 255, nullable = false) + private String name; + + @NotNull + @Column(name = "is_main", nullable = false) + private boolean isMain = false; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @OneToMany(mappedBy = "timetableFrame", orphanRemoval = true, cascade = ALL) + private List timetableLectures; + + public void updateStatusMain(boolean isMain) { + this.isMain = isMain; + } + + @Builder + private TimetableFrame( + User user, + Semester semester, + String name, + boolean isMain, + List timetableLectures, + boolean isDeleted + ) { + this.user = user; + this.semester = semester; + this.name = name; + this.isMain = isMain; + this.timetableLectures = timetableLectures; + this.isDeleted = isDeleted; + } + + public void updateTimetableFrame(Semester semester, String name, boolean isMain) { + this.semester = semester; + this.name = name; + this.isMain = isMain; + } + + public void cancelMain() { + isMain = false; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableLecture.java b/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableLecture.java new file mode 100644 index 000000000..2b43ac90f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/model/TimetableLecture.java @@ -0,0 +1,106 @@ +package in.koreatech.koin.domain.timetableV2.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.domain.timetable.dto.TimetableUpdateRequest; +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest; +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "timetable_lecture") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class TimetableLecture extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Size(max = 100) + @Column(name = "class_title", length = 100) + private String classTitle; + + @Size(max = 100) + @Column(name = "class_time", length = 100) + private String classTime; + + @Size(max = 30) + @Column(name = "class_place", length = 30) + private String classPlace; + + @Size(max = 30) + @Column(name = "professor", length = 30) + private String professor; + + @Size(max = 255) + @NotNull + @Column(name = "grades", nullable = false) + private String grades = "0"; + + @Size(max = 200) + @Column(name = "memo", length = 200) + private String memo; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lectures_id") + private Lecture lecture; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "frame_id") + private TimetableFrame timetableFrame; + + @Builder + public TimetableLecture(String classTitle, String classTime, String classPlace, String professor, + String grades, String memo, boolean isDeleted, Lecture lecture, TimetableFrame timetableFrame) { + this.classTitle = classTitle; + this.classTime = classTime; + this.classPlace = classPlace; + this.professor = professor; + this.grades = grades; + this.memo = memo; + this.isDeleted = isDeleted; + this.lecture = lecture; + this.timetableFrame = timetableFrame; + } + + public void update(String classTitle, String classTime, String classPlace, String professor, + String grades, String memo) { + this.classTitle = classTitle; + this.classTime = classTime; + this.classPlace = classPlace; + this.professor = professor; + this.grades = grades; + this.memo = memo; + } + + public void update(TimetableUpdateRequest.InnerTimetableRequest request) { + this.classTitle = request.classTitle(); + this.classTime = request.classTime().toString(); + this.classPlace = request.classPlace(); + this.professor = request.professor(); + this.grades = grades; + this.memo = request.memo(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/LectureRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/LectureRepositoryV2.java new file mode 100644 index 000000000..ec735bba5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/LectureRepositoryV2.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.timetableV2.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.exception.LectureNotFoundException; +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; +import in.koreatech.koin.domain.timetable.model.Lecture; + +public interface LectureRepositoryV2 extends Repository { + + Lecture save(Lecture lecture); + + Optional findById(Integer id); + + default Lecture getLectureById(Integer id) { + return findById(id) + .orElseThrow(() -> LectureNotFoundException.withDetail("lecture_id: " + id)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/SemesterRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/SemesterRepositoryV2.java new file mode 100644 index 000000000..ee3e1367c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/SemesterRepositoryV2.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.timetableV2.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.exception.SemesterNotFoundException; +import in.koreatech.koin.domain.timetable.model.Semester; + +public interface SemesterRepositoryV2 extends Repository { + + Semester save(Semester semester); + + Optional findBySemester(String semester); + + default Semester getBySemester(String semester) { + return findBySemester(semester) + .orElseThrow(() -> SemesterNotFoundException.withDetail("semester: " + semester)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java new file mode 100644 index 000000000..8962bdd69 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableFrameRepositoryV2.java @@ -0,0 +1,65 @@ +package in.koreatech.koin.domain.timetableV2.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.timetable.exception.TimetableNotFoundException; +import in.koreatech.koin.domain.timetableV2.exception.TimetableFrameNotFoundException; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.user.model.User; + +public interface TimetableFrameRepositoryV2 extends Repository { + + Optional findById(Integer id); + + List findByUserIdAndIsMainTrue(Integer userId); + + Optional findByUserIdAndSemesterIdAndIsMainTrue(Integer userId, Integer semesterId); + + default TimetableFrame getById(Integer id) { + return findById(id) + .orElseThrow(() -> TimetableNotFoundException.withDetail("id: " + id)); + } + + default TimetableFrame getMainTimetableByUserIdAndSemesterId(Integer userId, Integer semesterId) { + return findByUserIdAndSemesterIdAndIsMainTrue(userId, semesterId) + .orElseThrow( + () -> TimetableFrameNotFoundException.withDetail("userId: " + userId + ", semesterId: " + semesterId)); + } + + List findAllByUserIdAndSemesterId(Integer userId, Integer semesterId); + + TimetableFrame save(TimetableFrame timetableFrame); + + Optional findByUser(User user); + + default TimetableFrame getByUser(User user) { + return findByUser(user) + .orElseThrow(() -> TimetableFrameNotFoundException.withDetail("userId: " + user.getId())); + } + + @Query( + """ + SELECT tf FROM TimetableFrame tf + WHERE tf.user.id = :userId + AND tf.semester.id = :semesterId + AND tf.isMain = false ORDER BY tf.createdAt ASC + """) + TimetableFrame findFirstNonMainFrame(@Param("userId") Integer userId, @Param("semesterId") Integer semesterId); + + @Query( + """ + SELECT COUNT(t) FROM TimetableFrame t + WHERE t.user.id = :userId + AND t.semester.id = :semesterId + """) + int countByUserIdAndSemesterId(@Param("userId") Integer userId, @Param("semesterId") Integer semesterId); + + void deleteById(Integer id); + + void deleteAllByUser(User user); +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java new file mode 100644 index 000000000..ee0c82047 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/repository/TimetableLectureRepositoryV2.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.timetableV2.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetableV2.exception.TimetableLectureNotFoundException; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; + +public interface TimetableLectureRepositoryV2 extends Repository { + + Optional findById(Integer id); + + List findAllByTimetableFrameId(Integer id); + + void deleteById(Integer id); + + default TimetableLecture getById(Integer id) { + return findById(id) + .orElseThrow(() -> TimetableLectureNotFoundException.withDetail("id: " + id)); + } + TimetableLecture save(TimetableLecture timetableLecture); +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java new file mode 100644 index 000000000..e4675b340 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/timetableV2/service/TimetableServiceV2.java @@ -0,0 +1,188 @@ +package in.koreatech.koin.domain.timetableV2.service; + +import static in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest.*; +import static in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest.*; + +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableFrameUpdateResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureCreateRequest; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureResponse; +import in.koreatech.koin.domain.timetableV2.dto.TimetableLectureUpdateRequest; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import in.koreatech.koin.domain.timetableV2.repository.LectureRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.SemesterRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.auth.exception.AuthorizationException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimetableServiceV2 { + + private final LectureRepositoryV2 lectureRepositoryV2; + private final TimetableLectureRepositoryV2 timetableLectureRepositoryV2; + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; + private final UserRepository userRepository; + private final SemesterRepositoryV2 semesterRepositoryV2; + + @Transactional + public TimetableFrameResponse createTimetablesFrame(Integer userId, TimetableFrameCreateRequest request) { + Semester semester = semesterRepositoryV2.getBySemester(request.semester()); + User user = userRepository.getById(userId); + int currentFrameCount = timetableFrameRepositoryV2.countByUserIdAndSemesterId(userId, semester.getId()); + boolean isMain = (currentFrameCount == 0); + + TimetableFrame timetableFrame = request.toTimetablesFrame(user, semester, "시간표" + (currentFrameCount+1), isMain); + TimetableFrame savedTimetableFrame = timetableFrameRepositoryV2.save(timetableFrame); + return TimetableFrameResponse.from(savedTimetableFrame); + } + + @Transactional + public TimetableFrameUpdateResponse updateTimetableFrame(Integer timetableFrameId, + TimetableFrameUpdateRequest timetableFrameUpdateRequest, Integer userId) { + TimetableFrame timeTableFrame = timetableFrameRepositoryV2.getById(timetableFrameId); + Semester semester = timeTableFrame.getSemester(); + boolean isMain = timetableFrameUpdateRequest.isMain(); + if (isMain) { + cancelMainTimetable(userId, semester.getId()); + } + timeTableFrame.updateTimetableFrame(semester, timetableFrameUpdateRequest.name(), isMain); + return TimetableFrameUpdateResponse.from(timeTableFrame); + } + + public List getTimetablesFrame(Integer userId, String semesterRequest) { + Semester semester = semesterRepositoryV2.getBySemester(semesterRequest); + return timetableFrameRepositoryV2.findAllByUserIdAndSemesterId(userId, semester.getId()).stream() + .map(TimetableFrameResponse::from) + .toList(); + } + + @Transactional + public void deleteTimetablesFrame(Integer userId, Integer frameId) { + TimetableFrame frame = timetableFrameRepositoryV2.getById(frameId); + if (!Objects.equals(frame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + if (frame.isMain()) { + TimetableFrame nextMainFrame = + timetableFrameRepositoryV2.findFirstNonMainFrame(userId, frame.getSemester().getId()); + if (nextMainFrame != null) { + nextMainFrame.updateStatusMain(true); + timetableFrameRepositoryV2.save(nextMainFrame); + } + } + timetableFrameRepositoryV2.deleteById(frameId); + } + + @Transactional + public TimetableLectureResponse createTimetableLectures(Integer userId, TimetableLectureCreateRequest request) { + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getById(request.timetableFrameId()); + if (!Objects.equals(timetableFrame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + + for (InnerTimeTableLectureRequest timetableLectureRequest : request.timetableLecture()) { + Lecture lecture = timetableLectureRequest.lectureId() == null ? + null : lectureRepositoryV2.getLectureById(timetableLectureRequest.lectureId()); + TimetableLecture timetableLecture = timetableLectureRequest.toTimetableLecture(timetableFrame, lecture); + timetableLectureRepositoryV2.save(timetableLecture); + } + + List timetableLectures = timetableLectureRepositoryV2.findAllByTimetableFrameId( + timetableFrame.getId()); + return getTimetableLectureResponse(userId, timetableFrame, timetableLectures); + } + + @Transactional + public TimetableLectureResponse updateTimetablesLectures(Integer userId, TimetableLectureUpdateRequest request) { + TimetableFrame timetableFrame = timetableFrameRepositoryV2.getById(request.timetableFrameId()); + if (!Objects.equals(timetableFrame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + + for (InnerTimetableLectureRequest timetableRequest : request.timetableLecture()) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetableRequest.id()); + if (timetableRequest.lectureId() == null) { + timetableLecture.update( + timetableRequest.classTitle(), + timetableRequest.classTime().toString(), + timetableRequest.classPlace(), + timetableRequest.professor(), + timetableRequest.grades(), + timetableRequest.memo()); + } + } + List timetableLectures = timetableFrame.getTimetableLectures(); + return getTimetableLectureResponse(userId, timetableFrame, timetableLectures); + } + + @Transactional + public TimetableLectureResponse getTimetableLectures(Integer userId, Integer timetableFrameId) { + TimetableFrame frame = timetableFrameRepositoryV2.getById(timetableFrameId); + List timetableLectures = frame.getTimetableLectures(); + if (!Objects.equals(frame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + return getTimetableLectureResponse(userId, frame, timetableLectures); + } + + @Transactional + public void deleteTimetableLecture(Integer userId, Integer timetableLectureId) { + TimetableLecture timetableLecture = timetableLectureRepositoryV2.getById(timetableLectureId); + TimetableFrame frame = timetableLecture.getTimetableFrame(); + if (!Objects.equals(frame.getUser().getId(), userId)) { + throw AuthorizationException.withDetail("userId: " + userId); + } + timetableLectureRepositoryV2.deleteById(timetableLectureId); + } + + private TimetableLectureResponse getTimetableLectureResponse(Integer userId, TimetableFrame timetableFrame, + List timetableLectures) { + int grades = 0; + int totalGrades = 0; + + if (timetableFrame.isMain()) { + grades = calculateGrades(timetableLectures); + } + + for (TimetableFrame timetableFrames : timetableFrameRepositoryV2.findByUserIdAndIsMainTrue(userId)) { + totalGrades += calculateGrades( + timetableLectureRepositoryV2.findAllByTimetableFrameId(timetableFrames.getId())); + } + + return TimetableLectureResponse.of(timetableFrame.getId(), timetableLectures, grades, totalGrades); + } + + private int calculateGrades(List timetableLectures) { + return timetableLectures.stream() + .mapToInt(lecture -> { + if (lecture.getLecture() != null) { + return Integer.parseInt(lecture.getLecture().getGrades()); + } else { + return Integer.parseInt(lecture.getGrades()); + } + }) + .sum(); + } + + private void cancelMainTimetable(Integer userId, Integer semesterId) { + TimetableFrame mainTimetableFrame = timetableFrameRepositoryV2.getMainTimetableByUserIdAndSemesterId(userId, + semesterId); + mainTimetableFrame.cancelMain(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java index 93843f8de..e0e73d27f 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java @@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.domain.owner.repository.OwnerRepository; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; import in.koreatech.koin.domain.user.dto.AuthResponse; import in.koreatech.koin.domain.user.dto.CoopResponse; import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest; @@ -45,6 +46,7 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final UserTokenRepository userTokenRepository; private final ApplicationEventPublisher eventPublisher; + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; @Transactional public UserLoginResponse login(UserLoginRequest request) { @@ -96,6 +98,7 @@ private String getUserId(String refreshToken) { public void withdraw(Integer userId) { User user = userRepository.getById(userId); if (user.getUserType() == UserType.STUDENT) { + timetableFrameRepositoryV2.deleteAllByUser(user); studentRepository.deleteByUserId(userId); } else if (user.getUserType() == UserType.OWNER) { ownerRepository.deleteByUserId(userId); diff --git a/src/main/resources/db/migration/V19__add_timetable_frame.sql b/src/main/resources/db/migration/V19__add_timetable_frame.sql new file mode 100644 index 000000000..7969945e5 --- /dev/null +++ b/src/main/resources/db/migration/V19__add_timetable_frame.sql @@ -0,0 +1,12 @@ +CREATE TABLE timetable_frame ( + id INT UNSIGNED AUTO_INCREMENT NOT NULL comment '고유 id', + user_id INT UNSIGNED NOT NULL comment '유저 id', + semester_id INT UNSIGNED NOT NULL comment '학기 id', + name VARCHAR(255) NOT NULL comment '시간표 이름', + is_main TINYINT(1) NOT NULL DEFAULT 0 comment '메인 시간표 여부', + is_deleted TINYINT(1) NOT NULL DEFAULT 0 comment '시간표 삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '수정 일자', + PRIMARY KEY (`id`), + INDEX timetable_frame_INDEX (user_id, semester_id) USING BTREE +); diff --git a/src/main/resources/db/migration/V20__insert_timetable_frame.sql b/src/main/resources/db/migration/V20__insert_timetable_frame.sql new file mode 100644 index 000000000..87db47cfc --- /dev/null +++ b/src/main/resources/db/migration/V20__insert_timetable_frame.sql @@ -0,0 +1,3 @@ +INSERT INTO timetable_frame (user_id, semester_id, name, is_main, is_deleted) +SELECT user_id, semester_id, '시간표1', 1, is_deleted +FROM timetables GROUP BY user_id, semester_id, is_deleted; diff --git a/src/main/resources/db/migration/V21__add_timetable_lecture.sql b/src/main/resources/db/migration/V21__add_timetable_lecture.sql new file mode 100644 index 000000000..ce30cb694 --- /dev/null +++ b/src/main/resources/db/migration/V21__add_timetable_lecture.sql @@ -0,0 +1,17 @@ +CREATE TABLE `timetable_lecture` ( + id INT UNSIGNED AUTO_INCREMENT NOT NULL comment '고유 id' primary key, + class_title VARCHAR(255) NULL comment '수업 이름', + class_time VARCHAR(255) NULL comment '강의 시간', + class_place VARCHAR(255) NULL comment '수업 장소', + professor VARCHAR(255) NULL comment '교수', + grades VARCHAR(2) not null comment '학점' default '0', + memo VARCHAR(255) NULL comment '메모', + is_deleted TINYINT(1) NULL DEFAULT 0 comment '삭제 여부', + created_at timestamp default CURRENT_TIMESTAMP not null comment '생성 일자', + updated_at timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '업데이트 일자', + lectures_id INT UNSIGNED NULL comment '강의_id', + frame_id INT UNSIGNED NULL comment '시간표 프레임 id', + user_id INT UNSIGNED NULL comment '유저 id', + semester_id INT UNSIGNED NULL comment '학기 id', + CONSTRAINT FK_TIMETABLE_FRAME_ON_TIMETABLE_LECTURE FOREIGN KEY (frame_id) references `koin`.`timetable_frame`(id) on delete cascade +); diff --git a/src/main/resources/db/migration/V22__insert_timetable_lecture.sql b/src/main/resources/db/migration/V22__insert_timetable_lecture.sql new file mode 100644 index 000000000..ecd67503d --- /dev/null +++ b/src/main/resources/db/migration/V22__insert_timetable_lecture.sql @@ -0,0 +1,3 @@ +INSERT INTO timetable_lecture (class_place, memo, is_deleted, user_id, semester_id) +SELECT t.class_place, t.memo, is_deleted, t.user_id, t.semester_id +FROM timetables t; diff --git a/src/main/resources/db/migration/V23__alter_timetable_lecture_lectures_id.sql b/src/main/resources/db/migration/V23__alter_timetable_lecture_lectures_id.sql new file mode 100644 index 000000000..c87bda421 --- /dev/null +++ b/src/main/resources/db/migration/V23__alter_timetable_lecture_lectures_id.sql @@ -0,0 +1,6 @@ +UPDATE timetable_lecture tl + JOIN timetables t ON tl.id = t.id + JOIN lectures l ON t.class_title = l.name + AND t.class_time = l.class_time + JOIN semester s ON t.semester_id = s.id + SET tl.lectures_id = l.id; diff --git a/src/main/resources/db/migration/V24__alter_timetable_lecture_timetable_id.sql b/src/main/resources/db/migration/V24__alter_timetable_lecture_timetable_id.sql new file mode 100644 index 000000000..ac0159a9c --- /dev/null +++ b/src/main/resources/db/migration/V24__alter_timetable_lecture_timetable_id.sql @@ -0,0 +1,3 @@ +UPDATE timetable_lecture t + JOIN timetable_frame f ON t.user_id = f.user_id AND t.semester_id = f.semester_id + SET t.timetable_id = f.id; diff --git a/src/main/resources/db/migration/V25__delete_timetable_lecture_user_id_and_semester_id.sql b/src/main/resources/db/migration/V25__delete_timetable_lecture_user_id_and_semester_id.sql new file mode 100644 index 000000000..857e29d56 --- /dev/null +++ b/src/main/resources/db/migration/V25__delete_timetable_lecture_user_id_and_semester_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE timetable_lecture DROP user_id; +ALTER TABLE timetable_lecture DROP semester_id; diff --git a/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java index c1f405608..ddac42b52 100644 --- a/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/TimetableApiTest.java @@ -8,13 +8,13 @@ import org.springframework.http.HttpStatus; import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.timetable.model.Lecture; import in.koreatech.koin.domain.timetable.model.Semester; -import in.koreatech.koin.domain.timetable.model.TimeTable; -import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.timetable.repository.TimetableRepository; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.fixture.LectureFixture; import in.koreatech.koin.fixture.SemesterFixture; -import in.koreatech.koin.fixture.TimeTableFixture; +import in.koreatech.koin.fixture.TimeTableV2Fixture; import in.koreatech.koin.fixture.UserFixture; import in.koreatech.koin.support.JsonAssertions; import io.restassured.RestAssured; @@ -24,7 +24,10 @@ class TimetableApiTest extends AcceptanceTest { @Autowired - private TimeTableRepository timeTableRepository; + private TimeTableV2Fixture timetableV2Fixture; + + @Autowired + private TimetableRepository timetableRepository; @Autowired private UserFixture userFixture; @@ -35,9 +38,6 @@ class TimetableApiTest extends AcceptanceTest { @Autowired private SemesterFixture semesterFixture; - @Autowired - private TimeTableFixture timeTableFixture; - @Test @DisplayName("특정 학기 강의를 조회한다") void getSemesterLecture() { @@ -58,6 +58,7 @@ void getSemesterLecture() { .isEqualTo(""" [ { + "id": 1, "code": "BSM590", "name": "컴퓨팅사고", "grades": "3", @@ -98,6 +99,7 @@ void getSemesterLectures() { .isEqualTo(""" [ { + "id": 1, "code": "BSM590", "name": "컴퓨팅사고", "grades": "3", @@ -114,6 +116,7 @@ void getSemesterLectures() { ] }, { + "id": 2, "code": "ARB244", "name": "건축구조의 이해 및 실습", "grades": "3", @@ -130,6 +133,7 @@ void getSemesterLectures() { ] }, { + "id": 3, "code": "MEB311", "name": "재료역학", "grades": "3", @@ -212,9 +216,11 @@ void getTimeTables() { User user = userFixture.준호_학생().getUser(); String token = userFixture.getToken(user); Semester semester = semesterFixture.semester("20192"); - TimeTable 이산수학 = timeTableFixture.이산수학(user, semester); - TimeTable 알고리즘및실습 = timeTableFixture.알고리즘및실습(user, semester); - TimeTable 컴퓨터구조 = timeTableFixture.컴퓨터구조(user, semester); + + Lecture 건축구조의_이해_및_실습 = lectureFixture.건축구조의_이해_및_실습(semester.getSemester()); + Lecture HRD_개론 = lectureFixture.HRD_개론(semester.getSemester()); + + timetableV2Fixture.시간표6(user, semester, 건축구조의_이해_및_실습, HRD_개론); // when & then var response = RestAssured @@ -233,61 +239,40 @@ void getTimeTables() { "semester": "20192", "timetable": [ { - "id": %d, - "regular_number": "40", - "code": "CSE125", + "id" : 1, + "regular_number": "25", + "code": "ARB244", "design_score": "0", - "class_time": [ - 14, 15, 16, 17, 312, 313 - ], + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], "class_place": null, "memo": null, "grades": "3", - "class_title": "이산수학", + "class_title": "건축구조의 이해 및 실습", "lecture_class": "01", - "target": "컴부전체", - "professor": "서정빈", - "department": "컴퓨터공학부" - }, - { - "id": %d, - "regular_number": "32", - "code": "CSE130", - "design_score": "0", - "class_time": [ - 14, 15, 16, 17, 310, 311, 312, 313 - ], - "class_place": null, - "memo": null, - "grades": "3", - "class_title": "알고리즘및실습", - "lecture_class": "03", - "target": "컴부전체", - "professor": "박다희", - "department": "컴퓨터공학부" + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" }, { - "id": %d, - "regular_number": "28", - "code": "CS101", + "id": 2, + "regular_number": "22", + "code": "BSM590", "design_score": "0", - "class_time": [ - 14, 15, 16, 17, 204, 205, 206, 207 - ], + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], "class_place": null, "memo": null, "grades": "3", - "class_title": "컴퓨터 구조", - "lecture_class": "02", - "target": "컴부전체", - "professor": "김성재", - "department": "컴퓨터공학부" + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" } ], - "grades": 9, - "total_grades": 9 + "grades": 6, + "total_grades": 6 } - """, 이산수학.getId(), 알고리즘및실습.getId(), 컴퓨터구조.getId() + """ )); } @@ -298,8 +283,11 @@ void getStudentCheckSemester() { String token = userFixture.getToken(user); Semester semester1 = semesterFixture.semester("20192"); Semester semester2 = semesterFixture.semester("20201"); - TimeTable 이산수학 = timeTableFixture.이산수학(user, semester1); - TimeTable 알고리즘및실습 = timeTableFixture.알고리즘및실습(user, semester2); + Lecture HRD_개론 = lectureFixture.HRD_개론(semester1.getSemester()); + Lecture 건축구조의_이해_및_실습 = lectureFixture.건축구조의_이해_및_실습(semester2.getSemester()); + timetableV2Fixture.시간표6(user, semester1, HRD_개론, null); + timetableV2Fixture.시간표6(user, semester2, 건축구조의_이해_및_실습, null); + var response = RestAssured .given() @@ -323,27 +311,6 @@ void getStudentCheckSemester() { ); } - @Test - @DisplayName("조회된 시간표가 없으면 404에러를 반환한다.") - void getTimeTablesNotFound() { - User user = userFixture.준호_학생().getUser(); - String token = userFixture.getToken(user); - Semester semester = semesterFixture.semester("20192"); - timeTableFixture.이산수학(user, semester); - timeTableFixture.알고리즘및실습(user, semester); - timeTableFixture.컴퓨터구조(user, semester); - - RestAssured - .given() - .header("Authorization", "Bearer " + token) - .when() - .param("semester", "20231") - .get("/timetables") - .then() - .statusCode(HttpStatus.NOT_FOUND.value()) - .extract(); - } - @Test @DisplayName("시간표를 생성한다.") void createTimeTables() { @@ -351,288 +318,120 @@ void createTimeTables() { String token = userFixture.getToken(user); Semester semester = semesterFixture.semester("20192"); - var response = RestAssured - .given() - .header("Authorization", "Bearer " + token) - .contentType(ContentType.JSON) - .body(String.format(""" - { - "timetable": [ - { - "code": "CPC490", - "class_title": "운영체제", - "class_time": [ - 210, - 211 - ], - "class_place": null, - "professor": "이돈우", - "grades": null, - "lecture_class": "01", - "target": "디자 1 건축", - "regular_number": "25", - "design_score": "0", - "department": "디자인ㆍ건축공학부", - "memo": null - }, - { - "code": "CSE201", - "class_title": "컴퓨터구조", - "class_time": [ - ], - "class_place": null, - "professor": "이강환", - "grades": "1", - "lecture_class": "02", - "target": "컴퓨 3", - "regular_number": "38", - "design_score": "0", - "department": "컴퓨터공학부", - "memo": null - } - ], - "semester": "%s" - } - """, semester.getSemester() - )) - .when() - .post("/timetables") - .then() - .statusCode(HttpStatus.OK.value()) - .extract(); - - JsonAssertions.assertThat(response.asPrettyString()) - .isEqualTo(""" - { - "semester": "20192", - "timetable": [ - { - "id": 1, - "regular_number": "25", - "code": "CPC490", - "design_score": "0", - "class_time": [ - 210, 211 - ], - "class_place": null, - "memo": null, - "grades": "0", - "class_title": "운영체제", - "lecture_class": "01", - "target": "디자 1 건축", - "professor": "이돈우", - "department": "디자인ㆍ건축공학부" - }, - { - "id": 2, - "regular_number": "38", - "code": "CSE201", - "design_score": "0", - "class_time": [ - - ], - "class_place": null, - "memo": null, - "grades": "1", - "class_title": "컴퓨터구조", - "lecture_class": "02", - "target": "컴퓨 3", - "professor": "이강환", - "department": "컴퓨터공학부" - } - ], - "grades": 1, - "total_grades": 1 - } - """); - } + lectureFixture.건축구조의_이해_및_실습(semester.getSemester()); + lectureFixture.HRD_개론(semester.getSemester()); - @Test - @DisplayName("시간표 생성시 필수 필드를 안넣을때 에러코드 400을 반환한다.") - void createTimeTablesBadRequest() { - User user = userFixture.준호_학생().getUser(); - String token = userFixture.getToken(user); - Semester semester = semesterFixture.semester("20192"); + timetableV2Fixture.시간표1(user, semester); var response = RestAssured .given() .header("Authorization", "Bearer " + token) .contentType(ContentType.JSON) - .body(String.format(""" - { - "timetable": [ - { - "code": "CPC490", - "class_title": null, - "class_time": [ - 210, - 211 - ], - "class_place": null, - "professor": null, - "grades": null, - "lecture_class": "01", - "target": "디자 1 건축", - "regular_number": "25", - "design_score": "0", - "department": "디자인ㆍ건축공학부", - "memo": null - } - ], - "semester": "%s" - } - """, semester.getSemester() - )) + .body(""" + { + "timetable": [ + { + "regular_number": "25", + "code": "ARB244", + "design_score": "0", + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "건축구조의 이해 및 실습", + "lecture_class": "01", + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" + }, + { + "regular_number": "22", + "code": "BSM590", + "design_score": "0", + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" + } + ], + "semester": "20192" + } + """) .when() .post("/timetables") .then() - .statusCode(HttpStatus.BAD_REQUEST.value()) - .extract(); - } - - @Test - @DisplayName("시간표 수정한다.") - void updateTimeTables() { - User user = userFixture.준호_학생().getUser(); - String token = userFixture.getToken(user); - Semester semester = semesterFixture.semester("20192"); - TimeTable timeTable1 = timeTableFixture.이산수학(user, semester); - TimeTable timeTable2 = timeTableFixture.알고리즘및실습(user, semester); - - var response = RestAssured - .given() - .header("Authorization", "Bearer " + token) - .contentType(ContentType.JSON) - .body(String.format(""" - { - "timetable": [ - { - "id": %d, - "code": "CPC999", - "class_title": "안녕체제", - "class_time": [ - 210, 211 - ], - "class_place": null, - "professor": "차은우", - "grades": "1", - "lecture_class": "01", - "target": "전체", - "regular_number": "25", - "design_score": "0", - "department": "교양학부", - "memo": null - }, - { - "id": %d, - "code": "CSE777", - "class_title": "구조화된컴퓨터", - "class_time": [ - ], - "class_place": null, - "professor": "장원영", - "grades": "1", - "lecture_class": "02", - "target": "컴퓨 3", - "regular_number": "38", - "design_score": "0", - "department": "컴퓨터공학부", - "memo": null - } - ], - "semester": "%s" - } - """, timeTable1.getId(), timeTable2.getId(), semester.getSemester() - )) - .when() - .put("/timetables") - .then() .statusCode(HttpStatus.OK.value()) - .extract(); + .extract() + .response(); JsonAssertions.assertThat(response.asPrettyString()) .isEqualTo(""" + { + "semester": "20192", + "timetable": [ { - "semester": "20192", - "timetable": [ - { - "id": 1, - "regular_number": "25", - "code": "CPC999", - "design_score": "0", - "class_time": [ - 210, 211 - ], - "class_place": null, - "memo": null, - "grades": "1", - "class_title": "안녕체제", - "lecture_class": "01", - "target": "전체", - "professor": "차은우", - "department": "교양학부" - }, - { - "id": 2, - "regular_number": "38", - "code": "CSE777", - "design_score": "0", - "class_time": [ - - ], - "class_place": null, - "memo": null, - "grades": "1", - "class_title": "구조화된컴퓨터", - "lecture_class": "02", - "target": "컴퓨 3", - "professor": "장원영", - "department": "컴퓨터공학부" - } - ], - "grades": 2, - "total_grades": 2 + "id": 1, + "regular_number": "25", + "code": "ARB244", + "design_score": "0", + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "건축구조의 이해 및 실습", + "lecture_class": "01", + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" + }, + { + "id": 2, + "regular_number": "22", + "code": "BSM590", + "design_score": "0", + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" } - """); + ], + "grades": 6, + "total_grades": 6 + } + """); } @Test @DisplayName("시간표를 삭제한다.") - void deleteTimeTable() { + void deleteTimetable() { User user = userFixture.준호_학생().getUser(); String token = userFixture.getToken(user); Semester semester = semesterFixture.semester("20192"); - TimeTable timeTable = timeTableFixture.이산수학(user, semester); - RestAssured - .given() - .header("Authorization", "Bearer " + token) - .when() - .param("id", timeTable.getId()) - .delete("/timetable") - .then() - .statusCode(HttpStatus.OK.value()); + Lecture 건축구조의_이해_및_실습 = lectureFixture.건축구조의_이해_및_실습(semester.getSemester()); + Lecture HRD_개론 = lectureFixture.HRD_개론(semester.getSemester()); - assertThat(timeTableRepository.findById(timeTable.getId())).isNotPresent(); - } - - @Test - @DisplayName("시간표 삭제 실패시(=조회 실패시) 404 에러코드를 반환한다.") - void deleteTimeTableNotFound() { - User user = userFixture.준호_학생().getUser(); - String token = userFixture.getToken(user); - Semester semester = semesterFixture.semester("20192"); - timeTableFixture.이산수학(user, semester); - timeTableFixture.알고리즘및실습(user, semester); - timeTableFixture.컴퓨터구조(user, semester); + timetableV2Fixture.시간표6(user, semester, 건축구조의_이해_및_실습, HRD_개론); RestAssured .given() .header("Authorization", "Bearer " + token) .when() - .param("id", 999) + .param("id", 2) .delete("/timetable") .then() - .statusCode(HttpStatus.NOT_FOUND.value()); + .statusCode(HttpStatus.OK.value()); + + assertThat(timetableRepository.findById(2)).isNotPresent(); } } diff --git a/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java b/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java new file mode 100644 index 000000000..f565a61d2 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/TimetableV2ApiTest.java @@ -0,0 +1,449 @@ +package in.koreatech.koin.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.fixture.LectureFixture; +import in.koreatech.koin.fixture.SemesterFixture; +import in.koreatech.koin.fixture.TimeTableV2Fixture; +import in.koreatech.koin.fixture.UserFixture; +import in.koreatech.koin.support.JsonAssertions; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@SuppressWarnings("NonAsciiCharacters") +public class TimetableV2ApiTest extends AcceptanceTest { + + @Autowired + private TimeTableV2Fixture timetableV2Fixture; + + @Autowired + private UserFixture userFixture; + + @Autowired + private SemesterFixture semesterFixture; + + @Autowired + private LectureFixture lectureFixture; + + @Autowired + private TimetableFrameRepositoryV2 timetableFrameRepositoryV2; + + @Autowired + private TimetableLectureRepositoryV2 timetableLectureRepositoryV2; + + @Test + @DisplayName("특정 시간표 frame을 생성한다") + void createTimeTablesFrame() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "semester": "%s" + } + """, semester.getSemester() + )) + .when() + .post("/v2/timetables/frame") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "timetable_name": "시간표1", + "is_main": true + } + """); + } + + @Test + @DisplayName("특정 시간표 frame을 수정한다") + void updateTimetableFrame() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + TimetableFrame frame = timetableV2Fixture.시간표1(user, semester); + Integer frameId = frame.getId(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType(ContentType.JSON) + .body(String.format(""" + { + "name": "새로운 이름", + "is_main": true + } + """ + )) + .when() + .put("/v2/timetables/frame/{id}", frameId) + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "id": 1, + "name": "새로운 이름", + "is_main": true + } + """); + } + + @Test + @DisplayName("모든 시간표 frame을 조회한다") + void getAllTimeTablesFrame() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + + timetableV2Fixture.시간표1(user, semester); + timetableV2Fixture.시간표2(user, semester); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("semester", semester.getSemester()) + .get("/v2/timetables/frames") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "id": 1, + "timetable_name": "시간표1", + "is_main": true + }, + { + "id": 2, + "timetable_name": "시간표2", + "is_main": false + } + ] + """); + } + + @Test + @DisplayName("강의를 담고 있는 특정 시간표 frame을 삭제한다") + void deleteTimeTablesFrame() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + Lecture lecture = lectureFixture.HRD_개론(semester.getSemester()); + + TimetableFrame frame1 = timetableV2Fixture.시간표5(user, semester, lecture); + + RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("id", frame1.getId()) + .delete("/v2/timetables/frame") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + + assertThat(timetableFrameRepositoryV2.findById(frame1.getId())).isNotPresent(); + assertThat(timetableLectureRepositoryV2.findById(frame1.getTimetableLectures().get(1).getId())).isNotPresent(); + } + + @Test + @DisplayName("특정 시간표 frame을 삭제한다 - 본인 삭제가 아니면 403 반환") + void deleteTimeTablesFrameNoAuth() { + User user1 = userFixture.준호_학생().getUser(); + User user2 = userFixture.성빈_학생().getUser(); + String token = userFixture.getToken(user2); + Semester semester = semesterFixture.semester("20192"); + + TimetableFrame frame1 = timetableV2Fixture.시간표1(user1, semester); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .param("id", frame1.getId()) + .delete("/v2/timetables/frame") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("시간표를 생성한다 - TimetableLecture") + void createTimetableLecture() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + timetableV2Fixture.시간표1(user, semester); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "timetable_frame_id" : 1, + "timetable_lecture": [ + { + "class_title": "커스텀생성1", + "class_time" : [200, 201], + "class_place" : "한기대", + "professor" : "서정빈", + "grades": "2", + "memo" : "메모" + }, + { + "class_title": "커스텀생성2", + "class_time" : [202, 203], + "class_place" : "참빛관 편의점", + "professor" : "감사 서정빈", + "grades": "1", + "memo" : "메모" + } + ] + } + """) + .when() + .post("/v2/timetables/lecture") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "timetable_frame_id": 1, + "timetable": [ + { + "id": 1, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [200, 201], + "class_place": "한기대", + "memo": "메모", + "grades": "2", + "class_title": "커스텀생성1", + "lecture_class": null, + "target": null, + "professor": "서정빈", + "department": null + }, + { + "id": 2, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [202, 203], + "class_place": "참빛관 편의점", + "memo": "메모", + "grades": "1", + "class_title": "커스텀생성2", + "lecture_class": null, + "target": null, + "professor": "감사 서정빈", + "department": null + } + ], + "grades": 3, + "total_grades": 3 + } + """); + } + + @Test + @DisplayName("시간표를 수정한다 - TimetableLecture") + void updateTimetableLecture() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + TimetableFrame frame = timetableV2Fixture.시간표3(user, semester); + Integer frameId = frame.getId(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .body(""" + { + "timetable_frame_id" : 1, + "timetable_lecture": [ + { + "id": 1, + "class_title": "커스텀바꿔요1", + "class_time" : [200, 201], + "class_place" : "한기대", + "professor" : "서정빈", + "grades" : "0", + "memo" : "메모한당 히히" + }, + { + "id": 2, + "class_title": "커스텀바꿔요2", + "class_time" : [202, 203], + "class_place" : "참빛관 편의점", + "professor" : "알바 서정빈", + "grades" : "0", + "memo" : "메모한당 히히" + } + ] + } + """) + .when() + .put("/v2/timetables/lecture") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "timetable_frame_id": 1, + "timetable": [ + { + "id": 1, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [200, 201], + "class_place": "한기대", + "memo": "메모한당 히히", + "grades": "0", + "class_title": "커스텀바꿔요1", + "lecture_class": null, + "target": null, + "professor": "서정빈", + "department": null + }, + { + "id": 2, + "regular_number": null, + "code": null, + "design_score": null, + "class_time": [202, 203], + "class_place": "참빛관 편의점", + "memo": "메모한당 히히", + "grades": "0", + "class_title": "커스텀바꿔요2", + "lecture_class": null, + "target": null, + "professor": "알바 서정빈", + "department": null + } + ], + "grades": 0, + "total_grades": 0 + } + """); + } + + @Test + @DisplayName("시간표를 조회한다 - TimetableLecture") + void getTimetableLecture() { + User user = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user); + Semester semester = semesterFixture.semester("20192"); + + Lecture 건축구조의_이해_및_실습 = lectureFixture.건축구조의_이해_및_실습(semester.getSemester()); + Lecture HRD_개론 = lectureFixture.HRD_개론(semester.getSemester()); + + TimetableFrame frame = timetableV2Fixture.시간표6(user, semester, 건축구조의_이해_및_실습, HRD_개론); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .contentType("application/json") + .when() + .param("timetable_frame_id", frame.getId()) + .get("/v2/timetables/lecture") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "timetable_frame_id": 1, + "timetable": [ + { + "id" : 1, + "regular_number": "25", + "code": "ARB244", + "design_score": "0", + "class_time": [200, 201, 202, 203, 204, 205, 206, 207], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "건축구조의 이해 및 실습", + "lecture_class": "01", + "target": "디자 1 건축", + "professor": "황현식", + "department": "디자인ㆍ건축공학부" + }, + { + "id": 2, + "regular_number": "22", + "code": "BSM590", + "design_score": "0", + "class_time": [12, 13, 14, 15, 210, 211, 212, 213], + "class_place": null, + "memo": null, + "grades": "3", + "class_title": "컴퓨팅사고", + "lecture_class": "06", + "target": "기공1", + "professor": "박한수,최준호", + "department": "기계공학부" + } + ], + "grades": 6, + "total_grades": 6 + } + """); + } + + @Test + @DisplayName("시간표에서 특정 강의를 삭제한다") + void deleteTimetableLecture() { + User user1 = userFixture.준호_학생().getUser(); + String token = userFixture.getToken(user1); + Semester semester = semesterFixture.semester("20192"); + Lecture lecture1 = lectureFixture.HRD_개론("20192"); + Lecture lecture2 = lectureFixture.영어청해("20192"); + TimetableFrame frame = timetableV2Fixture.시간표4(user1, semester, lecture1, lecture2); + + Integer lectureId = lecture1.getId(); + + var response = RestAssured + .given() + .header("Authorization", "Bearer " + token) + .when() + .delete("/v2/timetables/lecture/{id}", lectureId) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/TimeTableV2Fixture.java b/src/test/java/in/koreatech/koin/fixture/TimeTableV2Fixture.java new file mode 100644 index 000000000..a18efc808 --- /dev/null +++ b/src/test/java/in/koreatech/koin/fixture/TimeTableV2Fixture.java @@ -0,0 +1,174 @@ +package in.koreatech.koin.fixture; + +import java.util.ArrayList; + +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV2.model.TimetableFrame; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.timetableV2.repository.TimetableLectureRepositoryV2; +import in.koreatech.koin.domain.user.model.User; + +@Component +@SuppressWarnings("NonAsciiCharacters") +public class TimeTableV2Fixture { + + private final TimetableFrameRepositoryV2 timetableFrameRepositoryV2; + + private final TimetableLectureRepositoryV2 timetableLectureRepositoryV2; + + public TimeTableV2Fixture( + TimetableFrameRepositoryV2 timetableFrameRepositoryV2, + TimetableLectureRepositoryV2 timetableLectureRepositoryV2 + ) { + this.timetableFrameRepositoryV2 = timetableFrameRepositoryV2; + this.timetableLectureRepositoryV2 = timetableLectureRepositoryV2; + } + + public TimetableFrame 시간표1(User user, Semester semester) { + return timetableFrameRepositoryV2.save( + TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표1") + .isMain(true) + .build() + ); + } + + public TimetableFrame 시간표2(User user, Semester semester) { + return timetableFrameRepositoryV2.save( + TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표2") + .isMain(false) + .build() + ); + } + + public TimetableFrame 시간표3(User user, Semester semester) { + TimetableFrame frame = TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표3") + .isMain(false) + .timetableLectures(new ArrayList<>()) + .build(); + + TimetableLecture timetableLecture1 = TimetableLecture.builder() + .grades("0") + .classTitle("커스텀1") + .classTime("[932]") + .isDeleted(false) + .timetableFrame(frame) + .build(); + + TimetableLecture timetableLecture2 = TimetableLecture.builder() + .grades("0") + .classTitle("커스텀2") + .classTime("[933]") + .isDeleted(false) + .timetableFrame(frame) + .build(); + + frame.getTimetableLectures().add(timetableLecture1); + frame.getTimetableLectures().add(timetableLecture2); + + return timetableFrameRepositoryV2.save(frame); + } + + public TimetableFrame 시간표4(User user, Semester semester, Lecture lecture1, Lecture lecture2) { + TimetableFrame frame = TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표4") + .isMain(false) + .timetableLectures(new ArrayList<>()) + .build(); + + TimetableLecture timetableLecture1 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture1) + .timetableFrame(frame) + .build(); + + TimetableLecture timetableLecture2 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture2) + .timetableFrame(frame) + .build(); + + frame.getTimetableLectures().add(timetableLecture1); + frame.getTimetableLectures().add(timetableLecture2); + + return timetableFrameRepositoryV2.save(frame); + } + + public TimetableFrame 시간표5(User user, Semester semester, Lecture lecture1) { + TimetableFrame frame = TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표4") + .isMain(false) + .timetableLectures(new ArrayList<>()) + .build(); + + TimetableLecture timetableLecture1 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture1) + .timetableFrame(frame) + .build(); + + TimetableLecture timetableLecture2 = TimetableLecture.builder() + .classTitle("커스텀1") + .classTime("[933]") + .classPlace("2공") + .professor("김성재") + .grades("0") + .memo("공부 하기 싫다") + .isDeleted(false) + .timetableFrame(frame) + .build(); + + frame.getTimetableLectures().add(timetableLecture1); + frame.getTimetableLectures().add(timetableLecture2); + + return timetableFrameRepositoryV2.save(frame); + } + + public TimetableFrame 시간표6(User user, Semester semester, Lecture lecture1, Lecture lecture2) { + TimetableFrame frame = TimetableFrame.builder() + .user(user) + .semester(semester) + .name("시간표6") + .isMain(true) + .timetableLectures(new ArrayList<>()) + .build(); + + TimetableLecture timetableLecture1 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture1) + .timetableFrame(frame) + .build(); + + TimetableLecture timetableLecture2 = TimetableLecture.builder() + .grades("0") + .isDeleted(false) + .lecture(lecture2) + .timetableFrame(frame) + .build(); + + frame.getTimetableLectures().add(timetableLecture1); + frame.getTimetableLectures().add(timetableLecture2); + + return timetableFrameRepositoryV2.save(frame); + } +} diff --git a/src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java b/src/test/java/in/koreatech/koin/fixture/TimetableFixture.java similarity index 81% rename from src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java rename to src/test/java/in/koreatech/koin/fixture/TimetableFixture.java index b2c94ebf3..ac8fd71d3 100644 --- a/src/test/java/in/koreatech/koin/fixture/TimeTableFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/TimetableFixture.java @@ -3,23 +3,23 @@ import org.springframework.stereotype.Component; import in.koreatech.koin.domain.timetable.model.Semester; -import in.koreatech.koin.domain.timetable.model.TimeTable; -import in.koreatech.koin.domain.timetable.repository.TimeTableRepository; +import in.koreatech.koin.domain.timetable.model.Timetable; +import in.koreatech.koin.domain.timetable.repository.TimetableRepository; import in.koreatech.koin.domain.user.model.User; @Component @SuppressWarnings("NonAsciiCharacters") -public class TimeTableFixture { +public class TimetableFixture { - private final TimeTableRepository timeTableRepository; + private final TimetableRepository timeTableRepository; - public TimeTableFixture(TimeTableRepository timeTableRepository) { + public TimetableFixture(TimetableRepository timeTableRepository) { this.timeTableRepository = timeTableRepository; } - public TimeTable 컴퓨터구조(User user, Semester semester) { + public Timetable 컴퓨터구조(User user, Semester semester) { return timeTableRepository.save( - TimeTable.builder() + Timetable.builder() .user(user) .semester(semester) .code("CS101") @@ -39,9 +39,9 @@ public TimeTableFixture(TimeTableRepository timeTableRepository) { ); } - public TimeTable 운영체제(User user, Semester semester) { + public Timetable 운영체제(User user, Semester semester) { return timeTableRepository.save( - TimeTable.builder() + Timetable.builder() .user(user) .semester(semester) .code("CS102") @@ -61,9 +61,9 @@ public TimeTableFixture(TimeTableRepository timeTableRepository) { ); } - public TimeTable 이산수학(User user, Semester semester) { + public Timetable 이산수학(User user, Semester semester) { return timeTableRepository.save( - TimeTable.builder() + Timetable.builder() .user(user) .semester(semester) .code("CSE125") @@ -83,9 +83,9 @@ public TimeTableFixture(TimeTableRepository timeTableRepository) { ); } - public TimeTable 알고리즘및실습(User user, Semester semester) { + public Timetable 알고리즘및실습(User user, Semester semester) { return timeTableRepository.save( - TimeTable.builder() + Timetable.builder() .user(user) .semester(semester) .code("CSE130") From 307b6dbaed538d140869df6b1b8ab04fff804917 Mon Sep 17 00:00:00 2001 From: duehee <149302959+duehee@users.noreply.github.com> Date: Sat, 29 Jun 2024 13:13:35 +0900 Subject: [PATCH 27/37] =?UTF-8?q?fix=20:=20flyway=20=EB=B2=84=EC=A0=80?= =?UTF-8?q?=EB=8B=9D=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20(#646)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...{V19__add_timetable_frame.sql => V21__add_timetable_frame.sql} | 0 ...insert_timetable_frame.sql => V22__insert_timetable_frame.sql} | 0 ...__add_timetable_lecture.sql => V23__add_timetable_lecture.sql} | 0 ...rt_timetable_lecture.sql => V24__insert_timetable_lecture.sql} | 0 ...ctures_id.sql => V25__alter_timetable_lecture_lectures_id.sql} | 0 ...table_id.sql => V26__alter_timetable_lecture_timetable_id.sql} | 0 ... => V27__delete_timetable_lecture_user_id_and_semester_id.sql} | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V19__add_timetable_frame.sql => V21__add_timetable_frame.sql} (100%) rename src/main/resources/db/migration/{V20__insert_timetable_frame.sql => V22__insert_timetable_frame.sql} (100%) rename src/main/resources/db/migration/{V21__add_timetable_lecture.sql => V23__add_timetable_lecture.sql} (100%) rename src/main/resources/db/migration/{V22__insert_timetable_lecture.sql => V24__insert_timetable_lecture.sql} (100%) rename src/main/resources/db/migration/{V23__alter_timetable_lecture_lectures_id.sql => V25__alter_timetable_lecture_lectures_id.sql} (100%) rename src/main/resources/db/migration/{V24__alter_timetable_lecture_timetable_id.sql => V26__alter_timetable_lecture_timetable_id.sql} (100%) rename src/main/resources/db/migration/{V25__delete_timetable_lecture_user_id_and_semester_id.sql => V27__delete_timetable_lecture_user_id_and_semester_id.sql} (100%) diff --git a/src/main/resources/db/migration/V19__add_timetable_frame.sql b/src/main/resources/db/migration/V21__add_timetable_frame.sql similarity index 100% rename from src/main/resources/db/migration/V19__add_timetable_frame.sql rename to src/main/resources/db/migration/V21__add_timetable_frame.sql diff --git a/src/main/resources/db/migration/V20__insert_timetable_frame.sql b/src/main/resources/db/migration/V22__insert_timetable_frame.sql similarity index 100% rename from src/main/resources/db/migration/V20__insert_timetable_frame.sql rename to src/main/resources/db/migration/V22__insert_timetable_frame.sql diff --git a/src/main/resources/db/migration/V21__add_timetable_lecture.sql b/src/main/resources/db/migration/V23__add_timetable_lecture.sql similarity index 100% rename from src/main/resources/db/migration/V21__add_timetable_lecture.sql rename to src/main/resources/db/migration/V23__add_timetable_lecture.sql diff --git a/src/main/resources/db/migration/V22__insert_timetable_lecture.sql b/src/main/resources/db/migration/V24__insert_timetable_lecture.sql similarity index 100% rename from src/main/resources/db/migration/V22__insert_timetable_lecture.sql rename to src/main/resources/db/migration/V24__insert_timetable_lecture.sql diff --git a/src/main/resources/db/migration/V23__alter_timetable_lecture_lectures_id.sql b/src/main/resources/db/migration/V25__alter_timetable_lecture_lectures_id.sql similarity index 100% rename from src/main/resources/db/migration/V23__alter_timetable_lecture_lectures_id.sql rename to src/main/resources/db/migration/V25__alter_timetable_lecture_lectures_id.sql diff --git a/src/main/resources/db/migration/V24__alter_timetable_lecture_timetable_id.sql b/src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql similarity index 100% rename from src/main/resources/db/migration/V24__alter_timetable_lecture_timetable_id.sql rename to src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql diff --git a/src/main/resources/db/migration/V25__delete_timetable_lecture_user_id_and_semester_id.sql b/src/main/resources/db/migration/V27__delete_timetable_lecture_user_id_and_semester_id.sql similarity index 100% rename from src/main/resources/db/migration/V25__delete_timetable_lecture_user_id_and_semester_id.sql rename to src/main/resources/db/migration/V27__delete_timetable_lecture_user_id_and_semester_id.sql From 240aa8fa53298e3939d81b21ad1386ca9f21e43e Mon Sep 17 00:00:00 2001 From: duehee <149302959+duehee@users.noreply.github.com> Date: Mon, 1 Jul 2024 00:59:56 +0900 Subject: [PATCH 28/37] =?UTF-8?q?fix=20:=20=EB=A1=A4=EB=B0=B1=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20flyway=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95(ti?= =?UTF-8?q?metable=5Fid=20->=20frame=5Fid)=20(#648)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V26__alter_timetable_lecture_timetable_id.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql b/src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql index ac0159a9c..ef28cceb2 100644 --- a/src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql +++ b/src/main/resources/db/migration/V26__alter_timetable_lecture_timetable_id.sql @@ -1,3 +1,3 @@ UPDATE timetable_lecture t JOIN timetable_frame f ON t.user_id = f.user_id AND t.semester_id = f.semester_id - SET t.timetable_id = f.id; + SET t.frame_id = f.id; From dee91854dd5532552d05b3c010895c553d810877 Mon Sep 17 00:00:00 2001 From: Jang-JunYoung <79901434+johnny19991006@users.noreply.github.com> Date: Mon, 1 Jul 2024 18:18:04 +0900 Subject: [PATCH 29/37] =?UTF-8?q?refactor:=20internalName=20=ED=95=84?= =?UTF-8?q?=EC=88=98=EC=9A=94=EA=B5=AC=20=EC=A0=9C=EA=B1=B0=20(#650)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jang Jun Young --- .../in/koreatech/koin/admin/land/dto/AdminLandRequest.java | 3 +-- .../in/koreatech/koin/admin/land/dto/AdminLandResponse.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java index 1bab535fc..a5523ec18 100644 --- a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandRequest.java @@ -22,8 +22,7 @@ public record AdminLandRequest( @Size(max = 255, message = "방이름의 최대 길이는 255자입니다.") String name, - @Schema(description = "이름 - not null - 최대 50자", example = "금실타운", requiredMode = REQUIRED) - @NotNull(message = "방이름은 필수입니다.") + @Schema(description = "이름 - not null - 최대 50자", example = "금실타운") @Size(max = 50, message = "방이름의 최대 길이는 50자입니다.") String internalName, diff --git a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java index 5958f27ad..db96acb70 100644 --- a/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java +++ b/src/main/java/in/koreatech/koin/admin/land/dto/AdminLandResponse.java @@ -17,7 +17,7 @@ public record AdminLandResponse( @Schema(description = "이름", example = "금실타운", requiredMode = Schema.RequiredMode.REQUIRED) String name, - @Schema(description = "내부 이름", example = "금실타운", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "내부 이름", example = "금실타운") String internalName, @Schema(description = "크기", example = "9.0") From 658bbdd97da923c4706427212042a5f22f014cfb Mon Sep 17 00:00:00 2001 From: Jang-JunYoung <79901434+johnny19991006@users.noreply.github.com> Date: Tue, 2 Jul 2024 21:13:45 +0900 Subject: [PATCH 30/37] =?UTF-8?q?Refactor:=20=EC=82=AC=EC=9E=A5=EB=8B=98/?= =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20=EC=83=81=EC=A0=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=8B=9C=20=EC=9D=80=ED=96=89,=EA=B3=84=EC=A2=8C?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=B6=94=EA=B0=80=20(#642)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 어드민 dto변경 * refactor: 어드민 서비스 변경 * refactor: 오너 dto 변경 * refactor: 오너 서비스 변경 * refactor: 상점 entity 변경 * refactor: 상점 컬럼 추가 * feat: 어드민 response추가 * feat: 학생 상점 response추가 * refactor: 테스트 코드 수정 * refactor: 비밀번호 해싱 복원 * refactor: flyway 버전 번호 변경 * refactor: 엔티티 수정 * refactor: 테스트 수정 * refactor: 리뷰반영 --------- Co-authored-by: Jang Jun Young --- .../shop/dto/AdminModifyShopRequest.java | 10 +++++++++- .../admin/shop/dto/AdminShopResponse.java | 12 +++++++++-- .../admin/shop/service/AdminShopService.java | 9 ++++++--- .../ownershop/service/OwnerShopService.java | 4 +++- .../domain/shop/dto/ModifyShopRequest.java | 10 +++++++++- .../koin/domain/shop/dto/ShopResponse.java | 12 +++++++++-- .../koin/domain/shop/model/Shop.java | 20 +++++++++++++++++-- .../V28__add_shop_bank_account_number.sql | 3 +++ .../koin/acceptance/OwnerShopApiTest.java | 4 +++- .../koin/acceptance/ShopApiTest.java | 4 +++- .../admin/acceptance/AdminShopApiTest.java | 4 +++- .../koreatech/koin/fixture/ShopFixture.java | 2 ++ 12 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/db/migration/V28__add_shop_bank_account_number.sql diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java index 69a7ce730..831e37944 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminModifyShopRequest.java @@ -75,7 +75,15 @@ public record AdminModifyShopRequest( @Schema(description = "전화번호", example = "041-000-0000", requiredMode = NOT_REQUIRED) @NotBlank(message = "전화번호는 필수입니다.") @Pattern(regexp = "^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$", message = "전화번호 형식이 유효하지 않습니다.") - String phone + String phone, + + @Schema(example = "국민은행", description = "은행", requiredMode = NOT_REQUIRED) + @Size(max = 10, message = "은행명은 10자 이내로 입력해주세요") + String bank, + + @Schema(example = "110-439-1234567", description = "계좌번호", requiredMode = NOT_REQUIRED) + @Size(max = 20, message = "계좌번호는 20자 이내로 입력해주세요") + String accountNumber ) { public AdminModifyShopRequest { if (imageUrls == null) { diff --git a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java index b292e9231..41268782f 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java +++ b/src/main/java/in/koreatech/koin/admin/shop/dto/AdminShopResponse.java @@ -67,7 +67,13 @@ public record AdminShopResponse( Boolean isDeleted, @Schema(description = "상점 이벤트 진행 여부", example = "true", requiredMode = REQUIRED) - Boolean isEvent + Boolean isEvent, + + @Schema(description = "은행", example = "국민은행", requiredMode = NOT_REQUIRED) + String bank, + + @Schema(description = "계좌번호", example = "110-439-1234567", requiredMode = NOT_REQUIRED) + String accountNumber ) { public static AdminShopResponse from(Shop shop, Boolean isEvent) { @@ -108,7 +114,9 @@ public static AdminShopResponse from(Shop shop, Boolean isEvent) { }).toList(), shop.getUpdatedAt(), shop.isDeleted(), - isEvent + isEvent, + shop.getBank(), + shop.getAccountNumber() ); } diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index 4cb4189ac..c6c55c487 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -229,7 +229,7 @@ public void createMenuCategory(Integer shopId, AdminCreateMenuCategoryRequest ad @Transactional public void cancelShopDelete(Integer shopId) { Optional shop = adminShopRepository.findDeletedShopById(shopId); - if(shop.isPresent()) { + if (shop.isPresent()) { shop.get().cancelDelete(); } } @@ -245,7 +245,9 @@ public void modifyShop(Integer shopId, AdminModifyShopRequest adminModifyShopReq adminModifyShopRequest.delivery(), adminModifyShopRequest.deliveryPrice(), adminModifyShopRequest.payCard(), - adminModifyShopRequest.payBank() + adminModifyShopRequest.payBank(), + adminModifyShopRequest.bank(), + adminModifyShopRequest.accountNumber() ); shop.modifyShopCategories( adminShopCategoryRepository.findAllByIdIn(adminModifyShopRequest.categoryIds()), @@ -283,7 +285,8 @@ public void modifyMenu(Integer shopId, Integer menuId, AdminModifyMenuRequest ad adminModifyMenuRequest.description() ); menu.modifyMenuImages(adminModifyMenuRequest.imageUrls(), entityManager); - menu.modifyMenuCategories(adminMenuCategoryRepository.findAllByIdIn(adminModifyMenuRequest.categoryIds()), entityManager); + menu.modifyMenuCategories(adminMenuCategoryRepository.findAllByIdIn(adminModifyMenuRequest.categoryIds()), + entityManager); if (adminModifyMenuRequest.isSingle()) { menu.adminModifyMenuSingleOptions(adminModifyMenuRequest, entityManager); } else { diff --git a/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java b/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java index c6c92991f..a96423a1a 100644 --- a/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java +++ b/src/main/java/in/koreatech/koin/domain/ownershop/service/OwnerShopService.java @@ -261,7 +261,9 @@ public void modifyShop(Integer ownerId, Integer shopId, ModifyShopRequest modify modifyShopRequest.delivery(), modifyShopRequest.deliveryPrice(), modifyShopRequest.payCard(), - modifyShopRequest.payBank() + modifyShopRequest.payBank(), + modifyShopRequest.bank(), + modifyShopRequest.accountNumber() ); shop.modifyShopImages(modifyShopRequest.imageUrls(), entityManager); shop.modifyShopOpens(modifyShopRequest.open(), entityManager); diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java index 78933ba2d..c0c94dc87 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ModifyShopRequest.java @@ -70,7 +70,15 @@ public record ModifyShopRequest( @Schema(example = "041-000-0000", description = "전화번호", requiredMode = NOT_REQUIRED) @Size(max = 50, message = "전화번호는 50자 이하로 입력해주세요.") - String phone + String phone, + + @Schema(example = "국민은행", description = "은행", requiredMode = NOT_REQUIRED) + @Size(max = 10, message = "은행명은 10자 이내로 입력해주세요") + String bank, + + @Schema(example = "110-439-1234567", description = "계좌번호", requiredMode = NOT_REQUIRED) + @Size(max = 20, message = "계좌번호는 20자 이내로 입력해주세요") + String accountNumber ) { @JsonNaming(value = SnakeCaseStrategy.class) diff --git a/src/main/java/in/koreatech/koin/domain/shop/dto/ShopResponse.java b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopResponse.java index 2df144aac..3f0475648 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/dto/ShopResponse.java +++ b/src/main/java/in/koreatech/koin/domain/shop/dto/ShopResponse.java @@ -64,7 +64,13 @@ public record ShopResponse( LocalDateTime updatedAt, @Schema(example = "true", description = "상점 이벤트 진행 여부", requiredMode = REQUIRED) - Boolean isEvent + Boolean isEvent, + + @Schema(example = "국민은행", description = "은행", requiredMode = NOT_REQUIRED) + String bank, + + @Schema(example = "110-439-1234567", description = "계좌번호", requiredMode = NOT_REQUIRED) + String accountNumber ) { public static ShopResponse from(Shop shop, Boolean isEvent) { @@ -104,7 +110,9 @@ public static ShopResponse from(Shop shop, Boolean isEvent) { ); }).toList(), shop.getUpdatedAt(), - isEvent + isEvent, + shop.getBank(), + shop.getAccountNumber() ); } diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java index 87549b328..61d17fc6c 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/Shop.java @@ -120,6 +120,14 @@ public class Shop extends BaseEntity { @OneToMany(mappedBy = "shop", orphanRemoval = true, cascade = {PERSIST, REFRESH, MERGE, REMOVE}) private List eventArticles = new ArrayList<>(); + @Size(max = 10) + @Column(name = "bank", length = 10) + private String bank; + + @Size(max = 20) + @Column(name = "accountNumber", length = 20) + private String accountNumber; + @Builder private Shop( Owner owner, @@ -136,7 +144,9 @@ private Shop( boolean isDeleted, boolean isEvent, String remarks, - Integer hit + Integer hit, + String bank, + String accountNumber ) { this.owner = owner; this.name = name; @@ -153,6 +163,8 @@ private Shop( this.isEvent = isEvent; this.remarks = remarks; this.hit = hit; + this.bank = bank; + this.accountNumber = accountNumber; } public void modifyShop( @@ -163,7 +175,9 @@ public void modifyShop( boolean delivery, Integer deliveryPrice, Boolean payCard, - boolean payBank + boolean payBank, + String bank, + String accountNumber ) { this.address = address; this.delivery = delivery; @@ -173,6 +187,8 @@ public void modifyShop( this.payBank = payBank; this.payCard = payCard; this.phone = phone; + this.bank = bank; + this.accountNumber = accountNumber; } public void modifyShopImages(List imageUrls, EntityManager entityManager) { diff --git a/src/main/resources/db/migration/V28__add_shop_bank_account_number.sql b/src/main/resources/db/migration/V28__add_shop_bank_account_number.sql new file mode 100644 index 000000000..bf369f4dc --- /dev/null +++ b/src/main/resources/db/migration/V28__add_shop_bank_account_number.sql @@ -0,0 +1,3 @@ +ALTER TABLE `koin`.`shops` + ADD COLUMN `bank` VARCHAR(10) NULL DEFAULT NULL AFTER `hit`, + ADD COLUMN `account_number` VARCHAR(20) NULL DEFAULT NULL AFTER `bank`; diff --git a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java index b0990df6c..607c6e364 100644 --- a/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/OwnerShopApiTest.java @@ -295,7 +295,9 @@ void getShop() { ], "updated_at": "2024-01-15", - "is_event": false + "is_event": false, + "bank": "국민", + "account_number": "01022595923" } """); } diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index c82492394..40294646c 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -268,7 +268,9 @@ void getShop() { ], "updated_at": "2024-01-15", - "is_event": false + "is_event": false, + "bank": "국민", + "account_number": "01022595923" } """ ); diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java index 75219c54d..81ecda526 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -196,7 +196,9 @@ void findShop() { ], "updated_at": "2024-01-15", "is_deleted": false, - "is_event": false + "is_event": false, + "bank": "국민", + "account_number": "01022595923" } """); } diff --git a/src/test/java/in/koreatech/koin/fixture/ShopFixture.java b/src/test/java/in/koreatech/koin/fixture/ShopFixture.java index 28b54d4bd..62ad113be 100644 --- a/src/test/java/in/koreatech/koin/fixture/ShopFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/ShopFixture.java @@ -42,6 +42,8 @@ public ShopFixture(ShopRepository shopRepository) { .isEvent(false) .remarks("비고") .hit(0) + .bank("국민") + .accountNumber("01022595923") .build() ); shop.getShopImages().addAll( From ed1a7651b70ff50d84131e93069c9a843f3f552e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9B=90=EA=B2=BD?= <148550522+kwoo28@users.noreply.github.com> Date: Wed, 3 Jul 2024 13:38:34 +0900 Subject: [PATCH 31/37] =?UTF-8?q?Fix:=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(#651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 이모지 검증 추가 * fix: 이모지 검증 추가 * fix: 이모지 검증 수정 * fix: 이모지 검증 수정 * fix: UserLoginRequest 수정 --- .../domain/user/dto/StudentLoginRequest.java | 1 + .../user/dto/StudentRegisterRequest.java | 3 ++ .../domain/user/dto/StudentUpdateRequest.java | 3 ++ .../domain/user/dto/UserLoginRequest.java | 3 ++ .../global/validation/EmojiValidator.java | 29 +++++++++++++++++++ .../koin/global/validation/NotEmoji.java | 23 +++++++++++++++ 6 files changed, 62 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/global/validation/EmojiValidator.java create mode 100644 src/main/java/in/koreatech/koin/global/validation/NotEmoji.java diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java index 0d08a3021..e305349ad 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java @@ -9,6 +9,7 @@ public record StudentLoginRequest( @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식을 지켜주세요. ${validatedValue}") String email, @Schema( diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java index 8c3693ec7..a274456d0 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentRegisterRequest.java @@ -18,6 +18,7 @@ import in.koreatech.koin.domain.user.model.UserGender; import in.koreatech.koin.domain.user.model.UserIdentity; import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.global.validation.NotEmoji; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -33,6 +34,7 @@ public record StudentRegisterRequest( @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) @Size(max = 50, message = "이름은 50자 이내여야 합니다.") + @NotEmoji String name, @Schema(description = " SHA 256 해시 알고리즘으로 암호화된 비밀번호", example = "cd06f8c2b0dd065faf6ef910c7f15934363df71c33740fd245590665286ed268", requiredMode = REQUIRED) @@ -41,6 +43,7 @@ public record StudentRegisterRequest( @Schema(description = "닉네임", example = "bbo", requiredMode = NOT_REQUIRED) @Size(max = 10, message = "닉네임은 최대 10자입니다.") + @NotEmoji String nickname, @Schema(description = "성별(남:0, 여:1)", example = "0", requiredMode = NOT_REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java index b02904cae..691215677 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentUpdateRequest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import in.koreatech.koin.global.validation.NotEmoji; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Size; @@ -37,6 +38,7 @@ public record StudentUpdateRequest @Size(max = 50, message = "이름의 길이는 최대 50자 입니다.") @Schema(description = "이름", example = "최준호", requiredMode = NOT_REQUIRED) + @NotEmoji String name, @Size(message = "SHA 256 해시 알고리즘으로 암호화 된 비밀번호") @@ -45,6 +47,7 @@ public record StudentUpdateRequest @Size(max = 10, message = "닉네임은 10자 이내여야 합니다.") @Schema(description = "닉네임", example = "juno", requiredMode = NOT_REQUIRED) + @NotEmoji String nickname, @Schema(description = "휴대폰 번호", example = "01000000000", requiredMode = NOT_REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginRequest.java index a4d060713..b492eeda9 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserLoginRequest.java @@ -2,12 +2,15 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +import in.koreatech.koin.global.validation.NotEmoji; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public record UserLoginRequest( @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) @NotBlank(message = "이메일을 입력해주세요.") + @NotEmoji String email, @Schema( diff --git a/src/main/java/in/koreatech/koin/global/validation/EmojiValidator.java b/src/main/java/in/koreatech/koin/global/validation/EmojiValidator.java new file mode 100644 index 000000000..066dd9179 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/EmojiValidator.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.global.validation; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +@Component +public class EmojiValidator implements ConstraintValidator { + + private static final Pattern EMOJI_PATTERN = Pattern.compile("[\\uD83C-\\uDBFF\\uDC00-\\uDFFF]+"); + + @Override + public void initialize(NotEmoji constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String field, ConstraintValidatorContext constraintValidatorContext) { + Matcher emojiMatcher = EMOJI_PATTERN.matcher(field); + if (emojiMatcher.find()) { + return false; + } + return true; + } +} diff --git a/src/main/java/in/koreatech/koin/global/validation/NotEmoji.java b/src/main/java/in/koreatech/koin/global/validation/NotEmoji.java new file mode 100644 index 000000000..6f8b6980a --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/validation/NotEmoji.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.global.validation; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = EmojiValidator.class) +@Target({FIELD, PARAMETER, LOCAL_VARIABLE}) +@Retention(RUNTIME) +public @interface NotEmoji { + String message() default "이모지가 허용되지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} From ef98dc88b1a5d1a8c6e89d4f736b44da969f2f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=9E=AC?= <103095432+seongjae6751@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:41:11 +0900 Subject: [PATCH 32/37] =?UTF-8?q?fix:=20=EC=A1=B0=ED=9A=8C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=B3=80=EA=B2=BD=20(#660)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timetable/repository/LectureRepository.java | 8 ++++---- .../koin/domain/timetable/service/TimetableService.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java b/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java index 02a163dec..64235b95f 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/repository/LectureRepository.java @@ -17,11 +17,11 @@ public interface LectureRepository extends Repository { Optional findById(Integer id); - Optional findBySemesterAndNameAndLectureClass(String semesterDate, String name, String classLecture); + Optional findBySemesterAndCodeAndLectureClass(String semesterDate, String code, String classLecture); - default Lecture getBySemesterAndNameAndLectureClass(String semesterDate, String name, String classLecture) { - return findBySemesterAndNameAndLectureClass(semesterDate, name, classLecture) - .orElseThrow(() -> SemesterNotFoundException.withDetail("semester: " + semesterDate + " name: " + name + " classLecture: " + classLecture)); + default Lecture getBySemesterAndCodeAndLectureClass(String semesterDate, String code, String classLecture) { + return findBySemesterAndCodeAndLectureClass(semesterDate, code, classLecture) + .orElseThrow(() -> SemesterNotFoundException.withDetail("semester: " + semesterDate + " code: " + code + " classLecture: " + classLecture)); } default Lecture getLectureById(Integer id) { diff --git a/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java b/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java index 41aacf4bb..639045d3b 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/service/TimetableService.java @@ -51,8 +51,8 @@ public TimetableResponse createTimetables(Integer userId, TimetableCreateRequest semester.getId()); for (TimetableCreateRequest.InnerTimetableRequest timeTable : request.timetable()) { - Lecture lecture = lectureRepository.getBySemesterAndNameAndLectureClass(request.semester(), - timeTable.classTitle(), timeTable.lectureClass()); + Lecture lecture = lectureRepository.getBySemesterAndCodeAndLectureClass(request.semester(), + timeTable.code(), timeTable.lectureClass()); TimetableLecture timetableLecture = TimetableLecture.builder() .classPlace(timeTable.classPlace()) .grades("0") From 1d6eff9bfc600d45a4887bba1e69e2fd9100805b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9B=90=EA=B2=BD?= <148550522+kwoo28@users.noreply.github.com> Date: Sat, 6 Jul 2024 02:35:10 +0900 Subject: [PATCH 33/37] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=88=98=EC=A0=95=20(#668)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 김원경 --- .../in/koreatech/koin/global/validation/EmojiValidator.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/in/koreatech/koin/global/validation/EmojiValidator.java b/src/main/java/in/koreatech/koin/global/validation/EmojiValidator.java index 066dd9179..316116137 100644 --- a/src/main/java/in/koreatech/koin/global/validation/EmojiValidator.java +++ b/src/main/java/in/koreatech/koin/global/validation/EmojiValidator.java @@ -20,6 +20,9 @@ public void initialize(NotEmoji constraintAnnotation) { @Override public boolean isValid(String field, ConstraintValidatorContext constraintValidatorContext) { + if (field == null) { + return true; + } Matcher emojiMatcher = EMOJI_PATTERN.matcher(field); if (emojiMatcher.find()) { return false; From aaa6dda23c70fd800357d715b4aed364e449b4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9B=90=EA=B2=BD?= <148550522+kwoo28@users.noreply.github.com> Date: Sat, 6 Jul 2024 11:56:23 +0900 Subject: [PATCH 34/37] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9E=A5=EB=8B=98=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=88=98=EC=A0=95=20(#654)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 이모지 검증 추가 * fix: 이모지 검증 추가 * fix: 이모지 검증 수정 * fix: 이모지 검증 수정 * fix: UserLoginRequest 수정 * fix: 사장님 페이지리스트 로직 수정 * fix: 신규사장님 페이지리스트 로직 수정 * fix: 테스트 수정 * refactor: 라인포맷팅 & 학생로그인 이모지검증 제거 * refactor: 불필요한 import 제거 * refactor: 테스트 수정 * refactor: 로직 수정 --------- Co-authored-by: 김원경 --- .../user/dto/AdminNewOwnersResponse.java | 4 +- .../admin/user/dto/AdminOwnersResponse.java | 18 +++---- .../user/repository/AdminOwnerRepository.java | 53 ++++++++++++------- .../user/repository/AdminUserRepository.java | 9 ++++ .../admin/user/service/AdminUserService.java | 46 ++++++++++++---- .../owner/model/OwnerIncludingShop.java | 4 +- .../domain/user/dto/StudentLoginRequest.java | 2 - .../admin/acceptance/AdminUserApiTest.java | 27 ++++------ 8 files changed, 102 insertions(+), 61 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminNewOwnersResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminNewOwnersResponse.java index faadcc481..818d069d4 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/AdminNewOwnersResponse.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminNewOwnersResponse.java @@ -73,13 +73,13 @@ public static InnerNewOwnerResponse from(OwnerIncludingShop ownerIncludingShop) } } - public static AdminNewOwnersResponse of(Page pagedResult, Criteria criteria) { + public static AdminNewOwnersResponse of(List ownerIncludingShops, Page pagedResult, Criteria criteria) { return new AdminNewOwnersResponse( pagedResult.getTotalElements(), pagedResult.getContent().size(), pagedResult.getTotalPages(), criteria.getPage() + 1, - pagedResult.getContent().stream() + ownerIncludingShops.stream() .map(InnerNewOwnerResponse::from) .collect(Collectors.toList()) ); diff --git a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java index 8d2f112bc..e74ef1ba3 100644 --- a/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java +++ b/src/main/java/in/koreatech/koin/admin/user/dto/AdminOwnersResponse.java @@ -13,8 +13,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; -import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.global.model.Criteria; import io.swagger.v3.oas.annotations.media.Schema; @@ -53,19 +52,18 @@ public record InnerOwnersResponse( @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt ) { - public static InnerOwnersResponse from(OwnerIncludingShop ownerIncludingShop) { - User user = ownerIncludingShop.getOwner().getUser(); + public static InnerOwnersResponse from(Owner owner) { return new InnerOwnersResponse( - user.getId(), - user.getEmail(), - user.getName(), - user.getPhoneNumber(), - user.getCreatedAt() + owner.getUser().getId(), + owner.getUser().getEmail(), + owner.getUser().getName(), + owner.getUser().getPhoneNumber(), + owner.getUser().getCreatedAt() ); } } - public static AdminOwnersResponse of(Page pagedResult, Criteria criteria) { + public static AdminOwnersResponse of(Page pagedResult, Criteria criteria) { return new AdminOwnersResponse( pagedResult.getTotalElements(), pagedResult.getContent().size(), diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java index d42c6d94b..ad1dfca37 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java @@ -9,7 +9,6 @@ import in.koreatech.koin.domain.owner.exception.OwnerNotFoundException; import in.koreatech.koin.domain.owner.model.Owner; -import in.koreatech.koin.domain.owner.model.OwnerIncludingShop; import in.koreatech.koin.domain.user.model.UserType; import io.lettuce.core.dynamic.annotation.Param; @@ -22,36 +21,50 @@ public interface AdminOwnerRepository extends Repository { void deleteById(Integer ownerId); @Query(""" - SELECT COUNT(o) FROM Owner o - WHERE o.user.userType = 'OWNER' - AND o.user.isAuthed = false + SELECT o FROM Owner o + JOIN o.user u + WHERE u.isAuthed = true """) - Integer findUnauthenticatedOwnersCount(); + Page findPageOwners(Pageable pageable); - Integer countByUserUserType(UserType userType); + @Query(""" + SELECT o FROM Owner o + JOIN o.user u + WHERE u.isAuthed = true + AND u.email LIKE CONCAT('%', :query, '%') + """) + Page findPageOwnersByEmail(@Param("query") String query, Pageable pageable); + + @Query(""" + SELECT o FROM Owner o + JOIN o.user u + WHERE u.isAuthed = true + AND u.name LIKE CONCAT('%', :query, '%') + """) + Page findPageOwnersByName(@Param("query") String query, Pageable pageable); @Query(""" - SELECT new in.koreatech.koin.domain.owner.model.OwnerIncludingShop(o, s.id, s.name) - FROM Owner o - LEFT JOIN Shop s ON s.owner = o + SELECT o FROM Owner o + JOIN o.user u + WHERE u.isAuthed = false """) - Page findPageUnauthenticatedOwners(Pageable pageable); + Page findPageUnauthenticatedOwners(Pageable pageable); @Query(""" - SELECT new in.koreatech.koin.domain.owner.model.OwnerIncludingShop(o, s.id, s.name) - FROM Owner o - LEFT JOIN Shop s ON s.owner = o - WHERE o.user.email LIKE CONCAT('%', :query, '%') + SELECT o FROM Owner o + JOIN o.user u + WHERE u.isAuthed = false + AND u.email LIKE CONCAT('%', :query, '%') """) - Page findPageUnauthenticatedOwnersByEmail(@Param("query") String query, Pageable pageable); + Page findPageUnauthenticatedOwnersByEmail(@Param("query") String query, Pageable pageable); @Query(""" - SELECT new in.koreatech.koin.domain.owner.model.OwnerIncludingShop(o, s.id, s.name) - FROM Owner o - LEFT JOIN Shop s ON s.owner = o - WHERE o.user.name LIKE CONCAT('%', :query, '%') + SELECT o FROM Owner o + JOIN o.user u + WHERE u.isAuthed = false + AND u.name LIKE CONCAT('%', :query, '%') """) - Page findPageUnauthenticatedOwnersByName(@Param("query") String query, Pageable pageable); + Page findPageUnauthenticatedOwnersByName(@Param("query") String query, Pageable pageable); default Owner getById(Integer ownerId) { return findById(ownerId).orElseThrow(() -> OwnerNotFoundException.withDetail("ownerId: " + ownerId)); diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java index 83796bcb0..cb2941451 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminUserRepository.java @@ -2,10 +2,12 @@ import java.util.Optional; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; public interface AdminUserRepository extends Repository { @@ -15,6 +17,13 @@ public interface AdminUserRepository extends Repository { Optional findById(Integer id); + @Query(""" + SELECT COUNT(u) FROM User u + WHERE u.userType = :userType + AND u.isAuthed = :isAuthed + """) + Integer findUsersCountByUserTypeAndIsAuthed(UserType userType, Boolean isAuthed); + default User getByEmail(String email) { return findByEmail(email) .orElseThrow(() -> UserNotFoundException.withDetail("email: " + email)); diff --git a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java index 636fce11f..30fb87198 100644 --- a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java +++ b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java @@ -2,6 +2,7 @@ import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import java.util.ArrayList; import java.util.List; import org.springframework.data.domain.Page; @@ -12,6 +13,7 @@ import java.util.Optional; import java.util.Objects; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -86,7 +88,7 @@ public AdminLoginResponse adminLogin(AdminLoginRequest request) { User user = adminUserRepository.getByEmail(request.email()); /* 어드민 권한이 없으면 없는 회원으로 간주 */ - if(user.getUserType() != ADMIN) { + if (user.getUserType() != ADMIN) { throw UserNotFoundException.withDetail("email" + request.email()); } @@ -166,32 +168,59 @@ public AdminStudentUpdateResponse updateStudent(Integer id, AdminStudentUpdateRe public AdminNewOwnersResponse getNewOwners(OwnersCondition ownersCondition) { ownersCondition.checkDataConstraintViolation(); - Integer totalOwners = adminOwnerRepository.findUnauthenticatedOwnersCount(); + Integer totalOwners = adminUserRepository.findUsersCountByUserTypeAndIsAuthed(UserType.OWNER, false); Criteria criteria = Criteria.of(ownersCondition.page(), ownersCondition.limit(), totalOwners); Sort.Direction direction = ownersCondition.getDirection(); - Page result = getResultPage(ownersCondition, criteria, direction); + Page result = getNewOwnersResultPage(ownersCondition, criteria, direction); - return AdminNewOwnersResponse.of(result, criteria); + List ownerIncludingShops = result.getContent().stream() + .map(owner -> { + Optional ownerShop = adminOwnerShopRedisRepository.findById(owner.getId()); + return ownerShop.map(os -> OwnerIncludingShop.of(owner, adminShopRepository.getById(os.getShopId()))) + .orElseGet(() -> OwnerIncludingShop.of(owner, null)); + }) + .collect(Collectors.toList()); + + return AdminNewOwnersResponse.of(ownerIncludingShops, result, criteria); } public AdminOwnersResponse getOwners(OwnersCondition ownersCondition) { ownersCondition.checkDataConstraintViolation(); - Integer totalOwners = adminOwnerRepository.countByUserUserType(UserType.OWNER); + Integer totalOwners = adminUserRepository.findUsersCountByUserTypeAndIsAuthed(UserType.OWNER, true); Criteria criteria = Criteria.of(ownersCondition.page(), ownersCondition.limit(), totalOwners); Sort.Direction direction = ownersCondition.getDirection(); - Page result = getResultPage(ownersCondition, criteria, direction); + Page result = getOwnersResultPage(ownersCondition, criteria, direction); return AdminOwnersResponse.of(result, criteria); } - private Page getResultPage(OwnersCondition ownersCondition, Criteria criteria, Sort.Direction direction) { + private Page getOwnersResultPage(OwnersCondition ownersCondition, Criteria criteria, + Sort.Direction direction) { + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), + Sort.by(direction, "user.createdAt")); + + Page result; + + if (ownersCondition.searchType() == OwnersCondition.SearchType.EMAIL) { + result = adminOwnerRepository.findPageOwnersByEmail(ownersCondition.query(), pageRequest); + } else if (ownersCondition.searchType() == OwnersCondition.SearchType.NAME) { + result = adminOwnerRepository.findPageOwnersByName(ownersCondition.query(), pageRequest); + } else { + result = adminOwnerRepository.findPageOwners(pageRequest); + } + + return result; + } + + private Page getNewOwnersResultPage(OwnersCondition ownersCondition, Criteria criteria, + Sort.Direction direction) { PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), Sort.by(direction, "user.createdAt")); - Page result; + Page result; if (ownersCondition.searchType() == OwnersCondition.SearchType.EMAIL) { result = adminOwnerRepository.findPageUnauthenticatedOwnersByEmail(ownersCondition.query(), pageRequest); @@ -204,7 +233,6 @@ private Page getResultPage(OwnersCondition ownersCondition, return result; } - private void validateNicknameDuplication(String nickname, Integer userId) { if (nickname != null && adminUserRepository.existsByNicknameAndIdNot(nickname, userId)) { diff --git a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerIncludingShop.java b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerIncludingShop.java index 8e30cec0e..88caa4ae2 100644 --- a/src/main/java/in/koreatech/koin/domain/owner/model/OwnerIncludingShop.java +++ b/src/main/java/in/koreatech/koin/domain/owner/model/OwnerIncludingShop.java @@ -19,8 +19,8 @@ public OwnerIncludingShop(Owner owner, Integer shop_id, String shop_name) { public static OwnerIncludingShop of(Owner owner, Shop shop) { return new OwnerIncludingShop( owner, - shop.getId(), - shop.getName() + shop != null ? shop.getId() : null, + shop != null ? shop.getName() : null ); } } diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java index e305349ad..372e2d211 100644 --- a/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java +++ b/src/main/java/in/koreatech/koin/domain/user/dto/StudentLoginRequest.java @@ -3,13 +3,11 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public record StudentLoginRequest( @Schema(description = "이메일", example = "koin123@koreatech.ac.kr", requiredMode = REQUIRED) @NotBlank(message = "이메일을 입력해주세요.") - @Email(message = "이메일 형식을 지켜주세요. ${validatedValue}") String email, @Schema( diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java index d82f37a68..1c889fa92 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminUserApiTest.java @@ -565,15 +565,19 @@ void updateOwner() { @Test @DisplayName("관리자가 가입 신청한 사장님 리스트 조회한다.") void getNewOwnersAdmin() { - Owner unauthenticatedOwner = userFixture.철수_사장님(); - Owner authenticatedOwner = userFixture.준영_사장님(); - - Shop shopA = shopFixture.마슬랜(unauthenticatedOwner); - Shop shopB = shopFixture.신전_떡볶이(unauthenticatedOwner); + Owner owner = userFixture.철수_사장님(); + Shop shop = shopFixture.마슬랜(null); User adminUser = userFixture.코인_운영자(); String token = userFixture.getToken(adminUser); + OwnerShop ownerShop = OwnerShop.builder() + .ownerId(owner.getId()) + .shopId(shop.getId()) + .build(); + + ownerShopRedisRepository.save(ownerShop); + var response = RestAssured .given() .header("Authorization", "Bearer " + token) @@ -589,8 +593,8 @@ void getNewOwnersAdmin() { JsonAssertions.assertThat(response.asPrettyString()) .isEqualTo(String.format(""" { - "total_count": 2, - "current_count": 2, + "total_count": 1, + "current_count": 1, "total_page": 1, "current_page": 1, "owners": [ @@ -602,15 +606,6 @@ void getNewOwnersAdmin() { "shop_id": 1, "shop_name": "마슬랜 치킨", "created_at" : "2024-01-15 12:00:00" - }, - { - "id": 1, - "email": "testchulsu@gmail.com", - "name": "테스트용_철수(인증X)", - "phone_number": "01097765112", - "shop_id": 2, - "shop_name": "신전 떡볶이", - "created_at" : "2024-01-15 12:00:00" } ] } From 3a6308bbef56466469b94ad024b50b1446d4822a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A4=80=ED=98=B8?= Date: Tue, 9 Jul 2024 11:56:31 +0900 Subject: [PATCH 35/37] =?UTF-8?q?feat:=20github=20workflow=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#671)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: github workflow 추가 * refactor: path 수정 --- .github/workflows/pick-reviewer.yml | 69 +++++++++++++++++++++++++++++ .github/workflows/pr-merged.yml | 27 +++++++++++ .github/workflows/reviewer.json | 64 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 .github/workflows/pick-reviewer.yml create mode 100644 .github/workflows/pr-merged.yml create mode 100644 .github/workflows/reviewer.json diff --git a/.github/workflows/pick-reviewer.yml b/.github/workflows/pick-reviewer.yml new file mode 100644 index 000000000..fc5528950 --- /dev/null +++ b/.github/workflows/pick-reviewer.yml @@ -0,0 +1,69 @@ +name: "Pick Reviewer" + +on: + pull_request: + types: opened + +jobs: + pick-random-reviewer: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Pick random reviewer + id: pick_random_reviewer + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + const developers = JSON.parse(fs.readFileSync(`${{ github.workspace }}/.github/workflows/reviewer.json`)); + const prCreator = context.payload.pull_request.user.login; + const prUrl = context.payload.pull_request.html_url; + const prCreatorJson = developers.reviewers.find(person => person.githubName === prCreator); + + //PrCreator가 reviewer에 등록되지 않은 사람인 경우 + if (!prCreatorJson) { + const reviewerArr = developers.reviewers; + const randomReviewer1 = getRandomReviewer(reviewerArr); + const randomReviewer2 = getRandomReviewer(reviewerArr.filter(reviewer => reviewer.name !== randomReviewer1.name)); + setOutput(prCreator, prUrl, randomReviewer1, randomReviewer2); + } else { + const candidateInternalReviewers = developers.reviewers.filter(person => person.team === prCreatorJson.team && person.githubName !== prCreator); + const candidateExternalReviewers = developers.reviewers.filter(person => person.team !== prCreatorJson.team); + const randomReviewer1 = getRandomReviewer(candidateInternalReviewers); + const randomReviewer2 = getRandomReviewer(candidateExternalReviewers); + setOutput(prCreatorJson.name, prUrl, randomReviewer1, randomReviewer2); + } + + function getRandomReviewer(reviewers) { + return reviewers[Math.floor(Math.random() * reviewers.length)]; + } + + function setOutput(prCreator, prUrl, reviewer1, reviewer2) { + core.setOutput('writer', JSON.stringify(prCreator)); + core.setOutput('pullRequestLink', JSON.stringify(prUrl)); + core.setOutput('reviewer1Name', JSON.stringify(reviewer1.name)); + core.setOutput('reviewer2Name', JSON.stringify(reviewer2.name)); + core.setOutput('reviewer1GithubName', reviewer1.githubName); + core.setOutput('reviewer2GithubName', reviewer2.githubName); + } + + - name: Add Reviewers + uses: madrapps/add-reviewers@v1 + with: + reviewers: ${{ steps.pick_random_reviewer.outputs.reviewer1GithubName }},${{ steps.pick_random_reviewer.outputs.reviewer2GithubName }} + token: ${{ secrets.GITHUB_TOKEN }} + + + - name: Send Slack Trigger + run: | + curl -X POST https://api-slack.internal.bcsdlab.com/api/review-request/backend \ + -H 'Content-Type: application/json' \ + -d '{ + "pullRequestLink": ${{ steps.pick_random_reviewer.outputs.pullRequestLink }}, + "writer": ${{ steps.pick_random_reviewer.outputs.writer }}, + "reviewers": [${{ steps.pick_random_reviewer.outputs.reviewer1Name }}, ${{ steps.pick_random_reviewer.outputs.reviewer2Name }}] + }' diff --git a/.github/workflows/pr-merged.yml b/.github/workflows/pr-merged.yml new file mode 100644 index 000000000..2ca3a09bd --- /dev/null +++ b/.github/workflows/pr-merged.yml @@ -0,0 +1,27 @@ +on: + pull_request: + types: closed + +jobs: + check_pr_merged: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Check PR Merged + id: check_pr_merged + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prUrl = context.payload.pull_request.html_url ?? context.payload.pull_request._links.html.href; + core.setOutput('pullRequestLink', JSON.stringify(prUrl)); + + - name: Send Slack Trigger + run: | + curl -X POST https://api-slack.internal.bcsdlab.com/api/pr-merged/backend \ + -H 'Content-Type: application/json' \ + -d '{ + "pullRequestLink": ${{ steps.check_pr_merged.outputs.pullRequestLink }} + }' diff --git a/.github/workflows/reviewer.json b/.github/workflows/reviewer.json new file mode 100644 index 000000000..fc50260f1 --- /dev/null +++ b/.github/workflows/reviewer.json @@ -0,0 +1,64 @@ +{ + "reviewers": [ + { + "name": "박다희", + "githubName": "daheeParkk", + "team": "user" + }, + { + "name": "김성재", + "githubName": "seongjae6751", + "team": "user" + }, + { + "name": "김원경", + "githubName": "kwoo28", + "team": "user" + }, + { + "name": "서정빈", + "githubName": "duehee", + "team": "user" + }, + { + "name": "이현수", + "githubName": "20HyeonsuLee", + "team": "business" + }, + { + "name": "최준호", + "githubName": "Choi-JJunho", + "team": "business" + }, + { + "name": "윤용운", + "githubName": "YunYongWoon", + "team": "business" + }, + { + "name": "장준영", + "githubName": "johnny19991006", + "team": "business" + }, + { + "name": "허준기", + "githubName": "dradnats1012", + "team": "campus" + }, + { + "name": "황현식", + "githubName": "Choon0414", + "team": "campus" + }, + { + "name": "박성빈", + "githubName": "ImTotem", + "team": "campus" + }, + { + "name": "송선권", + "githubName": "songsunkook", + "team": "campus" + } + ] +} From afce39d528c841c19148521f6fd0a4f1275103e3 Mon Sep 17 00:00:00 2001 From: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:50:19 +0900 Subject: [PATCH 36/37] =?UTF-8?q?develop=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=ED=95=B4=EA=B2=B0=20(#677)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : notification FK 회원 삭제 오류 수정 (#514) * fix : notification/notification_subscribe DELETE CASCADE로 변경 * chore : Front 요청으로 인한 회원가입 에러코드 409 추가 * chore : DB 생략 * fix: 에러 반환값 수정 (#517) * hotfix: 학생 회원 가입 시에 전화번호 형식 추가 허용 (#530) * chore: 회원가입 전화번호 형식 추가 허용 * chore: 전화번호 가운데 세자리도 되게 허용 * chore: 비밀번호 틀렸을시에 400 반환하는 것으로 수정 (#605) * develop -> main Merge (#652) * fix: dto @NotNull 어노테이션 추가 * fix: 테스트코드 수정 * fix : 품절 알림 문구 점(.) 삭제 (#599) * fix : 품절 알림 문구 점 삭제 * fix : 품절 알림 문구 점 삭제 * feat : put /admin/members/{id} api 추가 (#595) * feat : put /admin/members/{id} api 추가 * fix: trackName 체크 메서드 생성 * fix: track 변경 체크 로직 및 track 수정 로직 수정 * feat: 버스 필터링 (#586) * feat: 노선 정보 캐싱 - 정류장을 지나는 노선 정보들 캐싱 * refactor: 필터링 위치 변경 - 버스 시간 조회시 필터링 * feat: 크롤링 추가 * feat: 테스트 추가 * refactor: 피드백 반영 * chore: 주석 추가 * refactor: 피드백 반영 - save -> saveAll로 변경 * feat : 학생, 사장님, 영양사 로그인 분기처리 (#563) * feat : 사장님 / 영양사 로그인 분기 처리 추가 * chore : StudentUpdateResponse의 gender 검증 변수 제거 * feat : 테스트 추가 * feat: 가입 신청한 사장님 페이지네이션 조회(어드민계정) (#539) * feat: 응답객체 생성 * feat: controller 생성 * refactor: 접근제어 변경 * feat: 요청 모델앤뷰 dto * feat: 응답 객체 * feat: repository 생성 * feat: shop_id와 shop_name이 있는 owner객체 생성 * feat: service * feat: 테스트 추가 * fix: 필요없는 코드 제거 * refactor: required 관련 수정 * refactor: 컨벤션 적용 * refactor: 로직 수정 * refactor: 로직 수정 * refactor: AdminShopRepository 제거 * refactor: controller api 수정 * refactor: 충돌 해결 * refactor: 충돌 해결 * refactor: 주석 추가 * refactor: 빌더 삭제 및 코드 개선 * refactor: 요청dto requiredMode 전부 적용 * refactor: 빌더 생성 * refactor: Enum위치 변경 및 빌더 삭제 * refactor: controller api implement 추가 * refactor: dto 메서드 스웨거에서 숨기기 * feat: POST /admin/members API 추가 (#541) * feat: POST /admin/members API 추가 * fix: @Auth 추가 및 테스트 수정 * chore: 공백 제거 * refactor: GET /shops api 성능최적화 (#549) * feat: swwagger 인증번호 발송 관련 api명세 추가 * feat: 마크업으로 변경 * feat: 레디스 캐싱 * feat: save메소드 추가 * feat: 캐시가 없으면 캐시에 데이터를 올린다 * refactor: 필요없는 설정 정리 * feat: 리뷰반영 --------- Co-authored-by: HyeonsuLee * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 (#564) * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 * feat: 스웨거 이슈로 길어져버린 dto이름ㅠ * feat: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * 버스 openApi 에러 수정 (#565) * fix: openApi scheduled에서 try catch * fix: Exception catch로 변경 * chore : 사장님 로그인 api 작성 * feat : 로그인 API(학생, 사장님, 영양사) 분리 * chore : UserFixture 원경_사장님 수정 * chore : UserFixture 중복 사항 제거 * chore : UserFixture 오류 수정 * chore : 리뷰 반영(메소드 이름 변경) * chore : 리뷰 반영(전화번호 숨김 및 이메일 형식 삭제) * chore : 리뷰 반영(메소드 위치 변경 및 Response userType 삭제) * chore : 사장님 로그인 테스트 위치 변경 * feat : flyway 추가 * feat : 영양사 테이블 추가 및 로그인 로직 수정 * feat : 사장님 테이블 컬럼 추가 및 로그인 로직 수정 * test : 테스트 코드 수정 * test : 테스트 오류 수정을 위한 기존 request 수정 * chore : 리뷰 반영(이메일 형식 확인 삭제) * chore : flyway 변경(V16 수정 및 V18 추가) * chore : User email @NotNull 제거(email null 허용) * chore : Owner 핸드폰 회원가입 email null 값 허용 * chore : Test 수정 및 User email @Column(nullable = false) 제거 * chore : 전화번호 관련 로직 "01000000000" 로 수정 및 테스트 수정 * test : Admin User 테스트 수정 --------- Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> * hotfix: 비밀번호 검증 api 비밀번호 틀렸을시에 400 반환하는 것으로 수정(develop) (#607) * fix : notification FK 회원 삭제 오류 수정 (#514) * fix : notification/notification_subscribe DELETE CASCADE로 변경 * chore : Front 요청으로 인한 회원가입 에러코드 409 추가 * chore : DB 생략 * fix: 에러 반환값 수정 (#517) * hotfix: 학생 회원 가입 시에 전화번호 형식 추가 허용 (#530) * chore: 회원가입 전화번호 형식 추가 허용 * chore: 전화번호 가운데 세자리도 되게 허용 * chore: 비밀번호 틀렸을시에 400 반환하는 것으로 수정 * chore: swagger 400 추가 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 최준호 Co-authored-by: 송선권 * feat: POST /admin/members/{id}/undelete API 추가 (#600) * feat: Admin BCSDLab 트랙 API 구현 (#606) * feat: 기술스택 삭제 기능 구현 * test: admin으로 기술스택 제거 테스트 추가 * feat: admin 단일 트랙 조회 기능 추가 * test: admin으로 트랙 정보 단건 조회 테스트 추가 * feat: admin으로 트랙 생성 기능 추가 * test: admin으로 트랙 정보 생성 테스트 추가 * feat: 버스 필터링 (#586) * feat: 노선 정보 캐싱 - 정류장을 지나는 노선 정보들 캐싱 * refactor: 필터링 위치 변경 - 버스 시간 조회시 필터링 * feat: 크롤링 추가 * feat: 테스트 추가 * refactor: 피드백 반영 * chore: 주석 추가 * refactor: 피드백 반영 - save -> saveAll로 변경 * feat : 학생, 사장님, 영양사 로그인 분기처리 (#563) * feat : 사장님 / 영양사 로그인 분기 처리 추가 * chore : StudentUpdateResponse의 gender 검증 변수 제거 * feat : 테스트 추가 * feat: 가입 신청한 사장님 페이지네이션 조회(어드민계정) (#539) * feat: 응답객체 생성 * feat: controller 생성 * refactor: 접근제어 변경 * feat: 요청 모델앤뷰 dto * feat: 응답 객체 * feat: repository 생성 * feat: shop_id와 shop_name이 있는 owner객체 생성 * feat: service * feat: 테스트 추가 * fix: 필요없는 코드 제거 * refactor: required 관련 수정 * refactor: 컨벤션 적용 * refactor: 로직 수정 * refactor: 로직 수정 * refactor: AdminShopRepository 제거 * refactor: controller api 수정 * refactor: 충돌 해결 * refactor: 충돌 해결 * refactor: 주석 추가 * refactor: 빌더 삭제 및 코드 개선 * refactor: 요청dto requiredMode 전부 적용 * refactor: 빌더 생성 * refactor: Enum위치 변경 및 빌더 삭제 * refactor: controller api implement 추가 * refactor: dto 메서드 스웨거에서 숨기기 * feat: POST /admin/members API 추가 (#541) * feat: POST /admin/members API 추가 * fix: @Auth 추가 및 테스트 수정 * chore: 공백 제거 * refactor: GET /shops api 성능최적화 (#549) * feat: swwagger 인증번호 발송 관련 api명세 추가 * feat: 마크업으로 변경 * feat: 레디스 캐싱 * feat: save메소드 추가 * feat: 캐시가 없으면 캐시에 데이터를 올린다 * refactor: 필요없는 설정 정리 * feat: 리뷰반영 --------- Co-authored-by: HyeonsuLee * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 (#564) * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 * feat: 스웨거 이슈로 길어져버린 dto이름ㅠ * feat: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * 버스 openApi 에러 수정 (#565) * fix: openApi scheduled에서 try catch * fix: Exception catch로 변경 * chore : 사장님 로그인 api 작성 * feat : 로그인 API(학생, 사장님, 영양사) 분리 * chore : UserFixture 원경_사장님 수정 * chore : UserFixture 중복 사항 제거 * chore : UserFixture 오류 수정 * chore : 리뷰 반영(메소드 이름 변경) * chore : 리뷰 반영(전화번호 숨김 및 이메일 형식 삭제) * chore : 리뷰 반영(메소드 위치 변경 및 Response userType 삭제) * chore : 사장님 로그인 테스트 위치 변경 * feat : flyway 추가 * feat : 영양사 테이블 추가 및 로그인 로직 수정 * feat : 사장님 테이블 컬럼 추가 및 로그인 로직 수정 * test : 테스트 코드 수정 * test : 테스트 오류 수정을 위한 기존 request 수정 * chore : 리뷰 반영(이메일 형식 확인 삭제) * chore : flyway 변경(V16 수정 및 V18 추가) * chore : User email @NotNull 제거(email null 허용) * chore : Owner 핸드폰 회원가입 email null 값 허용 * chore : Test 수정 및 User email @Column(nullable = false) 제거 * chore : 전화번호 관련 로직 "01000000000" 로 수정 및 테스트 수정 * test : Admin User 테스트 수정 --------- Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> * feat: admin으로 트랙 수정 기능 추가 * fix: legacy request의 불필요한 id 값 제거 * feat: 트랙명 중복 예외처리 추가 * test: admin 트랙 생성, 수정 중복 트랙명 예외 테스트 추가 * test: admin 트랙 수정 테스트 추가 * feat: admin으로 트랙 삭제 기능 추가 * test: admin 트랙 삭제 테스트 추가 * chore: 1차 피드백 반영 --------- Co-authored-by: 박성빈 <46699595+ImTotem@users.noreply.github.com> Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> * Feature: 어드민 복덕방 삭제 (#612) * feat: AdminLandApi 구현 * feat: AdminLandController * feat: AdminLandRepository * feat: AdminLandService * feat: Land * refactor: Admin권한 추가 * feat: 복덕방 삭제 테스트 코드 추가 * refactor: 복덕방 삭제 테스트 공백 추가 --------- Co-authored-by: Jang Jun Young * refactor: 호환성 유지하기 위한 오너 회원가입 휴대폰번호 양식 롤백 (#614) * refactor: 회원가입 휴대폰번호 양식 롤백 * refactor: 오너 회원가입 휴대폰번호 양식 테스트코드 롤백 * refactor: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * feat : coop_id 관련 flyway 추가 (#618) * feat : coop_id 관련 flyway 추가 * chore : 리뷰 반영(flyway 분리) * refactor: 시내버스 정류장 불일치 해결 (#601) * fix: 시내버스 노선 캐시 저장 로직 수정 * refactor: 정류장 추가 * refactor: 정류장 조회 로직 수정 * refactor: 테스트 수정 * fix : 품절 알림 처리 순서 수정 (#611) * fix : 품절 캐시 저장, 알림 발송 순서 수정 * fix : dining 응답 0 -> null 반환으로 수정 * refactor: sms회원가입 redis초기화 로직 변경 (#622) * refactor: sms회원가입 redi초기화 로직 변경 * refactor: eventListener 메소드 이름 변경 * refactor: 테스트 코드 변경 --------- Co-authored-by: HyeonsuLee * fix: 상점 수정, 삭제시 운영 요일 형식 검증 (#624) * feat: 상점 운영시간 요일 형식 검증 추가 * feat: Inner Record에 @Valid추가 * refactor: 필요없는 어노테이션 삭제 --------- Co-authored-by: HyeonsuLee * feat: 사업자번호, 아이디 중복검증 추가 (#610) * feat: 사업자등록번호 검증 추가 * feat: 전화번호 중복 검증 추가 * refactor: 전화번호 중복 사장님으로 이관 * refactor: phone_number -> account * test: 테스트 수정 * refactor: check -> exists * fix : Kcal가 null 일 경우 0을 반환하도록 롤백 (#626) * fix: CityBusRoute 저장시 null 제외 추가 (#629) * feat: 어드민권한 상점 관련 api작성 (#619) * feat: 컨트롤러 작성 * feat: 상점 메뉴관련 조회 api 구현 * feat: 상점 메뉴관련 조회 api 구현 2 * feat: 특정 상점 메뉴 조회 테스트코드 작성 * feat: 테스트코드 작성 * feat: 테스트코드 작성 * refactor: 불필요한 공백 제거 * refactor: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * feat: 어드민 복덕방 조회,수정,삭제취소 (#631) * feat: 복덕방 조회 컨트롤러 구현 * feat: 복덕방 조회 서비스 구현 * feat: 복덕방 조회 테스트 구현 * feat: 복덕방 수정,삭제취소 구현 * feat: 복덕방 수정,삭제취소 서비스 구현 * refactor: land 모델 수정 * refactor: land 테스트 케이스 추가 * refactor: 어드민 dto 수정 * feat: 어드민 수정, 삭제취소 테스트 구현 * refactor: 라인 포맷팅 * refactor: 테스트 코드 수정 * refactor: dto반환형식 변경 * refactor: 라인포맷팅 --------- Co-authored-by: Jang Jun Young * feat: 어드민 82~86,89,90,93~95 (#621) * feat: 컨트롤러 구현 * feat: 사장님 인증권한 허용 * feat: 사장님 인증권한 허용 * feat: 테스트 추가 * feat: 테스트 import문 사용 * feat: 학생 리스트 조회 구현 * feat: 어드민 특정 사장님 수정 * feat: 어드민 사장님 페이지네이션 * feat: 어드민 회원 삭제 * feat: 어드민 로그인 구현 * feat: 어드민 로그아웃 구현 * feat: 어드민 리프레쉬 구현 * feat: 어드민 회원 조회 * fix: 페이지네이션 오류 수정 * test: 테스트 구현 * test: 나머지 모든 테스트 구현 * chore: 개행 처리 * chore: 개행 처리 * chore: 개행 처리 * chore: 이상한 설명 수정 * feat: ModelAttribute 파라미터로 전달되게 변경 * refactor: 사장님 인증 shop에 owner_id 할당되게 변경 * feat: 회원 탈퇴 취소 API 구현 * refactor: 83~86 충돌 수정 * refactor: 사장님인증 후 OwnerShop redis삭제 추가 * refactor: 사장님인증 테스트 수정 * refactor: AdminStudentResponse 메서드 of -> from 변경 * refactor: shopId null체크 * refactor: GrantShop 로직 수정, 로그인 save()제거 * refactor: 응답객체 수정 * refactor: 충돌 수정 * refactor: 충돌 수정 * refactor: 테스트 수정 * refactor: 응답반환수정 --------- Co-authored-by: seongjae6751 * feat: 어드민권한 상점, 카테고리 api작성 (#627) * fix : notification FK 회원 삭제 오류 수정 (#514) * fix : notification/notification_subscribe DELETE CASCADE로 변경 * chore : Front 요청으로 인한 회원가입 에러코드 409 추가 * chore : DB 생략 * fix: 에러 반환값 수정 (#517) * hotfix: 학생 회원 가입 시에 전화번호 형식 추가 허용 (#530) * chore: 회원가입 전화번호 형식 추가 허용 * chore: 전화번호 가운데 세자리도 되게 허용 * chore: 비밀번호 틀렸을시에 400 반환하는 것으로 수정 (#605) * feat: controller 작성 * feat: service, repository 작성 * test: 어드민 shop, category 테스트 작성 * refactor: 1차 피드백 반영 * fix: 버그 수정 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 최준호 Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: 송선권 * refactor: ADMIN 권한 추가 (#638) * fix: 상점 생성,수정 / 메뉴 추가,수정 버그 해결 (#644) * feat: DTO수정 * feat: imageUrl이 null인경우를 위한 DTO 생성자 추가 * feat: 상점 운영시간 DTO validation 문구 추가 * feat: 상점 운영시간 DTO 개수 제한 수정 --------- Co-authored-by: HyeonsuLee * feat: new timetable api 구현 (#615) * feat: flyway 추가 * refactor: flyway 수정 * feat: timetable frame api 중 post, get, delete 구현 (#592) * feat : 기본 Model 및 Repository 생성 * chore : isMain 추가 * feat: timetablesFrame post, get delete 기능 구현 * chore: 겹치는 로직 수정 및 일부 버그 수정 * feat: 회원 탈퇴시 timetableframe 및 timetable 수동 삭제 * feat: 삭제 전 검증 로직 추가 * chore: api 오타 수정 * chore: frame 빌더 파라미터 추가 * chore: snakecase로 dto 변경 * feat: lecture repo 메서드 추가 * test: timetable v2 test 코드 작성 * chore: 오타 수정 * feat: 삭제되는 frame이 main일때 다른 frame을 main으로 설정 * chore: 개행 제거 * chore: 개행 추가 * chore: 어색한 말 수정 * chore: 오타 수정 * chore: 변수명 및 개행 수정 * chore: 피드백 반영 * chore: 메서드 명 변경 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * feat: timetableFrame put, timetableLecture delete 구현 & reafactor: 기존 timetable delete, get 수정 (#594) * feat : 기본 Model 및 Repository 생성 * refactor: 기존 delete timetable 새로운 db와 연결 & v2 api 추가 * feat: tiemtable frame 수정 api 구현 * refactor: tiemtable frame 수정 api 반환값 dto 추가 * refactor: PUT timetable/frame에서 main으로 업데이트할 시 기존 main 테이블의 is_main을 false로 수정 * refactor: Get /timetables api 변경된 테이블 구조에 맞게 수정 * refactor: timeTable -> timetable로 변경 * refactor: deleteTimetableLecture() 유저 본인의 시간표에서 삭제하는지 검증 추가 * refactor: repository에서 main timetable을 가져오게 수정 * refactor: url 앞에 / 추가 & 기존에 없던 api url에 v2제거 * refactor: TimetableFrameUpdateRequest, TimetableFrameUpdateResponse에서 name Schema 수정 * refactor: TimetableFrameUpdateResponse에서 semester 대신 id를 반환하도록 수정 * refactor: timeTable -> timetable 이름 변경 * refactor: TimetableFrame에서 필요 없는 isMain()함수 제거 * refactor: LectureRepository에서 findByIdIn를 사용해 Lecture List를 가져오도록 수정 * refactor: TimetableLecture와 TimetableFrame에서 is_deleted=0으로 찾는 where 제거 * refactor: timetableFrame 수정 시 semester 변경 못하도록 수정 * fix: is_main 타입변경, snakecase적용되도록 수정 & test: timetableframe 수정 api, timetablelecture 삭제 api 테스트 작성 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * feat: GET/semester/check수정 & POST/timetables수정 & POST/v2/timetables/lecture 추가 (#596) * feat : 기본 Model 및 Repository 생성 * chore : isMain 추가 * feat: 시간표 생성 수정 및 추가 * feat: 강의시간표 응답 수정 * feat: 코드 오류 수정 * feat: API 수정 * feat: pr 수정 * feat: repository semester타입 수정 * fear : flyway 추가 * fear : flyway 추가 * fear : Lecture id 반환 추가 * fear : PUT /timetables 수정 및 /v2/timetables 추가 * feat: getTimetables API 수정 * refactor: PR 리뷰 반영 * fear : 리뷰 반영 * refactor : 코드 리팩터링 * refactor: 충돌 수정 * refactor: 충돌 수정 * refactor: 로직 수정 및 학점추가 * refactor: 학점 계산 로직 수정 * refactor: 오류 수정 * refactor: 코드 수정 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * refactor: flyway timetable_id -> frame_id 변경 * refactor: TimetableFrame에 @Where(clause = is_deleted=0) 추가 * refactor: frame api의 url에 v2추가 * refactor: TimetableLectures create,update 문에 user 인증 추가 * refactor : TimetableUpdate에 id가 null인 경우 삭제 * refactor: swagger 수정 * refactor: v2 패키지로 이동 * fix: v2 api 이름에 v2 추가 * chore : 리뷰 반영(Repo 미사용 메소드 삭제) * chore : 리뷰 반영(isMain boolean으로 수정) * chore : 리뷰 반영(변수 추가 및 getIsMain() -> isMain() 수정) * chore : 리뷰 반영 * refactor: createTimetablesFrame에서 main 시간표 count 시 0부터 세도록 수정 * fix: createTimetablesFrame에서 이름 생성할 때 카운트 +1하도록 수정 * fix: createTimetablesFrame에서 카운트 +1할 때 괄호 추가 --------- Co-authored-by: kwoo28 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * fix : flyway 버저닝 충돌 해결 (#646) * fix : 롤백 이후 flyway 오류 수정(timetable_id -> frame_id) (#648) * refactor: internalName 필수요구 제거 (#650) Co-authored-by: Jang Jun Young --------- Co-authored-by: HyeonsuLee Co-authored-by: 최준호 Co-authored-by: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: 박성빈 <46699595+ImTotem@users.noreply.github.com> Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: 송선권 Co-authored-by: 배진호 <72592302+BaeJinho4028@users.noreply.github.com> Co-authored-by: Jang-JunYoung <79901434+johnny19991006@users.noreply.github.com> Co-authored-by: Jang Jun Young Co-authored-by: seongjae6751 Co-authored-by: Dahee Park <106418303+daheeParkk@users.noreply.github.com> * feat: 충돌 해결 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 최준호 Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: 송선권 Co-authored-by: HyeonsuLee Co-authored-by: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: 박성빈 <46699595+ImTotem@users.noreply.github.com> Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> Co-authored-by: 배진호 <72592302+BaeJinho4028@users.noreply.github.com> Co-authored-by: Jang-JunYoung <79901434+johnny19991006@users.noreply.github.com> Co-authored-by: Jang Jun Young Co-authored-by: seongjae6751 Co-authored-by: Dahee Park <106418303+daheeParkk@users.noreply.github.com> --- .../koin/admin/user/repository/AdminOwnerRepository.java | 2 ++ .../in/koreatech/koin/admin/user/service/AdminUserService.java | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java index ad1dfca37..1ebc65c9c 100644 --- a/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java +++ b/src/main/java/in/koreatech/koin/admin/user/repository/AdminOwnerRepository.java @@ -20,6 +20,8 @@ public interface AdminOwnerRepository extends Repository { void deleteById(Integer ownerId); + Integer countByUserUserType(UserType userType); + @Query(""" SELECT o FROM Owner o JOIN o.user u diff --git a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java index 30fb87198..65271faf7 100644 --- a/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java +++ b/src/main/java/in/koreatech/koin/admin/user/service/AdminUserService.java @@ -233,6 +233,7 @@ private Page getNewOwnersResultPage(OwnersCondition ownersCondition, Crit return result; } + private void validateNicknameDuplication(String nickname, Integer userId) { if (nickname != null && adminUserRepository.existsByNicknameAndIdNot(nickname, userId)) { From 760e9491839b1e6c106d614ca0d3cd542f470405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=A7=84=ED=98=B8?= <72592302+BaeJinho4028@users.noreply.github.com> Date: Thu, 11 Jul 2024 23:16:30 +0900 Subject: [PATCH 37/37] =?UTF-8?q?main<-develop=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : notification FK 회원 삭제 오류 수정 (#514) * fix : notification/notification_subscribe DELETE CASCADE로 변경 * chore : Front 요청으로 인한 회원가입 에러코드 409 추가 * chore : DB 생략 * fix: 에러 반환값 수정 (#517) * hotfix: 학생 회원 가입 시에 전화번호 형식 추가 허용 (#530) * chore: 회원가입 전화번호 형식 추가 허용 * chore: 전화번호 가운데 세자리도 되게 허용 * chore: 비밀번호 틀렸을시에 400 반환하는 것으로 수정 (#605) * develop -> main Merge (#652) * fix: dto @NotNull 어노테이션 추가 * fix: 테스트코드 수정 * fix : 품절 알림 문구 점(.) 삭제 (#599) * fix : 품절 알림 문구 점 삭제 * fix : 품절 알림 문구 점 삭제 * feat : put /admin/members/{id} api 추가 (#595) * feat : put /admin/members/{id} api 추가 * fix: trackName 체크 메서드 생성 * fix: track 변경 체크 로직 및 track 수정 로직 수정 * feat: 버스 필터링 (#586) * feat: 노선 정보 캐싱 - 정류장을 지나는 노선 정보들 캐싱 * refactor: 필터링 위치 변경 - 버스 시간 조회시 필터링 * feat: 크롤링 추가 * feat: 테스트 추가 * refactor: 피드백 반영 * chore: 주석 추가 * refactor: 피드백 반영 - save -> saveAll로 변경 * feat : 학생, 사장님, 영양사 로그인 분기처리 (#563) * feat : 사장님 / 영양사 로그인 분기 처리 추가 * chore : StudentUpdateResponse의 gender 검증 변수 제거 * feat : 테스트 추가 * feat: 가입 신청한 사장님 페이지네이션 조회(어드민계정) (#539) * feat: 응답객체 생성 * feat: controller 생성 * refactor: 접근제어 변경 * feat: 요청 모델앤뷰 dto * feat: 응답 객체 * feat: repository 생성 * feat: shop_id와 shop_name이 있는 owner객체 생성 * feat: service * feat: 테스트 추가 * fix: 필요없는 코드 제거 * refactor: required 관련 수정 * refactor: 컨벤션 적용 * refactor: 로직 수정 * refactor: 로직 수정 * refactor: AdminShopRepository 제거 * refactor: controller api 수정 * refactor: 충돌 해결 * refactor: 충돌 해결 * refactor: 주석 추가 * refactor: 빌더 삭제 및 코드 개선 * refactor: 요청dto requiredMode 전부 적용 * refactor: 빌더 생성 * refactor: Enum위치 변경 및 빌더 삭제 * refactor: controller api implement 추가 * refactor: dto 메서드 스웨거에서 숨기기 * feat: POST /admin/members API 추가 (#541) * feat: POST /admin/members API 추가 * fix: @Auth 추가 및 테스트 수정 * chore: 공백 제거 * refactor: GET /shops api 성능최적화 (#549) * feat: swwagger 인증번호 발송 관련 api명세 추가 * feat: 마크업으로 변경 * feat: 레디스 캐싱 * feat: save메소드 추가 * feat: 캐시가 없으면 캐시에 데이터를 올린다 * refactor: 필요없는 설정 정리 * feat: 리뷰반영 --------- Co-authored-by: HyeonsuLee * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 (#564) * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 * feat: 스웨거 이슈로 길어져버린 dto이름ㅠ * feat: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * 버스 openApi 에러 수정 (#565) * fix: openApi scheduled에서 try catch * fix: Exception catch로 변경 * chore : 사장님 로그인 api 작성 * feat : 로그인 API(학생, 사장님, 영양사) 분리 * chore : UserFixture 원경_사장님 수정 * chore : UserFixture 중복 사항 제거 * chore : UserFixture 오류 수정 * chore : 리뷰 반영(메소드 이름 변경) * chore : 리뷰 반영(전화번호 숨김 및 이메일 형식 삭제) * chore : 리뷰 반영(메소드 위치 변경 및 Response userType 삭제) * chore : 사장님 로그인 테스트 위치 변경 * feat : flyway 추가 * feat : 영양사 테이블 추가 및 로그인 로직 수정 * feat : 사장님 테이블 컬럼 추가 및 로그인 로직 수정 * test : 테스트 코드 수정 * test : 테스트 오류 수정을 위한 기존 request 수정 * chore : 리뷰 반영(이메일 형식 확인 삭제) * chore : flyway 변경(V16 수정 및 V18 추가) * chore : User email @NotNull 제거(email null 허용) * chore : Owner 핸드폰 회원가입 email null 값 허용 * chore : Test 수정 및 User email @Column(nullable = false) 제거 * chore : 전화번호 관련 로직 "01000000000" 로 수정 및 테스트 수정 * test : Admin User 테스트 수정 --------- Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> * hotfix: 비밀번호 검증 api 비밀번호 틀렸을시에 400 반환하는 것으로 수정(develop) (#607) * fix : notification FK 회원 삭제 오류 수정 (#514) * fix : notification/notification_subscribe DELETE CASCADE로 변경 * chore : Front 요청으로 인한 회원가입 에러코드 409 추가 * chore : DB 생략 * fix: 에러 반환값 수정 (#517) * hotfix: 학생 회원 가입 시에 전화번호 형식 추가 허용 (#530) * chore: 회원가입 전화번호 형식 추가 허용 * chore: 전화번호 가운데 세자리도 되게 허용 * chore: 비밀번호 틀렸을시에 400 반환하는 것으로 수정 * chore: swagger 400 추가 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 최준호 Co-authored-by: 송선권 * feat: POST /admin/members/{id}/undelete API 추가 (#600) * feat: Admin BCSDLab 트랙 API 구현 (#606) * feat: 기술스택 삭제 기능 구현 * test: admin으로 기술스택 제거 테스트 추가 * feat: admin 단일 트랙 조회 기능 추가 * test: admin으로 트랙 정보 단건 조회 테스트 추가 * feat: admin으로 트랙 생성 기능 추가 * test: admin으로 트랙 정보 생성 테스트 추가 * feat: 버스 필터링 (#586) * feat: 노선 정보 캐싱 - 정류장을 지나는 노선 정보들 캐싱 * refactor: 필터링 위치 변경 - 버스 시간 조회시 필터링 * feat: 크롤링 추가 * feat: 테스트 추가 * refactor: 피드백 반영 * chore: 주석 추가 * refactor: 피드백 반영 - save -> saveAll로 변경 * feat : 학생, 사장님, 영양사 로그인 분기처리 (#563) * feat : 사장님 / 영양사 로그인 분기 처리 추가 * chore : StudentUpdateResponse의 gender 검증 변수 제거 * feat : 테스트 추가 * feat: 가입 신청한 사장님 페이지네이션 조회(어드민계정) (#539) * feat: 응답객체 생성 * feat: controller 생성 * refactor: 접근제어 변경 * feat: 요청 모델앤뷰 dto * feat: 응답 객체 * feat: repository 생성 * feat: shop_id와 shop_name이 있는 owner객체 생성 * feat: service * feat: 테스트 추가 * fix: 필요없는 코드 제거 * refactor: required 관련 수정 * refactor: 컨벤션 적용 * refactor: 로직 수정 * refactor: 로직 수정 * refactor: AdminShopRepository 제거 * refactor: controller api 수정 * refactor: 충돌 해결 * refactor: 충돌 해결 * refactor: 주석 추가 * refactor: 빌더 삭제 및 코드 개선 * refactor: 요청dto requiredMode 전부 적용 * refactor: 빌더 생성 * refactor: Enum위치 변경 및 빌더 삭제 * refactor: controller api implement 추가 * refactor: dto 메서드 스웨거에서 숨기기 * feat: POST /admin/members API 추가 (#541) * feat: POST /admin/members API 추가 * fix: @Auth 추가 및 테스트 수정 * chore: 공백 제거 * refactor: GET /shops api 성능최적화 (#549) * feat: swwagger 인증번호 발송 관련 api명세 추가 * feat: 마크업으로 변경 * feat: 레디스 캐싱 * feat: save메소드 추가 * feat: 캐시가 없으면 캐시에 데이터를 올린다 * refactor: 필요없는 설정 정리 * feat: 리뷰반영 --------- Co-authored-by: HyeonsuLee * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 (#564) * feat: 상점 사장님 휴대폰번호로 회원가입 api작성 * feat: 스웨거 이슈로 길어져버린 dto이름ㅠ * feat: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * 버스 openApi 에러 수정 (#565) * fix: openApi scheduled에서 try catch * fix: Exception catch로 변경 * chore : 사장님 로그인 api 작성 * feat : 로그인 API(학생, 사장님, 영양사) 분리 * chore : UserFixture 원경_사장님 수정 * chore : UserFixture 중복 사항 제거 * chore : UserFixture 오류 수정 * chore : 리뷰 반영(메소드 이름 변경) * chore : 리뷰 반영(전화번호 숨김 및 이메일 형식 삭제) * chore : 리뷰 반영(메소드 위치 변경 및 Response userType 삭제) * chore : 사장님 로그인 테스트 위치 변경 * feat : flyway 추가 * feat : 영양사 테이블 추가 및 로그인 로직 수정 * feat : 사장님 테이블 컬럼 추가 및 로그인 로직 수정 * test : 테스트 코드 수정 * test : 테스트 오류 수정을 위한 기존 request 수정 * chore : 리뷰 반영(이메일 형식 확인 삭제) * chore : flyway 변경(V16 수정 및 V18 추가) * chore : User email @NotNull 제거(email null 허용) * chore : Owner 핸드폰 회원가입 email null 값 허용 * chore : Test 수정 및 User email @Column(nullable = false) 제거 * chore : 전화번호 관련 로직 "01000000000" 로 수정 및 테스트 수정 * test : Admin User 테스트 수정 --------- Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> * feat: admin으로 트랙 수정 기능 추가 * fix: legacy request의 불필요한 id 값 제거 * feat: 트랙명 중복 예외처리 추가 * test: admin 트랙 생성, 수정 중복 트랙명 예외 테스트 추가 * test: admin 트랙 수정 테스트 추가 * feat: admin으로 트랙 삭제 기능 추가 * test: admin 트랙 삭제 테스트 추가 * chore: 1차 피드백 반영 --------- Co-authored-by: 박성빈 <46699595+ImTotem@users.noreply.github.com> Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> * Feature: 어드민 복덕방 삭제 (#612) * feat: AdminLandApi 구현 * feat: AdminLandController * feat: AdminLandRepository * feat: AdminLandService * feat: Land * refactor: Admin권한 추가 * feat: 복덕방 삭제 테스트 코드 추가 * refactor: 복덕방 삭제 테스트 공백 추가 --------- Co-authored-by: Jang Jun Young * refactor: 호환성 유지하기 위한 오너 회원가입 휴대폰번호 양식 롤백 (#614) * refactor: 회원가입 휴대폰번호 양식 롤백 * refactor: 오너 회원가입 휴대폰번호 양식 테스트코드 롤백 * refactor: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * feat : coop_id 관련 flyway 추가 (#618) * feat : coop_id 관련 flyway 추가 * chore : 리뷰 반영(flyway 분리) * refactor: 시내버스 정류장 불일치 해결 (#601) * fix: 시내버스 노선 캐시 저장 로직 수정 * refactor: 정류장 추가 * refactor: 정류장 조회 로직 수정 * refactor: 테스트 수정 * fix : 품절 알림 처리 순서 수정 (#611) * fix : 품절 캐시 저장, 알림 발송 순서 수정 * fix : dining 응답 0 -> null 반환으로 수정 * refactor: sms회원가입 redis초기화 로직 변경 (#622) * refactor: sms회원가입 redi초기화 로직 변경 * refactor: eventListener 메소드 이름 변경 * refactor: 테스트 코드 변경 --------- Co-authored-by: HyeonsuLee * fix: 상점 수정, 삭제시 운영 요일 형식 검증 (#624) * feat: 상점 운영시간 요일 형식 검증 추가 * feat: Inner Record에 @Valid추가 * refactor: 필요없는 어노테이션 삭제 --------- Co-authored-by: HyeonsuLee * feat: 사업자번호, 아이디 중복검증 추가 (#610) * feat: 사업자등록번호 검증 추가 * feat: 전화번호 중복 검증 추가 * refactor: 전화번호 중복 사장님으로 이관 * refactor: phone_number -> account * test: 테스트 수정 * refactor: check -> exists * fix : Kcal가 null 일 경우 0을 반환하도록 롤백 (#626) * fix: CityBusRoute 저장시 null 제외 추가 (#629) * feat: 어드민권한 상점 관련 api작성 (#619) * feat: 컨트롤러 작성 * feat: 상점 메뉴관련 조회 api 구현 * feat: 상점 메뉴관련 조회 api 구현 2 * feat: 특정 상점 메뉴 조회 테스트코드 작성 * feat: 테스트코드 작성 * feat: 테스트코드 작성 * refactor: 불필요한 공백 제거 * refactor: 리뷰 반영 --------- Co-authored-by: HyeonsuLee * feat: 어드민 복덕방 조회,수정,삭제취소 (#631) * feat: 복덕방 조회 컨트롤러 구현 * feat: 복덕방 조회 서비스 구현 * feat: 복덕방 조회 테스트 구현 * feat: 복덕방 수정,삭제취소 구현 * feat: 복덕방 수정,삭제취소 서비스 구현 * refactor: land 모델 수정 * refactor: land 테스트 케이스 추가 * refactor: 어드민 dto 수정 * feat: 어드민 수정, 삭제취소 테스트 구현 * refactor: 라인 포맷팅 * refactor: 테스트 코드 수정 * refactor: dto반환형식 변경 * refactor: 라인포맷팅 --------- Co-authored-by: Jang Jun Young * feat: 어드민 82~86,89,90,93~95 (#621) * feat: 컨트롤러 구현 * feat: 사장님 인증권한 허용 * feat: 사장님 인증권한 허용 * feat: 테스트 추가 * feat: 테스트 import문 사용 * feat: 학생 리스트 조회 구현 * feat: 어드민 특정 사장님 수정 * feat: 어드민 사장님 페이지네이션 * feat: 어드민 회원 삭제 * feat: 어드민 로그인 구현 * feat: 어드민 로그아웃 구현 * feat: 어드민 리프레쉬 구현 * feat: 어드민 회원 조회 * fix: 페이지네이션 오류 수정 * test: 테스트 구현 * test: 나머지 모든 테스트 구현 * chore: 개행 처리 * chore: 개행 처리 * chore: 개행 처리 * chore: 이상한 설명 수정 * feat: ModelAttribute 파라미터로 전달되게 변경 * refactor: 사장님 인증 shop에 owner_id 할당되게 변경 * feat: 회원 탈퇴 취소 API 구현 * refactor: 83~86 충돌 수정 * refactor: 사장님인증 후 OwnerShop redis삭제 추가 * refactor: 사장님인증 테스트 수정 * refactor: AdminStudentResponse 메서드 of -> from 변경 * refactor: shopId null체크 * refactor: GrantShop 로직 수정, 로그인 save()제거 * refactor: 응답객체 수정 * refactor: 충돌 수정 * refactor: 충돌 수정 * refactor: 테스트 수정 * refactor: 응답반환수정 --------- Co-authored-by: seongjae6751 * feat: 어드민권한 상점, 카테고리 api작성 (#627) * fix : notification FK 회원 삭제 오류 수정 (#514) * fix : notification/notification_subscribe DELETE CASCADE로 변경 * chore : Front 요청으로 인한 회원가입 에러코드 409 추가 * chore : DB 생략 * fix: 에러 반환값 수정 (#517) * hotfix: 학생 회원 가입 시에 전화번호 형식 추가 허용 (#530) * chore: 회원가입 전화번호 형식 추가 허용 * chore: 전화번호 가운데 세자리도 되게 허용 * chore: 비밀번호 틀렸을시에 400 반환하는 것으로 수정 (#605) * feat: controller 작성 * feat: service, repository 작성 * test: 어드민 shop, category 테스트 작성 * refactor: 1차 피드백 반영 * fix: 버그 수정 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 최준호 Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: 송선권 * refactor: ADMIN 권한 추가 (#638) * fix: 상점 생성,수정 / 메뉴 추가,수정 버그 해결 (#644) * feat: DTO수정 * feat: imageUrl이 null인경우를 위한 DTO 생성자 추가 * feat: 상점 운영시간 DTO validation 문구 추가 * feat: 상점 운영시간 DTO 개수 제한 수정 --------- Co-authored-by: HyeonsuLee * feat: new timetable api 구현 (#615) * feat: flyway 추가 * refactor: flyway 수정 * feat: timetable frame api 중 post, get, delete 구현 (#592) * feat : 기본 Model 및 Repository 생성 * chore : isMain 추가 * feat: timetablesFrame post, get delete 기능 구현 * chore: 겹치는 로직 수정 및 일부 버그 수정 * feat: 회원 탈퇴시 timetableframe 및 timetable 수동 삭제 * feat: 삭제 전 검증 로직 추가 * chore: api 오타 수정 * chore: frame 빌더 파라미터 추가 * chore: snakecase로 dto 변경 * feat: lecture repo 메서드 추가 * test: timetable v2 test 코드 작성 * chore: 오타 수정 * feat: 삭제되는 frame이 main일때 다른 frame을 main으로 설정 * chore: 개행 제거 * chore: 개행 추가 * chore: 어색한 말 수정 * chore: 오타 수정 * chore: 변수명 및 개행 수정 * chore: 피드백 반영 * chore: 메서드 명 변경 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * feat: timetableFrame put, timetableLecture delete 구현 & reafactor: 기존 timetable delete, get 수정 (#594) * feat : 기본 Model 및 Repository 생성 * refactor: 기존 delete timetable 새로운 db와 연결 & v2 api 추가 * feat: tiemtable frame 수정 api 구현 * refactor: tiemtable frame 수정 api 반환값 dto 추가 * refactor: PUT timetable/frame에서 main으로 업데이트할 시 기존 main 테이블의 is_main을 false로 수정 * refactor: Get /timetables api 변경된 테이블 구조에 맞게 수정 * refactor: timeTable -> timetable로 변경 * refactor: deleteTimetableLecture() 유저 본인의 시간표에서 삭제하는지 검증 추가 * refactor: repository에서 main timetable을 가져오게 수정 * refactor: url 앞에 / 추가 & 기존에 없던 api url에 v2제거 * refactor: TimetableFrameUpdateRequest, TimetableFrameUpdateResponse에서 name Schema 수정 * refactor: TimetableFrameUpdateResponse에서 semester 대신 id를 반환하도록 수정 * refactor: timeTable -> timetable 이름 변경 * refactor: TimetableFrame에서 필요 없는 isMain()함수 제거 * refactor: LectureRepository에서 findByIdIn를 사용해 Lecture List를 가져오도록 수정 * refactor: TimetableLecture와 TimetableFrame에서 is_deleted=0으로 찾는 where 제거 * refactor: timetableFrame 수정 시 semester 변경 못하도록 수정 * fix: is_main 타입변경, snakecase적용되도록 수정 & test: timetableframe 수정 api, timetablelecture 삭제 api 테스트 작성 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * feat: GET/semester/check수정 & POST/timetables수정 & POST/v2/timetables/lecture 추가 (#596) * feat : 기본 Model 및 Repository 생성 * chore : isMain 추가 * feat: 시간표 생성 수정 및 추가 * feat: 강의시간표 응답 수정 * feat: 코드 오류 수정 * feat: API 수정 * feat: pr 수정 * feat: repository semester타입 수정 * fear : flyway 추가 * fear : flyway 추가 * fear : Lecture id 반환 추가 * fear : PUT /timetables 수정 및 /v2/timetables 추가 * feat: getTimetables API 수정 * refactor: PR 리뷰 반영 * fear : 리뷰 반영 * refactor : 코드 리팩터링 * refactor: 충돌 수정 * refactor: 충돌 수정 * refactor: 로직 수정 및 학점추가 * refactor: 학점 계산 로직 수정 * refactor: 오류 수정 * refactor: 코드 수정 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * refactor: flyway timetable_id -> frame_id 변경 * refactor: TimetableFrame에 @Where(clause = is_deleted=0) 추가 * refactor: frame api의 url에 v2추가 * refactor: TimetableLectures create,update 문에 user 인증 추가 * refactor : TimetableUpdate에 id가 null인 경우 삭제 * refactor: swagger 수정 * refactor: v2 패키지로 이동 * fix: v2 api 이름에 v2 추가 * chore : 리뷰 반영(Repo 미사용 메소드 삭제) * chore : 리뷰 반영(isMain boolean으로 수정) * chore : 리뷰 반영(변수 추가 및 getIsMain() -> isMain() 수정) * chore : 리뷰 반영 * refactor: createTimetablesFrame에서 main 시간표 count 시 0부터 세도록 수정 * fix: createTimetablesFrame에서 이름 생성할 때 카운트 +1하도록 수정 * fix: createTimetablesFrame에서 카운트 +1할 때 괄호 추가 --------- Co-authored-by: kwoo28 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> * fix : flyway 버저닝 충돌 해결 (#646) * fix : 롤백 이후 flyway 오류 수정(timetable_id -> frame_id) (#648) * refactor: internalName 필수요구 제거 (#650) Co-authored-by: Jang Jun Young --------- Co-authored-by: HyeonsuLee Co-authored-by: 최준호 Co-authored-by: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: 박성빈 <46699595+ImTotem@users.noreply.github.com> Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: 송선권 Co-authored-by: 배진호 <72592302+BaeJinho4028@users.noreply.github.com> Co-authored-by: Jang-JunYoung <79901434+johnny19991006@users.noreply.github.com> Co-authored-by: Jang Jun Young Co-authored-by: seongjae6751 Co-authored-by: Dahee Park <106418303+daheeParkk@users.noreply.github.com> * fix: git 충돌 해결 * fix: git 충돌 해결 --------- Co-authored-by: duehee <149302959+duehee@users.noreply.github.com> Co-authored-by: 최준호 Co-authored-by: 김성재 <103095432+seongjae6751@users.noreply.github.com> Co-authored-by: 송선권 Co-authored-by: Hyeonsu Lee <127578418+20HyeonsuLee@users.noreply.github.com> Co-authored-by: HyeonsuLee Co-authored-by: Hwang HyeonSik <142300831+Choon0414@users.noreply.github.com> Co-authored-by: YunYongWoon <46861704+YunYongWoon@users.noreply.github.com> Co-authored-by: 박성빈 <46699595+ImTotem@users.noreply.github.com> Co-authored-by: 김원경 <148550522+kwoo28@users.noreply.github.com> Co-authored-by: 허준기 <112807640+dradnats1012@users.noreply.github.com> Co-authored-by: Jang-JunYoung <79901434+johnny19991006@users.noreply.github.com> Co-authored-by: Jang Jun Young Co-authored-by: seongjae6751 Co-authored-by: Dahee Park <106418303+daheeParkk@users.noreply.github.com> --- .../in/koreatech/koin/admin/acceptance/AdminShopApiTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java index 81ecda526..52292faaa 100644 --- a/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/admin/acceptance/AdminShopApiTest.java @@ -198,7 +198,7 @@ void findShop() { "is_deleted": false, "is_event": false, "bank": "국민", - "account_number": "01022595923" + "account_number": "01022595923" } """); }