ITN Dev 07-24
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
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -5,6 +5,7 @@
 import kr.itn.itnhub.feed.ChannelNotReadyException;
 import kr.itn.itnhub.feed.InvalidMessageException;
 import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.review.ReviewNotFoundException;
 import kr.itn.itnhub.seed.SeedParseException;
 import kr.itn.itnhub.stage.InvalidStageException;
 import org.springframework.http.HttpStatus;
@@ -52,6 +53,9 @@
  *
  * <p>{@link InvalidStageException}은 진행단계 변경 요청의 값이 1..12 범위를 벗어났을 때
  * 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p>
+ *
+ * <p>{@link ReviewNotFoundException}은 존재하지 않는 권리확인 게시물 id로 조회/수정/삭제를
+ * 시도했을 때 던진다 - URL의 id가 단순히 틀린 것뿐이므로 404가 맞다.</p>
  *
  * <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
  * 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리
@@ -111,4 +115,9 @@
     public ResponseEntity<ApiError> handleInvalidStage(InvalidStageException e) {
         return ResponseEntity.badRequest().body(new ApiError(e.getMessage()));
     }
+
+    @ExceptionHandler(ReviewNotFoundException.class)
+    public ResponseEntity<ApiError> handleReviewNotFound(ReviewNotFoundException e) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
+    }
 }
 
src/main/java/kr/itn/itnhub/review/JudgmentRequest.java (added)
+++ src/main/java/kr/itn/itnhub/review/JudgmentRequest.java
@@ -0,0 +1,14 @@
+package kr.itn.itnhub.review;
+
+/** 권리확인 상세의 [수정] 저장 요청. 전부 선택값이며, 저장 시 web_edited_at이 채워진다. */
+public record JudgmentRequest(
+        String openable,
+        String reviewMajor,
+        String reviewMinor,
+        String reviewResult,
+        String judgedKoglType,
+        String judgedAiType,
+        String opinion,
+        String lawyerNote,
+        String needsProcessing) {
+}
 
