From 4b29b67072fa335ada2227cb5425218083e21f3a Mon Sep 17 00:00:00 2001 From: DHkimgit Date: Sun, 18 Jan 2026 20:33:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B6=84=EC=8B=A4=EB=AC=BC=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/LostItemArticleApi.java | 29 +++++++++ .../controller/LostItemArticleController.java | 16 ++++- .../article/dto/LostItemArticleResponse.java | 1 + .../dto/LostItemArticleUpdateRequest.java | 49 ++++++++++++++ .../community/article/model/Article.java | 10 +++ .../article/model/LostItemArticle.java | 65 +++++++++++++++++++ .../article/model/LostItemImage.java | 3 + .../service/LostItemArticleService.java | 19 ++++++ .../koin/global/code/ApiResponseCode.java | 2 + 9 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/dto/LostItemArticleUpdateRequest.java diff --git a/src/main/java/in/koreatech/koin/domain/community/article/controller/LostItemArticleApi.java b/src/main/java/in/koreatech/koin/domain/community/article/controller/LostItemArticleApi.java index ef569c838..c17bfa4a8 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/controller/LostItemArticleApi.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/controller/LostItemArticleApi.java @@ -9,12 +9,14 @@ 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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import in.koreatech.koin.domain.community.article.dto.LostItemArticleResponse; import in.koreatech.koin.domain.community.article.dto.LostItemArticleStatisticsResponse; +import in.koreatech.koin.domain.community.article.dto.LostItemArticleUpdateRequest; import in.koreatech.koin.domain.community.article.dto.LostItemArticlesRequest; import in.koreatech.koin.domain.community.article.dto.LostItemArticlesResponse; import in.koreatech.koin.domain.community.article.model.filter.LostItemAuthorFilter; @@ -124,6 +126,33 @@ ResponseEntity createLostItemArticle( @RequestBody @Valid LostItemArticlesRequest lostItemArticlesRequest ); + @ApiResponseCodes({ + OK, + UNAUTHORIZED_USER, + CANNOT_UPDATE_FOUND_ITEM, + NOT_FOUND_IMAGE, + FORBIDDEN_AUTHOR, + }) + @Operation(summary = "분실물 게시글 수정", description = """ + ### 분실물 게시글 수정 API + - category: 신분증, 카드, 지갑, 전자제품, 그 외 + - new_images: 새로 추가할 이미지 링크 + - delete_image_ids: 삭제할 이미지 id + + ### 예외 + - UNAUTHORIZED_USER : 인증 토큰 누락 + - CANNOT_UPDATE_FOUND_ITEM : 찾음 상태인 게시글은 수정할 수 없음 + - NOT_FOUND_IMAGE : 게시글에 이미지가 없는 경우 + - FORBIDDEN_AUTHOR : 게시글 작성자가 아닌 경우 + """) + @PutMapping("/lost-item/{id}") + ResponseEntity updateLostItemArticle( + @Parameter(description = "게시글 Id") + @PathVariable("id") Integer articleId, + @RequestBody @Valid LostItemArticleUpdateRequest lostItemArticleUpdateRequest, + @Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "204"), diff --git a/src/main/java/in/koreatech/koin/domain/community/article/controller/LostItemArticleController.java b/src/main/java/in/koreatech/koin/domain/community/article/controller/LostItemArticleController.java index e044cc1de..2bc7e44cf 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/controller/LostItemArticleController.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/controller/LostItemArticleController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -15,6 +16,7 @@ import in.koreatech.koin.domain.community.article.dto.LostItemArticleResponse; import in.koreatech.koin.domain.community.article.dto.LostItemArticleStatisticsResponse; +import in.koreatech.koin.domain.community.article.dto.LostItemArticleUpdateRequest; import in.koreatech.koin.domain.community.article.dto.LostItemArticlesRequest; import in.koreatech.koin.domain.community.article.dto.LostItemArticlesResponse; import in.koreatech.koin.domain.community.article.model.filter.LostItemAuthorFilter; @@ -44,8 +46,8 @@ public ResponseEntity searchArticles( @IpAddress String ipAddress, @UserId Integer userId ) { - LostItemArticlesResponse foundArticles = lostItemArticleService.searchLostItemArticles(query, page, limit, ipAddress, - userId); + LostItemArticlesResponse foundArticles = lostItemArticleService.searchLostItemArticles(query, page, limit, + ipAddress, userId); return ResponseEntity.ok().body(foundArticles); } @@ -95,6 +97,16 @@ public ResponseEntity createLostItemArticle( return ResponseEntity.status(HttpStatus.CREATED).body(response); } + @PutMapping("/lost-item/{id}") + public ResponseEntity updateLostItemArticle( + @PathVariable("id") Integer articleId, + @RequestBody @Valid LostItemArticleUpdateRequest lostItemArticleUpdateRequest, + @Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId + ) { + return ResponseEntity.ok() + .body(lostItemArticleService.updateLostItemArticle(userId, articleId, lostItemArticleUpdateRequest)); + } + @DeleteMapping("/lost-item/{id}") public ResponseEntity deleteLostItemArticle( @PathVariable("id") Integer articleId, diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/LostItemArticleResponse.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/LostItemArticleResponse.java index f52cb6f84..cf6126f8f 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/dto/LostItemArticleResponse.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/LostItemArticleResponse.java @@ -82,6 +82,7 @@ public static LostItemArticleResponse of(Article article, Boolean isMine) { isMine, lostItemArticle.getIsFound(), lostItemArticle.getImages().stream() + .filter(image -> !image.getIsDeleted()) .map(InnerLostItemImageResponse::from) .toList(), article.getPrevId(), diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/LostItemArticleUpdateRequest.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/LostItemArticleUpdateRequest.java new file mode 100644 index 000000000..dcec4418e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/LostItemArticleUpdateRequest.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.domain.community.article.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record LostItemArticleUpdateRequest( + @NotNull(message = "분실물 종류는 필수로 입력해야 합니다.") + @Schema(description = "분실물 종류", example = "신분증", requiredMode = REQUIRED) + String category, + + @NotNull(message = "분실물 습득 장소는 필수로 입력해야 합니다.") + @Schema(description = "습득 장소", example = "학생회관 앞", requiredMode = REQUIRED) + String foundPlace, + + @NotNull(message = "분실물 습득 날짜는 필수로 입력해야 합니다.") + @Schema(description = "습득 날짜", example = "2025-01-01", requiredMode = REQUIRED) + LocalDate foundDate, + + @Size(max = 1000, message = "본문의 내용은 최대 1,000자까지만 입력할 수 있습니다.") + @Schema(description = "본문", example = "학생회관 앞 계단에 …") + String content, + + @Size(max = 10, message = "이미지는 최대 10개까지만 업로드할 수 있습니다.") + @Schema(description = "분실물 사진 (새로 추가할 이미지)") + List newImages, + + @Schema(description = "삭제할 이미지 ID 목록") + List deleteImageIds +) { + public LostItemArticleUpdateRequest { + if (newImages == null) { + newImages = new ArrayList<>(); + } + if (deleteImageIds == null) { + deleteImageIds = new ArrayList<>(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/Article.java b/src/main/java/in/koreatech/koin/domain/community/article/model/Article.java index 05a35d7ff..6771e10b5 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/model/Article.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/Article.java @@ -288,4 +288,14 @@ public static Article createLostItemArticle( private static String getValidatedFoundPlace(String foundPlace) { return (foundPlace == null || foundPlace.isBlank()) ? "장소 미상" : foundPlace; } + + public void updateTitle(String title) { + if (title != null && !title.isBlank()) { + this.title = title; + } + } + + public void updateContent(String content) { + this.content = content; + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/LostItemArticle.java b/src/main/java/in/koreatech/koin/domain/community/article/model/LostItemArticle.java index 667cea349..81c5cfeca 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/model/LostItemArticle.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/LostItemArticle.java @@ -176,4 +176,69 @@ public void markAsFound() { this.isFound = true; this.foundAt = LocalDateTime.now(); } + + public void update(String category, String foundPlace, LocalDate foundDate, String content) { + validateNotFound(); + this.category = category; + this.foundPlace = foundPlace; + this.foundDate = foundDate; + updateArticleTitle(); + updateArticleContent(content); + } + + private void validateNotFound() { + if (this.isFound) { + throw CustomException.of(ApiResponseCode.CANNOT_UPDATE_FOUND_ITEM); + } + } + + private void updateArticleTitle() { + if (this.article != null) { + String newTitle = generateTitle(); + this.article.updateTitle(newTitle); + } + } + + private void updateArticleContent(String content) { + if (this.article != null && content != null) { + this.article.updateContent(content); + } + } + + public void addNewImages(List imageUrls) { + if (imageUrls == null || imageUrls.isEmpty()) { + return; + } + + List newImages = imageUrls.stream() + .map(url -> { + LostItemImage image = LostItemImage.builder() + .imageUrl(url) + .isDeleted(false) + .build(); + image.setArticle(this); + return image; + }) + .toList(); + + this.images.addAll(newImages); + } + + public void deleteImages(List imageIds) { + if (imageIds == null || imageIds.isEmpty()) { + return; + } + + imageIds.forEach(imageId -> { + LostItemImage image = findImageById(imageId); + image.delete(); + }); + } + + private LostItemImage findImageById(Integer imageId) { + return this.images.stream() + .filter(image -> Objects.equals(image.getId(), imageId)) + .findFirst() + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_IMAGE)); + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/LostItemImage.java b/src/main/java/in/koreatech/koin/domain/community/article/model/LostItemImage.java index 3ae004c59..c8b0f733e 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/model/LostItemImage.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/LostItemImage.java @@ -1,5 +1,7 @@ package in.koreatech.koin.domain.community.article.model; +import org.hibernate.annotations.Where; + import in.koreatech.koin.common.model.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -19,6 +21,7 @@ @Getter @Entity @Table(name = "lost_item_images", schema = "koin") +@Where(clause = "is_deleted=0") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class LostItemImage extends BaseEntity { diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java index 1b3135641..724df8b4c 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java @@ -17,6 +17,7 @@ import in.koreatech.koin.common.model.Criteria; import in.koreatech.koin.domain.community.article.dto.LostItemArticleResponse; import in.koreatech.koin.domain.community.article.dto.LostItemArticleStatisticsResponse; +import in.koreatech.koin.domain.community.article.dto.LostItemArticleUpdateRequest; import in.koreatech.koin.domain.community.article.dto.LostItemArticlesRequest; import in.koreatech.koin.domain.community.article.dto.LostItemArticlesResponse; import in.koreatech.koin.domain.community.article.exception.ArticleBoardMisMatchException; @@ -179,6 +180,24 @@ public void markLostItemArticleAsFound(Integer userId, Integer articleId) { lostItemArticle.markAsFound(); } + @Transactional + public LostItemArticleResponse updateLostItemArticle(Integer userId, Integer articleId, LostItemArticleUpdateRequest request) { + LostItemArticle lostItemArticle = lostItemArticleRepository.getByArticleId(articleId); + lostItemArticle.checkOwnership(userId); + + lostItemArticle.update( + request.category(), + request.foundPlace(), + request.foundDate(), + request.content() + ); + + lostItemArticle.deleteImages(request.deleteImageIds()); + lostItemArticle.addNewImages(request.newImages()); + + return LostItemArticleResponse.of(lostItemArticle.getArticle(), true); + } + private void setPrevNextArticle(Integer boardId, Article article) { Article prevArticle; Article nextArticle; diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index f0dc8cc43..931874b63 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -83,6 +83,7 @@ public enum ApiResponseCode { INVALID_NODE_INFO_END_POINT(HttpStatus.BAD_REQUEST, "올바른 정거장 끝 위치가 아닙니다."), INVALID_SEMESTER_FORMAT(HttpStatus.BAD_REQUEST, "올바르지 않은 학기 형식입니다."), INVALID_DETAIL_SUBSCRIBE_TYPE(HttpStatus.BAD_REQUEST, "세부 구독 타입이 구독 타입에 속하지 않습니다."), + CANNOT_UPDATE_FOUND_ITEM(HttpStatus.BAD_REQUEST, "이미 찾은 물건의 정보를 수정할 수 없습니다"), /** * 401 Unauthorized (인증 필요) @@ -131,6 +132,7 @@ public enum ApiResponseCode { NOT_FOUND_COOP_SEMESTER(HttpStatus.NOT_FOUND, "해당 학기가 존재하지 않습니다."), NOT_FOUND_SHOP_ORDER_SERVICE_REQUEST(HttpStatus.NOT_FOUND, "상점 서비스 전환 요청이 존재하지 않습니다."), NOT_FOUND_CHAT_PARTNER(HttpStatus.NOT_FOUND, "채팅 상대방이 존재하지 않습니다."), + NOT_FOUND_IMAGE(HttpStatus.NOT_FOUND, "이미지를 찾을 수 없습니다"), /** * 409 CONFLICT (중복 혹은 충돌)