diff --git a/src/main/java/com/sku/refit/domain/comment/controller/CommentControllerImpl.java b/src/main/java/com/sku/refit/domain/comment/controller/CommentControllerImpl.java index 1aab203..f22edc2 100644 --- a/src/main/java/com/sku/refit/domain/comment/controller/CommentControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/comment/controller/CommentControllerImpl.java @@ -18,11 +18,9 @@ import com.sku.refit.global.response.BaseResponse; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; @RestController @RequiredArgsConstructor -@Slf4j public class CommentControllerImpl implements CommentController { private final CommentService commentService; diff --git a/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java b/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java index d7e4ea0..cafde2d 100644 --- a/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java @@ -33,7 +33,6 @@ @Service @RequiredArgsConstructor @Slf4j -@Transactional(readOnly = true) public class CommentServiceImpl implements CommentService { private final CommentRepository commentRepository; diff --git a/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeController.java b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeController.java new file mode 100644 index 0000000..396ad92 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeController.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.exchange.dto.request.ExchangePostRequest; +import com.sku.refit.domain.exchange.dto.response.ExchangePostCardResponse; +import com.sku.refit.domain.exchange.dto.response.ExchangePostDetailResponse; +import com.sku.refit.global.page.response.PageResponse; +import com.sku.refit.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "교환 게시글", description = "교환 게시글 관련 API") +@RequestMapping("/api/exchanges") +public interface ExchangeController { + + @PostMapping(value = "/new", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "새 게시글 작성", description = "새 게시글을 작성합니다.") + ResponseEntity> createExchangePost( + @Parameter( + description = "게시글 이미지 리스트", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "imageList") + List imageList, + @Parameter( + description = "게시글 내용", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @RequestPart(value = "request") + @Valid + ExchangePostRequest request); + + @GetMapping + @Operation(summary = "교환 게시글 목록(페이지) 조회 (위치 기반)") + ResponseEntity>> getExchangePostsByLocation( + @Parameter(description = "페이지 번호", example = "1") @RequestParam Integer pageNum, + @Parameter(description = "페이지 크기", example = "4") @RequestParam Integer pageSize, + @Parameter(description = "위도", example = "37.544018") + @RequestParam(defaultValue = "37.544018") + Double latitude, + @Parameter(description = "경도", example = "126.951592") + @RequestParam(defaultValue = "126.951592") + Double longitude); + + @GetMapping("/{exchangePostId}") + @Operation(summary = "교환 게시글 단일 조회", description = "교환 게시글 ID로 단일 게시글을 조회합니다.") + ResponseEntity> getExchangePost( + @Parameter(description = "교환 게시글 ID", example = "1") @PathVariable Long exchangePostId); + + @PutMapping(value = "/{exchangePostId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "교환 게시글 수정", description = "특정 교환 게시글을 수정합니다.") + ResponseEntity> updateExchangePost( + @Parameter(description = "교환 게시글 ID", example = "1") @PathVariable Long exchangePostId, + @Parameter( + description = "수정할 게시글 이미지 리스트", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "imageList", required = false) + List imageList, + @Parameter( + description = "수정할 게시글 내용", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @RequestPart(value = "request") + @Valid + ExchangePostRequest request); + + @DeleteMapping("/{exchangePostId}") + @Operation(summary = "교환 게시글 삭제", description = "특정 교환 게시글을 삭제합니다.") + ResponseEntity> deleteExchangePost( + @Parameter(description = "교환 게시글 ID", example = "1") @PathVariable Long exchangePostId); +} diff --git a/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeControllerImpl.java b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeControllerImpl.java new file mode 100644 index 0000000..8d66def --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/controller/ExchangeControllerImpl.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.exchange.dto.request.ExchangePostRequest; +import com.sku.refit.domain.exchange.dto.response.ExchangePostCardResponse; +import com.sku.refit.domain.exchange.dto.response.ExchangePostDetailResponse; +import com.sku.refit.domain.exchange.service.ExchangeService; +import com.sku.refit.global.exception.CustomException; +import com.sku.refit.global.page.exception.PageErrorStatus; +import com.sku.refit.global.page.response.PageResponse; +import com.sku.refit.global.response.BaseResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ExchangeControllerImpl implements ExchangeController { + + private final ExchangeService exchangeService; + + @Override + public ResponseEntity> createExchangePost( + List imageList, @Valid ExchangePostRequest request) { + + ExchangePostDetailResponse response = exchangeService.createExchangePost(imageList, request); + + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity>> + getExchangePostsByLocation( + Integer pageNum, Integer pageSize, Double latitude, Double longitude) { + + if (pageNum < 1) { + throw new CustomException(PageErrorStatus.PAGE_NOT_FOUND); + } + if (pageSize < 1) { + throw new CustomException(PageErrorStatus.PAGE_SIZE_ERROR); + } + + Pageable pageable = PageRequest.of(pageNum - 1, pageSize); + PageResponse exchangePostCardResponsePageResponse; + + exchangePostCardResponsePageResponse = + exchangeService.getExchangePostsByLocation(pageable, latitude, longitude); + + return ResponseEntity.ok(BaseResponse.success(exchangePostCardResponsePageResponse)); + } + + @Override + public ResponseEntity> getExchangePost( + Long exchangePostId) { + + ExchangePostDetailResponse response = exchangeService.getExchangePost(exchangePostId); + + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity> updateExchangePost( + Long exchangePostId, List imageList, @Valid ExchangePostRequest request) { + + ExchangePostDetailResponse response = + exchangeService.updateExchangePost(exchangePostId, imageList, request); + + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity> deleteExchangePost(Long exchangePostId) { + + exchangeService.deleteExchangePost(exchangePostId); + + return ResponseEntity.ok(BaseResponse.success(null)); + } +} diff --git a/src/main/java/com/sku/refit/domain/exchange/dto/request/ExchangePostRequest.java b/src/main/java/com/sku/refit/domain/exchange/dto/request/ExchangePostRequest.java new file mode 100644 index 0000000..6d686dc --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/dto/request/ExchangePostRequest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.dto.request; + +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import com.sku.refit.domain.exchange.entity.ClothSize; +import com.sku.refit.domain.exchange.entity.ClothStatus; +import com.sku.refit.domain.exchange.entity.ExchangeCategory; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(title = "ExchangePostRequest DTO", description = "새 교환 게시물 등록을 위한 데이터 전송") +public class ExchangePostRequest { + + @NotBlank(message = "교환 게시글 제목은 필수입니다.") + @Schema(description = "교환 게시글 제목", example = "오버핏 흰색 셔츠") + private String title; + + @NotNull(message = "교환 카테고리는 필수입니다.") @Schema(description = "교환 카테고리", example = "OUTER") + private ExchangeCategory exchangeCategory; + + @NotNull(message = "옷 상태는 필수입니다.") @Schema(description = "옷 상태", example = "GOOD") + private ClothStatus clothStatus; + + @NotNull(message = "옷 사이즈는 필수입니다.") @Schema(description = "옷 사이즈", example = "M") + private ClothSize clothSize; + + @NotBlank(message = "교환 게시글 설명은 필수입니다.") + @Schema(description = "교환게시글 설명", example = "아이템에 대한 상세 설명을 작성해주세요.") + private String description; + + @NotEmpty(message = "교환 선호 카테고리는 필수입니다.") + @Schema(description = "선호 카테고리") + private List preferCategoryList; + + @NotBlank(message = "교환 희망 스팟은 필수입니다.") + @Schema(description = "교환 희망 스팟", example = "서울역") + private String exchangeSpot; + + @NotNull(message = "교환 희망 스팟 위도는 필수입니다.") @Schema(description = "교환 희망 스팟 위도", example = "37.544018") + private Double spotLatitude; + + @NotNull(message = "교환 희망 스팟 경도는 필수입니다.") @Schema(description = "교환 희망 스팟 경도", example = "126.951592") + private Double spotLongitude; + + @NotBlank(message = "교환 편지는 필수입니다.") + @Schema(description = "교환 편지", example = "이젠 안녕! 청바지야.") + private String letter; +} diff --git a/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostCardResponse.java b/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostCardResponse.java new file mode 100644 index 0000000..bed1ad1 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostCardResponse.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.dto.response; + +import com.sku.refit.domain.exchange.entity.ExchangeCategory; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(title = "ExchangePostCardResponse DTO", description = "교환글 카드 형식 응답 반환") +public class ExchangePostCardResponse { + + @Schema(description = "썸네일 이미지 URL") + private String thumbnailImageUrl; + + @Schema(description = "교환 카테고리", example = "PANTS") + private ExchangeCategory category; + + @Schema(description = "교환 제목", example = "스판 여성용 빈티지 청바지") + private String title; + + @Schema(description = "교환 희망 스팟", example = "서울역") + private String exchangeSpot; +} diff --git a/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostDetailResponse.java b/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostDetailResponse.java new file mode 100644 index 0000000..9fe7746 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/dto/response/ExchangePostDetailResponse.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.sku.refit.domain.exchange.entity.ClothSize; +import com.sku.refit.domain.exchange.entity.ClothStatus; +import com.sku.refit.domain.exchange.entity.ExchangeCategory; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(title = "ExchangePostDetailResponse DTO", description = "교환글 상세 정보 응답 반환") +public class ExchangePostDetailResponse { + + @Schema(description = "교환 게시글 식별자", example = "1") + private Long exchangePostId; + + @Schema(description = "게시글 작성자", example = "김다입") + private String nickname; + + @Schema(description = "이미지 URL 리스트") + private List imageUrlList; + + @Schema(description = "교환 카테고리", example = "PANTS") + private ExchangeCategory category; + + @Schema(description = "교환 제목", example = "스판 여성용 빈티지 청바지") + private String title; + + @Schema(description = "옷 사이즈", example = "M") + private ClothSize size; + + @Schema(description = "옷 상태", example = "GOOD") + private ClothStatus status; + + @Schema(description = "선호 카테고리") + private List preferCategoryList; + + @Schema(description = "교환 희망 스팟", example = "서울역") + private String exchangeSpot; + + @Schema(description = "교환 희망 스팟 위도", example = "37.544018") + private Double spotLatitude; + + @Schema(description = "교환 희망 스팟 경도", example = "126.951592") + private Double spotLongitude; + + @Schema(description = "작성자 본인 여부", example = "true") + private Boolean isAuthor; + + @Schema(description = "게시글 작성 시간", example = "2025-12-03T14:37:17") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sku/refit/domain/exchange/entity/ClothSize.java b/src/main/java/com/sku/refit/domain/exchange/entity/ClothSize.java new file mode 100644 index 0000000..6a74a59 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/entity/ClothSize.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "옷 사이즈 Enum") +public enum ClothSize { + @Schema(description = "FREE") + FREE, + @Schema(description = "2XS") + XS2, + @Schema(description = "XS") + XS, + @Schema(description = "S") + S, + @Schema(description = "M") + M, + @Schema(description = "L") + L, + @Schema(description = "XL") + XL, + @Schema(description = "XL2") + XL2, + @Schema(description = "XL3") + XL3; +} diff --git a/src/main/java/com/sku/refit/domain/exchange/entity/ClothStatus.java b/src/main/java/com/sku/refit/domain/exchange/entity/ClothStatus.java new file mode 100644 index 0000000..f9ab663 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/entity/ClothStatus.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "옷 상태 Enum") +public enum ClothStatus { + @Schema(description = "상") + GOOD("상"), + @Schema(description = "중") + FAIR("중"), + @Schema(description = "하") + BAD("하"); + + private final String ko; +} diff --git a/src/main/java/com/sku/refit/domain/exchange/entity/ExchangeCategory.java b/src/main/java/com/sku/refit/domain/exchange/entity/ExchangeCategory.java new file mode 100644 index 0000000..035d278 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/entity/ExchangeCategory.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "교환 카테고리 Enum") +public enum ExchangeCategory { + @Schema(description = "아우터") + OUTER("아우터"), + @Schema(description = "상의") + SHIRTS("상의"), + @Schema(description = "하의") + PANTS("하의"), + @Schema(description = "신발") + SHOES("신발"), + @Schema(description = "액세서리") + ACCESSORY("액세서리"); + + private final String ko; +} diff --git a/src/main/java/com/sku/refit/domain/exchange/entity/ExchangePost.java b/src/main/java/com/sku/refit/domain/exchange/entity/ExchangePost.java new file mode 100644 index 0000000..51ddd93 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/entity/ExchangePost.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.entity; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "exchange_post") +public class ExchangePost extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ElementCollection + @CollectionTable( + name = "exchange_post_image_url", + joinColumns = @JoinColumn(name = "exchange_post_id")) + @Column(name = "image_url", nullable = false) + private List imageUrlList = new ArrayList<>(); + + @Column(nullable = false) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ExchangeCategory exchangeCategory; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ClothStatus clothStatus; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ClothSize clothSize; + + @Column(columnDefinition = "TEXT") + private String description; + + @ElementCollection + @CollectionTable( + name = "exchange_post_prefer_category", + joinColumns = @JoinColumn(name = "exchange_post_id")) + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false) + private List preferCategories = new ArrayList<>(); + + @Column(nullable = false) + private String exchangeSpot; + + @Column(nullable = false) + private Double spotLatitude; + + @Column(nullable = false) + private Double spotLongitude; + + @Column(nullable = false, columnDefinition = "TEXT") + private String letter; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private ExchangeStatus exchangeStatus = ExchangeStatus.BEFORE; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + public void update( + List imageUrlList, + String title, + ExchangeCategory category, + ClothStatus status, + ClothSize size, + String description, + List preferCategoryList, + String exchangeSpot, + Double spotLatitude, + Double spotLongitude, + String letter) { + this.imageUrlList = imageUrlList; + this.title = title; + this.exchangeCategory = category; + this.clothStatus = status; + this.clothSize = size; + this.description = description; + this.preferCategories = preferCategoryList; + this.exchangeSpot = exchangeSpot; + this.spotLatitude = spotLatitude; + this.spotLongitude = spotLongitude; + this.letter = letter; + } +} diff --git a/src/main/java/com/sku/refit/domain/exchange/entity/ExchangeStatus.java b/src/main/java/com/sku/refit/domain/exchange/entity/ExchangeStatus.java new file mode 100644 index 0000000..8d16c91 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/entity/ExchangeStatus.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "교환 상태 Enum") +public enum ExchangeStatus { + @Schema(description = "교환 전") + BEFORE("교환전"), + @Schema(description = "교환 중") + IN_PROGRESS("교환중"), + @Schema(description = "교환 완료") + COMPLETED("교환완료"); + + private final String ko; +} diff --git a/src/main/java/com/sku/refit/domain/exchange/exception/ExchangeErrorCode.java b/src/main/java/com/sku/refit/domain/exchange/exception/ExchangeErrorCode.java new file mode 100644 index 0000000..4b1c55c --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/exception/ExchangeErrorCode.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.exception; + +import org.springframework.http.HttpStatus; + +import com.sku.refit.global.exception.model.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ExchangeErrorCode implements BaseErrorCode { + EXCHANGE_NOT_FOUND("EXCHANGE001", "교환 게시글이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + EXCHANGE_ACCESS_DENIED("EXCHANGE002", "해당 교환 게시글에 대한 권한이 없습니다.", HttpStatus.FORBIDDEN), + EXCHANGE_CATEGORY_INVALID("EXCHANGE003", "유효하지 않은 교환 카테고리입니다.", HttpStatus.BAD_REQUEST), + EXCHANGE_STATUS_INVALID("EXCHANGE004", "유효하지 않은 교환 상태입니다.", HttpStatus.BAD_REQUEST); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/sku/refit/domain/exchange/mapper/ExchangeMapper.java b/src/main/java/com/sku/refit/domain/exchange/mapper/ExchangeMapper.java new file mode 100644 index 0000000..6a4de72 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/mapper/ExchangeMapper.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.mapper; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.sku.refit.domain.exchange.dto.request.ExchangePostRequest; +import com.sku.refit.domain.exchange.dto.response.ExchangePostCardResponse; +import com.sku.refit.domain.exchange.dto.response.ExchangePostDetailResponse; +import com.sku.refit.domain.exchange.entity.ClothSize; +import com.sku.refit.domain.exchange.entity.ClothStatus; +import com.sku.refit.domain.exchange.entity.ExchangeCategory; +import com.sku.refit.domain.exchange.entity.ExchangePost; +import com.sku.refit.domain.user.entity.User; + +@Component +public class ExchangeMapper { + + public ExchangePost toExchangePost( + List imageUrlList, + ExchangePostRequest exchangePostRequest, + ExchangeCategory category, + ClothStatus status, + ClothSize size, + List preferCategoryList, + User user) { + + return ExchangePost.builder() + .imageUrlList(imageUrlList) + .title(exchangePostRequest.getTitle()) + .exchangeCategory(category) + .clothStatus(status) + .clothSize(size) + .description(exchangePostRequest.getDescription()) + .preferCategories(preferCategoryList) + .exchangeSpot(exchangePostRequest.getExchangeSpot()) + .spotLatitude(exchangePostRequest.getSpotLatitude()) + .spotLongitude(exchangePostRequest.getSpotLongitude()) + .letter(exchangePostRequest.getLetter()) + .user(user) + .build(); + } + + public ExchangePostDetailResponse toDetailResponse(ExchangePost exchangePost, User user) { + + return ExchangePostDetailResponse.builder() + .exchangePostId(exchangePost.getId()) + .nickname(exchangePost.getUser().getNickname()) + .imageUrlList(exchangePost.getImageUrlList()) + .category(exchangePost.getExchangeCategory()) + .title(exchangePost.getTitle()) + .size(exchangePost.getClothSize()) + .status(exchangePost.getClothStatus()) + .preferCategoryList(exchangePost.getPreferCategories()) + .exchangeSpot(exchangePost.getExchangeSpot()) + .spotLatitude(exchangePost.getSpotLatitude()) + .spotLongitude(exchangePost.getSpotLongitude()) + .isAuthor(exchangePost.getUser().getId().equals(user.getId())) + .createdAt(exchangePost.getCreatedAt()) + .build(); + } + + public ExchangePostCardResponse toCardResponse(ExchangePost exchangePost) { + return ExchangePostCardResponse.builder() + .thumbnailImageUrl(exchangePost.getImageUrlList().getFirst()) + .category(exchangePost.getExchangeCategory()) + .title(exchangePost.getTitle()) + .exchangeSpot(exchangePost.getExchangeSpot()) + .build(); + } +} diff --git a/src/main/java/com/sku/refit/domain/exchange/repository/ExchangeRepository.java b/src/main/java/com/sku/refit/domain/exchange/repository/ExchangeRepository.java new file mode 100644 index 0000000..1210175 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/repository/ExchangeRepository.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.sku.refit.domain.exchange.entity.ExchangePost; +import com.sku.refit.domain.exchange.entity.ExchangeStatus; + +@Repository +public interface ExchangeRepository extends JpaRepository { + + Optional findByIdAndExchangeStatus(Long id, ExchangeStatus exchangeStatus); + + @Query( + """ + SELECT e + FROM ExchangePost e + WHERE e.exchangeStatus = :status + ORDER BY + function('ST_Distance_Sphere', + point(e.spotLongitude, e.spotLatitude), + point(:longitude, :latitude) + ) + """) + Page findByDistanceAndStatus( + @Param("latitude") Double latitude, + @Param("longitude") Double longitude, + @Param("status") ExchangeStatus status, + Pageable pageable); +} diff --git a/src/main/java/com/sku/refit/domain/exchange/service/ExchangeService.java b/src/main/java/com/sku/refit/domain/exchange/service/ExchangeService.java new file mode 100644 index 0000000..a0a8531 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/service/ExchangeService.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.service; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.exchange.dto.request.ExchangePostRequest; +import com.sku.refit.domain.exchange.dto.response.ExchangePostCardResponse; +import com.sku.refit.domain.exchange.dto.response.ExchangePostDetailResponse; +import com.sku.refit.global.page.response.PageResponse; + +public interface ExchangeService { + + /** + * 교환 게시글 생성 + * + * @param imageList 첨부 이미지 리스트 + * @param request 교환 게시글 생성 요청 DTO + * @return 생성된 교환 게시글 상세 응답 + */ + ExchangePostDetailResponse createExchangePost( + List imageList, ExchangePostRequest request); + + /** + * 교환 게시글 위치 기반 조회 (페이지네이션) + * + * @param pageable 페이지 정보 + * @param latitude 사용자 위도 + * @param longitude 사용자 경도 + * @return 교환 게시글 카드 페이지 응답 + */ + PageResponse getExchangePostsByLocation( + Pageable pageable, Double latitude, Double longitude); + + ExchangePostDetailResponse getExchangePost(Long exchangePostId); + + /** + * 교환 게시글 수정 + * + * @param exchangePostId 교환 게시글 ID + * @param imageList 수정할 이미지 리스트 + * @param request 교환 게시글 수정 요청 DTO + * @return 수정된 교환 게시글 상세 응답 + */ + ExchangePostDetailResponse updateExchangePost( + Long exchangePostId, List imageList, ExchangePostRequest request); + + /** + * 교환 게시글 삭제 + * + * @param exchangePostId 교환 게시글 ID + */ + void deleteExchangePost(Long exchangePostId); +} diff --git a/src/main/java/com/sku/refit/domain/exchange/service/ExchangeServiceImpl.java b/src/main/java/com/sku/refit/domain/exchange/service/ExchangeServiceImpl.java new file mode 100644 index 0000000..aff53a1 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/exchange/service/ExchangeServiceImpl.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.exchange.service; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.exchange.dto.request.ExchangePostRequest; +import com.sku.refit.domain.exchange.dto.response.ExchangePostCardResponse; +import com.sku.refit.domain.exchange.dto.response.ExchangePostDetailResponse; +import com.sku.refit.domain.exchange.entity.ClothSize; +import com.sku.refit.domain.exchange.entity.ClothStatus; +import com.sku.refit.domain.exchange.entity.ExchangeCategory; +import com.sku.refit.domain.exchange.entity.ExchangePost; +import com.sku.refit.domain.exchange.entity.ExchangeStatus; +import com.sku.refit.domain.exchange.exception.ExchangeErrorCode; +import com.sku.refit.domain.exchange.mapper.ExchangeMapper; +import com.sku.refit.domain.exchange.repository.ExchangeRepository; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.domain.user.service.UserService; +import com.sku.refit.global.exception.CustomException; +import com.sku.refit.global.page.mapper.PageMapper; +import com.sku.refit.global.page.response.PageResponse; +import com.sku.refit.global.s3.entity.PathName; +import com.sku.refit.global.s3.service.S3Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ExchangeServiceImpl implements ExchangeService { + + private final ExchangeRepository exchangeRepository; + private final ExchangeMapper exchangeMapper; + private final UserService userService; + private final S3Service s3Service; + private final PageMapper pageMapper; + + @Override + @Transactional + public ExchangePostDetailResponse createExchangePost( + List imageList, ExchangePostRequest request) { + + User user = userService.getCurrentUser(); + List imageUrlList = new ArrayList<>(); + if (imageList != null && !imageList.isEmpty()) { + for (MultipartFile image : imageList) { + String imageUrl = s3Service.uploadImage(PathName.CLOTH, image).getImageUrl(); + imageUrlList.add(imageUrl); + } + } + + ExchangeCategory category = request.getExchangeCategory(); + ClothStatus status = request.getClothStatus(); + ClothSize size = request.getClothSize(); + List preferCategoryList = request.getPreferCategoryList().stream().toList(); + + ExchangePost exchangePost = + exchangeMapper.toExchangePost( + imageUrlList, request, category, status, size, preferCategoryList, user); + exchangeRepository.save(exchangePost); + + log.info( + "[ExchangePost CREATE] postId={}, userId={}, imageCount={}", + exchangePost.getId(), + user.getId(), + imageUrlList.size()); + + return exchangeMapper.toDetailResponse(exchangePost, user); + } + + @Override + @Transactional(readOnly = true) + public PageResponse getExchangePostsByLocation( + Pageable pageable, Double latitude, Double longitude) { + + Page page = + exchangeRepository.findByDistanceAndStatus( + latitude, longitude, ExchangeStatus.BEFORE, pageable); + + Page mappedPage = page.map(exchangeMapper::toCardResponse); + + return pageMapper.toPageResponse(mappedPage); + } + + @Override + @Transactional(readOnly = true) + public ExchangePostDetailResponse getExchangePost(Long exchangePostId) { + + User user = userService.getCurrentUser(); + ExchangePost exchangePost = + exchangeRepository + .findByIdAndExchangeStatus(exchangePostId, ExchangeStatus.BEFORE) + .orElseThrow(() -> new CustomException(ExchangeErrorCode.EXCHANGE_NOT_FOUND)); + + return exchangeMapper.toDetailResponse(exchangePost, user); + } + + @Override + @Transactional + public ExchangePostDetailResponse updateExchangePost( + Long exchangePostId, List imageList, ExchangePostRequest request) { + + User user = userService.getCurrentUser(); + + ExchangePost exchangePost = + exchangeRepository + .findByIdAndExchangeStatus(exchangePostId, ExchangeStatus.BEFORE) + .orElseThrow(() -> new CustomException(ExchangeErrorCode.EXCHANGE_NOT_FOUND)); + + if (!exchangePost.getUser().getId().equals(user.getId())) { + throw new CustomException(ExchangeErrorCode.EXCHANGE_ACCESS_DENIED); + } + + if (exchangePost.getImageUrlList() != null) { + for (String imageUrl : exchangePost.getImageUrlList()) { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(imageUrl)); + } + } + + List newImageUrlList = new ArrayList<>(); + if (imageList != null && !imageList.isEmpty()) { + for (MultipartFile image : imageList) { + String imageUrl = s3Service.uploadImage(PathName.CLOTH, image).getImageUrl(); + newImageUrlList.add(imageUrl); + } + } + + ExchangeCategory category = request.getExchangeCategory(); + ClothStatus status = request.getClothStatus(); + ClothSize size = request.getClothSize(); + List preferCategoryList = request.getPreferCategoryList().stream().toList(); + + exchangePost.update( + newImageUrlList, + request.getTitle(), + category, + status, + size, + request.getDescription(), + preferCategoryList, + request.getExchangeSpot(), + request.getSpotLatitude(), + request.getSpotLongitude(), + request.getLetter()); + + log.info( + "[ExchangePost UPDATE] postId={}, userId={}, imageCount={}", + exchangePostId, + user.getId(), + newImageUrlList.size()); + + return exchangeMapper.toDetailResponse(exchangePost, user); + } + + @Override + @Transactional + public void deleteExchangePost(Long exchangePostId) { + + User user = userService.getCurrentUser(); + + ExchangePost exchangePost = + exchangeRepository + .findById(exchangePostId) + .orElseThrow(() -> new CustomException(ExchangeErrorCode.EXCHANGE_NOT_FOUND)); + + if (!exchangePost.getUser().getId().equals(user.getId())) { + throw new CustomException(ExchangeErrorCode.EXCHANGE_ACCESS_DENIED); + } + + // 이미지 삭제 + if (exchangePost.getImageUrlList() != null) { + for (String imageUrl : exchangePost.getImageUrlList()) { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(imageUrl)); + } + } + + exchangeRepository.delete(exchangePost); + + log.info("[ExchangePost DELETE] postId={}, userId={}", exchangePostId, user.getId()); + } +} diff --git a/src/main/java/com/sku/refit/domain/post/dto/request/PostRequest.java b/src/main/java/com/sku/refit/domain/post/dto/request/PostRequest.java index 157b82a..ee2ef00 100644 --- a/src/main/java/com/sku/refit/domain/post/dto/request/PostRequest.java +++ b/src/main/java/com/sku/refit/domain/post/dto/request/PostRequest.java @@ -3,8 +3,6 @@ */ package com.sku.refit.domain.post.dto.request; -import java.util.List; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; @@ -22,8 +20,8 @@ public class PostRequest { @NotEmpty(message = "게시글 카테고리는 필수입니다.") - @Schema(description = "게시글 카테고리") - private List categoryList; + @Schema(description = "게시글 카테고리", example = "FREE") + private String postCategory; @NotBlank(message = "게시글 제목은 필수입니다.") @Schema(description = "게시글 제목", example = "21파티에선 정확히 어떤걸 하나요?") diff --git a/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java b/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java index 7dc1a91..c9aae55 100644 --- a/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java +++ b/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java @@ -6,6 +6,8 @@ import java.time.LocalDateTime; import java.util.List; +import com.sku.refit.domain.post.entity.PostCategory; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -15,8 +17,8 @@ @Schema(title = "PostDetailResponse DTO", description = "게시글 상세 정보 응답 반환") public class PostDetailResponse { - @Schema(description = "카테고리 리스트", example = "자유, 수선") - private List categoryList; + @Schema(description = "카테고리", example = "FREE") + private PostCategory category; @Schema(description = "게시글 식별자", example = "1") private Long postId; @@ -36,6 +38,9 @@ public class PostDetailResponse { @Schema(description = "게시글 작성자", example = "김다입") private String nickname; + @Schema(description = "작성자 본인 여부", example = "true") + private Boolean isAuthor; + @Schema(description = "이미지 URL 리스트") private List imageUrlList; diff --git a/src/main/java/com/sku/refit/domain/post/entity/Post.java b/src/main/java/com/sku/refit/domain/post/entity/Post.java index ec05b62..ff99cc5 100644 --- a/src/main/java/com/sku/refit/domain/post/entity/Post.java +++ b/src/main/java/com/sku/refit/domain/post/entity/Post.java @@ -11,6 +11,8 @@ import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -52,10 +54,14 @@ public class Post extends BaseTimeEntity { @Builder.Default private Long views = 0L; - @ElementCollection - @CollectionTable(name = "post_category", joinColumns = @JoinColumn(name = "post_id")) + @Enumerated(EnumType.STRING) @Column(nullable = false) - private List categoryList; + private PostCategory postCategory; + + // @ElementCollection + // @CollectionTable(name = "post_category", joinColumns = @JoinColumn(name = "post_id")) + // @Column(nullable = false) + // private List categoryList; @ElementCollection @CollectionTable(name = "post_image_url", joinColumns = @JoinColumn(name = "post_id")) diff --git a/src/main/java/com/sku/refit/domain/post/entity/PostCategory.java b/src/main/java/com/sku/refit/domain/post/entity/PostCategory.java new file mode 100644 index 0000000..c7f7cc3 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/entity/PostCategory.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "교환 카테고리 Enum") +public enum PostCategory { + @Schema(description = "자유질문") + FREE("자유질문"), + @Schema(description = "수선꿀팁") + REPAIR("수선꿀팁"), + @Schema(description = "정보공유") + INFO("정보공유"); + + private final String ko; +} diff --git a/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java b/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java index 885a6ef..1f595f5 100644 --- a/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java +++ b/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java @@ -11,17 +11,19 @@ import com.sku.refit.domain.post.dto.request.PostRequest; import com.sku.refit.domain.post.dto.response.PostDetailResponse; import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.post.entity.PostCategory; import com.sku.refit.domain.user.entity.User; @Component public class PostMapper { - public Post toPost(PostRequest postRequest, List imageUrlList, User user) { + public Post toPost( + PostCategory postCategory, PostRequest postRequest, List imageUrlList, User user) { return Post.builder() .title(postRequest.getTitle()) .content(postRequest.getContent()) - .categoryList(postRequest.getCategoryList()) + .postCategory(postCategory) .imageUrlList(imageUrlList) .user(user) .build(); @@ -35,8 +37,9 @@ public PostDetailResponse toDetailResponse(Post post, User user) { .content(post.getContent()) .views(post.getViews()) .createdAt(post.getCreatedAt()) - .nickname(user.getNickname()) - .categoryList(post.getCategoryList()) + .nickname(post.getUser().getNickname()) + .isAuthor(user != null && post.getUser().getUsername().equals(user.getUsername())) + .category(post.getPostCategory()) .commentIdList(post.getCommentList().stream().map(Comment::getId).toList()) .build(); } diff --git a/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java b/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java index a07de75..af0b2e6 100644 --- a/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java +++ b/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java @@ -13,8 +13,8 @@ @Repository public interface PostRepository extends JpaRepository { - Page findByCategoryListContaining(String category, Pageable pageable); + Page findByPostCategoryContaining(String category, Pageable pageable); - Page findByCategoryListContainingAndIdLessThan( + Page findByPostCategoryContainingAndIdLessThan( String category, Long lastPostId, Pageable pageable); } diff --git a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java index 774a298..f4a89c0 100644 --- a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java @@ -16,6 +16,7 @@ import com.sku.refit.domain.post.dto.request.PostRequest; import com.sku.refit.domain.post.dto.response.PostDetailResponse; import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.post.entity.PostCategory; import com.sku.refit.domain.post.exception.PostErrorCode; import com.sku.refit.domain.post.mapper.PostMapper; import com.sku.refit.domain.post.repository.PostRepository; @@ -33,7 +34,6 @@ @Service @RequiredArgsConstructor @Slf4j -@Transactional(readOnly = true) public class PostServiceImpl implements PostService { private final PostRepository postRepository; @@ -56,7 +56,14 @@ public PostDetailResponse createPost(PostRequest request, List im } } - Post post = postMapper.toPost(request, imageUrlList, user); + PostCategory category; + try { + category = PostCategory.valueOf(request.getPostCategory()); + } catch (IllegalArgumentException e) { + throw new CustomException(PostErrorCode.INVALID_CATEGORY); + } + + Post post = postMapper.toPost(category, request, imageUrlList, user); postRepository.save(post); log.info( @@ -91,11 +98,11 @@ public InfiniteResponse getPostsByCategory( List posts; if (lastPostId == null) { - posts = postRepository.findByCategoryListContaining(category, pageable).getContent(); + posts = postRepository.findByPostCategoryContaining(category, pageable).getContent(); } else { posts = postRepository - .findByCategoryListContainingAndIdLessThan(category, lastPostId, pageable) + .findByPostCategoryContainingAndIdLessThan(category, lastPostId, pageable) .getContent(); }