src/main/java/kr/itn/itnhub/review/ReviewController.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewController.java
@@ -0,0 +1,106 @@
+package kr.itn.itnhub.review;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import kr.itn.itnhub.seed.SeedParseException;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/** 기관관리 상세의 권리확인 탭이 부르는 엔드포인트. */
+@RestController
+public class ReviewController {
+
+    private final ReviewService reviewService;
+    private final ReviewImportService importService;
+    private final OrganizationMapper orgMapper;
+
+    public ReviewController(ReviewService reviewService, ReviewImportService importService,
+                             OrganizationMapper orgMapper) {
+        this.reviewService = reviewService;
+        this.importService = importService;
+        this.orgMapper = orgMapper;
+    }
+
+    @PostMapping("/api/orgs/{id}/review/import")
+    public ReviewImportReport upload(@PathVariable("id") Long orgId, @RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            throw new SeedParseException("업로드된 파일이 비어 있습니다.");
+        }
+        String filename = file.getOriginalFilename();
+        if (filename == null || !hasAllowedExtension(filename)) {
+            throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다.");
+        }
+        try (InputStream in = file.getInputStream()) {
+            return importService.importFile(orgId, in);
+        } catch (IOException e) {
+            throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
+        }
+    }
+
+    @GetMapping("/api/orgs/{id}/review")
+    public ReviewPage list(@PathVariable("id") Long orgId,
+                            @RequestParam(required = false) String keyword,
+                            @RequestParam(defaultValue = "0") int page,
+                            @RequestParam(defaultValue = "30") int size) {
+        return reviewService.list(orgId, keyword, page, size);
+    }
+
+    @GetMapping("/api/orgs/{id}/review/{itemId}")
+    public ReviewItem get(@PathVariable("id") Long orgId, @PathVariable Long itemId) {
+        return reviewService.get(orgId, itemId);
+    }
+
+    @PutMapping("/api/orgs/{id}/review/{itemId}")
+    public ReviewItem update(@PathVariable("id") Long orgId, @PathVariable Long itemId,
+                              @RequestBody JudgmentRequest request) {
+        return reviewService.updateJudgment(orgId, itemId, request);
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/orgs/{id}/review/{itemId}")
+    public void delete(@PathVariable("id") Long orgId, @PathVariable Long itemId) {
+        reviewService.delete(orgId, itemId);
+    }
+
+    /** 한글 파일명이라 ChannelFeedController.download와 동일하게 RFC 5987 인코딩을 쓴다. */
+    @GetMapping("/api/orgs/{id}/review/download")
+    public ResponseEntity<byte[]> download(@PathVariable("id") Long orgId) {
+        Organization org = orgMapper.findById(orgId);
+        if (org == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+        byte[] content = reviewService.downloadWorkbook(orgId);
+
+        String filename = "권리확인_" + org.getOrgName() + ".xlsx";
+        String encodedName = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName)
+                .contentType(MediaType.parseMediaType(
+                        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+                .body(content);
+    }
+
+    private static boolean hasAllowedExtension(String filename) {
+        String lower = filename.toLowerCase();
+        return lower.endsWith(".xlsx") || lower.endsWith(".xlsm");
+    }
+}
 
src/main/java/kr/itn/itnhub/review/ReviewImportReport.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewImportReport.java
@@ -0,0 +1,4 @@
+package kr.itn.itnhub.review;
+
+public record ReviewImportReport(int created, int updated, int total) {
+}
 
src/main/java/kr/itn/itnhub/review/ReviewImportService.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewImportService.java
@@ -0,0 +1,88 @@
+package kr.itn.itnhub.review;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class ReviewImportService {
+
+    private final ReviewParser parser;
+    private final ReviewItemMapper mapper;
+    private final OrganizationMapper orgMapper;
+    private final TransactionTemplate transactionTemplate;
+
+    public ReviewImportService(ReviewParser parser, ReviewItemMapper mapper, OrganizationMapper orgMapper,
+                                PlatformTransactionManager transactionManager) {
+        this.parser = parser;
+        this.mapper = mapper;
+        this.orgMapper = orgMapper;
+        this.transactionTemplate = new TransactionTemplate(transactionManager);
+    }
+
+    /**
+     * 엑셀 파싱은 DB와 무관하므로 트랜잭션 밖에서 수행한다(SeedService와 동일한 이유).
+     */
+    public ReviewImportReport importFile(Long orgId, InputStream xlsx) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+        List<ReviewRow> rows = parser.parse(xlsx);
+        return transactionTemplate.execute(status -> importRows(orgId, rows));
+    }
+
+    private ReviewImportReport importRows(Long orgId, List<ReviewRow> rows) {
+        // 이미 DB에 있는 순번 + 이번 파일에서 먼저 처리된 순번을 모두 "이미 존재함"으로 취급한다
+        // (SeedService.seedRows와 동일한 관례 - 같은 순번이 파일에 중복돼 있어도 두 번째는 갱신으로 집계).
+        Set<Integer> seen = new HashSet<>(mapper.findSeqsByOrg(orgId));
+
+        int created = 0;
+        int updated = 0;
+        for (ReviewRow row : rows) {
+            if (seen.add(row.seq())) {
+                created++;
+            } else {
+                updated++;
+            }
+            mapper.upsertFromFile(toReviewItem(orgId, row));
+        }
+        return new ReviewImportReport(created, updated, rows.size());
+    }
+
+    private ReviewItem toReviewItem(Long orgId, ReviewRow row) {
+        ReviewItem item = new ReviewItem();
+        item.setOrgId(orgId);
+        item.setSeq(row.seq());
+        item.setSiteName(row.siteName());
+        item.setCategory(row.category());
+        item.setBoardPath(row.boardPath());
+        item.setBoardName(row.boardName());
+        item.setPostTitle(row.postTitle());
+        item.setUrl(row.url());
+        item.setPostRegistered(row.postRegistered());
+        item.setProducedDate(row.producedDate());
+        item.setPublishedDate(row.publishedDate());
+        item.setHasAttachment(row.hasAttachment());
+        item.setKoglAttached(row.koglAttached());
+        item.setKoglType(row.koglType());
+        item.setAiType(row.aiType());
+        item.setSurveyorNote(row.surveyorNote());
+        item.setOpenable(row.openable());
+        item.setReviewMajor(row.reviewMajor());
+        item.setReviewMinor(row.reviewMinor());
+        item.setReviewResult(row.reviewResult());
+        item.setJudgedKoglType(row.judgedKoglType());
+        item.setJudgedAiType(row.judgedAiType());
+        item.setOpinion(row.opinion());
+        item.setLawyerNote(row.lawyerNote());
+        item.setNeedsProcessing(row.needsProcessing());
+        return item;
+    }
+}
 
src/main/java/kr/itn/itnhub/review/ReviewItem.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewItem.java
@@ -0,0 +1,133 @@
+package kr.itn.itnhub.review;
+
+/**
+ * 권리확인 게시물 한 건. {@code A:V, X, Y} 열이 그대로 대응한다({@code W}는 원본 시트의
+ * 빈 열이라 컬럼이 없다). 조사원 영역(site_name..surveyor_note)은 항상 파일이 최신값을
+ * 덮어쓰고, 변호사 영역(openable..needs_processing)은 {@link #webEditedAt}이 null일 때만
+ * 파일이 덮어쓴다 - 자세한 병합 정책은 {@code ReviewItemMapper.xml}의 upsertFromFile 참고.
+ */
+public class ReviewItem {
+
+    private Long id;
+    private Long orgId;
+    private int seq;
+
+    // 게시물 정보(조사원) - 재업로드 시 항상 파일 값으로 갱신
+    private String siteName;
+    private String category;
+    private String boardPath;
+    private String boardName;
+    private String postTitle;
+    private String url;
+    private String postRegistered;
+    private String producedDate;
+    private String publishedDate;
+    private String hasAttachment;
+    private String koglAttached;
+    private String koglType;
+    private String aiType;
+    private String surveyorNote;
+
+    // 권리확인(변호사) - webEditedAt이 null일 때만 재업로드가 덮어씀
+    private String openable;
+    private String reviewMajor;
+    private String reviewMinor;
+    private String reviewResult;
+    private String judgedKoglType;
+    private String judgedAiType;
+    private String opinion;
+    private String lawyerNote;
+    private String needsProcessing;
+
+    /** null이면 아직 웹에서 변호사 판정을 수정한 적이 없다는 뜻이다(재업로드 병합 판단 기준). */
+    private Long webEditedAt;
+    private long createdAt;
+    private long updatedAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+
+    public Long getOrgId() { return orgId; }
+    public void setOrgId(Long orgId) { this.orgId = orgId; }
+
+    public int getSeq() { return seq; }
+    public void setSeq(int seq) { this.seq = seq; }
+
+    public String getSiteName() { return siteName; }
+    public void setSiteName(String siteName) { this.siteName = siteName; }
+
+    public String getCategory() { return category; }
+    public void setCategory(String category) { this.category = category; }
+
+    public String getBoardPath() { return boardPath; }
+    public void setBoardPath(String boardPath) { this.boardPath = boardPath; }
+
+    public String getBoardName() { return boardName; }
+    public void setBoardName(String boardName) { this.boardName = boardName; }
+
+    public String getPostTitle() { return postTitle; }
+    public void setPostTitle(String postTitle) { this.postTitle = postTitle; }
+
+    public String getUrl() { return url; }
+    public void setUrl(String url) { this.url = url; }
+
+    public String getPostRegistered() { return postRegistered; }
+    public void setPostRegistered(String postRegistered) { this.postRegistered = postRegistered; }
+
+    public String getProducedDate() { return producedDate; }
+    public void setProducedDate(String producedDate) { this.producedDate = producedDate; }
+
+    public String getPublishedDate() { return publishedDate; }
+    public void setPublishedDate(String publishedDate) { this.publishedDate = publishedDate; }
+
+    public String getHasAttachment() { return hasAttachment; }
+    public void setHasAttachment(String hasAttachment) { this.hasAttachment = hasAttachment; }
+
+    public String getKoglAttached() { return koglAttached; }
+    public void setKoglAttached(String koglAttached) { this.koglAttached = koglAttached; }
+
+    public String getKoglType() { return koglType; }
+    public void setKoglType(String koglType) { this.koglType = koglType; }
+
+    public String getAiType() { return aiType; }
+    public void setAiType(String aiType) { this.aiType = aiType; }
+
+    public String getSurveyorNote() { return surveyorNote; }
+    public void setSurveyorNote(String surveyorNote) { this.surveyorNote = surveyorNote; }
+
+    public String getOpenable() { return openable; }
+    public void setOpenable(String openable) { this.openable = openable; }
+
+    public String getReviewMajor() { return reviewMajor; }
+    public void setReviewMajor(String reviewMajor) { this.reviewMajor = reviewMajor; }
+
+    public String getReviewMinor() { return reviewMinor; }
+    public void setReviewMinor(String reviewMinor) { this.reviewMinor = reviewMinor; }
+
+    public String getReviewResult() { return reviewResult; }
+    public void setReviewResult(String reviewResult) { this.reviewResult = reviewResult; }
+
+    public String getJudgedKoglType() { return judgedKoglType; }
+    public void setJudgedKoglType(String judgedKoglType) { this.judgedKoglType = judgedKoglType; }
+
+    public String getJudgedAiType() { return judgedAiType; }
+    public void setJudgedAiType(String judgedAiType) { this.judgedAiType = judgedAiType; }
+
+    public String getOpinion() { return opinion; }
+    public void setOpinion(String opinion) { this.opinion = opinion; }
+
+    public String getLawyerNote() { return lawyerNote; }
+    public void setLawyerNote(String lawyerNote) { this.lawyerNote = lawyerNote; }
+
+    public String getNeedsProcessing() { return needsProcessing; }
+    public void setNeedsProcessing(String needsProcessing) { this.needsProcessing = needsProcessing; }
+
+    public Long getWebEditedAt() { return webEditedAt; }
+    public void setWebEditedAt(Long webEditedAt) { this.webEditedAt = webEditedAt; }
+
+    public long getCreatedAt() { return createdAt; }
+    public void setCreatedAt(long createdAt) { this.createdAt = createdAt; }
+
+    public long getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; }
+}
 
