feat: 권리확인 게시물 관리 백엔드(가져오기/목록/판정/다운로드) 추가
기관별 권리확인 엑셀(Sheet0, ~1000행)을 업로드해 review_item 테이블에 반영하고, 조사원 항목은 항상 최신화하되 변호사 판정은 웹에서 한번 수정되면(web_edited_at) 재업로드가 덮어쓰지 않는 병합 정책을 적용한다. 목록/단건조회/판정저장/삭제/원본 서식 그대로의 엑셀 다운로드까지 REST로 제공한다.
@09204bfea1cd2addb4581beae7b0a084d4f56ff2
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
... | ... | @@ -5,6 +5,7 @@ |
| 5 | 5 |
import kr.itn.itnhub.feed.ChannelNotReadyException; |
| 6 | 6 |
import kr.itn.itnhub.feed.InvalidMessageException; |
| 7 | 7 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 8 |
+import kr.itn.itnhub.review.ReviewNotFoundException; |
|
| 8 | 9 |
import kr.itn.itnhub.seed.SeedParseException; |
| 9 | 10 |
import kr.itn.itnhub.stage.InvalidStageException; |
| 10 | 11 |
import org.springframework.http.HttpStatus; |
... | ... | @@ -52,6 +53,9 @@ |
| 52 | 53 |
* |
| 53 | 54 |
* <p>{@link InvalidStageException}은 진행단계 변경 요청의 값이 1..12 범위를 벗어났을 때
|
| 54 | 55 |
* 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p> |
| 56 |
+ * |
|
| 57 |
+ * <p>{@link ReviewNotFoundException}은 존재하지 않는 권리확인 게시물 id로 조회/수정/삭제를
|
|
| 58 |
+ * 시도했을 때 던진다 - URL의 id가 단순히 틀린 것뿐이므로 404가 맞다.</p> |
|
| 55 | 59 |
* |
| 56 | 60 |
* <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
|
| 57 | 61 |
* 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리 |
... | ... | @@ -111,4 +115,9 @@ |
| 111 | 115 |
public ResponseEntity<ApiError> handleInvalidStage(InvalidStageException e) {
|
| 112 | 116 |
return ResponseEntity.badRequest().body(new ApiError(e.getMessage())); |
| 113 | 117 |
} |
| 118 |
+ |
|
| 119 |
+ @ExceptionHandler(ReviewNotFoundException.class) |
|
| 120 |
+ public ResponseEntity<ApiError> handleReviewNotFound(ReviewNotFoundException e) {
|
|
| 121 |
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage())); |
|
| 122 |
+ } |
|
| 114 | 123 |
} |
+++ src/main/java/kr/itn/itnhub/review/JudgmentRequest.java
... | ... | @@ -0,0 +1,14 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +/** 권리확인 상세의 [수정] 저장 요청. 전부 선택값이며, 저장 시 web_edited_at이 채워진다. */ | |
| 4 | +public record JudgmentRequest( | |
| 5 | + String openable, | |
| 6 | + String reviewMajor, | |
| 7 | + String reviewMinor, | |
| 8 | + String reviewResult, | |
| 9 | + String judgedKoglType, | |
| 10 | + String judgedAiType, | |
| 11 | + String opinion, | |
| 12 | + String lawyerNote, | |
| 13 | + String needsProcessing) { | |
| 14 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewController.java
... | ... | @@ -0,0 +1,106 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import kr.itn.itnhub.seed.SeedParseException; | |
| 7 | +import org.springframework.http.HttpHeaders; | |
| 8 | +import org.springframework.http.HttpStatus; | |
| 9 | +import org.springframework.http.MediaType; | |
| 10 | +import org.springframework.http.ResponseEntity; | |
| 11 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 12 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 13 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 14 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 15 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 16 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 17 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 18 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 19 | +import org.springframework.web.bind.annotation.RestController; | |
| 20 | +import org.springframework.web.multipart.MultipartFile; | |
| 21 | + | |
| 22 | +import java.io.IOException; | |
| 23 | +import java.io.InputStream; | |
| 24 | +import java.net.URLEncoder; | |
| 25 | +import java.nio.charset.StandardCharsets; | |
| 26 | + | |
| 27 | +/** 기관관리 상세의 권리확인 탭이 부르는 엔드포인트. */ | |
| 28 | +@RestController | |
| 29 | +public class ReviewController { | |
| 30 | + | |
| 31 | + private final ReviewService reviewService; | |
| 32 | + private final ReviewImportService importService; | |
| 33 | + private final OrganizationMapper orgMapper; | |
| 34 | + | |
| 35 | + public ReviewController(ReviewService reviewService, ReviewImportService importService, | |
| 36 | + OrganizationMapper orgMapper) { | |
| 37 | + this.reviewService = reviewService; | |
| 38 | + this.importService = importService; | |
| 39 | + this.orgMapper = orgMapper; | |
| 40 | + } | |
| 41 | + | |
| 42 | + @PostMapping("/api/orgs/{id}/review/import") | |
| 43 | + public ReviewImportReport upload(@PathVariable("id") Long orgId, @RequestParam("file") MultipartFile file) { | |
| 44 | + if (file.isEmpty()) { | |
| 45 | + throw new SeedParseException("업로드된 파일이 비어 있습니다."); | |
| 46 | + } | |
| 47 | + String filename = file.getOriginalFilename(); | |
| 48 | + if (filename == null || !hasAllowedExtension(filename)) { | |
| 49 | + throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다."); | |
| 50 | + } | |
| 51 | + try (InputStream in = file.getInputStream()) { | |
| 52 | + return importService.importFile(orgId, in); | |
| 53 | + } catch (IOException e) { | |
| 54 | + throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + @GetMapping("/api/orgs/{id}/review") | |
| 59 | + public ReviewPage list(@PathVariable("id") Long orgId, | |
| 60 | + @RequestParam(required = false) String keyword, | |
| 61 | + @RequestParam(defaultValue = "0") int page, | |
| 62 | + @RequestParam(defaultValue = "30") int size) { | |
| 63 | + return reviewService.list(orgId, keyword, page, size); | |
| 64 | + } | |
| 65 | + | |
| 66 | + @GetMapping("/api/orgs/{id}/review/{itemId}") | |
| 67 | + public ReviewItem get(@PathVariable("id") Long orgId, @PathVariable Long itemId) { | |
| 68 | + return reviewService.get(orgId, itemId); | |
| 69 | + } | |
| 70 | + | |
| 71 | + @PutMapping("/api/orgs/{id}/review/{itemId}") | |
| 72 | + public ReviewItem update(@PathVariable("id") Long orgId, @PathVariable Long itemId, | |
| 73 | + @RequestBody JudgmentRequest request) { | |
| 74 | + return reviewService.updateJudgment(orgId, itemId, request); | |
| 75 | + } | |
| 76 | + | |
| 77 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 78 | + @DeleteMapping("/api/orgs/{id}/review/{itemId}") | |
| 79 | + public void delete(@PathVariable("id") Long orgId, @PathVariable Long itemId) { | |
| 80 | + reviewService.delete(orgId, itemId); | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** 한글 파일명이라 ChannelFeedController.download와 동일하게 RFC 5987 인코딩을 쓴다. */ | |
| 84 | + @GetMapping("/api/orgs/{id}/review/download") | |
| 85 | + public ResponseEntity<byte[]> download(@PathVariable("id") Long orgId) { | |
| 86 | + Organization org = orgMapper.findById(orgId); | |
| 87 | + if (org == null) { | |
| 88 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 89 | + } | |
| 90 | + byte[] content = reviewService.downloadWorkbook(orgId); | |
| 91 | + | |
| 92 | + String filename = "권리확인_" + org.getOrgName() + ".xlsx"; | |
| 93 | + String encodedName = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); | |
| 94 | + | |
| 95 | + return ResponseEntity.ok() | |
| 96 | + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName) | |
| 97 | + .contentType(MediaType.parseMediaType( | |
| 98 | + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |
| 99 | + .body(content); | |
| 100 | + } | |
| 101 | + | |
| 102 | + private static boolean hasAllowedExtension(String filename) { | |
| 103 | + String lower = filename.toLowerCase(); | |
| 104 | + return lower.endsWith(".xlsx") || lower.endsWith(".xlsm"); | |
| 105 | + } | |
| 106 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewImportReport.java
... | ... | @@ -0,0 +1,4 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +public record ReviewImportReport(int created, int updated, int total) { | |
| 4 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewImportService.java
... | ... | @@ -0,0 +1,88 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | +import org.springframework.stereotype.Service; | |
| 6 | +import org.springframework.transaction.PlatformTransactionManager; | |
| 7 | +import org.springframework.transaction.support.TransactionTemplate; | |
| 8 | + | |
| 9 | +import java.io.InputStream; | |
| 10 | +import java.util.HashSet; | |
| 11 | +import java.util.List; | |
| 12 | +import java.util.Set; | |
| 13 | + | |
| 14 | +@Service | |
| 15 | +public class ReviewImportService { | |
| 16 | + | |
| 17 | + private final ReviewParser parser; | |
| 18 | + private final ReviewItemMapper mapper; | |
| 19 | + private final OrganizationMapper orgMapper; | |
| 20 | + private final TransactionTemplate transactionTemplate; | |
| 21 | + | |
| 22 | + public ReviewImportService(ReviewParser parser, ReviewItemMapper mapper, OrganizationMapper orgMapper, | |
| 23 | + PlatformTransactionManager transactionManager) { | |
| 24 | + this.parser = parser; | |
| 25 | + this.mapper = mapper; | |
| 26 | + this.orgMapper = orgMapper; | |
| 27 | + this.transactionTemplate = new TransactionTemplate(transactionManager); | |
| 28 | + } | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * 엑셀 파싱은 DB와 무관하므로 트랜잭션 밖에서 수행한다(SeedService와 동일한 이유). | |
| 32 | + */ | |
| 33 | + public ReviewImportReport importFile(Long orgId, InputStream xlsx) { | |
| 34 | + if (orgMapper.findById(orgId) == null) { | |
| 35 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 36 | + } | |
| 37 | + List<ReviewRow> rows = parser.parse(xlsx); | |
| 38 | + return transactionTemplate.execute(status -> importRows(orgId, rows)); | |
| 39 | + } | |
| 40 | + | |
| 41 | + private ReviewImportReport importRows(Long orgId, List<ReviewRow> rows) { | |
| 42 | + // 이미 DB에 있는 순번 + 이번 파일에서 먼저 처리된 순번을 모두 "이미 존재함"으로 취급한다 | |
| 43 | + // (SeedService.seedRows와 동일한 관례 - 같은 순번이 파일에 중복돼 있어도 두 번째는 갱신으로 집계). | |
| 44 | + Set<Integer> seen = new HashSet<>(mapper.findSeqsByOrg(orgId)); | |
| 45 | + | |
| 46 | + int created = 0; | |
| 47 | + int updated = 0; | |
| 48 | + for (ReviewRow row : rows) { | |
| 49 | + if (seen.add(row.seq())) { | |
| 50 | + created++; | |
| 51 | + } else { | |
| 52 | + updated++; | |
| 53 | + } | |
| 54 | + mapper.upsertFromFile(toReviewItem(orgId, row)); | |
| 55 | + } | |
| 56 | + return new ReviewImportReport(created, updated, rows.size()); | |
| 57 | + } | |
| 58 | + | |
| 59 | + private ReviewItem toReviewItem(Long orgId, ReviewRow row) { | |
| 60 | + ReviewItem item = new ReviewItem(); | |
| 61 | + item.setOrgId(orgId); | |
| 62 | + item.setSeq(row.seq()); | |
| 63 | + item.setSiteName(row.siteName()); | |
| 64 | + item.setCategory(row.category()); | |
| 65 | + item.setBoardPath(row.boardPath()); | |
| 66 | + item.setBoardName(row.boardName()); | |
| 67 | + item.setPostTitle(row.postTitle()); | |
| 68 | + item.setUrl(row.url()); | |
| 69 | + item.setPostRegistered(row.postRegistered()); | |
| 70 | + item.setProducedDate(row.producedDate()); | |
| 71 | + item.setPublishedDate(row.publishedDate()); | |
| 72 | + item.setHasAttachment(row.hasAttachment()); | |
| 73 | + item.setKoglAttached(row.koglAttached()); | |
| 74 | + item.setKoglType(row.koglType()); | |
| 75 | + item.setAiType(row.aiType()); | |
| 76 | + item.setSurveyorNote(row.surveyorNote()); | |
| 77 | + item.setOpenable(row.openable()); | |
| 78 | + item.setReviewMajor(row.reviewMajor()); | |
| 79 | + item.setReviewMinor(row.reviewMinor()); | |
| 80 | + item.setReviewResult(row.reviewResult()); | |
| 81 | + item.setJudgedKoglType(row.judgedKoglType()); | |
| 82 | + item.setJudgedAiType(row.judgedAiType()); | |
| 83 | + item.setOpinion(row.opinion()); | |
| 84 | + item.setLawyerNote(row.lawyerNote()); | |
| 85 | + item.setNeedsProcessing(row.needsProcessing()); | |
| 86 | + return item; | |
| 87 | + } | |
| 88 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewItem.java
... | ... | @@ -0,0 +1,133 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 권리확인 게시물 한 건. {@code A:V, X, Y} 열이 그대로 대응한다({@code W}는 원본 시트의 | |
| 5 | + * 빈 열이라 컬럼이 없다). 조사원 영역(site_name..surveyor_note)은 항상 파일이 최신값을 | |
| 6 | + * 덮어쓰고, 변호사 영역(openable..needs_processing)은 {@link #webEditedAt}이 null일 때만 | |
| 7 | + * 파일이 덮어쓴다 - 자세한 병합 정책은 {@code ReviewItemMapper.xml}의 upsertFromFile 참고. | |
| 8 | + */ | |
| 9 | +public class ReviewItem { | |
| 10 | + | |
| 11 | + private Long id; | |
| 12 | + private Long orgId; | |
| 13 | + private int seq; | |
| 14 | + | |
| 15 | + // 게시물 정보(조사원) - 재업로드 시 항상 파일 값으로 갱신 | |
| 16 | + private String siteName; | |
| 17 | + private String category; | |
| 18 | + private String boardPath; | |
| 19 | + private String boardName; | |
| 20 | + private String postTitle; | |
| 21 | + private String url; | |
| 22 | + private String postRegistered; | |
| 23 | + private String producedDate; | |
| 24 | + private String publishedDate; | |
| 25 | + private String hasAttachment; | |
| 26 | + private String koglAttached; | |
| 27 | + private String koglType; | |
| 28 | + private String aiType; | |
| 29 | + private String surveyorNote; | |
| 30 | + | |
| 31 | + // 권리확인(변호사) - webEditedAt이 null일 때만 재업로드가 덮어씀 | |
| 32 | + private String openable; | |
| 33 | + private String reviewMajor; | |
| 34 | + private String reviewMinor; | |
| 35 | + private String reviewResult; | |
| 36 | + private String judgedKoglType; | |
| 37 | + private String judgedAiType; | |
| 38 | + private String opinion; | |
| 39 | + private String lawyerNote; | |
| 40 | + private String needsProcessing; | |
| 41 | + | |
| 42 | + /** null이면 아직 웹에서 변호사 판정을 수정한 적이 없다는 뜻이다(재업로드 병합 판단 기준). */ | |
| 43 | + private Long webEditedAt; | |
| 44 | + private long createdAt; | |
| 45 | + private long updatedAt; | |
| 46 | + | |
| 47 | + public Long getId() { return id; } | |
| 48 | + public void setId(Long id) { this.id = id; } | |
| 49 | + | |
| 50 | + public Long getOrgId() { return orgId; } | |
| 51 | + public void setOrgId(Long orgId) { this.orgId = orgId; } | |
| 52 | + | |
| 53 | + public int getSeq() { return seq; } | |
| 54 | + public void setSeq(int seq) { this.seq = seq; } | |
| 55 | + | |
| 56 | + public String getSiteName() { return siteName; } | |
| 57 | + public void setSiteName(String siteName) { this.siteName = siteName; } | |
| 58 | + | |
| 59 | + public String getCategory() { return category; } | |
| 60 | + public void setCategory(String category) { this.category = category; } | |
| 61 | + | |
| 62 | + public String getBoardPath() { return boardPath; } | |
| 63 | + public void setBoardPath(String boardPath) { this.boardPath = boardPath; } | |
| 64 | + | |
| 65 | + public String getBoardName() { return boardName; } | |
| 66 | + public void setBoardName(String boardName) { this.boardName = boardName; } | |
| 67 | + | |
| 68 | + public String getPostTitle() { return postTitle; } | |
| 69 | + public void setPostTitle(String postTitle) { this.postTitle = postTitle; } | |
| 70 | + | |
| 71 | + public String getUrl() { return url; } | |
| 72 | + public void setUrl(String url) { this.url = url; } | |
| 73 | + | |
| 74 | + public String getPostRegistered() { return postRegistered; } | |
| 75 | + public void setPostRegistered(String postRegistered) { this.postRegistered = postRegistered; } | |
| 76 | + | |
| 77 | + public String getProducedDate() { return producedDate; } | |
| 78 | + public void setProducedDate(String producedDate) { this.producedDate = producedDate; } | |
| 79 | + | |
| 80 | + public String getPublishedDate() { return publishedDate; } | |
| 81 | + public void setPublishedDate(String publishedDate) { this.publishedDate = publishedDate; } | |
| 82 | + | |
| 83 | + public String getHasAttachment() { return hasAttachment; } | |
| 84 | + public void setHasAttachment(String hasAttachment) { this.hasAttachment = hasAttachment; } | |
| 85 | + | |
| 86 | + public String getKoglAttached() { return koglAttached; } | |
| 87 | + public void setKoglAttached(String koglAttached) { this.koglAttached = koglAttached; } | |
| 88 | + | |
| 89 | + public String getKoglType() { return koglType; } | |
| 90 | + public void setKoglType(String koglType) { this.koglType = koglType; } | |
| 91 | + | |
| 92 | + public String getAiType() { return aiType; } | |
| 93 | + public void setAiType(String aiType) { this.aiType = aiType; } | |
| 94 | + | |
| 95 | + public String getSurveyorNote() { return surveyorNote; } | |
| 96 | + public void setSurveyorNote(String surveyorNote) { this.surveyorNote = surveyorNote; } | |
| 97 | + | |
| 98 | + public String getOpenable() { return openable; } | |
| 99 | + public void setOpenable(String openable) { this.openable = openable; } | |
| 100 | + | |
| 101 | + public String getReviewMajor() { return reviewMajor; } | |
| 102 | + public void setReviewMajor(String reviewMajor) { this.reviewMajor = reviewMajor; } | |
| 103 | + | |
| 104 | + public String getReviewMinor() { return reviewMinor; } | |
| 105 | + public void setReviewMinor(String reviewMinor) { this.reviewMinor = reviewMinor; } | |
| 106 | + | |
| 107 | + public String getReviewResult() { return reviewResult; } | |
| 108 | + public void setReviewResult(String reviewResult) { this.reviewResult = reviewResult; } | |
| 109 | + | |
| 110 | + public String getJudgedKoglType() { return judgedKoglType; } | |
| 111 | + public void setJudgedKoglType(String judgedKoglType) { this.judgedKoglType = judgedKoglType; } | |
| 112 | + | |
| 113 | + public String getJudgedAiType() { return judgedAiType; } | |
| 114 | + public void setJudgedAiType(String judgedAiType) { this.judgedAiType = judgedAiType; } | |
| 115 | + | |
| 116 | + public String getOpinion() { return opinion; } | |
| 117 | + public void setOpinion(String opinion) { this.opinion = opinion; } | |
| 118 | + | |
| 119 | + public String getLawyerNote() { return lawyerNote; } | |
| 120 | + public void setLawyerNote(String lawyerNote) { this.lawyerNote = lawyerNote; } | |
| 121 | + | |
| 122 | + public String getNeedsProcessing() { return needsProcessing; } | |
| 123 | + public void setNeedsProcessing(String needsProcessing) { this.needsProcessing = needsProcessing; } | |
| 124 | + | |
| 125 | + public Long getWebEditedAt() { return webEditedAt; } | |
| 126 | + public void setWebEditedAt(Long webEditedAt) { this.webEditedAt = webEditedAt; } | |
| 127 | + | |
| 128 | + public long getCreatedAt() { return createdAt; } | |
| 129 | + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } | |
| 130 | + | |
| 131 | + public long getUpdatedAt() { return updatedAt; } | |
| 132 | + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } | |
| 133 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
... | ... | @@ -0,0 +1,49 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface ReviewItemMapper { | |
| 10 | + | |
| 11 | + /** 순번 오름차순, keyword는 사이트명/게시판명/게시물제목 LIKE. keyword가 null이면 필터 없음. */ | |
| 12 | + List<ReviewItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword, | |
| 13 | + @Param("offset") int offset, @Param("limit") int limit); | |
| 14 | + | |
| 15 | + int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword); | |
| 16 | + | |
| 17 | + /** 처리결과(review_result)가 채워진 행 수 - 검색어와 무관한 기관 전체 진행률. */ | |
| 18 | + int countDone(@Param("orgId") Long orgId); | |
| 19 | + | |
| 20 | + /** 다운로드/재업로드 판정용으로 페이징 없이 순번순 전체를 읽는다. */ | |
| 21 | + List<ReviewItem> findAllByOrg(@Param("orgId") Long orgId); | |
| 22 | + | |
| 23 | + /** 이미 저장된 순번 전체 - SeedService처럼 신규/갱신 집계를 메모리에서 판단하기 위함. */ | |
| 24 | + List<Integer> findSeqsByOrg(@Param("orgId") Long orgId); | |
| 25 | + | |
| 26 | + ReviewItem findById(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * 엑셀 재업로드 1행 반영. (org_id, seq) 충돌 시 조사원 영역은 항상 덮어쓰고, | |
| 30 | + * 변호사 영역은 web_edited_at이 null일 때만 덮어쓴다 - 정책 세부는 XML 주석 참고. | |
| 31 | + */ | |
| 32 | + int upsertFromFile(ReviewItem item); | |
| 33 | + | |
| 34 | + int updateJudgment(@Param("orgId") Long orgId, @Param("id") Long id, | |
| 35 | + @Param("openable") String openable, | |
| 36 | + @Param("reviewMajor") String reviewMajor, | |
| 37 | + @Param("reviewMinor") String reviewMinor, | |
| 38 | + @Param("reviewResult") String reviewResult, | |
| 39 | + @Param("judgedKoglType") String judgedKoglType, | |
| 40 | + @Param("judgedAiType") String judgedAiType, | |
| 41 | + @Param("opinion") String opinion, | |
| 42 | + @Param("lawyerNote") String lawyerNote, | |
| 43 | + @Param("needsProcessing") String needsProcessing); | |
| 44 | + | |
| 45 | + int deleteById(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 46 | + | |
| 47 | + /** 기관 삭제 캐스케이드 외에는 아직 쓰이지 않지만, 저장 비용이 낮아 함께 둔다. */ | |
| 48 | + int deleteByOrg(@Param("orgId") Long orgId); | |
| 49 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewNotFoundException.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 존재하지 않는 권리확인 게시물 id로 조회/수정/삭제를 시도했을 때 던진다. | |
| 5 | + * URL 경로의 id가 단순히 틀린 것뿐이므로 GlobalExceptionHandler가 404로 변환한다. | |
| 6 | + */ | |
| 7 | +public class ReviewNotFoundException extends RuntimeException { | |
| 8 | + public ReviewNotFoundException(String message) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewPage.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 권리확인 탭 목록 응답. {@code total}은 현재 keyword 필터 기준 건수(페이징 계산에 사용), | |
| 7 | + * {@code done}은 검색어와 무관한 기관 전체의 처리결과 완료 건수다(헤더의 "처리 n건"은 | |
| 8 | + * 검색 결과가 아니라 기관 전체 진행률을 보여주기 위함). | |
| 9 | + */ | |
| 10 | +public record ReviewPage(List<ReviewItem> items, int total, int done, int page, int size) { | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewParser.java
... | ... | @@ -0,0 +1,154 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.seed.SeedParseException; | |
| 4 | +import org.apache.poi.ss.usermodel.Cell; | |
| 5 | +import org.apache.poi.ss.usermodel.CellType; | |
| 6 | +import org.apache.poi.ss.usermodel.Row; | |
| 7 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 8 | +import org.apache.poi.ss.usermodel.Workbook; | |
| 9 | +import org.apache.poi.ss.usermodel.WorkbookFactory; | |
| 10 | +import org.springframework.stereotype.Component; | |
| 11 | + | |
| 12 | +import java.io.IOException; | |
| 13 | +import java.io.InputStream; | |
| 14 | +import java.util.ArrayList; | |
| 15 | +import java.util.List; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * 기관별 권리확인 엑셀(시트명 {@code Sheet0}, 없으면 첫 시트로 대체)을 읽는다. | |
| 19 | + * | |
| 20 | + * 시트 배치 (1행 그룹제목, 2행 헤더, 3행부터 데이터, A열(순번)이 빈 첫 행에서 데이터 끝): | |
| 21 | + * A 순번 B 사이트명 C 범주 D 게시판 경로 E 게시판명 F 게시물제목 G URL주소 | |
| 22 | + * H 게시글 등록 I 제작일 J 공표일 K 첨부파일여부 L 기존 공공누리부착 M 기존 공공누리유형 | |
| 23 | + * N AI유형 O 비고(조사원) P 개방가능여부 Q 권리확인(대분류) R 권리확인(세부) S 처리결과 | |
| 24 | + * T 공공누리유형(판정) U AI유형(판정) V 의견 W (빈 열) X 비고(변호사) Y 권리처리필요 | |
| 25 | + * | |
| 26 | + * 시트의 선언된 dimension은 서식만 채워진 40만 행 안팎이라, lastRowNum까지 무조건 돌되 | |
| 27 | + * A열이 비면 즉시 멈춘다(전체 dimension을 다 읽지 않는다). | |
| 28 | + * | |
| 29 | + * 제작일/공표일 셀은 날짜서식이 입혀진 숫자({@code 20230904})라도 절대 날짜로 읽지 않고 | |
| 30 | + * 문자열 그대로 저장한다 - {@link #cellValue}가 NUMERIC을 항상 반올림 문자열로 바꾸고 | |
| 31 | + * {@code getDateCellValue()}를 호출하지 않으므로 자동으로 이 요구사항을 만족한다. | |
| 32 | + * 수식 오류(#VALUE! 등) 셀은 캐시된 결과 타입이 ERROR가 되어 default 분기(빈 문자열 → null)로 | |
| 33 | + * 처리된다. | |
| 34 | + */ | |
| 35 | +@Component | |
| 36 | +public class ReviewParser { | |
| 37 | + | |
| 38 | + public static final String PRIMARY_SHEET_NAME = "Sheet0"; | |
| 39 | + | |
| 40 | + private static final int DATA_START_ROW_INDEX = 2; // 3행 | |
| 41 | + | |
| 42 | + private static final int COL_SEQ = 0; // A | |
| 43 | + private static final int COL_SITE_NAME = 1; // B | |
| 44 | + private static final int COL_CATEGORY = 2; // C | |
| 45 | + private static final int COL_BOARD_PATH = 3; // D | |
| 46 | + private static final int COL_BOARD_NAME = 4; // E | |
| 47 | + private static final int COL_POST_TITLE = 5; // F | |
| 48 | + private static final int COL_URL = 6; // G | |
| 49 | + private static final int COL_POST_REGISTERED = 7; // H | |
| 50 | + private static final int COL_PRODUCED_DATE = 8; // I | |
| 51 | + private static final int COL_PUBLISHED_DATE = 9; // J | |
| 52 | + private static final int COL_HAS_ATTACHMENT = 10; // K | |
| 53 | + private static final int COL_KOGL_ATTACHED = 11; // L | |
| 54 | + private static final int COL_KOGL_TYPE = 12; // M | |
| 55 | + private static final int COL_AI_TYPE = 13; // N | |
| 56 | + private static final int COL_SURVEYOR_NOTE = 14; // O | |
| 57 | + private static final int COL_OPENABLE = 15; // P | |
| 58 | + private static final int COL_REVIEW_MAJOR = 16; // Q | |
| 59 | + private static final int COL_REVIEW_MINOR = 17; // R | |
| 60 | + private static final int COL_REVIEW_RESULT = 18; // S | |
| 61 | + private static final int COL_JUDGED_KOGL_TYPE = 19; // T | |
| 62 | + private static final int COL_JUDGED_AI_TYPE = 20; // U | |
| 63 | + private static final int COL_OPINION = 21; // V | |
| 64 | + // 22 = W, 빈 열 | |
| 65 | + private static final int COL_LAWYER_NOTE = 23; // X | |
| 66 | + private static final int COL_NEEDS_PROCESSING = 24; // Y | |
| 67 | + | |
| 68 | + public List<ReviewRow> parse(InputStream xlsx) { | |
| 69 | + try (Workbook wb = WorkbookFactory.create(xlsx)) { | |
| 70 | + Sheet sheet = wb.getSheet(PRIMARY_SHEET_NAME); | |
| 71 | + if (sheet == null && wb.getNumberOfSheets() > 0) { | |
| 72 | + sheet = wb.getSheetAt(0); | |
| 73 | + } | |
| 74 | + if (sheet == null) { | |
| 75 | + throw new SeedParseException("통합문서에 읽을 수 있는 시트가 없습니다."); | |
| 76 | + } | |
| 77 | + return readRows(sheet); | |
| 78 | + } catch (IOException e) { | |
| 79 | + throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e); | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + private List<ReviewRow> readRows(Sheet sheet) { | |
| 84 | + List<ReviewRow> result = new ArrayList<>(); | |
| 85 | + | |
| 86 | + for (int i = DATA_START_ROW_INDEX; i <= sheet.getLastRowNum(); i++) { | |
| 87 | + Row row = sheet.getRow(i); | |
| 88 | + if (row == null) { | |
| 89 | + break; // 데이터 영역은 연속적이다 - 빈 행이 나오면 그게 끝이다 | |
| 90 | + } | |
| 91 | + | |
| 92 | + String seqRaw = string(row, COL_SEQ); | |
| 93 | + if (seqRaw.isBlank()) { | |
| 94 | + break; | |
| 95 | + } | |
| 96 | + int seq; | |
| 97 | + try { | |
| 98 | + seq = Integer.parseInt(seqRaw.trim()); | |
| 99 | + } catch (NumberFormatException e) { | |
| 100 | + break; | |
| 101 | + } | |
| 102 | + | |
| 103 | + result.add(new ReviewRow( | |
| 104 | + seq, | |
| 105 | + nullIfBlank(string(row, COL_SITE_NAME)), | |
| 106 | + nullIfBlank(string(row, COL_CATEGORY)), | |
| 107 | + nullIfBlank(string(row, COL_BOARD_PATH)), | |
| 108 | + nullIfBlank(string(row, COL_BOARD_NAME)), | |
| 109 | + nullIfBlank(string(row, COL_POST_TITLE)), | |
| 110 | + nullIfBlank(string(row, COL_URL)), | |
| 111 | + nullIfBlank(string(row, COL_POST_REGISTERED)), | |
| 112 | + nullIfBlank(string(row, COL_PRODUCED_DATE)), | |
| 113 | + nullIfBlank(string(row, COL_PUBLISHED_DATE)), | |
| 114 | + nullIfBlank(string(row, COL_HAS_ATTACHMENT)), | |
| 115 | + nullIfBlank(string(row, COL_KOGL_ATTACHED)), | |
| 116 | + nullIfBlank(string(row, COL_KOGL_TYPE)), | |
| 117 | + nullIfBlank(string(row, COL_AI_TYPE)), | |
| 118 | + nullIfBlank(string(row, COL_SURVEYOR_NOTE)), | |
| 119 | + nullIfBlank(string(row, COL_OPENABLE)), | |
| 120 | + nullIfBlank(string(row, COL_REVIEW_MAJOR)), | |
| 121 | + nullIfBlank(string(row, COL_REVIEW_MINOR)), | |
| 122 | + nullIfBlank(string(row, COL_REVIEW_RESULT)), | |
| 123 | + nullIfBlank(string(row, COL_JUDGED_KOGL_TYPE)), | |
| 124 | + nullIfBlank(string(row, COL_JUDGED_AI_TYPE)), | |
| 125 | + nullIfBlank(string(row, COL_OPINION)), | |
| 126 | + nullIfBlank(string(row, COL_LAWYER_NOTE)), | |
| 127 | + nullIfBlank(string(row, COL_NEEDS_PROCESSING)))); | |
| 128 | + } | |
| 129 | + return result; | |
| 130 | + } | |
| 131 | + | |
| 132 | + private String string(Row row, int columnIndex) { | |
| 133 | + Cell cell = row.getCell(columnIndex); | |
| 134 | + if (cell == null) { | |
| 135 | + return ""; | |
| 136 | + } | |
| 137 | + return cellValue(cell, cell.getCellType()); | |
| 138 | + } | |
| 139 | + | |
| 140 | + /** SeedParser/MemberDirectoryParser와 동일한 셀 값 변환 규칙. */ | |
| 141 | + private String cellValue(Cell cell, CellType cellType) { | |
| 142 | + return switch (cellType) { | |
| 143 | + case STRING -> cell.getStringCellValue().trim(); | |
| 144 | + case NUMERIC -> String.valueOf(Math.round(cell.getNumericCellValue())); | |
| 145 | + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); | |
| 146 | + case FORMULA -> cellValue(cell, cell.getCachedFormulaResultType()); | |
| 147 | + default -> ""; | |
| 148 | + }; | |
| 149 | + } | |
| 150 | + | |
| 151 | + private String nullIfBlank(String value) { | |
| 152 | + return (value == null || value.isBlank()) ? null : value; | |
| 153 | + } | |
| 154 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewRow.java
... | ... | @@ -0,0 +1,29 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +/** {@link ReviewParser}가 엑셀 한 행에서 읽어낸 원시 값. W열은 원본 시트의 빈 열이라 대응 필드가 없다. */ | |
| 4 | +public record ReviewRow( | |
| 5 | + int seq, | |
| 6 | + String siteName, | |
| 7 | + String category, | |
| 8 | + String boardPath, | |
| 9 | + String boardName, | |
| 10 | + String postTitle, | |
| 11 | + String url, | |
| 12 | + String postRegistered, | |
| 13 | + String producedDate, | |
| 14 | + String publishedDate, | |
| 15 | + String hasAttachment, | |
| 16 | + String koglAttached, | |
| 17 | + String koglType, | |
| 18 | + String aiType, | |
| 19 | + String surveyorNote, | |
| 20 | + String openable, | |
| 21 | + String reviewMajor, | |
| 22 | + String reviewMinor, | |
| 23 | + String reviewResult, | |
| 24 | + String judgedKoglType, | |
| 25 | + String judgedAiType, | |
| 26 | + String opinion, | |
| 27 | + String lawyerNote, | |
| 28 | + String needsProcessing) { | |
| 29 | +} |
+++ src/main/java/kr/itn/itnhub/review/ReviewService.java
... | ... | @@ -0,0 +1,156 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | +import org.apache.poi.ss.usermodel.Cell; | |
| 6 | +import org.apache.poi.ss.usermodel.Row; | |
| 7 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 8 | +import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
| 9 | +import org.springframework.stereotype.Service; | |
| 10 | +import org.springframework.transaction.annotation.Transactional; | |
| 11 | + | |
| 12 | +import java.io.ByteArrayOutputStream; | |
| 13 | +import java.io.IOException; | |
| 14 | +import java.io.UncheckedIOException; | |
| 15 | +import java.util.List; | |
| 16 | + | |
| 17 | +/** 기관관리 상세의 권리확인 탭이 쓰는 목록/상세/수정/삭제/다운로드 서비스. */ | |
| 18 | +@Service | |
| 19 | +public class ReviewService { | |
| 20 | + | |
| 21 | + /** 1행 그룹 제목 + 2행 컬럼 헤더. W열은 원본 시트의 빈 열이라 빈 문자열로 남겨둔다. */ | |
| 22 | + private static final String[] COLUMN_HEADERS = { | |
| 23 | + "순번", "사이트명", "범주", "게시판 경로", "게시판명", "게시물제목", "URL주소", | |
| 24 | + "게시글 등록", "제작일", "공표일", "첨부파일여부", "기존 공공누리부착", "기존 공공누리유형", "AI유형", | |
| 25 | + "비고(조사원)", "개방가능여부", "권리확인(대분류)", "권리확인(세부)", "처리결과", | |
| 26 | + "공공누리유형(판정)", "AI유형(판정)", "의견", "", "비고(변호사)", "권리처리필요" | |
| 27 | + }; | |
| 28 | + | |
| 29 | + private final ReviewItemMapper mapper; | |
| 30 | + private final OrganizationMapper orgMapper; | |
| 31 | + | |
| 32 | + public ReviewService(ReviewItemMapper mapper, OrganizationMapper orgMapper) { | |
| 33 | + this.mapper = mapper; | |
| 34 | + this.orgMapper = orgMapper; | |
| 35 | + } | |
| 36 | + | |
| 37 | + public ReviewPage list(Long orgId, String keyword, int page, int size) { | |
| 38 | + requireOrg(orgId); | |
| 39 | + String kw = normalizeKeyword(keyword); | |
| 40 | + int safePage = Math.max(page, 0); | |
| 41 | + int safeSize = size <= 0 ? 30 : size; | |
| 42 | + int offset = safePage * safeSize; | |
| 43 | + | |
| 44 | + List<ReviewItem> items = mapper.findPage(orgId, kw, offset, safeSize); | |
| 45 | + int total = mapper.countByOrg(orgId, kw); | |
| 46 | + int done = mapper.countDone(orgId); | |
| 47 | + return new ReviewPage(items, total, done, safePage, safeSize); | |
| 48 | + } | |
| 49 | + | |
| 50 | + public ReviewItem get(Long orgId, Long itemId) { | |
| 51 | + requireOrg(orgId); | |
| 52 | + return requireItem(orgId, itemId); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Transactional | |
| 56 | + public ReviewItem updateJudgment(Long orgId, Long itemId, JudgmentRequest request) { | |
| 57 | + requireOrg(orgId); | |
| 58 | + int updated = mapper.updateJudgment(orgId, itemId, | |
| 59 | + request.openable(), request.reviewMajor(), request.reviewMinor(), request.reviewResult(), | |
| 60 | + request.judgedKoglType(), request.judgedAiType(), request.opinion(), request.lawyerNote(), | |
| 61 | + request.needsProcessing()); | |
| 62 | + if (updated == 0) { | |
| 63 | + throw new ReviewNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 64 | + } | |
| 65 | + return mapper.findById(orgId, itemId); | |
| 66 | + } | |
| 67 | + | |
| 68 | + @Transactional | |
| 69 | + public void delete(Long orgId, Long itemId) { | |
| 70 | + requireOrg(orgId); | |
| 71 | + int deleted = mapper.deleteById(orgId, itemId); | |
| 72 | + if (deleted == 0) { | |
| 73 | + throw new ReviewNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + /** 원본 파일과 같은 헤더 배치(1행 그룹제목, 2행 컬럼헤더, 3행부터 데이터)로 xlsx를 만든다. */ | |
| 78 | + public byte[] downloadWorkbook(Long orgId) { | |
| 79 | + requireOrg(orgId); | |
| 80 | + List<ReviewItem> items = mapper.findAllByOrg(orgId); | |
| 81 | + | |
| 82 | + try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 83 | + Sheet sheet = wb.createSheet(ReviewParser.PRIMARY_SHEET_NAME); | |
| 84 | + | |
| 85 | + Row groupRow = sheet.createRow(0); | |
| 86 | + groupRow.createCell(0).setCellValue("게시물 정보(조사원)"); | |
| 87 | + groupRow.createCell(15).setCellValue("권리확인(변호사)"); | |
| 88 | + | |
| 89 | + Row headerRow = sheet.createRow(1); | |
| 90 | + for (int i = 0; i < COLUMN_HEADERS.length; i++) { | |
| 91 | + headerRow.createCell(i).setCellValue(COLUMN_HEADERS[i]); | |
| 92 | + } | |
| 93 | + | |
| 94 | + int rowNum = 2; | |
| 95 | + for (ReviewItem item : items) { | |
| 96 | + Row row = sheet.createRow(rowNum++); | |
| 97 | + setCell(row, 0, String.valueOf(item.getSeq())); | |
| 98 | + setCell(row, 1, item.getSiteName()); | |
| 99 | + setCell(row, 2, item.getCategory()); | |
| 100 | + setCell(row, 3, item.getBoardPath()); | |
| 101 | + setCell(row, 4, item.getBoardName()); | |
| 102 | + setCell(row, 5, item.getPostTitle()); | |
| 103 | + setCell(row, 6, item.getUrl()); | |
| 104 | + setCell(row, 7, item.getPostRegistered()); | |
| 105 | + setCell(row, 8, item.getProducedDate()); | |
| 106 | + setCell(row, 9, item.getPublishedDate()); | |
| 107 | + setCell(row, 10, item.getHasAttachment()); | |
| 108 | + setCell(row, 11, item.getKoglAttached()); | |
| 109 | + setCell(row, 12, item.getKoglType()); | |
| 110 | + setCell(row, 13, item.getAiType()); | |
| 111 | + setCell(row, 14, item.getSurveyorNote()); | |
| 112 | + setCell(row, 15, item.getOpenable()); | |
| 113 | + setCell(row, 16, item.getReviewMajor()); | |
| 114 | + setCell(row, 17, item.getReviewMinor()); | |
| 115 | + setCell(row, 18, item.getReviewResult()); | |
| 116 | + setCell(row, 19, item.getJudgedKoglType()); | |
| 117 | + setCell(row, 20, item.getJudgedAiType()); | |
| 118 | + setCell(row, 21, item.getOpinion()); | |
| 119 | + // 22 = W, 빈 열 | |
| 120 | + setCell(row, 23, item.getLawyerNote()); | |
| 121 | + setCell(row, 24, item.getNeedsProcessing()); | |
| 122 | + } | |
| 123 | + | |
| 124 | + wb.write(out); | |
| 125 | + return out.toByteArray(); | |
| 126 | + } catch (IOException e) { | |
| 127 | + throw new UncheckedIOException("권리확인 엑셀을 만들지 못했습니다.", e); | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + private void setCell(Row row, int columnIndex, String value) { | |
| 132 | + if (value == null) { | |
| 133 | + return; | |
| 134 | + } | |
| 135 | + Cell cell = row.createCell(columnIndex); | |
| 136 | + cell.setCellValue(value); | |
| 137 | + } | |
| 138 | + | |
| 139 | + private String normalizeKeyword(String keyword) { | |
| 140 | + return (keyword == null || keyword.isBlank()) ? null : keyword.trim(); | |
| 141 | + } | |
| 142 | + | |
| 143 | + private void requireOrg(Long orgId) { | |
| 144 | + if (orgMapper.findById(orgId) == null) { | |
| 145 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 146 | + } | |
| 147 | + } | |
| 148 | + | |
| 149 | + private ReviewItem requireItem(Long orgId, Long itemId) { | |
| 150 | + ReviewItem item = mapper.findById(orgId, itemId); | |
| 151 | + if (item == null) { | |
| 152 | + throw new ReviewNotFoundException("게시물을 찾을 수 없습니다: " + itemId); | |
| 153 | + } | |
| 154 | + return item; | |
| 155 | + } | |
| 156 | +} |
+++ src/main/resources/db/migration/V9__review_item.sql
... | ... | @@ -0,0 +1,33 @@ |
| 1 | +create table review_item ( | |
| 2 | + id bigserial primary key, | |
| 3 | + org_id bigint not null references organization (id) on delete cascade, | |
| 4 | + seq integer not null, -- 순번 | |
| 5 | + site_name varchar(300), | |
| 6 | + category varchar(500), | |
| 7 | + board_path varchar(500), | |
| 8 | + board_name varchar(300), | |
| 9 | + post_title varchar(1000), | |
| 10 | + url varchar(1000), | |
| 11 | + post_registered varchar(50), | |
| 12 | + produced_date varchar(20), | |
| 13 | + published_date varchar(20), | |
| 14 | + has_attachment varchar(50), | |
| 15 | + kogl_attached varchar(50), | |
| 16 | + kogl_type varchar(50), | |
| 17 | + ai_type varchar(50), | |
| 18 | + surveyor_note varchar(1000), | |
| 19 | + openable varchar(100), | |
| 20 | + review_major varchar(200), -- 권리확인(대분류) | |
| 21 | + review_minor varchar(300), -- 권리확인(세부) | |
| 22 | + review_result varchar(200), -- 처리결과 | |
| 23 | + judged_kogl_type varchar(50), -- 공공누리유형(판정) | |
| 24 | + judged_ai_type varchar(50), | |
| 25 | + opinion varchar(2000), | |
| 26 | + lawyer_note varchar(1000), | |
| 27 | + needs_processing varchar(50), -- 권리처리필요 | |
| 28 | + web_edited_at timestamptz, -- null = 웹에서 손대지 않음 (재업로드 병합 판단 기준) | |
| 29 | + created_at timestamptz not null default now(), | |
| 30 | + updated_at timestamptz not null default now(), | |
| 31 | + constraint uq_review_item unique (org_id, seq) | |
| 32 | +); | |
| 33 | +create index ix_review_item_org on review_item (org_id, seq); |
+++ src/main/resources/mapper/ReviewItemMapper.xml
... | ... | @@ -0,0 +1,138 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.review.ReviewItemMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + web_edited_at/created_at/updated_at을 밀리초 epoch로 내려준다(work_memo와 동일 관례). | |
| 8 | + web_edited_at은 null이면 extract(epoch from null)도 null이라 그대로 null이 내려간다. | |
| 9 | + --> | |
| 10 | + <sql id="columns"> | |
| 11 | + id, org_id, seq, site_name, category, board_path, board_name, post_title, url, | |
| 12 | + post_registered, produced_date, published_date, has_attachment, kogl_attached, kogl_type, ai_type, | |
| 13 | + surveyor_note, openable, review_major, review_minor, review_result, judged_kogl_type, judged_ai_type, | |
| 14 | + opinion, lawyer_note, needs_processing, | |
| 15 | + (extract(epoch from web_edited_at) * 1000)::bigint as web_edited_at, | |
| 16 | + (extract(epoch from created_at) * 1000)::bigint as created_at, | |
| 17 | + (extract(epoch from updated_at) * 1000)::bigint as updated_at | |
| 18 | + </sql> | |
| 19 | + | |
| 20 | + <sql id="keywordFilter"> | |
| 21 | + <if test="keyword != null"> | |
| 22 | + and (site_name ilike concat('%', #{keyword}, '%') | |
| 23 | + or board_name ilike concat('%', #{keyword}, '%') | |
| 24 | + or post_title ilike concat('%', #{keyword}, '%')) | |
| 25 | + </if> | |
| 26 | + </sql> | |
| 27 | + | |
| 28 | + <select id="findPage" resultType="kr.itn.itnhub.review.ReviewItem"> | |
| 29 | + select <include refid="columns"/> | |
| 30 | + from review_item | |
| 31 | + where org_id = #{orgId} | |
| 32 | + <include refid="keywordFilter"/> | |
| 33 | + order by seq asc | |
| 34 | + limit #{limit} offset #{offset} | |
| 35 | + </select> | |
| 36 | + | |
| 37 | + <select id="countByOrg" resultType="int"> | |
| 38 | + select count(*) | |
| 39 | + from review_item | |
| 40 | + where org_id = #{orgId} | |
| 41 | + <include refid="keywordFilter"/> | |
| 42 | + </select> | |
| 43 | + | |
| 44 | + <select id="countDone" resultType="int"> | |
| 45 | + select count(*) | |
| 46 | + from review_item | |
| 47 | + where org_id = #{orgId} | |
| 48 | + and review_result is not null | |
| 49 | + and review_result <> '' | |
| 50 | + </select> | |
| 51 | + | |
| 52 | + <select id="findAllByOrg" resultType="kr.itn.itnhub.review.ReviewItem"> | |
| 53 | + select <include refid="columns"/> | |
| 54 | + from review_item | |
| 55 | + where org_id = #{orgId} | |
| 56 | + order by seq asc | |
| 57 | + </select> | |
| 58 | + | |
| 59 | + <select id="findSeqsByOrg" resultType="int"> | |
| 60 | + select seq from review_item where org_id = #{orgId} | |
| 61 | + </select> | |
| 62 | + | |
| 63 | + <select id="findById" resultType="kr.itn.itnhub.review.ReviewItem"> | |
| 64 | + select <include refid="columns"/> | |
| 65 | + from review_item | |
| 66 | + where org_id = #{orgId} and id = #{id} | |
| 67 | + </select> | |
| 68 | + | |
| 69 | + <!-- | |
| 70 | + 정책(b): 조사원 영역(site_name..surveyor_note)은 재업로드마다 항상 최신 파일값으로 | |
| 71 | + 덮어쓴다. 변호사 영역(openable..needs_processing)은 web_edited_at이 null일 때만 | |
| 72 | + (=웹에서 아직 아무도 손대지 않았을 때만) 파일값을 반영한다 - 이미 웹에서 판정을 | |
| 73 | + 입력한 행은 재업로드가 절대 덮어쓰지 않는다. | |
| 74 | + --> | |
| 75 | + <insert id="upsertFromFile" parameterType="kr.itn.itnhub.review.ReviewItem"> | |
| 76 | + insert into review_item ( | |
| 77 | + org_id, seq, site_name, category, board_path, board_name, post_title, url, | |
| 78 | + post_registered, produced_date, published_date, has_attachment, kogl_attached, kogl_type, ai_type, | |
| 79 | + surveyor_note, openable, review_major, review_minor, review_result, judged_kogl_type, judged_ai_type, | |
| 80 | + opinion, lawyer_note, needs_processing | |
| 81 | + ) values ( | |
| 82 | + #{orgId}, #{seq}, #{siteName}, #{category}, #{boardPath}, #{boardName}, #{postTitle}, #{url}, | |
| 83 | + #{postRegistered}, #{producedDate}, #{publishedDate}, #{hasAttachment}, #{koglAttached}, #{koglType}, #{aiType}, | |
| 84 | + #{surveyorNote}, #{openable}, #{reviewMajor}, #{reviewMinor}, #{reviewResult}, #{judgedKoglType}, #{judgedAiType}, | |
| 85 | + #{opinion}, #{lawyerNote}, #{needsProcessing} | |
| 86 | + ) | |
| 87 | + on conflict (org_id, seq) do update set | |
| 88 | + site_name = excluded.site_name, | |
| 89 | + category = excluded.category, | |
| 90 | + board_path = excluded.board_path, | |
| 91 | + board_name = excluded.board_name, | |
| 92 | + post_title = excluded.post_title, | |
| 93 | + url = excluded.url, | |
| 94 | + post_registered = excluded.post_registered, | |
| 95 | + produced_date = excluded.produced_date, | |
| 96 | + published_date = excluded.published_date, | |
| 97 | + has_attachment = excluded.has_attachment, | |
| 98 | + kogl_attached = excluded.kogl_attached, | |
| 99 | + kogl_type = excluded.kogl_type, | |
| 100 | + ai_type = excluded.ai_type, | |
| 101 | + surveyor_note = excluded.surveyor_note, | |
| 102 | + openable = case when review_item.web_edited_at is null then excluded.openable else review_item.openable end, | |
| 103 | + review_major = case when review_item.web_edited_at is null then excluded.review_major else review_item.review_major end, | |
| 104 | + review_minor = case when review_item.web_edited_at is null then excluded.review_minor else review_item.review_minor end, | |
| 105 | + review_result = case when review_item.web_edited_at is null then excluded.review_result else review_item.review_result end, | |
| 106 | + judged_kogl_type = case when review_item.web_edited_at is null then excluded.judged_kogl_type else review_item.judged_kogl_type end, | |
| 107 | + judged_ai_type = case when review_item.web_edited_at is null then excluded.judged_ai_type else review_item.judged_ai_type end, | |
| 108 | + opinion = case when review_item.web_edited_at is null then excluded.opinion else review_item.opinion end, | |
| 109 | + lawyer_note = case when review_item.web_edited_at is null then excluded.lawyer_note else review_item.lawyer_note end, | |
| 110 | + needs_processing = case when review_item.web_edited_at is null then excluded.needs_processing else review_item.needs_processing end, | |
| 111 | + updated_at = now() | |
| 112 | + </insert> | |
| 113 | + | |
| 114 | + <update id="updateJudgment"> | |
| 115 | + update review_item set | |
| 116 | + openable = #{openable}, | |
| 117 | + review_major = #{reviewMajor}, | |
| 118 | + review_minor = #{reviewMinor}, | |
| 119 | + review_result = #{reviewResult}, | |
| 120 | + judged_kogl_type = #{judgedKoglType}, | |
| 121 | + judged_ai_type = #{judgedAiType}, | |
| 122 | + opinion = #{opinion}, | |
| 123 | + lawyer_note = #{lawyerNote}, | |
| 124 | + needs_processing = #{needsProcessing}, | |
| 125 | + web_edited_at = now(), | |
| 126 | + updated_at = now() | |
| 127 | + where org_id = #{orgId} and id = #{id} | |
| 128 | + </update> | |
| 129 | + | |
| 130 | + <delete id="deleteById"> | |
| 131 | + delete from review_item where org_id = #{orgId} and id = #{id} | |
| 132 | + </delete> | |
| 133 | + | |
| 134 | + <delete id="deleteByOrg"> | |
| 135 | + delete from review_item where org_id = #{orgId} | |
| 136 | + </delete> | |
| 137 | + | |
| 138 | +</mapper> |
+++ src/test/java/kr/itn/itnhub/review/ReviewControllerTest.java
... | ... | @@ -0,0 +1,325 @@ |
| 1 | +package kr.itn.itnhub.review; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import org.apache.poi.ss.usermodel.Cell; | |
| 7 | +import org.apache.poi.ss.usermodel.CellStyle; | |
| 8 | +import org.apache.poi.ss.usermodel.DataFormat; | |
| 9 | +import org.apache.poi.ss.usermodel.Row; | |
| 10 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 11 | +import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
| 12 | +import org.junit.jupiter.api.BeforeEach; | |
| 13 | +import org.junit.jupiter.api.Test; | |
| 14 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 15 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 16 | +import org.springframework.http.MediaType; | |
| 17 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 18 | +import org.springframework.mock.web.MockMultipartFile; | |
| 19 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 20 | +import org.springframework.test.web.servlet.MockMvc; | |
| 21 | + | |
| 22 | +import java.io.ByteArrayOutputStream; | |
| 23 | +import java.util.List; | |
| 24 | + | |
| 25 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 26 | +import static org.hamcrest.Matchers.containsString; | |
| 27 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 28 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |
| 29 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 30 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; | |
| 31 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 32 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 33 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 34 | + | |
| 35 | +@AutoConfigureMockMvc | |
| 36 | +@WithMockUser(roles = "ADMIN") | |
| 37 | +class ReviewControllerTest extends AbstractDbTest { | |
| 38 | + | |
| 39 | + @Autowired | |
| 40 | + MockMvc mvc; | |
| 41 | + | |
| 42 | + @Autowired | |
| 43 | + OrganizationMapper orgMapper; | |
| 44 | + | |
| 45 | + @Autowired | |
| 46 | + ReviewItemMapper reviewMapper; | |
| 47 | + | |
| 48 | + @Autowired | |
| 49 | + JdbcTemplate jdbc; | |
| 50 | + | |
| 51 | + private Long orgId; | |
| 52 | + | |
| 53 | + @BeforeEach | |
| 54 | + void setUp() { | |
| 55 | + jdbc.update("delete from review_item"); | |
| 56 | + jdbc.update("delete from organization"); | |
| 57 | + | |
| 58 | + Organization org = new Organization(); | |
| 59 | + org.setOrgNo("001"); | |
| 60 | + org.setOrgName("한국출판문화산업진흥원"); | |
| 61 | + org.setChannelSlug("001"); | |
| 62 | + orgMapper.upsertBySeed(org); | |
| 63 | + | |
| 64 | + orgId = orgMapper.findAll().get(0).getId(); | |
| 65 | + } | |
| 66 | + | |
| 67 | + /** 필드 순서: seq,siteName,category,boardPath,boardName,postTitle,url,postRegistered, | |
| 68 | + * producedDate,publishedDate,hasAttachment,koglAttached,koglType,aiType,surveyorNote, | |
| 69 | + * openable,reviewMajor,reviewMinor,reviewResult,judgedKoglType,judgedAiType,opinion, | |
| 70 | + * lawyerNote,needsProcessing (24개, W열은 건너뛴다). */ | |
| 71 | + private byte[] workbook(String[]... dataRows) throws Exception { | |
| 72 | + try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 73 | + Sheet sheet = wb.createSheet(ReviewParser.PRIMARY_SHEET_NAME); | |
| 74 | + sheet.createRow(0); | |
| 75 | + sheet.createRow(1); | |
| 76 | + | |
| 77 | + int rowNum = 2; | |
| 78 | + for (String[] data : dataRows) { | |
| 79 | + Row row = sheet.createRow(rowNum++); | |
| 80 | + for (int i = 0; i < data.length; i++) { | |
| 81 | + if (data[i] == null) { | |
| 82 | + continue; | |
| 83 | + } | |
| 84 | + int col = i < 22 ? i : i + 1; // 22 = W, 건너뜀 | |
| 85 | + row.createCell(col).setCellValue(data[i]); | |
| 86 | + } | |
| 87 | + } | |
| 88 | + wb.write(out); | |
| 89 | + return out.toByteArray(); | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + private MockMultipartFile file(byte[] bytes) { | |
| 94 | + return new MockMultipartFile("file", "review.xlsx", | |
| 95 | + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", bytes); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + void 최초_업로드는_전부_신규이고_제작일은_날짜서식_숫자여도_문자열_그대로_저장된다() throws Exception { | |
| 100 | + byte[] xlsx = numericDateWorkbook(); | |
| 101 | + | |
| 102 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf())) | |
| 103 | + .andExpect(status().isOk()) | |
| 104 | + .andExpect(jsonPath("$.created").value(1)) | |
| 105 | + .andExpect(jsonPath("$.updated").value(0)) | |
| 106 | + .andExpect(jsonPath("$.total").value(1)); | |
| 107 | + | |
| 108 | + ReviewItem item = reviewMapper.findAllByOrg(orgId).get(0); | |
| 109 | + assertThat(item.getProducedDate()).isEqualTo("20230904"); | |
| 110 | + assertThat(item.getSiteName()).isEqualTo("사이트"); | |
| 111 | + } | |
| 112 | + | |
| 113 | + /** 제작일(I열, index 8)에 날짜서식이 입혀진 숫자 20230904를 직접 심어 재현한다. */ | |
| 114 | + private byte[] numericDateWorkbook() throws Exception { | |
| 115 | + try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 116 | + Sheet sheet = wb.createSheet(ReviewParser.PRIMARY_SHEET_NAME); | |
| 117 | + sheet.createRow(0); | |
| 118 | + sheet.createRow(1); | |
| 119 | + Row row = sheet.createRow(2); | |
| 120 | + row.createCell(0).setCellValue("1"); | |
| 121 | + row.createCell(1).setCellValue("사이트"); | |
| 122 | + | |
| 123 | + CellStyle dateStyle = wb.createCellStyle(); | |
| 124 | + DataFormat fmt = wb.createDataFormat(); | |
| 125 | + dateStyle.setDataFormat(fmt.getFormat("yyyy-mm-dd")); | |
| 126 | + Cell produced = row.createCell(8); | |
| 127 | + produced.setCellStyle(dateStyle); | |
| 128 | + produced.setCellValue(20230904); | |
| 129 | + | |
| 130 | + wb.write(out); | |
| 131 | + return out.toByteArray(); | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + @Test | |
| 136 | + void 같은_파일을_다시_올려도_행이_늘지_않고_갱신으로_집계된다() throws Exception { | |
| 137 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 138 | + | |
| 139 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf())) | |
| 140 | + .andExpect(status().isOk()); | |
| 141 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf())) | |
| 142 | + .andExpect(status().isOk()) | |
| 143 | + .andExpect(jsonPath("$.created").value(0)) | |
| 144 | + .andExpect(jsonPath("$.updated").value(1)); | |
| 145 | + | |
| 146 | + assertThat(reviewMapper.findAllByOrg(orgId)).hasSize(1); | |
| 147 | + } | |
| 148 | + | |
| 149 | + @Test | |
| 150 | + void 웹에서_수정한_변호사_판정은_재업로드가_덮어쓰지_않고_조사원_항목은_계속_갱신된다() throws Exception { | |
| 151 | + byte[] first = workbook( | |
| 152 | + row("1", "사이트A", "공지"), | |
| 153 | + row("2", "사이트B", "공지")); | |
| 154 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(first)).with(csrf())) | |
| 155 | + .andExpect(status().isOk()); | |
| 156 | + | |
| 157 | + Long editedId = reviewMapper.findAllByOrg(orgId).stream() | |
| 158 | + .filter(i -> i.getSeq() == 1).findFirst().get().getId(); | |
| 159 | + | |
| 160 | + // seq=1은 웹에서 변호사 판정을 직접 입력한다. | |
| 161 | + mvc.perform(put("/api/orgs/{id}/review/{itemId}", orgId, editedId) | |
| 162 | + .with(csrf()) | |
| 163 | + .contentType(MediaType.APPLICATION_JSON) | |
| 164 | + .content(""" | |
| 165 | + {"reviewResult": "신유형 개방", "openable": "Y"} | |
| 166 | + """)) | |
| 167 | + .andExpect(status().isOk()); | |
| 168 | + | |
| 169 | + // 재업로드: 두 행 모두 조사원 항목(site_name)이 바뀌고, 변호사 항목(reviewResult)도 새 값이 온다. | |
| 170 | + byte[] second = workbook( | |
| 171 | + withReview(row("1", "사이트A-수정", "공지"), "N", "계약서 등 재확인"), | |
| 172 | + withReview(row("2", "사이트B-수정", "공지"), "Y", "권리처리 추진")); | |
| 173 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(second)).with(csrf())) | |
| 174 | + .andExpect(status().isOk()) | |
| 175 | + .andExpect(jsonPath("$.created").value(0)) | |
| 176 | + .andExpect(jsonPath("$.updated").value(2)); | |
| 177 | + | |
| 178 | + ReviewItem edited = reviewMapper.findById(orgId, editedId); | |
| 179 | + assertThat(edited.getSiteName()).isEqualTo("사이트A-수정"); // 조사원 항목은 항상 갱신 | |
| 180 | + assertThat(edited.getReviewResult()).isEqualTo("신유형 개방"); // 웹 입력값 보존 | |
| 181 | + assertThat(edited.getOpenable()).isEqualTo("Y"); | |
| 182 | + assertThat(edited.getWebEditedAt()).isNotNull(); | |
| 183 | + | |
| 184 | + ReviewItem untouched = reviewMapper.findAllByOrg(orgId).stream() | |
| 185 | + .filter(i -> i.getSeq() == 2).findFirst().get(); | |
| 186 | + assertThat(untouched.getSiteName()).isEqualTo("사이트B-수정"); | |
| 187 | + assertThat(untouched.getReviewResult()).isEqualTo("권리처리 추진"); // 웹 수정 이력 없으니 파일값 반영 | |
| 188 | + assertThat(untouched.getWebEditedAt()).isNull(); | |
| 189 | + } | |
| 190 | + | |
| 191 | + private String[] row(String seq, String siteName, String category) { | |
| 192 | + return new String[]{ | |
| 193 | + seq, siteName, category, null, "게시판명", "제목", "http://example.com", null, | |
| 194 | + null, null, "N", null, null, null, null, | |
| 195 | + null, null, null, null, null, null, null, | |
| 196 | + null, null | |
| 197 | + }; | |
| 198 | + } | |
| 199 | + | |
| 200 | + private String[] withReview(String[] base, String openable, String reviewResult) { | |
| 201 | + String[] copy = base.clone(); | |
| 202 | + copy[15] = openable; // P 개방가능여부 | |
| 203 | + copy[18] = reviewResult; // S 처리결과 | |
| 204 | + return copy; | |
| 205 | + } | |
| 206 | + | |
| 207 | + @Test | |
| 208 | + void 목록은_페이징되고_검색어로_필터링되며_처리건수는_검색과_무관하다() throws Exception { | |
| 209 | + byte[] xlsx = workbook( | |
| 210 | + row("1", "국립중앙도서관", "공지"), | |
| 211 | + row("2", "국립중앙박물관", "공지"), | |
| 212 | + row("3", "다른기관", "공지")); | |
| 213 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf())) | |
| 214 | + .andExpect(status().isOk()); | |
| 215 | + | |
| 216 | + Long doneId = reviewMapper.findAllByOrg(orgId).stream() | |
| 217 | + .filter(i -> i.getSeq() == 1).findFirst().get().getId(); | |
| 218 | + mvc.perform(put("/api/orgs/{id}/review/{itemId}", orgId, doneId) | |
| 219 | + .with(csrf()) | |
| 220 | + .contentType(MediaType.APPLICATION_JSON) | |
| 221 | + .content(""" | |
| 222 | + {"reviewResult": "신유형 개방"} | |
| 223 | + """)) | |
| 224 | + .andExpect(status().isOk()); | |
| 225 | + | |
| 226 | + // size=2, page=0 : 전체 3건 중 앞 2건 | |
| 227 | + mvc.perform(get("/api/orgs/{id}/review", orgId).param("page", "0").param("size", "2")) | |
| 228 | + .andExpect(status().isOk()) | |
| 229 | + .andExpect(jsonPath("$.items.length()").value(2)) | |
| 230 | + .andExpect(jsonPath("$.total").value(3)) | |
| 231 | + .andExpect(jsonPath("$.done").value(1)) | |
| 232 | + .andExpect(jsonPath("$.page").value(0)) | |
| 233 | + .andExpect(jsonPath("$.size").value(2)); | |
| 234 | + | |
| 235 | + // 검색어로 "국립"이 들어간 2건만 필터링되지만 done은 여전히 기관 전체 기준(1)이다. | |
| 236 | + mvc.perform(get("/api/orgs/{id}/review", orgId).param("keyword", "국립")) | |
| 237 | + .andExpect(status().isOk()) | |
| 238 | + .andExpect(jsonPath("$.items.length()").value(2)) | |
| 239 | + .andExpect(jsonPath("$.total").value(2)) | |
| 240 | + .andExpect(jsonPath("$.done").value(1)); | |
| 241 | + } | |
| 242 | + | |
| 243 | + @Test | |
| 244 | + void 판정을_저장하면_웹수정시각이_찍히고_단건_조회로_확인된다() throws Exception { | |
| 245 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 246 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf())) | |
| 247 | + .andExpect(status().isOk()); | |
| 248 | + Long itemId = reviewMapper.findAllByOrg(orgId).get(0).getId(); | |
| 249 | + | |
| 250 | + mvc.perform(put("/api/orgs/{id}/review/{itemId}", orgId, itemId) | |
| 251 | + .with(csrf()) | |
| 252 | + .contentType(MediaType.APPLICATION_JSON) | |
| 253 | + .content(""" | |
| 254 | + { | |
| 255 | + "openable": "Y", | |
| 256 | + "reviewMajor": "만료 저작물", | |
| 257 | + "reviewResult": "신유형 개방", | |
| 258 | + "judgedKoglType": "1유형", | |
| 259 | + "needsProcessing": "Y" | |
| 260 | + } | |
| 261 | + """)) | |
| 262 | + .andExpect(status().isOk()) | |
| 263 | + .andExpect(jsonPath("$.reviewResult").value("신유형 개방")) | |
| 264 | + .andExpect(jsonPath("$.webEditedAt").isNumber()); | |
| 265 | + | |
| 266 | + mvc.perform(get("/api/orgs/{id}/review/{itemId}", orgId, itemId)) | |
| 267 | + .andExpect(status().isOk()) | |
| 268 | + .andExpect(jsonPath("$.openable").value("Y")) | |
| 269 | + .andExpect(jsonPath("$.judgedKoglType").value("1유형")) | |
| 270 | + .andExpect(jsonPath("$.needsProcessing").value("Y")); | |
| 271 | + } | |
| 272 | + | |
| 273 | + @Test | |
| 274 | + void 게시물을_삭제하면_204와_함께_목록에서_사라진다() throws Exception { | |
| 275 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 276 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf())) | |
| 277 | + .andExpect(status().isOk()); | |
| 278 | + Long itemId = reviewMapper.findAllByOrg(orgId).get(0).getId(); | |
| 279 | + | |
| 280 | + mvc.perform(delete("/api/orgs/{id}/review/{itemId}", orgId, itemId).with(csrf())) | |
| 281 | + .andExpect(status().isNoContent()); | |
| 282 | + | |
| 283 | + assertThat(reviewMapper.findAllByOrg(orgId)).isEmpty(); | |
| 284 | + } | |
| 285 | + | |
| 286 | + @Test | |
| 287 | + void 존재하지_않는_기관으로_업로드하면_404다() throws Exception { | |
| 288 | + long missingOrgId = orgId + 999999L; | |
| 289 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 290 | + | |
| 291 | + mvc.perform(multipart("/api/orgs/{id}/review/import", missingOrgId).file(file(xlsx)).with(csrf())) | |
| 292 | + .andExpect(status().isNotFound()) | |
| 293 | + .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingOrgId)))); | |
| 294 | + } | |
| 295 | + | |
| 296 | + @Test | |
| 297 | + void 존재하지_않는_게시물이면_404다() throws Exception { | |
| 298 | + long missingItemId = 999999L; | |
| 299 | + | |
| 300 | + mvc.perform(get("/api/orgs/{id}/review/{itemId}", orgId, missingItemId)) | |
| 301 | + .andExpect(status().isNotFound()) | |
| 302 | + .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingItemId)))); | |
| 303 | + } | |
| 304 | + | |
| 305 | + @Test | |
| 306 | + void 확장자가_xlsx_xlsm이_아니면_400이다() throws Exception { | |
| 307 | + MockMultipartFile txt = new MockMultipartFile("file", "review.txt", | |
| 308 | + "text/plain", "아무 내용".getBytes()); | |
| 309 | + | |
| 310 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(txt).with(csrf())) | |
| 311 | + .andExpect(status().isBadRequest()) | |
| 312 | + .andExpect(jsonPath("$.message").value(containsString("xlsx"))); | |
| 313 | + } | |
| 314 | + | |
| 315 | + @Test | |
| 316 | + void 다운로드는_엑셀_파일을_돌려준다() throws Exception { | |
| 317 | + byte[] xlsx = workbook(row("1", "사이트A", "공지")); | |
| 318 | + mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf())) | |
| 319 | + .andExpect(status().isOk()); | |
| 320 | + | |
| 321 | + mvc.perform(get("/api/orgs/{id}/review/download", orgId)) | |
| 322 | + .andExpect(status().isOk()) | |
| 323 | + .andExpect(result -> assertThat(result.getResponse().getContentAsByteArray()).isNotEmpty()); | |
| 324 | + } | |
| 325 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?