Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -124,6 +126,33 @@ ResponseEntity<LostItemArticleResponse> 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<LostItemArticleResponse> updateLostItemArticle(
@Parameter(description = "게시글 Id")
@PathVariable("id") Integer articleId,
@RequestBody @Valid LostItemArticleUpdateRequest lostItemArticleUpdateRequest,
@Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId
);

@ApiResponses(
value = {
@ApiResponse(responseCode = "204"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
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 org.springframework.web.bind.annotation.RestController;

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;
Expand Down Expand Up @@ -44,8 +46,8 @@ public ResponseEntity<LostItemArticlesResponse> 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);
}

Expand Down Expand Up @@ -95,6 +97,16 @@ public ResponseEntity<LostItemArticleResponse> createLostItemArticle(
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@PutMapping("/lost-item/{id}")
public ResponseEntity<LostItemArticleResponse> 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<Void> deleteLostItemArticle(
@PathVariable("id") Integer articleId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> newImages,

@Schema(description = "삭제할 이미지 ID 목록")
List<Integer> deleteImageIds
) {
public LostItemArticleUpdateRequest {
if (newImages == null) {
newImages = new ArrayList<>();
}
if (deleteImageIds == null) {
deleteImageIds = new ArrayList<>();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> imageUrls) {
if (imageUrls == null || imageUrls.isEmpty()) {
return;
}

List<LostItemImage> 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<Integer> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Comment on lines +195 to +196
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

삭제 없이 새 이미지를 매 요청마다 최대 10개씩 추가할 수 있어, 동일한 요청을 여러 번 보낼 경우 총 이미지 수가 10개를 초과할 위험이 있어 보여요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 게시글 등록 API에서도 검증 로직이 없어서 뺐습니다.


return LostItemArticleResponse.of(lostItemArticle.getArticle(), true);
}
Comment on lines +183 to +199
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

깔끔쓰하네요


private void setPrevNextArticle(Integer boardId, Article article) {
Article prevArticle;
Article nextArticle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (인증 필요)
Expand Down Expand Up @@ -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 (중복 혹은 충돌)
Expand Down
Loading