diff --git a/back/build.gradle b/back/build.gradle index 379129f0b3..488a335860 100644 --- a/back/build.gradle +++ b/back/build.gradle @@ -42,6 +42,8 @@ dependencies { //Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' +// implementation group: 'io.springfox', name: 'springfox-swagger2', version: '3.0.0' +// implementation group: 'io.springfox', name: 'springfox-swagger-ui', version: '3.0.0' //JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java b/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java index 60f6c5d66c..98f3f820b1 100644 --- a/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java +++ b/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java @@ -1,19 +1,23 @@ package com.example.capstone.domain.announcement.controller; import com.example.capstone.domain.announcement.dto.AnnouncementListResponse; +import com.example.capstone.domain.announcement.dto.AnnouncementListWrapper; import com.example.capstone.domain.announcement.entity.Announcement; import com.example.capstone.domain.announcement.service.AnnouncementCallerService; import com.example.capstone.domain.announcement.service.AnnouncementSearchService; +import com.example.capstone.global.dto.ApiResult; 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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Slice; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.HashMap; import java.util.List; -import java.util.Map; @Slf4j @RestController @@ -24,8 +28,10 @@ public class AnnouncementController { private final AnnouncementSearchService announcementSearchService; @PostMapping("/test") - @Operation(summary = "공지사항 크롤링", description = "강제로 공지사항 크롤링을 시킴 (테스트용)") - ResponseEntity test(@RequestParam(value = "key") String key) { + @Operation(summary = "공지사항 크롤링", description = "강제로 공지사항 크롤링을 진행합니다.") + ResponseEntity test( + @Parameter(description = "해당 매서드를 실행하기 위해서 관리자 키가 필요합니다.", required = true) + @RequestParam(value = "key") String key) { announcementSearchService.testKeyCheck(key); announcementCallerService.crawlingAnnouncement(); return ResponseEntity @@ -33,10 +39,17 @@ ResponseEntity test(@RequestParam(value = "key") String key) { } @GetMapping("") - @Operation(summary = "공지사항 받아오기", description = "현재 페이지네이션 구현 안됨. 전부다 줌!!") - ResponseEntity> getAnnouncementList( + @Operation(summary = "공지사항 받아오기", description = "커서기반으로 공지사항을 받아옵니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정보 받기 성공"), + @ApiResponse(responseCode = "400", description = "정보 받기 실패", content = @Content(mediaType = "application/json")) + }) + ResponseEntity> getAnnouncementList( + @Parameter(description = "공지사항 유형입니다. 입력하지 않으면 전체를 받아옵니다.") @RequestParam(defaultValue = "all", value = "type") String type, + @Parameter(description = "공지사항 언어입니다. 입력하지 않으면 한국어로 받아옵니다.") @RequestParam(defaultValue = "KO", value = "language") String language, + @Parameter(description = "어디까지 로드됐는지 가르키는 커서입니다. 입력하지 않으면 처음부터 10개 받아옵니다.") @RequestParam(defaultValue = "0", value = "cursor") long cursor ) { Slice slice = announcementSearchService.getAnnouncementList(cursor, type, language); @@ -44,23 +57,28 @@ ResponseEntity> getAnnouncementList( List announcements = slice.getContent(); boolean hasNext = slice.hasNext(); - Map response = new HashMap<>(); - response.put("announcements", announcements); - response.put("hasNext", hasNext); - if(hasNext && !announcements.isEmpty()){ + AnnouncementListWrapper response = new AnnouncementListWrapper(null, hasNext, announcements); + + + if (hasNext && !announcements.isEmpty()) { AnnouncementListResponse lastAnnouncement = announcements.get(announcements.size() - 1); - response.put("lastCursorId", lastAnnouncement.id()); + response.setLastCursorId(lastAnnouncement.id()); } - return ResponseEntity.ok(response); + return ResponseEntity + .ok(new ApiResult<>(response)); } @GetMapping("/{announcementId}") - ResponseEntity getAnnouncementDetail(@PathVariable(value = "announcementId") long announcementId) { + @Operation(summary = "공지사항 세부정보 받아오기", description = "공지사항의 세부적인 내용을 받아옵니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정보 받기 성공"), + @ApiResponse(responseCode = "400", description = "정보 받기 실패", content = @Content(mediaType = "application/json")) + }) + ResponseEntity> getAnnouncementDetail(@PathVariable(value = "announcementId") long announcementId) { Announcement announcement = announcementSearchService.getAnnouncementDetail(announcementId); return ResponseEntity - .ok(announcement); + .ok(new ApiResult<>(announcement)); } - } diff --git a/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListResponse.java b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListResponse.java index 525705ccc9..43c1a8a30a 100644 --- a/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListResponse.java +++ b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListResponse.java @@ -1,5 +1,7 @@ package com.example.capstone.domain.announcement.dto; +import com.example.capstone.domain.announcement.entity.Announcement; + import java.time.LocalDate; public record AnnouncementListResponse( diff --git a/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListWrapper.java b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListWrapper.java new file mode 100644 index 0000000000..0ee1e2448f --- /dev/null +++ b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListWrapper.java @@ -0,0 +1,20 @@ +package com.example.capstone.domain.announcement.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class AnnouncementListWrapper{ + @JsonInclude(JsonInclude.Include.NON_NULL) + private Long lastCursorId; + + private Boolean hasNext; + private List announcements; + + public void setLastCursorId(Long lastCursorId){ + this.lastCursorId = lastCursorId; + } +} diff --git a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java index 224148c8bf..b20b7acab6 100644 --- a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java +++ b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java @@ -78,19 +78,19 @@ public void crawlKookminAnnouncement(AnnouncementUrl url) { String authorPhone = (phoneElement != null) ? phoneElement.text().replace("☎ ", "") : "No Phone"; Elements fileInfo = doc.select("div.board_atc.file > ul > li > a"); String html = doc.select(".view_inner").outerHtml(); - boolean flag = false; Translator translator = new Translator(authKey); for (String language : languages) { - String[] basicInfo = {title, department}; + String translatedTitle = title; + String translatedDepartment = department; if (!language.equals("KO")) { - basicInfo = translator.translateText(title + "&" + department, "KO", language) - .getText().split("&"); + translatedTitle = translator.translateText(title, "KO", language).getText(); + translatedDepartment = translator.translateText(department, "KO", language).getText(); } - Optional check = announcementRepository.findByTitle(basicInfo[0]); + Optional check = announcementRepository.findByTitle(translatedTitle); if (!check.isEmpty()) continue; String document = html; @@ -117,10 +117,10 @@ public void crawlKookminAnnouncement(AnnouncementUrl url) { Announcement announcement = Announcement.builder() .type(type) - .title(basicInfo[0]) + .title(translatedTitle) .author(author) .authorPhone(authorPhone) - .department(basicInfo[1]) + .department(translatedDepartment) .writtenDate(LocalDate.parse(writeDate, formatter)) .document(document) .language(language) @@ -154,7 +154,7 @@ public void crawlInternationlAnnouncement(AnnouncementUrl url) { String dateStr = dateElements.text(); LocalDate noticeDate = LocalDate.parse(dateStr, formatter); - if (noticeDate.isAfter(currentDate.minusMonths(5))) { + if (noticeDate.isAfter(currentDate.minusDays(1))) { Elements linkElements = announcement.select("td.b-td-left a"); if (!linkElements.isEmpty()) { String href = linkElements.attr("href"); @@ -195,14 +195,15 @@ public void crawlInternationlAnnouncement(AnnouncementUrl url) { String html = doc.select(".b-content-box").outerHtml(); for (String language : languages) { - String[] basicInfo = {title, department}; + String translatedTitle = title; + String translatedDepartment = department; if (!language.equals("KO")) { - basicInfo = translator.translateText(title + "&" + department, "KO", language) - .getText().split("&"); + translatedTitle = translator.translateText(title, "KO", language).getText(); + translatedDepartment = translator.translateText(department, "KO", language).getText(); } - Optional check = announcementRepository.findByTitle(basicInfo[0]); + Optional check = announcementRepository.findByTitle(translatedTitle); if (!check.isEmpty()) continue; String document = html; @@ -216,10 +217,10 @@ public void crawlInternationlAnnouncement(AnnouncementUrl url) { Announcement announcement = Announcement.builder() .type(url.getType()) - .title(basicInfo[0]) + .title(translatedTitle) .author(author) .authorPhone(authorPhone) - .department(basicInfo[1]) + .department(translatedDepartment) .writtenDate(LocalDate.parse(writeDate, formatter)) .document(document) .language(language) @@ -237,7 +238,7 @@ public void crawlInternationlAnnouncement(AnnouncementUrl url) { } private String translateRecursive(String html, String language, int part, Translator translator){ - if(part == 5) return ""; + if(part == 11) return ""; String document = ""; try { diff --git a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementSearchService.java b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementSearchService.java index 4a13d1171a..afc18a68f8 100644 --- a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementSearchService.java +++ b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementSearchService.java @@ -9,9 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; +import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/back/src/main/java/com/example/capstone/domain/auth/controller/AuthController.java b/back/src/main/java/com/example/capstone/domain/auth/controller/AuthController.java index eb3f79df29..7555779108 100644 --- a/back/src/main/java/com/example/capstone/domain/auth/controller/AuthController.java +++ b/back/src/main/java/com/example/capstone/domain/auth/controller/AuthController.java @@ -3,6 +3,8 @@ import com.example.capstone.domain.auth.dto.ReissueRequest; import com.example.capstone.domain.auth.dto.TokenResponse; import com.example.capstone.domain.auth.service.AuthService; +import com.example.capstone.global.dto.ApiResult; +import com.google.protobuf.Api; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,10 +23,9 @@ public class AuthController { private final AuthService authService; @PostMapping("/reissue") - public ResponseEntity reissue(@RequestBody @Valid ReissueRequest reissueRequest) { + public ResponseEntity> reissue(@RequestBody @Valid ReissueRequest reissueRequest) { TokenResponse tokenResponse = authService.reissueToken(reissueRequest.refreshToekn()); return ResponseEntity - .ok() - .body(tokenResponse); + .ok(new ApiResult<>(tokenResponse)); } } diff --git a/back/src/main/java/com/example/capstone/domain/menu/controller/MenuController.java b/back/src/main/java/com/example/capstone/domain/menu/controller/MenuController.java index b80b4c4718..43249305d9 100644 --- a/back/src/main/java/com/example/capstone/domain/menu/controller/MenuController.java +++ b/back/src/main/java/com/example/capstone/domain/menu/controller/MenuController.java @@ -3,6 +3,7 @@ import com.example.capstone.domain.menu.service.MenuCrawlingService; import com.example.capstone.domain.menu.service.MenuSearchService; import com.example.capstone.domain.menu.service.MenuUpdateService; +import com.example.capstone.global.dto.ApiResult; import io.swagger.v3.oas.annotations.Operation; import jakarta.json.JsonArray; import lombok.RequiredArgsConstructor; @@ -21,9 +22,10 @@ public class MenuController { @ResponseBody @GetMapping("/daily") - public ResponseEntity getMenuByDate(@RequestParam LocalDate date, @RequestParam String language){ + public ResponseEntity> getMenuByDate(@RequestParam LocalDate date, @RequestParam String language){ JsonArray menu = menuSearchService.findMenuByDate(date, language); - return ResponseEntity.ok(menu); + return ResponseEntity + .ok(new ApiResult<>(menu)); } @PostMapping("/test") diff --git a/back/src/main/java/com/example/capstone/domain/menu/service/DecodeUnicodeService.java b/back/src/main/java/com/example/capstone/domain/menu/service/DecodeUnicodeService.java index f311dfcaa4..58e848f6d8 100644 --- a/back/src/main/java/com/example/capstone/domain/menu/service/DecodeUnicodeService.java +++ b/back/src/main/java/com/example/capstone/domain/menu/service/DecodeUnicodeService.java @@ -1,31 +1,31 @@ -package com.example.capstone.domain.menu.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -import java.util.concurrent.CompletableFuture; - -@Service -@RequiredArgsConstructor -public class DecodeUnicodeService { - - @Async - public CompletableFuture decodeUnicode(String str) { - StringBuilder builder = new StringBuilder(); - int i = 0; - while(i < str.length()) { - char ch = str.charAt(i); - if(ch == '\\' && i + 1 < str.length() && str.charAt(i + 1) == 'u') { - int codePoint = Integer.parseInt(str.substring(i + 2, i + 6), 16); - builder.append(Character.toChars(codePoint)); - i += 6; - } - else { - builder.append(ch); - i++; - } - } - return CompletableFuture.completedFuture(builder.toString()); - } -} +package com.example.capstone.domain.menu.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +public class DecodeUnicodeService { + + @Async + public CompletableFuture decodeUnicode(String str) { + StringBuilder builder = new StringBuilder(); + int i = 0; + while(i < str.length()) { + char ch = str.charAt(i); + if(ch == '\\' && i + 1 < str.length() && str.charAt(i + 1) == 'u') { + int codePoint = Integer.parseInt(str.substring(i + 2, i + 6), 16); + builder.append(Character.toChars(codePoint)); + i += 6; + } + else { + builder.append(ch); + i++; + } + } + return CompletableFuture.completedFuture(builder.toString()); + } +} diff --git a/back/src/main/java/com/example/capstone/domain/menu/service/MenuCrawlingService.java b/back/src/main/java/com/example/capstone/domain/menu/service/MenuCrawlingService.java index af2413961a..9c3d9e079a 100644 --- a/back/src/main/java/com/example/capstone/domain/menu/service/MenuCrawlingService.java +++ b/back/src/main/java/com/example/capstone/domain/menu/service/MenuCrawlingService.java @@ -1,35 +1,35 @@ -package com.example.capstone.domain.menu.service; - -import com.example.capstone.global.error.exception.BusinessException; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; - -import static com.example.capstone.global.error.exception.ErrorCode.TEST_KEY_NOT_VALID; - -@Service -@RequiredArgsConstructor -public class MenuCrawlingService { - - private final MenuUpdateService menuUpdateService; - - @Value("${test.key}") - private String testKey; - - public boolean testKeyCheck(String key){ - if(key.equals(testKey)) return true; - else throw new BusinessException(TEST_KEY_NOT_VALID); - } - - @Scheduled(cron = "0 0 4 * * MON") - public void crawlingMenus(){ - LocalDateTime startDay = LocalDateTime.now(); - - for(int i=0; i<7; i++){ - menuUpdateService.updateMenus(startDay.plusDays(i)); - } - } -} +package com.example.capstone.domain.menu.service; + +import com.example.capstone.global.error.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +import static com.example.capstone.global.error.exception.ErrorCode.TEST_KEY_NOT_VALID; + +@Service +@RequiredArgsConstructor +public class MenuCrawlingService { + + private final MenuUpdateService menuUpdateService; + + @Value("${test.key}") + private String testKey; + + public boolean testKeyCheck(String key){ + if(key.equals(testKey)) return true; + else throw new BusinessException(TEST_KEY_NOT_VALID); + } + + @Scheduled(cron = "0 0 4 * * MON") + public void crawlingMenus(){ + LocalDateTime startDay = LocalDateTime.now(); + + for(int i=0; i<7; i++){ + menuUpdateService.updateMenus(startDay.plusDays(i)); + } + } +} diff --git a/back/src/main/java/com/example/capstone/domain/menu/service/MenuSearchService.java b/back/src/main/java/com/example/capstone/domain/menu/service/MenuSearchService.java index 5088bc5623..75a391d57a 100644 --- a/back/src/main/java/com/example/capstone/domain/menu/service/MenuSearchService.java +++ b/back/src/main/java/com/example/capstone/domain/menu/service/MenuSearchService.java @@ -1,45 +1,45 @@ -package com.example.capstone.domain.menu.service; - -import com.example.capstone.domain.menu.entity.Menu; -import com.example.capstone.domain.menu.repository.MenuRepository; -import jakarta.json.Json; -import jakarta.json.JsonArray; -import jakarta.json.JsonArrayBuilder; -import jakarta.json.JsonObjectBuilder; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class MenuSearchService { - private final MenuRepository menuRepository; - - public JsonArray findMenuByDate(LocalDate date, String language) { - LocalDateTime dateTime = LocalDateTime.of(date, LocalTime.MIN); - List cafeList = menuRepository.findMenuCafeByDateAndLang(dateTime, language); - - JsonArrayBuilder infos = Json.createArrayBuilder(); - - for(String cafe : cafeList) { - List menuList = menuRepository.findMenuByDateAndCafeteria(dateTime, cafe, language); - JsonArrayBuilder subInfos = Json.createArrayBuilder(); - for(Menu menu : menuList) { - JsonObjectBuilder menuInfo = Json.createObjectBuilder() - .add("메뉴", menu.getName()) - .add("가격", menu.getPrice().toString()); - subInfos.add(menuInfo); - } - JsonObjectBuilder cafeInfo = Json.createObjectBuilder() - .add(cafe, Json.createObjectBuilder() - .add(dateTime.toLocalDate().toString(), subInfos)); - infos.add(cafeInfo); - } - - return infos.build(); - } -} +package com.example.capstone.domain.menu.service; + +import com.example.capstone.domain.menu.entity.Menu; +import com.example.capstone.domain.menu.repository.MenuRepository; +import jakarta.json.Json; +import jakarta.json.JsonArray; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonObjectBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MenuSearchService { + private final MenuRepository menuRepository; + + public JsonArray findMenuByDate(LocalDate date, String language) { + LocalDateTime dateTime = LocalDateTime.of(date, LocalTime.MIN); + List cafeList = menuRepository.findMenuCafeByDateAndLang(dateTime, language); + + JsonArrayBuilder infos = Json.createArrayBuilder(); + + for(String cafe : cafeList) { + List menuList = menuRepository.findMenuByDateAndCafeteria(dateTime, cafe, language); + JsonArrayBuilder subInfos = Json.createArrayBuilder(); + for(Menu menu : menuList) { + JsonObjectBuilder menuInfo = Json.createObjectBuilder() + .add("메뉴", menu.getName()) + .add("가격", menu.getPrice().toString()); + subInfos.add(menuInfo); + } + JsonObjectBuilder cafeInfo = Json.createObjectBuilder() + .add(cafe, Json.createObjectBuilder() + .add(dateTime.toLocalDate().toString(), subInfos)); + infos.add(cafeInfo); + } + + return infos.build(); + } +} diff --git a/back/src/main/java/com/example/capstone/domain/menu/service/MenuUpdateService.java b/back/src/main/java/com/example/capstone/domain/menu/service/MenuUpdateService.java index 1c0638e044..446c915280 100644 --- a/back/src/main/java/com/example/capstone/domain/menu/service/MenuUpdateService.java +++ b/back/src/main/java/com/example/capstone/domain/menu/service/MenuUpdateService.java @@ -1,82 +1,82 @@ -package com.example.capstone.domain.menu.service; - -import com.deepl.api.Translator; -import com.example.capstone.domain.menu.entity.Menu; -import com.example.capstone.domain.menu.repository.MenuRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.math.NumberUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -@Service -@RequiredArgsConstructor -public class MenuUpdateService { - private final MenuRepository menuRepository; - private final DecodeUnicodeService decodeUnicodeService; - - @Value("${deepl.api.key}") - private String authKey; - - @Async - public void updateMenus(LocalDateTime startTime) { - RestTemplate restTemplate = new RestTemplateBuilder().build(); - String sdate = startTime.format(DateTimeFormatter.ISO_LOCAL_DATE); - String url = "https://kmucoop.kookmin.ac.kr/menu/menujson.php?callback=jQuery112401919322099601417_1711424604017"; - - url += "&sdate=" + sdate + "&edate=" + sdate + "&today=" + sdate + "&_=1711424604018"; - String escapeString = restTemplate.getForObject(url, String.class); - CompletableFuture decode = decodeUnicodeService.decodeUnicode(escapeString); - - Translator translator = new Translator(authKey); - List languages = List.of("KO", "EN-US"); - - try { - String response = decode.get(); - for(String language : languages) { - String json = response.substring(response.indexOf("(") + 1, response.lastIndexOf(")")); - ObjectMapper mapper = new ObjectMapper(); - Map allMap = mapper.readValue(json, Map.class); - - for (Map.Entry cafeEntry : allMap.entrySet()) { - String cafeteria = translator.translateText(cafeEntry.getKey(), null, language).getText(); - - for (Map.Entry dateEntry : ((Map) cafeEntry.getValue()).entrySet()) { - String dateString = dateEntry.getKey(); - LocalDateTime date = LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay(); - - for (Map.Entry sectionEntry : ((Map) dateEntry.getValue()).entrySet()) { - String section = translator.translateText(sectionEntry.getKey(), null, language).getText(); - String name = ""; - Long price = 0L; - - for (Map.Entry context : ((Map) sectionEntry.getValue()).entrySet()) { - if (context.getKey().equals("메뉴") && context.getValue().equals("") == false) { - name = translator.translateText(context.getValue().toString(), null, language).getText(); - } else if (context.getKey().equals("가격")) { - price = NumberUtils.toLong(context.getValue().toString()); - } - } - if (name.equals("") == false) { - menuRepository.save(Menu.builder().cafeteria(cafeteria).section(section).date(date).name(name).price(price).language(language).build()); - } - } - } - } - } - } - catch (Exception e) { - e.printStackTrace(); - } - } +package com.example.capstone.domain.menu.service; + +import com.deepl.api.Translator; +import com.example.capstone.domain.menu.entity.Menu; +import com.example.capstone.domain.menu.repository.MenuRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.math.NumberUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +public class MenuUpdateService { + private final MenuRepository menuRepository; + private final DecodeUnicodeService decodeUnicodeService; + + @Value("${deepl.api.key}") + private String authKey; + + @Async + public void updateMenus(LocalDateTime startTime) { + RestTemplate restTemplate = new RestTemplateBuilder().build(); + String sdate = startTime.format(DateTimeFormatter.ISO_LOCAL_DATE); + String url = "https://kmucoop.kookmin.ac.kr/menu/menujson.php?callback=jQuery112401919322099601417_1711424604017"; + + url += "&sdate=" + sdate + "&edate=" + sdate + "&today=" + sdate + "&_=1711424604018"; + String escapeString = restTemplate.getForObject(url, String.class); + CompletableFuture decode = decodeUnicodeService.decodeUnicode(escapeString); + + Translator translator = new Translator(authKey); + List languages = List.of("KO", "EN-US"); + + try { + String response = decode.get(); + for(String language : languages) { + String json = response.substring(response.indexOf("(") + 1, response.lastIndexOf(")")); + ObjectMapper mapper = new ObjectMapper(); + Map allMap = mapper.readValue(json, Map.class); + + for (Map.Entry cafeEntry : allMap.entrySet()) { + String cafeteria = translator.translateText(cafeEntry.getKey(), null, language).getText(); + + for (Map.Entry dateEntry : ((Map) cafeEntry.getValue()).entrySet()) { + String dateString = dateEntry.getKey(); + LocalDateTime date = LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay(); + + for (Map.Entry sectionEntry : ((Map) dateEntry.getValue()).entrySet()) { + String section = translator.translateText(sectionEntry.getKey(), null, language).getText(); + String name = ""; + Long price = 0L; + + for (Map.Entry context : ((Map) sectionEntry.getValue()).entrySet()) { + if (context.getKey().equals("메뉴") && context.getValue().equals("") == false) { + name = translator.translateText(context.getValue().toString(), null, language).getText(); + } else if (context.getKey().equals("가격")) { + price = NumberUtils.toLong(context.getValue().toString()); + } + } + if (name.equals("") == false) { + menuRepository.save(Menu.builder().cafeteria(cafeteria).section(section).date(date).name(name).price(price).language(language).build()); + } + } + } + } + } + } + catch (Exception e) { + e.printStackTrace(); + } + } } \ No newline at end of file diff --git a/back/src/main/java/com/example/capstone/domain/speech/controller/SpeechController.java b/back/src/main/java/com/example/capstone/domain/speech/controller/SpeechController.java index cbe6d5e36d..7a52f7de32 100644 --- a/back/src/main/java/com/example/capstone/domain/speech/controller/SpeechController.java +++ b/back/src/main/java/com/example/capstone/domain/speech/controller/SpeechController.java @@ -1,6 +1,7 @@ package com.example.capstone.domain.speech.controller; import com.example.capstone.domain.speech.service.SpeechService; +import com.example.capstone.global.dto.ApiResult; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; @@ -26,9 +27,10 @@ public class SpeechController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @Operation(summary = "발음평가 매서드", description = "발음평가 음성파일(.wav)과 비교문을 통해 발음평가 결과를 반환합니다.") @ApiResponse(responseCode = "200", description = "speech-text로 인식된 텍스트, 전체 텍스트 단위 평가 점수, 단어 종합 평가 점수, 각 단어별 평가 내용이 반환됩니다.") - public ResponseEntity uploadSpeech(@RequestPart("file") MultipartFile file, @RequestPart("context") String context) + public ResponseEntity> uploadSpeech(@RequestPart("file") MultipartFile file, @RequestPart("context") String context) throws ExecutionException, InterruptedException, IOException { CompletableFuture result = speechService.pronunciation(context, file); - return ResponseEntity.ok(result.get()); + return ResponseEntity + .ok(new ApiResult<>(result.get())); } } diff --git a/back/src/main/java/com/example/capstone/domain/user/controller/UserController.java b/back/src/main/java/com/example/capstone/domain/user/controller/UserController.java index 29feabbd9e..ef508dd3b1 100644 --- a/back/src/main/java/com/example/capstone/domain/user/controller/UserController.java +++ b/back/src/main/java/com/example/capstone/domain/user/controller/UserController.java @@ -1,14 +1,20 @@ package com.example.capstone.domain.user.controller; +import com.example.capstone.domain.auth.dto.TokenResponse; import com.example.capstone.domain.jwt.PrincipalDetails; import com.example.capstone.domain.user.dto.SigninRequest; -import com.example.capstone.domain.auth.dto.TokenResponse; import com.example.capstone.domain.user.dto.SignupRequest; import com.example.capstone.domain.user.dto.UserProfileUpdateRequest; import com.example.capstone.domain.user.entity.User; import com.example.capstone.domain.user.service.LoginService; import com.example.capstone.domain.user.service.UserService; import com.example.capstone.domain.user.util.UserMapper; +import com.example.capstone.global.dto.ApiResult; +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,45 +32,77 @@ public class UserController { private final UserService userService; @PostMapping("/signup") + @Operation(summary = "회원가입", description = "FireBase로 인증된 유저를 회원가입 시킵니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "회원가입 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "이미 존재하는 이메일", content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", description = "HMAC 인증 실패", content = @Content(mediaType = "application/json")) + }) @ResponseStatus(HttpStatus.CREATED) - public ResponseEntity signup(@RequestHeader(name = "HMAC") String hmac, - @RequestBody @Valid SignupRequest signupRequest) { - //TODO : HMAC을 통한 검증 로직 추가 필요 + public ResponseEntity> signup( + @Parameter(description = "HMAC은 데이터 무결성을 위해서 반드시 Base64로 인코딩해서 보내야됩니다.", required = true) + @RequestHeader(name = "HMAC") String hmac, + @Parameter(description = "HMAC은 해당 Request의 Value들을 |로 구분자로 넣어서 만든 내용으로 만들면 됩니다.", required = true) + @RequestBody @Valid SignupRequest signupRequest) { + loginService.verifyHmac(hmac, signupRequest); loginService.signUp(signupRequest); return ResponseEntity .status(HttpStatus.CREATED) - .body("Successfully Signup"); + .body(new ApiResult<>("Successfully Signup")); } @PostMapping("/signin") - public ResponseEntity signin(@RequestHeader(name = "HMAC") String hmac, + @Operation(summary = "로그인", description = "FireBase로 인증이 완료된 유저를 로그인 시키고 Token을 부여합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "400", description = "존재하지 않는 유저", content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", description = "HMAC 인증 실패", content = @Content(mediaType = "application/json")) + }) + public ResponseEntity> signin(@RequestHeader(name = "HMAC") String hmac, @RequestBody @Valid SigninRequest signinRequest) { - //TODO : HMAC을 통한 검증 로직 추가 필요 + loginService.verifyHmac(hmac, signinRequest); TokenResponse response = loginService.signIn(signinRequest); - return ResponseEntity.ok().body(response); + return ResponseEntity + .ok(new ApiResult<>("Successfully Sign in", response)); } - @GetMapping("") - public ResponseEntity getMyProfile(@AuthenticationPrincipal PrincipalDetails principalDetails) { + @Operation(summary = "내 정보 받아오기", description = "내 정보를 받아옵니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정보 받기 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰", content = @Content(mediaType = "application/json")) + }) + @GetMapping("/me") + public ResponseEntity> getMyProfile(@AuthenticationPrincipal PrincipalDetails principalDetails) { User user = UserMapper.INSTANCE.principalDetailsToUser(principalDetails); return ResponseEntity - .ok() - .body(user); + .ok(new ApiResult<>(user)); } - @PutMapping("") - public ResponseEntity updateProfile(@AuthenticationPrincipal PrincipalDetails principalDetails, + + @PutMapping("/me") + @Operation(summary = "내 정보 수정하기", description = "내 정보를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정보 받기 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰", content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", description = "권한 없음", content = @Content(mediaType = "application/json")) + }) + public ResponseEntity> updateProfile(@AuthenticationPrincipal PrincipalDetails principalDetails, @RequestBody @Valid final UserProfileUpdateRequest userProfileUpdateRequest) { String UUID = principalDetails.getUuid(); User user = userService.updateUser(UUID, userProfileUpdateRequest); return ResponseEntity - .ok(user); + .ok(new ApiResult<>(user)); } @GetMapping("/{userId}") - public ResponseEntity getUserInfo(@PathVariable String userId) { + @Operation(summary = "특정 유저 정보 받기", description = "특정 유저 정보를 받아옵니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정보 받기 성공"), + @ApiResponse(responseCode = "400", description = "존재하지 않는 유저", content = @Content(mediaType = "application/json")), + }) + public ResponseEntity> getUserInfo(@PathVariable String userId) { User user = userService.getUserInfo(userId); return ResponseEntity - .ok(user); + .ok(new ApiResult<>(user)); } } diff --git a/back/src/main/java/com/example/capstone/domain/user/dto/SigninRequest.java b/back/src/main/java/com/example/capstone/domain/user/dto/SigninRequest.java index 35c515fed2..a387b67e7c 100644 --- a/back/src/main/java/com/example/capstone/domain/user/dto/SigninRequest.java +++ b/back/src/main/java/com/example/capstone/domain/user/dto/SigninRequest.java @@ -1,5 +1,6 @@ package com.example.capstone.domain.user.dto; +import com.example.capstone.global.dto.HmacRequest; import jakarta.validation.constraints.Email; import java.util.UUID; @@ -7,5 +8,6 @@ public record SigninRequest( String uuid, @Email String email -) { +) implements HmacRequest { + } diff --git a/back/src/main/java/com/example/capstone/domain/user/dto/SignupRequest.java b/back/src/main/java/com/example/capstone/domain/user/dto/SignupRequest.java index 03e21fb3cf..b8d32f3700 100644 --- a/back/src/main/java/com/example/capstone/domain/user/dto/SignupRequest.java +++ b/back/src/main/java/com/example/capstone/domain/user/dto/SignupRequest.java @@ -1,5 +1,6 @@ package com.example.capstone.domain.user.dto; +import com.example.capstone.global.dto.HmacRequest; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -9,7 +10,8 @@ public record SignupRequest( @Email String email, @NotBlank String name, @NotBlank String country, - @Pattern(regexp = "[0-9]{10,11}") String phoneNumber, + @Pattern(regexp = "^010-\\d{4}-\\d{4}$") String phoneNumber, @NotBlank String major -) { +) implements HmacRequest { + } diff --git a/back/src/main/java/com/example/capstone/domain/user/service/LoginService.java b/back/src/main/java/com/example/capstone/domain/user/service/LoginService.java index c5bf52b472..ee7c4528c3 100644 --- a/back/src/main/java/com/example/capstone/domain/user/service/LoginService.java +++ b/back/src/main/java/com/example/capstone/domain/user/service/LoginService.java @@ -11,16 +11,30 @@ import com.example.capstone.domain.user.exception.UserNotFoundException; import com.example.capstone.domain.user.repository.UserRepository; import com.example.capstone.domain.user.util.UserMapper; +import com.example.capstone.global.dto.HmacRequest; import com.example.capstone.global.error.exception.BusinessException; import com.example.capstone.global.error.exception.EntityNotFoundException; import com.example.capstone.global.error.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.apache.coyote.Request; +import org.apache.tomcat.util.buf.HexUtils; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; import java.util.Collections; +import java.util.Objects; +import static com.example.capstone.global.error.exception.ErrorCode.HMAC_NOT_VALID; import static com.example.capstone.global.error.exception.ErrorCode.USER_NOT_FOUND; @Service @@ -30,6 +44,39 @@ public class LoginService { private final UserRepository userRepository; private final JwtTokenProvider jwtTokenProvider; + @Value("${hmac.secret}") + private String key; + + @Value("${hmac.algorithm}") + private String algorithm; + + public void verifyHmac(String hmac, HmacRequest request) { + try{ + ObjectMapper objectMapper = new ObjectMapper(); + String hashed = calculateHMAC(objectMapper.writeValueAsString(request)); + byte[] decodedBytes = Base64.getDecoder().decode(hmac); + String decoded = HexUtils.toHexString(decodedBytes); + + if(!decoded.equals(hashed)) { + throw new BusinessException(HMAC_NOT_VALID); + } + } + catch (Exception e){ + throw new BusinessException(HMAC_NOT_VALID); + } + } + + private String calculateHMAC(String data) throws NoSuchAlgorithmException, InvalidKeyException { + byte[] decodedKey = Base64.getDecoder().decode(key); + SecretKey secretKey = new SecretKeySpec(decodedKey, algorithm); + Mac hasher = Mac.getInstance(algorithm); + hasher.init(secretKey); + byte[] rawHmac = hasher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + String hashed = HexUtils.toHexString(rawHmac); + + return hashed; + } + public void emailExists(String email) { boolean exist = userRepository.existsByEmail(email); if (exist) throw new AlreadyEmailExistException(email); @@ -47,9 +94,8 @@ public void signUp(SignupRequest dto) { @Transactional public TokenResponse signIn(SigninRequest dto) { String uuid = dto.uuid(); - //TODO : UserNotFoundException 만들고 throw하기 User user = userRepository.findUserById(uuid) - .orElseThrow(() -> new BusinessException(USER_NOT_FOUND)); + .orElseThrow(() -> new UserNotFoundException(uuid)); PrincipalDetails principalDetails = new PrincipalDetails(user.getId(), user.getName(), user.getEmail(), user.getMajor(), user.getCountry(), user.getPhoneNumber(), diff --git a/back/src/main/java/com/example/capstone/global/config/SecurityConfig.java b/back/src/main/java/com/example/capstone/global/config/SecurityConfig.java index 08362af734..09bfff4179 100644 --- a/back/src/main/java/com/example/capstone/global/config/SecurityConfig.java +++ b/back/src/main/java/com/example/capstone/global/config/SecurityConfig.java @@ -43,7 +43,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ .requestMatchers("/favicon.ico").permitAll() .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .requestMatchers( "/swagger-ui/**", "/v3/api-docs/**").permitAll() - .requestMatchers("/api/user").authenticated() + .requestMatchers("/api/user/me").authenticated() .requestMatchers("/api/user/**").permitAll() .requestMatchers("/api/announcement/**").permitAll() .requestMatchers("/api/auth/reissue").permitAll() diff --git a/back/src/main/java/com/example/capstone/global/dto/ApiResult.java b/back/src/main/java/com/example/capstone/global/dto/ApiResult.java new file mode 100644 index 0000000000..3ecf3d5fc6 --- /dev/null +++ b/back/src/main/java/com/example/capstone/global/dto/ApiResult.java @@ -0,0 +1,48 @@ +package com.example.capstone.global.dto; + +import com.example.capstone.global.error.exception.ErrorCode; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.google.protobuf.Api; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +@JsonPropertyOrder({"success", "code", "message", "response"}) +public class ApiResult { + private final boolean success; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String code; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private T response; + + public ApiResult(String message){ + this.success = true; + this.message = message; + } + + public ApiResult(T response){ + this.success = true; + this.response = response; + } + + public ApiResult(String message, T response){ + this.success = true; + this.message = message; + this.response = response; + } + + public ApiResult(ErrorCode error){ + this.success = false; + this.code = error.getCode(); + this.message = error.getMessage(); + } +} diff --git a/back/src/main/java/com/example/capstone/global/dto/ErrorResponse.java b/back/src/main/java/com/example/capstone/global/dto/ErrorResponse.java deleted file mode 100644 index c370cee03e..0000000000 --- a/back/src/main/java/com/example/capstone/global/dto/ErrorResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example.capstone.global.error; - -import com.example.capstone.global.error.exception.ErrorCode; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class ErrorResponse { - - private final int status; - private final String code; - private final String message; - - public static ErrorResponse of(final ErrorCode error) { - return new ErrorResponse(error.getStatus(), error.getCode(), error.getMessage()); - } -} diff --git a/back/src/main/java/com/example/capstone/global/dto/HmacRequest.java b/back/src/main/java/com/example/capstone/global/dto/HmacRequest.java index 56f40b312d..64da64a9bd 100644 --- a/back/src/main/java/com/example/capstone/global/dto/HmacRequest.java +++ b/back/src/main/java/com/example/capstone/global/dto/HmacRequest.java @@ -1,2 +1,5 @@ -package com.example.capstone.global.dto;public interface HmacRequest { -} +package com.example.capstone.global.dto; + +public interface HmacRequest { + +} diff --git a/back/src/main/java/com/example/capstone/global/error/GlobalExceptionHandler.java b/back/src/main/java/com/example/capstone/global/error/GlobalExceptionHandler.java index de3a9ef9ac..c906d941dd 100644 --- a/back/src/main/java/com/example/capstone/global/error/GlobalExceptionHandler.java +++ b/back/src/main/java/com/example/capstone/global/error/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.example.capstone.domain.jwt.JwtTokenProvider; import com.example.capstone.domain.jwt.exception.JwtTokenInvalidException; +import com.example.capstone.global.dto.ApiResult; import com.example.capstone.global.error.exception.BusinessException; import com.example.capstone.global.error.exception.ErrorCode; import com.example.capstone.global.error.exception.InvalidValueException; @@ -25,17 +26,21 @@ public class GlobalExceptionHandler { * 지원하지 않은 HTTP method 호출 할 경우 발생합니다. */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - protected ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + protected ResponseEntity> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { log.error("handleHttpRequestMethodNotSupportedException", e); - final ErrorResponse response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED); - return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED); + final ErrorCode errorCode = ErrorCode.METHOD_NOT_ALLOWED; + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ApiResult<>(errorCode)); } @ExceptionHandler(AccessDeniedException.class) - protected ResponseEntity handleAccessDeniedException(final AccessDeniedException e) { + protected ResponseEntity> handleAccessDeniedException(final AccessDeniedException e) { log.error("handleAccessDeniedException", e); - final ErrorResponse response = ErrorResponse.of(ErrorCode.HANDLE_ACCESS_DENIED); - return new ResponseEntity<>(response, HttpStatus.valueOf(ErrorCode.HANDLE_ACCESS_DENIED.getStatus())); + final ErrorCode errorCode = ErrorCode.HANDLE_ACCESS_DENIED; + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ApiResult<>(errorCode)); } /** @@ -43,10 +48,12 @@ protected ResponseEntity handleAccessDeniedException(final Access * Controller 단에서 발생하여 Error가 넘어옵니다. */ @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) { + public ResponseEntity> handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) { log.error("handleMethodArgumentNotValidException", e); - final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + final ErrorCode errorCode = ErrorCode.INVALID_JWT_TOKEN; + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ApiResult<>(errorCode)); } /** @@ -54,55 +61,63 @@ public ResponseEntity handleMethodArgumentNotValidException(final * {@link JwtTokenProvider}에서 try catch에 의해 넘어옵니다. */ @ExceptionHandler(JwtTokenInvalidException.class) - protected ResponseEntity handleJwtTokenInvalidException(final JwtTokenInvalidException e){ + protected ResponseEntity> handleJwtTokenInvalidException(final JwtTokenInvalidException e){ log.error("handleJwtTokenInvalid", e); final ErrorCode errorCode = e.getErrorCode(); - final ErrorResponse response = ErrorResponse.of(errorCode); - return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus())); + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ApiResult<>(errorCode)); } @ExceptionHandler(RedisConnectionFailureException.class) - protected ResponseEntity handleRedisConnectionFailureException(final RedisConnectionFailureException e){ + protected ResponseEntity> handleRedisConnectionFailureException(final RedisConnectionFailureException e){ log.error("handleJwtTokenInvalid", e); - final ErrorResponse response = ErrorResponse.of(ErrorCode.REDIS_CONNECTION_FAIL); - return new ResponseEntity<>(response, HttpStatus.valueOf(ErrorCode.REDIS_CONNECTION_FAIL.getStatus())); + final ErrorCode erroCode = ErrorCode.REDIS_CONNECTION_FAIL; + return ResponseEntity + .status(erroCode.getStatus()) + .body(new ApiResult<>(erroCode)); } @ExceptionHandler(InvalidValueException.class) - protected ResponseEntity handleInvalidValueException(final InvalidValueException e){ + protected ResponseEntity> handleInvalidValueException(final InvalidValueException e){ log.error("handleInvalidValueException", e); log.error(e.getValue()); final ErrorCode errorCode = e.getErrorCode(); - final ErrorResponse response = ErrorResponse.of(errorCode); - return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus())); + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ApiResult<>(errorCode)); } @ExceptionHandler(BusinessException.class) - protected ResponseEntity handleBusinessException(final BusinessException e) { + protected ResponseEntity> handleBusinessException(final BusinessException e) { log.error("handleBusinessException", e); final ErrorCode errorCode = e.getErrorCode(); - final ErrorResponse response = ErrorResponse.of(errorCode); - return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus())); + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ApiResult<>(errorCode)); } @ExceptionHandler(NoHandlerFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) - protected ResponseEntity handle404(NoHandlerFoundException e){ + protected ResponseEntity> handle404(NoHandlerFoundException e){ return ResponseEntity - .status(e.getStatusCode()) - .body(ErrorResponse.builder() - .status(e.getStatusCode().value()) + .status(e.getStatusCode().value()) + .body(ApiResult.builder() + .success(false) .message(e.getMessage()) - .build()); + .build() + ); } /** * 예상치 못한 오류들은 다 이 곳에서 처리됩니다. */ @ExceptionHandler(Exception.class) - protected ResponseEntity handleUnExpectedException(final Exception e) { + protected ResponseEntity> handleUnExpectedException(final Exception e) { log.error("handleUnExpectedException", e); - final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR); - return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + final ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ApiResult<>(errorCode)); } } diff --git a/back/src/main/java/com/example/capstone/global/error/exception/ErrorCode.java b/back/src/main/java/com/example/capstone/global/error/exception/ErrorCode.java index f7f189b4fb..fe0a382f76 100644 --- a/back/src/main/java/com/example/capstone/global/error/exception/ErrorCode.java +++ b/back/src/main/java/com/example/capstone/global/error/exception/ErrorCode.java @@ -9,7 +9,7 @@ public enum ErrorCode { // Common Error INVALID_INPUT_VALUE(400, "C001", "Invalid Input Value"), - METHOD_NOT_ALLOWED(405, "C002", "Invalid Input Value"), + METHOD_NOT_ALLOWED(405, "C002", "Method Not Allowed"), ENTITY_NOT_FOUND(400, "C003", "Entity Not Found"), INTERNAL_SERVER_ERROR(500, "C004", "Server Error"), INVALID_TYPE_VALUE(400, "C005", "Invalid Type Value"), @@ -30,7 +30,10 @@ public enum ErrorCode { Crawling_FAIL(400, "CR001", "Crawling Failed"), // TestKey Error - TEST_KEY_NOT_VALID(403, "T001", "Test Key is not valid") + TEST_KEY_NOT_VALID(403, "T001", "Test Key is not valid"), + + // HMAC + HMAC_NOT_VALID(403, "HM001", "HMAC is not valid") ; private int status; diff --git a/back/src/test/java/com/example/capstone/BaseIntegrationTest.java b/back/src/test/java/com/example/capstone/BaseIntegrationTest.java new file mode 100644 index 0000000000..371c8a52e7 --- /dev/null +++ b/back/src/test/java/com/example/capstone/BaseIntegrationTest.java @@ -0,0 +1,50 @@ +package com.example.capstone; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.utility.DockerImageName; + +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +public abstract class BaseIntegrationTest { + + @Autowired + protected MockMvc mockMvc; + @Autowired protected ObjectMapper objectMapper; + + private static final String MYSQL_IMAGE = "mysql:8"; + private static final String REDIS_IMAGE = "redis:7-alpine"; + + private static final MySQLContainer mySqlContainer; + + private static final GenericContainer redisContainer; + + static { + mySqlContainer = new MySQLContainer<>(MYSQL_IMAGE); + mySqlContainer.start(); + + redisContainer = new GenericContainer<>(DockerImageName.parse(REDIS_IMAGE)) + .withExposedPorts(6379) + .withReuse(true); + redisContainer.start(); + } + + @DynamicPropertySource + private static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mySqlContainer::getJdbcUrl); + registry.add("spring.datasource.username", mySqlContainer::getUsername); + registry.add("spring.datasource.password", mySqlContainer::getPassword); + registry.add("spring.data.redis.host", redisContainer::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(redisContainer.getMappedPort(6379))); + } +} + diff --git a/back/src/test/java/com/example/capstone/CapstoneApplicationTests.java b/back/src/test/java/com/example/capstone/CapstoneApplicationTests.java index 23d7a86d71..f0036cb09e 100644 --- a/back/src/test/java/com/example/capstone/CapstoneApplicationTests.java +++ b/back/src/test/java/com/example/capstone/CapstoneApplicationTests.java @@ -6,8 +6,4 @@ @SpringBootTest class CapstoneApplicationTests { - @Test - void contextLoads() { - } - } diff --git a/back/src/test/java/com/example/capstone/domain/announcement/controller/AnnouncementControllerTest.java b/back/src/test/java/com/example/capstone/domain/announcement/controller/AnnouncementControllerTest.java new file mode 100644 index 0000000000..03b3718554 --- /dev/null +++ b/back/src/test/java/com/example/capstone/domain/announcement/controller/AnnouncementControllerTest.java @@ -0,0 +1,14 @@ +package com.example.capstone.domain.announcement.controller; + +import com.example.capstone.BaseIntegrationTest; +import org.junit.Before; +import org.junit.jupiter.api.BeforeEach; + +import static org.junit.jupiter.api.Assertions.*; + +class AnnouncementControllerTest extends BaseIntegrationTest { + + @Before + void setUp() { + } +} \ No newline at end of file diff --git a/back/src/test/java/com/example/capstone/domain/user/controller/UserControllerTest.java b/back/src/test/java/com/example/capstone/domain/user/controller/UserControllerTest.java new file mode 100644 index 0000000000..dab1489ca7 --- /dev/null +++ b/back/src/test/java/com/example/capstone/domain/user/controller/UserControllerTest.java @@ -0,0 +1,167 @@ +package com.example.capstone.domain.user.controller; + +import com.example.capstone.BaseIntegrationTest; +import com.example.capstone.domain.user.dto.SignupRequest; +import com.example.capstone.domain.user.entity.User; +import com.example.capstone.domain.user.repository.UserRepository; +import org.apache.tomcat.util.buf.HexUtils; +import org.junit.Before; +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.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.transaction.BeforeTransaction; +import org.springframework.test.web.servlet.ResultActions; + +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class UserControllerTest extends BaseIntegrationTest { + + @Value("${HMAC_SECRET}") + private String key; + + @Value("${HMAC_ALGORITHM}") + private String algorithm; + + final String BASE_URI = "/api/user"; + + @Autowired + private UserRepository userRepository; + + @BeforeTransaction + @DisplayName("사전 유저 데이터 생성") + void set_user(){ + final User user = User.builder() + .id("miku") + .email("dragonborn@naver.com") + .phoneNumber("010-1234-5678") + .major("skyrim") + .name("Hatsune Miku") + .country("Manchestor") + .build(); + + userRepository.save(user); + } + + @Test + @DisplayName("회원가입 성공") + @WithMockUser + void sign_up_success() throws Exception { + //given + final SignupRequest request = new SignupRequest( + "qwrjkjdslkfjlkfs", + "hongildong@naver.com", + "Hong Gill Dong", + "North Korea", + "010-1234-5678", + "A O Ji" + ); + + String data = objectMapper.writeValueAsString(request); + + byte[] decodedKey = Base64.getDecoder().decode(key); + SecretKey secretKey = new SecretKeySpec(decodedKey, algorithm); + Mac hasher = Mac.getInstance(algorithm); + hasher.init(secretKey); + byte[] rawHmac = hasher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + String HMAC = Base64.getEncoder().encodeToString(rawHmac); + + //when + final ResultActions resultActions = mockMvc.perform( + post(BASE_URI + "/signup") + .content(data) + .contentType(MediaType.APPLICATION_JSON) + .header("HMAC", HMAC) + .accept(MediaType.APPLICATION_JSON) + ); + + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("Successfully Signup")); + } + + @Test + @DisplayName("HMAC 오류로 회원가입 실패") + @WithMockUser + void signup_fail_hmac() throws Exception { + //given + final SignupRequest request = new SignupRequest( + "qwrjkjdslkfjlkfs", + "hongildong@naver.com", + "Hong Gill Dong", + "North Korea", + "010-1234-5678", + "A O Ji" + ); + + String data = objectMapper.writeValueAsString(request); + + byte[] decodedKey = Base64.getDecoder().decode(key); + SecretKey secretKey = new SecretKeySpec(decodedKey, algorithm); + Mac hasher = Mac.getInstance(algorithm); + hasher.init(secretKey); + byte[] rawHmac = hasher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + System.out.println(HexUtils.toHexString(rawHmac)); + String HMAC = Base64.getEncoder().encodeToString(rawHmac); + + //when + final ResultActions resultActions = mockMvc.perform( + post(BASE_URI + "/signup") + .content(data) + .contentType(MediaType.APPLICATION_JSON) + .header("HMAC", HMAC + "test") + .accept(MediaType.APPLICATION_JSON) + ); + + resultActions + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("중복 회원 오류로 회원가입 실패") + @WithMockUser + void signup_fail_duplicate() throws Exception { + //given + final SignupRequest request = new SignupRequest( + "miku", + "dragonborn@naver.com", + "Hatsune Miku", + "Manchestor", + "010-1234-5678", + "skyrim" + ); + + String data = objectMapper.writeValueAsString(request); + + byte[] decodedKey = Base64.getDecoder().decode(key); + SecretKey secretKey = new SecretKeySpec(decodedKey, algorithm); + Mac hasher = Mac.getInstance(algorithm); + hasher.init(secretKey); + byte[] rawHmac = hasher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + String HMAC = Base64.getEncoder().encodeToString(rawHmac); + + //when + final ResultActions resultActions = mockMvc.perform( + post(BASE_URI + "/signup") + .content(data) + .contentType(MediaType.APPLICATION_JSON) + .header("HMAC", HMAC) + .accept(MediaType.APPLICATION_JSON) + ); + + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("U001")); + } +} \ No newline at end of file diff --git a/back/src/test/resources/application.properties b/back/src/test/resources/application.properties new file mode 100644 index 0000000000..c542dafb48 --- /dev/null +++ b/back/src/test/resources/application.properties @@ -0,0 +1,26 @@ +spring.application.name=capstone + +spring.web.resources.add-mappings=false +spring.jpa.properties.hibernate.show_sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.hibernate.ddl-auto=create-drop +spring.data.redis.repositories.enabled=false + +jwt.secret=${JWT_SECRET} +jwt.token.access-expiration-time=${JWT_ACCESS_EXPIRATION_TIME} +jwt.token.refresh-expiration-time=${JWT_REFRESH_EXPIRATION_TIME} + +deepl.api.key=${DeepL_API_KEY} +azure.api.key=${6471} + +hmac.secret=${HMAC_SECRET} +hmac.algorithm=${HMAC_ALGORITHM} + +test.key=${TEST_KEY} + +logging.level.com.example.capstone=debug +logging.level.org.springframework.security=trace + +springdoc.swagger-ui.path=/api/swagger-ui.html +springdoc.api-docs.path=/api/api-docs +