src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
@@ -0,0 +1,49 @@
+package kr.itn.itnhub.review;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface ReviewItemMapper {
+
+    /** 순번 오름차순, keyword는 사이트명/게시판명/게시물제목 LIKE. keyword가 null이면 필터 없음. */
+    List<ReviewItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
+                               @Param("offset") int offset, @Param("limit") int limit);
+
+    int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword);
+
+    /** 처리결과(review_result)가 채워진 행 수 - 검색어와 무관한 기관 전체 진행률. */
+    int countDone(@Param("orgId") Long orgId);
+
+    /** 다운로드/재업로드 판정용으로 페이징 없이 순번순 전체를 읽는다. */
+    List<ReviewItem> findAllByOrg(@Param("orgId") Long orgId);
+
+    /** 이미 저장된 순번 전체 - SeedService처럼 신규/갱신 집계를 메모리에서 판단하기 위함. */
+    List<Integer> findSeqsByOrg(@Param("orgId") Long orgId);
+
+    ReviewItem findById(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    /**
+     * 엑셀 재업로드 1행 반영. (org_id, seq) 충돌 시 조사원 영역은 항상 덮어쓰고,
+     * 변호사 영역은 web_edited_at이 null일 때만 덮어쓴다 - 정책 세부는 XML 주석 참고.
+     */
+    int upsertFromFile(ReviewItem item);
+
+    int updateJudgment(@Param("orgId") Long orgId, @Param("id") Long id,
+                        @Param("openable") String openable,
+                        @Param("reviewMajor") String reviewMajor,
+                        @Param("reviewMinor") String reviewMinor,
+                        @Param("reviewResult") String reviewResult,
+                        @Param("judgedKoglType") String judgedKoglType,
+                        @Param("judgedAiType") String judgedAiType,
+                        @Param("opinion") String opinion,
+                        @Param("lawyerNote") String lawyerNote,
+                        @Param("needsProcessing") String needsProcessing);
+
+    int deleteById(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    /** 기관 삭제 캐스케이드 외에는 아직 쓰이지 않지만, 저장 비용이 낮아 함께 둔다. */
+    int deleteByOrg(@Param("orgId") Long orgId);
+}
 
src/main/java/kr/itn/itnhub/review/ReviewNotFoundException.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewNotFoundException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.review;
+
+/**
+ * 존재하지 않는 권리확인 게시물 id로 조회/수정/삭제를 시도했을 때 던진다.
+ * URL 경로의 id가 단순히 틀린 것뿐이므로 GlobalExceptionHandler가 404로 변환한다.
+ */
+public class ReviewNotFoundException extends RuntimeException {
+    public ReviewNotFoundException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/review/ReviewPage.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewPage.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.review;
+
+import java.util.List;
+
+/**
+ * 권리확인 탭 목록 응답. {@code total}은 현재 keyword 필터 기준 건수(페이징 계산에 사용),
+ * {@code done}은 검색어와 무관한 기관 전체의 처리결과 완료 건수다(헤더의 "처리 n건"은
+ * 검색 결과가 아니라 기관 전체 진행률을 보여주기 위함).
+ */
+public record ReviewPage(List<ReviewItem> items, int total, int done, int page, int size) {
+}
 
src/main/java/kr/itn/itnhub/review/ReviewParser.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewParser.java
@@ -0,0 +1,154 @@
+package kr.itn.itnhub.review;
+
+import kr.itn.itnhub.seed.SeedParseException;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 기관별 권리확인 엑셀(시트명 {@code Sheet0}, 없으면 첫 시트로 대체)을 읽는다.
+ *
+ * 시트 배치 (1행 그룹제목, 2행 헤더, 3행부터 데이터, A열(순번)이 빈 첫 행에서 데이터 끝):
+ *   A 순번  B 사이트명  C 범주  D 게시판 경로  E 게시판명  F 게시물제목  G URL주소
+ *   H 게시글 등록  I 제작일  J 공표일  K 첨부파일여부  L 기존 공공누리부착  M 기존 공공누리유형
+ *   N AI유형  O 비고(조사원)  P 개방가능여부  Q 권리확인(대분류)  R 권리확인(세부)  S 처리결과
+ *   T 공공누리유형(판정)  U AI유형(판정)  V 의견  W (빈 열)  X 비고(변호사)  Y 권리처리필요
+ *
+ * 시트의 선언된 dimension은 서식만 채워진 40만 행 안팎이라, lastRowNum까지 무조건 돌되
+ * A열이 비면 즉시 멈춘다(전체 dimension을 다 읽지 않는다).
+ *
+ * 제작일/공표일 셀은 날짜서식이 입혀진 숫자({@code 20230904})라도 절대 날짜로 읽지 않고
+ * 문자열 그대로 저장한다 - {@link #cellValue}가 NUMERIC을 항상 반올림 문자열로 바꾸고
+ * {@code getDateCellValue()}를 호출하지 않으므로 자동으로 이 요구사항을 만족한다.
+ * 수식 오류(#VALUE! 등) 셀은 캐시된 결과 타입이 ERROR가 되어 default 분기(빈 문자열 → null)로
+ * 처리된다.
+ */
+@Component
+public class ReviewParser {
+
+    public static final String PRIMARY_SHEET_NAME = "Sheet0";
+
+    private static final int DATA_START_ROW_INDEX = 2; // 3행
+
+    private static final int COL_SEQ = 0;              // A
+    private static final int COL_SITE_NAME = 1;        // B
+    private static final int COL_CATEGORY = 2;         // C
+    private static final int COL_BOARD_PATH = 3;       // D
+    private static final int COL_BOARD_NAME = 4;       // E
+    private static final int COL_POST_TITLE = 5;       // F
+    private static final int COL_URL = 6;               // G
+    private static final int COL_POST_REGISTERED = 7;  // H
+    private static final int COL_PRODUCED_DATE = 8;    // I
+    private static final int COL_PUBLISHED_DATE = 9;   // J
+    private static final int COL_HAS_ATTACHMENT = 10;  // K
+    private static final int COL_KOGL_ATTACHED = 11;   // L
+    private static final int COL_KOGL_TYPE = 12;       // M
+    private static final int COL_AI_TYPE = 13;         // N
+    private static final int COL_SURVEYOR_NOTE = 14;   // O
+    private static final int COL_OPENABLE = 15;        // P
+    private static final int COL_REVIEW_MAJOR = 16;    // Q
+    private static final int COL_REVIEW_MINOR = 17;    // R
+    private static final int COL_REVIEW_RESULT = 18;   // S
+    private static final int COL_JUDGED_KOGL_TYPE = 19; // T
+    private static final int COL_JUDGED_AI_TYPE = 20;  // U
+    private static final int COL_OPINION = 21;         // V
+    // 22 = W, 빈 열
+    private static final int COL_LAWYER_NOTE = 23;     // X
+    private static final int COL_NEEDS_PROCESSING = 24; // Y
+
+    public List<ReviewRow> parse(InputStream xlsx) {
+        try (Workbook wb = WorkbookFactory.create(xlsx)) {
+            Sheet sheet = wb.getSheet(PRIMARY_SHEET_NAME);
+            if (sheet == null && wb.getNumberOfSheets() > 0) {
+                sheet = wb.getSheetAt(0);
+            }
+            if (sheet == null) {
+                throw new SeedParseException("통합문서에 읽을 수 있는 시트가 없습니다.");
+            }
+            return readRows(sheet);
+        } catch (IOException e) {
+            throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
+        }
+    }
+
+    private List<ReviewRow> readRows(Sheet sheet) {
+        List<ReviewRow> result = new ArrayList<>();
+
+        for (int i = DATA_START_ROW_INDEX; i <= sheet.getLastRowNum(); i++) {
+            Row row = sheet.getRow(i);
+            if (row == null) {
+                break; // 데이터 영역은 연속적이다 - 빈 행이 나오면 그게 끝이다
+            }
+
+            String seqRaw = string(row, COL_SEQ);
+            if (seqRaw.isBlank()) {
+                break;
+            }
+            int seq;
+            try {
+                seq = Integer.parseInt(seqRaw.trim());
+            } catch (NumberFormatException e) {
+                break;
+            }
+
+            result.add(new ReviewRow(
+                    seq,
+                    nullIfBlank(string(row, COL_SITE_NAME)),
+                    nullIfBlank(string(row, COL_CATEGORY)),
+                    nullIfBlank(string(row, COL_BOARD_PATH)),
+                    nullIfBlank(string(row, COL_BOARD_NAME)),
+                    nullIfBlank(string(row, COL_POST_TITLE)),
+                    nullIfBlank(string(row, COL_URL)),
+                    nullIfBlank(string(row, COL_POST_REGISTERED)),
+                    nullIfBlank(string(row, COL_PRODUCED_DATE)),
+                    nullIfBlank(string(row, COL_PUBLISHED_DATE)),
+                    nullIfBlank(string(row, COL_HAS_ATTACHMENT)),
+                    nullIfBlank(string(row, COL_KOGL_ATTACHED)),
+                    nullIfBlank(string(row, COL_KOGL_TYPE)),
+                    nullIfBlank(string(row, COL_AI_TYPE)),
+                    nullIfBlank(string(row, COL_SURVEYOR_NOTE)),
+                    nullIfBlank(string(row, COL_OPENABLE)),
+                    nullIfBlank(string(row, COL_REVIEW_MAJOR)),
+                    nullIfBlank(string(row, COL_REVIEW_MINOR)),
+                    nullIfBlank(string(row, COL_REVIEW_RESULT)),
+                    nullIfBlank(string(row, COL_JUDGED_KOGL_TYPE)),
+                    nullIfBlank(string(row, COL_JUDGED_AI_TYPE)),
+                    nullIfBlank(string(row, COL_OPINION)),
+                    nullIfBlank(string(row, COL_LAWYER_NOTE)),
+                    nullIfBlank(string(row, COL_NEEDS_PROCESSING))));
+        }
+        return result;
+    }
+
+    private String string(Row row, int columnIndex) {
+        Cell cell = row.getCell(columnIndex);
+        if (cell == null) {
+            return "";
+        }
+        return cellValue(cell, cell.getCellType());
+    }
+
+    /** SeedParser/MemberDirectoryParser와 동일한 셀 값 변환 규칙. */
+    private String cellValue(Cell cell, CellType cellType) {
+        return switch (cellType) {
+            case STRING -> cell.getStringCellValue().trim();
+            case NUMERIC -> String.valueOf(Math.round(cell.getNumericCellValue()));
+            case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
+            case FORMULA -> cellValue(cell, cell.getCachedFormulaResultType());
+            default -> "";
+        };
+    }
+
+    private String nullIfBlank(String value) {
+        return (value == null || value.isBlank()) ? null : value;
+    }
+}
 
src/main/java/kr/itn/itnhub/review/ReviewRow.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewRow.java
@@ -0,0 +1,29 @@
+package kr.itn.itnhub.review;
+
+/** {@link ReviewParser}가 엑셀 한 행에서 읽어낸 원시 값. W열은 원본 시트의 빈 열이라 대응 필드가 없다. */
+public record ReviewRow(
+        int seq,
+        String siteName,
+        String category,
+        String boardPath,
+        String boardName,
+        String postTitle,
+        String url,
+        String postRegistered,
+        String producedDate,
+        String publishedDate,
+        String hasAttachment,
+        String koglAttached,
+        String koglType,
+        String aiType,
+        String surveyorNote,
+        String openable,
+        String reviewMajor,
+        String reviewMinor,
+        String reviewResult,
+        String judgedKoglType,
+        String judgedAiType,
+        String opinion,
+        String lawyerNote,
+        String needsProcessing) {
+}
 
src/main/java/kr/itn/itnhub/review/ReviewService.java (added)
+++ src/main/java/kr/itn/itnhub/review/ReviewService.java
@@ -0,0 +1,156 @@
+package kr.itn.itnhub.review;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+
+/** 기관관리 상세의 권리확인 탭이 쓰는 목록/상세/수정/삭제/다운로드 서비스. */
+@Service
+public class ReviewService {
+
+    /** 1행 그룹 제목 + 2행 컬럼 헤더. W열은 원본 시트의 빈 열이라 빈 문자열로 남겨둔다. */
+    private static final String[] COLUMN_HEADERS = {
+            "순번", "사이트명", "범주", "게시판 경로", "게시판명", "게시물제목", "URL주소",
+            "게시글 등록", "제작일", "공표일", "첨부파일여부", "기존 공공누리부착", "기존 공공누리유형", "AI유형",
+            "비고(조사원)", "개방가능여부", "권리확인(대분류)", "권리확인(세부)", "처리결과",
+            "공공누리유형(판정)", "AI유형(판정)", "의견", "", "비고(변호사)", "권리처리필요"
+    };
+
+    private final ReviewItemMapper mapper;
+    private final OrganizationMapper orgMapper;
+
+    public ReviewService(ReviewItemMapper mapper, OrganizationMapper orgMapper) {
+        this.mapper = mapper;
+        this.orgMapper = orgMapper;
+    }
+
+    public ReviewPage list(Long orgId, String keyword, int page, int size) {
+        requireOrg(orgId);
+        String kw = normalizeKeyword(keyword);
+        int safePage = Math.max(page, 0);
+        int safeSize = size <= 0 ? 30 : size;
+        int offset = safePage * safeSize;
+
+        List<ReviewItem> items = mapper.findPage(orgId, kw, offset, safeSize);
+        int total = mapper.countByOrg(orgId, kw);
+        int done = mapper.countDone(orgId);
+        return new ReviewPage(items, total, done, safePage, safeSize);
+    }
+
+    public ReviewItem get(Long orgId, Long itemId) {
+        requireOrg(orgId);
+        return requireItem(orgId, itemId);
+    }
+
+    @Transactional
+    public ReviewItem updateJudgment(Long orgId, Long itemId, JudgmentRequest request) {
+        requireOrg(orgId);
+        int updated = mapper.updateJudgment(orgId, itemId,
+                request.openable(), request.reviewMajor(), request.reviewMinor(), request.reviewResult(),
+                request.judgedKoglType(), request.judgedAiType(), request.opinion(), request.lawyerNote(),
+                request.needsProcessing());
+        if (updated == 0) {
+            throw new ReviewNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
+        }
+        return mapper.findById(orgId, itemId);
+    }
+
+    @Transactional
+    public void delete(Long orgId, Long itemId) {
+        requireOrg(orgId);
+        int deleted = mapper.deleteById(orgId, itemId);
+        if (deleted == 0) {
+            throw new ReviewNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
+        }
+    }
+
+    /** 원본 파일과 같은 헤더 배치(1행 그룹제목, 2행 컬럼헤더, 3행부터 데이터)로 xlsx를 만든다. */
+    public byte[] downloadWorkbook(Long orgId) {
+        requireOrg(orgId);
+        List<ReviewItem> items = mapper.findAllByOrg(orgId);
+
+        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            Sheet sheet = wb.createSheet(ReviewParser.PRIMARY_SHEET_NAME);
+
+            Row groupRow = sheet.createRow(0);
+            groupRow.createCell(0).setCellValue("게시물 정보(조사원)");
+            groupRow.createCell(15).setCellValue("권리확인(변호사)");
+
+            Row headerRow = sheet.createRow(1);
+            for (int i = 0; i < COLUMN_HEADERS.length; i++) {
+                headerRow.createCell(i).setCellValue(COLUMN_HEADERS[i]);
+            }
+
+            int rowNum = 2;
+            for (ReviewItem item : items) {
+                Row row = sheet.createRow(rowNum++);
+                setCell(row, 0, String.valueOf(item.getSeq()));
+                setCell(row, 1, item.getSiteName());
+                setCell(row, 2, item.getCategory());
+                setCell(row, 3, item.getBoardPath());
+                setCell(row, 4, item.getBoardName());
+                setCell(row, 5, item.getPostTitle());
+                setCell(row, 6, item.getUrl());
+                setCell(row, 7, item.getPostRegistered());
+                setCell(row, 8, item.getProducedDate());
+                setCell(row, 9, item.getPublishedDate());
+                setCell(row, 10, item.getHasAttachment());
+                setCell(row, 11, item.getKoglAttached());
+                setCell(row, 12, item.getKoglType());
+                setCell(row, 13, item.getAiType());
+                setCell(row, 14, item.getSurveyorNote());
+                setCell(row, 15, item.getOpenable());
+                setCell(row, 16, item.getReviewMajor());
+                setCell(row, 17, item.getReviewMinor());
+                setCell(row, 18, item.getReviewResult());
+                setCell(row, 19, item.getJudgedKoglType());
+                setCell(row, 20, item.getJudgedAiType());
+                setCell(row, 21, item.getOpinion());
+                // 22 = W, 빈 열
+                setCell(row, 23, item.getLawyerNote());
+                setCell(row, 24, item.getNeedsProcessing());
+            }
+
+            wb.write(out);
+            return out.toByteArray();
+        } catch (IOException e) {
+            throw new UncheckedIOException("권리확인 엑셀을 만들지 못했습니다.", e);
+        }
+    }
+
+    private void setCell(Row row, int columnIndex, String value) {
+        if (value == null) {
+            return;
+        }
+        Cell cell = row.createCell(columnIndex);
+        cell.setCellValue(value);
+    }
+
+    private String normalizeKeyword(String keyword) {
+        return (keyword == null || keyword.isBlank()) ? null : keyword.trim();
+    }
+
+    private void requireOrg(Long orgId) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+    }
+
+    private ReviewItem requireItem(Long orgId, Long itemId) {
+        ReviewItem item = mapper.findById(orgId, itemId);
+        if (item == null) {
+            throw new ReviewNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
+        }
+        return item;
+    }
+}
 
src/main/resources/db/migration/V9__review_item.sql (added)
+++ src/main/resources/db/migration/V9__review_item.sql
@@ -0,0 +1,33 @@
+create table review_item (
+    id             bigserial primary key,
+    org_id         bigint       not null references organization (id) on delete cascade,
+    seq            integer      not null,           -- 순번
+    site_name      varchar(300),
+    category       varchar(500),
+    board_path     varchar(500),
+    board_name     varchar(300),
+    post_title     varchar(1000),
+    url            varchar(1000),
+    post_registered varchar(50),
+    produced_date  varchar(20),
+    published_date varchar(20),
+    has_attachment varchar(50),
+    kogl_attached  varchar(50),
+    kogl_type      varchar(50),
+    ai_type        varchar(50),
+    surveyor_note  varchar(1000),
+    openable       varchar(100),
+    review_major   varchar(200),   -- 권리확인(대분류)
+    review_minor   varchar(300),   -- 권리확인(세부)
+    review_result  varchar(200),   -- 처리결과
+    judged_kogl_type varchar(50),  -- 공공누리유형(판정)
+    judged_ai_type varchar(50),
+    opinion        varchar(2000),
+    lawyer_note    varchar(1000),
+    needs_processing varchar(50),  -- 권리처리필요
+    web_edited_at  timestamptz,    -- null = 웹에서 손대지 않음 (재업로드 병합 판단 기준)
+    created_at     timestamptz not null default now(),
+    updated_at     timestamptz not null default now(),
+    constraint uq_review_item unique (org_id, seq)
+);
+create index ix_review_item_org on review_item (org_id, seq);
 
src/main/resources/mapper/ReviewItemMapper.xml (added)
+++ src/main/resources/mapper/ReviewItemMapper.xml
@@ -0,0 +1,138 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="kr.itn.itnhub.review.ReviewItemMapper">
+
+  <!--
+    web_edited_at/created_at/updated_at을 밀리초 epoch로 내려준다(work_memo와 동일 관례).
+    web_edited_at은 null이면 extract(epoch from null)도 null이라 그대로 null이 내려간다.
+  -->
+  <sql id="columns">
+    id, org_id, seq, site_name, category, board_path, board_name, post_title, url,
+    post_registered, produced_date, published_date, has_attachment, kogl_attached, kogl_type, ai_type,
+    surveyor_note, openable, review_major, review_minor, review_result, judged_kogl_type, judged_ai_type,
+    opinion, lawyer_note, needs_processing,
+    (extract(epoch from web_edited_at) * 1000)::bigint as web_edited_at,
+    (extract(epoch from created_at) * 1000)::bigint as created_at,
+    (extract(epoch from updated_at) * 1000)::bigint as updated_at
+  </sql>
+
+  <sql id="keywordFilter">
+    <if test="keyword != null">
+      and (site_name ilike concat('%', #{keyword}, '%')
+        or board_name ilike concat('%', #{keyword}, '%')
+        or post_title ilike concat('%', #{keyword}, '%'))
+    </if>
+  </sql>
+
+  <select id="findPage" resultType="kr.itn.itnhub.review.ReviewItem">
+    select <include refid="columns"/>
+    from review_item
+    where org_id = #{orgId}
+    <include refid="keywordFilter"/>
+    order by seq asc
+    limit #{limit} offset #{offset}
+  </select>
+
+  <select id="countByOrg" resultType="int">
+    select count(*)
+    from review_item
+    where org_id = #{orgId}
+    <include refid="keywordFilter"/>
+  </select>
+
+  <select id="countDone" resultType="int">
+    select count(*)
+    from review_item
+    where org_id = #{orgId}
+      and review_result is not null
+      and review_result &lt;&gt; ''
+  </select>
+
+  <select id="findAllByOrg" resultType="kr.itn.itnhub.review.ReviewItem">
+    select <include refid="columns"/>
+    from review_item
+    where org_id = #{orgId}
+    order by seq asc
+  </select>
+
+  <select id="findSeqsByOrg" resultType="int">
+    select seq from review_item where org_id = #{orgId}
+  </select>
+
+  <select id="findById" resultType="kr.itn.itnhub.review.ReviewItem">
+    select <include refid="columns"/>
+    from review_item
+    where org_id = #{orgId} and id = #{id}
+  </select>
+
+  <!--
+    정책(b): 조사원 영역(site_name..surveyor_note)은 재업로드마다 항상 최신 파일값으로
+    덮어쓴다. 변호사 영역(openable..needs_processing)은 web_edited_at이 null일 때만
+    (=웹에서 아직 아무도 손대지 않았을 때만) 파일값을 반영한다 - 이미 웹에서 판정을
+    입력한 행은 재업로드가 절대 덮어쓰지 않는다.
+  -->
+  <insert id="upsertFromFile" parameterType="kr.itn.itnhub.review.ReviewItem">
+    insert into review_item (
+      org_id, seq, site_name, category, board_path, board_name, post_title, url,
+      post_registered, produced_date, published_date, has_attachment, kogl_attached, kogl_type, ai_type,
+      surveyor_note, openable, review_major, review_minor, review_result, judged_kogl_type, judged_ai_type,
+      opinion, lawyer_note, needs_processing
+    ) values (
+      #{orgId}, #{seq}, #{siteName}, #{category}, #{boardPath}, #{boardName}, #{postTitle}, #{url},
+      #{postRegistered}, #{producedDate}, #{publishedDate}, #{hasAttachment}, #{koglAttached}, #{koglType}, #{aiType},
+      #{surveyorNote}, #{openable}, #{reviewMajor}, #{reviewMinor}, #{reviewResult}, #{judgedKoglType}, #{judgedAiType},
+      #{opinion}, #{lawyerNote}, #{needsProcessing}
+    )
+    on conflict (org_id, seq) do update set
+      site_name       = excluded.site_name,
+      category        = excluded.category,
+      board_path      = excluded.board_path,
+      board_name      = excluded.board_name,
+      post_title      = excluded.post_title,
+      url             = excluded.url,
+      post_registered = excluded.post_registered,
+      produced_date   = excluded.produced_date,
+      published_date  = excluded.published_date,
+      has_attachment  = excluded.has_attachment,
+      kogl_attached   = excluded.kogl_attached,
+      kogl_type       = excluded.kogl_type,
+      ai_type         = excluded.ai_type,
+      surveyor_note   = excluded.surveyor_note,
+      openable        = case when review_item.web_edited_at is null then excluded.openable else review_item.openable end,
+      review_major    = case when review_item.web_edited_at is null then excluded.review_major else review_item.review_major end,
+      review_minor    = case when review_item.web_edited_at is null then excluded.review_minor else review_item.review_minor end,
+      review_result   = case when review_item.web_edited_at is null then excluded.review_result else review_item.review_result end,
+      judged_kogl_type = case when review_item.web_edited_at is null then excluded.judged_kogl_type else review_item.judged_kogl_type end,
+      judged_ai_type  = case when review_item.web_edited_at is null then excluded.judged_ai_type else review_item.judged_ai_type end,
+      opinion         = case when review_item.web_edited_at is null then excluded.opinion else review_item.opinion end,
+      lawyer_note     = case when review_item.web_edited_at is null then excluded.lawyer_note else review_item.lawyer_note end,
+      needs_processing = case when review_item.web_edited_at is null then excluded.needs_processing else review_item.needs_processing end,
+      updated_at      = now()
+  </insert>
+
+  <update id="updateJudgment">
+    update review_item set
+      openable          = #{openable},
+      review_major      = #{reviewMajor},
+      review_minor      = #{reviewMinor},
+      review_result     = #{reviewResult},
+      judged_kogl_type  = #{judgedKoglType},
+      judged_ai_type    = #{judgedAiType},
+      opinion           = #{opinion},
+      lawyer_note       = #{lawyerNote},
+      needs_processing  = #{needsProcessing},
+      web_edited_at     = now(),
+      updated_at        = now()
+    where org_id = #{orgId} and id = #{id}
+  </update>
+
+  <delete id="deleteById">
+    delete from review_item where org_id = #{orgId} and id = #{id}
+  </delete>
+
+  <delete id="deleteByOrg">
+    delete from review_item where org_id = #{orgId}
+  </delete>
+
+</mapper>
 
src/test/java/kr/itn/itnhub/review/ReviewControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/review/ReviewControllerTest.java
@@ -0,0 +1,325 @@
+package kr.itn.itnhub.review;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.DataFormat;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class ReviewControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    ReviewItemMapper reviewMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from review_item");
+        jdbc.update("delete from organization");
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("한국출판문화산업진흥원");
+        org.setChannelSlug("001");
+        orgMapper.upsertBySeed(org);
+
+        orgId = orgMapper.findAll().get(0).getId();
+    }
+
+    /** 필드 순서: seq,siteName,category,boardPath,boardName,postTitle,url,postRegistered,
+     * producedDate,publishedDate,hasAttachment,koglAttached,koglType,aiType,surveyorNote,
+     * openable,reviewMajor,reviewMinor,reviewResult,judgedKoglType,judgedAiType,opinion,
+     * lawyerNote,needsProcessing (24개, W열은 건너뛴다). */
+    private byte[] workbook(String[]... dataRows) throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            Sheet sheet = wb.createSheet(ReviewParser.PRIMARY_SHEET_NAME);
+            sheet.createRow(0);
+            sheet.createRow(1);
+
+            int rowNum = 2;
+            for (String[] data : dataRows) {
+                Row row = sheet.createRow(rowNum++);
+                for (int i = 0; i < data.length; i++) {
+                    if (data[i] == null) {
+                        continue;
+                    }
+                    int col = i < 22 ? i : i + 1; // 22 = W, 건너뜀
+                    row.createCell(col).setCellValue(data[i]);
+                }
+            }
+            wb.write(out);
+            return out.toByteArray();
+        }
+    }
+
+    private MockMultipartFile file(byte[] bytes) {
+        return new MockMultipartFile("file", "review.xlsx",
+                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", bytes);
+    }
+
+    @Test
+    void 최초_업로드는_전부_신규이고_제작일은_날짜서식_숫자여도_문자열_그대로_저장된다() throws Exception {
+        byte[] xlsx = numericDateWorkbook();
+
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(1))
+                .andExpect(jsonPath("$.updated").value(0))
+                .andExpect(jsonPath("$.total").value(1));
+
+        ReviewItem item = reviewMapper.findAllByOrg(orgId).get(0);
+        assertThat(item.getProducedDate()).isEqualTo("20230904");
+        assertThat(item.getSiteName()).isEqualTo("사이트");
+    }
+
+    /** 제작일(I열, index 8)에 날짜서식이 입혀진 숫자 20230904를 직접 심어 재현한다. */
+    private byte[] numericDateWorkbook() throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            Sheet sheet = wb.createSheet(ReviewParser.PRIMARY_SHEET_NAME);
+            sheet.createRow(0);
+            sheet.createRow(1);
+            Row row = sheet.createRow(2);
+            row.createCell(0).setCellValue("1");
+            row.createCell(1).setCellValue("사이트");
+
+            CellStyle dateStyle = wb.createCellStyle();
+            DataFormat fmt = wb.createDataFormat();
+            dateStyle.setDataFormat(fmt.getFormat("yyyy-mm-dd"));
+            Cell produced = row.createCell(8);
+            produced.setCellStyle(dateStyle);
+            produced.setCellValue(20230904);
+
+            wb.write(out);
+            return out.toByteArray();
+        }
+    }
+
+    @Test
+    void 같은_파일을_다시_올려도_행이_늘지_않고_갱신으로_집계된다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(0))
+                .andExpect(jsonPath("$.updated").value(1));
+
+        assertThat(reviewMapper.findAllByOrg(orgId)).hasSize(1);
+    }
+
+    @Test
+    void 웹에서_수정한_변호사_판정은_재업로드가_덮어쓰지_않고_조사원_항목은_계속_갱신된다() throws Exception {
+        byte[] first = workbook(
+                row("1", "사이트A", "공지"),
+                row("2", "사이트B", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(first)).with(csrf()))
+                .andExpect(status().isOk());
+
+        Long editedId = reviewMapper.findAllByOrg(orgId).stream()
+                .filter(i -> i.getSeq() == 1).findFirst().get().getId();
+
+        // seq=1은 웹에서 변호사 판정을 직접 입력한다.
+        mvc.perform(put("/api/orgs/{id}/review/{itemId}", orgId, editedId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"reviewResult": "신유형 개방", "openable": "Y"}
+                                """))
+                .andExpect(status().isOk());
+
+        // 재업로드: 두 행 모두 조사원 항목(site_name)이 바뀌고, 변호사 항목(reviewResult)도 새 값이 온다.
+        byte[] second = workbook(
+                withReview(row("1", "사이트A-수정", "공지"), "N", "계약서 등 재확인"),
+                withReview(row("2", "사이트B-수정", "공지"), "Y", "권리처리 추진"));
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(second)).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.created").value(0))
+                .andExpect(jsonPath("$.updated").value(2));
+
+        ReviewItem edited = reviewMapper.findById(orgId, editedId);
+        assertThat(edited.getSiteName()).isEqualTo("사이트A-수정"); // 조사원 항목은 항상 갱신
+        assertThat(edited.getReviewResult()).isEqualTo("신유형 개방"); // 웹 입력값 보존
+        assertThat(edited.getOpenable()).isEqualTo("Y");
+        assertThat(edited.getWebEditedAt()).isNotNull();
+
+        ReviewItem untouched = reviewMapper.findAllByOrg(orgId).stream()
+                .filter(i -> i.getSeq() == 2).findFirst().get();
+        assertThat(untouched.getSiteName()).isEqualTo("사이트B-수정");
+        assertThat(untouched.getReviewResult()).isEqualTo("권리처리 추진"); // 웹 수정 이력 없으니 파일값 반영
+        assertThat(untouched.getWebEditedAt()).isNull();
+    }
+
+    private String[] row(String seq, String siteName, String category) {
+        return new String[]{
+                seq, siteName, category, null, "게시판명", "제목", "http://example.com", null,
+                null, null, "N", null, null, null, null,
+                null, null, null, null, null, null, null,
+                null, null
+        };
+    }
+
+    private String[] withReview(String[] base, String openable, String reviewResult) {
+        String[] copy = base.clone();
+        copy[15] = openable;      // P 개방가능여부
+        copy[18] = reviewResult;  // S 처리결과
+        return copy;
+    }
+
+    @Test
+    void 목록은_페이징되고_검색어로_필터링되며_처리건수는_검색과_무관하다() throws Exception {
+        byte[] xlsx = workbook(
+                row("1", "국립중앙도서관", "공지"),
+                row("2", "국립중앙박물관", "공지"),
+                row("3", "다른기관", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+
+        Long doneId = reviewMapper.findAllByOrg(orgId).stream()
+                .filter(i -> i.getSeq() == 1).findFirst().get().getId();
+        mvc.perform(put("/api/orgs/{id}/review/{itemId}", orgId, doneId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"reviewResult": "신유형 개방"}
+                                """))
+                .andExpect(status().isOk());
+
+        // size=2, page=0 : 전체 3건 중 앞 2건
+        mvc.perform(get("/api/orgs/{id}/review", orgId).param("page", "0").param("size", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.items.length()").value(2))
+                .andExpect(jsonPath("$.total").value(3))
+                .andExpect(jsonPath("$.done").value(1))
+                .andExpect(jsonPath("$.page").value(0))
+                .andExpect(jsonPath("$.size").value(2));
+
+        // 검색어로 "국립"이 들어간 2건만 필터링되지만 done은 여전히 기관 전체 기준(1)이다.
+        mvc.perform(get("/api/orgs/{id}/review", orgId).param("keyword", "국립"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.items.length()").value(2))
+                .andExpect(jsonPath("$.total").value(2))
+                .andExpect(jsonPath("$.done").value(1));
+    }
+
+    @Test
+    void 판정을_저장하면_웹수정시각이_찍히고_단건_조회로_확인된다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+        Long itemId = reviewMapper.findAllByOrg(orgId).get(0).getId();
+
+        mvc.perform(put("/api/orgs/{id}/review/{itemId}", orgId, itemId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "openable": "Y",
+                                  "reviewMajor": "만료 저작물",
+                                  "reviewResult": "신유형 개방",
+                                  "judgedKoglType": "1유형",
+                                  "needsProcessing": "Y"
+                                }
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.reviewResult").value("신유형 개방"))
+                .andExpect(jsonPath("$.webEditedAt").isNumber());
+
+        mvc.perform(get("/api/orgs/{id}/review/{itemId}", orgId, itemId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.openable").value("Y"))
+                .andExpect(jsonPath("$.judgedKoglType").value("1유형"))
+                .andExpect(jsonPath("$.needsProcessing").value("Y"));
+    }
+
+    @Test
+    void 게시물을_삭제하면_204와_함께_목록에서_사라진다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+        Long itemId = reviewMapper.findAllByOrg(orgId).get(0).getId();
+
+        mvc.perform(delete("/api/orgs/{id}/review/{itemId}", orgId, itemId).with(csrf()))
+                .andExpect(status().isNoContent());
+
+        assertThat(reviewMapper.findAllByOrg(orgId)).isEmpty();
+    }
+
+    @Test
+    void 존재하지_않는_기관으로_업로드하면_404다() throws Exception {
+        long missingOrgId = orgId + 999999L;
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+
+        mvc.perform(multipart("/api/orgs/{id}/review/import", missingOrgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingOrgId))));
+    }
+
+    @Test
+    void 존재하지_않는_게시물이면_404다() throws Exception {
+        long missingItemId = 999999L;
+
+        mvc.perform(get("/api/orgs/{id}/review/{itemId}", orgId, missingItemId))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingItemId))));
+    }
+
+    @Test
+    void 확장자가_xlsx_xlsm이_아니면_400이다() throws Exception {
+        MockMultipartFile txt = new MockMultipartFile("file", "review.txt",
+                "text/plain", "아무 내용".getBytes());
+
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(txt).with(csrf()))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("xlsx")));
+    }
+
+    @Test
+    void 다운로드는_엑셀_파일을_돌려준다() throws Exception {
+        byte[] xlsx = workbook(row("1", "사이트A", "공지"));
+        mvc.perform(multipart("/api/orgs/{id}/review/import", orgId).file(file(xlsx)).with(csrf()))
+                .andExpect(status().isOk());
+
+        mvc.perform(get("/api/orgs/{id}/review/download", orgId))
+                .andExpect(status().isOk())
+                .andExpect(result -> assertThat(result.getResponse().getContentAsByteArray()).isNotEmpty());
+    }
+}
Add a comment
List