Skip to content

Commit 7a8e9c6

Browse files
authored
[feat] 상품 Q&A 기능 구현(등록, 조회) (#365)
* work * Work * Work * work * Work
1 parent 8630d7a commit 7a8e9c6

File tree

13 files changed

+732
-5
lines changed

13 files changed

+732
-5
lines changed

src/main/java/com/back/domain/product/product/entity/Product.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.domain.product.product.entity;
22

33
import com.back.domain.product.category.entity.Category;
4+
import com.back.domain.product.qna.entity.ProductQna;
45
import com.back.domain.user.entity.User;
56
import com.back.global.jpa.entity.BaseEntity;
67
import jakarta.persistence.*;
@@ -126,6 +127,10 @@ public class Product extends BaseEntity {
126127
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)// 상품이 저장/삭제되면 매핑 중간테이블의 해당 데이터도 저장/삭제됨, 상품에서 특정 태그를 제거하면 DB에서 해당 매핑 데이터도 삭제됨.
127128
private Set<ProductTagMapping> productTags= new HashSet<>(); // 상품과 태그(스타일)의 중간 테이블. 하나의 상품에 동일한 태그를 중복으로 붙이는 걸 허용하지 않으므로 List말고 Set 사용
128129

130+
@Builder.Default
131+
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
132+
private List<ProductQna> productQnas = new ArrayList<>(); // 상품 Q&A
133+
129134
// 할인된 가격 계산
130135
public int getDiscountPrice() {
131136
return price - (price * discountRate / 100);

src/main/java/com/back/domain/product/product/service/ProductService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ private void checkProductOwner(Product product, User user) {
386386
}
387387
}
388388
// 존재하지 않는 상품 검증
389-
private Product getProductOrThrow(UUID productUuid) {
389+
public Product getProductOrThrow(UUID productUuid) {
390390
return productRepository.findByProductUuid(productUuid)
391391
.orElseThrow(() -> new ServiceException("404", "존재하지 않는 상품입니다. UUID: " + productUuid));
392392
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package com.back.domain.product.qna.controller;
2+
3+
import com.back.domain.product.qna.dto.request.ProductQnaRequestDto;
4+
import com.back.domain.product.qna.dto.response.ProductQnaListResponseDto;
5+
import com.back.domain.product.qna.dto.response.ProductQnaResponseDto;
6+
import com.back.domain.product.qna.service.ProductQnaService;
7+
import com.back.global.rsData.RsData;
8+
import com.back.global.security.auth.CustomUserDetails;
9+
import io.swagger.v3.oas.annotations.Operation;
10+
import io.swagger.v3.oas.annotations.Parameter;
11+
import io.swagger.v3.oas.annotations.media.Content;
12+
import io.swagger.v3.oas.annotations.media.Schema;
13+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
14+
import io.swagger.v3.oas.annotations.tags.Tag;
15+
import jakarta.validation.Valid;
16+
import lombok.RequiredArgsConstructor;
17+
import org.springframework.http.ResponseEntity;
18+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
19+
import org.springframework.web.bind.annotation.*;
20+
21+
import java.util.UUID;
22+
23+
@RestController
24+
@RequestMapping("/api/products/qna/{productUuid}")
25+
@RequiredArgsConstructor
26+
@Tag(name = "상품 Q&A", description = "상품 Q&A 관련 API")
27+
public class ProductQnaController {
28+
29+
private final ProductQnaService productQnaService;
30+
31+
/** 상품 Q&A 등록 */
32+
@PostMapping
33+
@Operation(
34+
summary = "상품 Q&A 등록",
35+
responses = {
36+
@ApiResponse(
37+
responseCode = "200",
38+
description = "상품 Q&A 등록 성공",
39+
content = @Content(
40+
mediaType = "application/json",
41+
schema = @Schema(
42+
example = """
43+
{
44+
"resultCode": "200",
45+
"msg": "상품 Q&A가 성공적으로 등록되었습니다.",
46+
"data": "550e8400-e29b-41d4-a716-446655440000"
47+
}
48+
"""
49+
)
50+
)
51+
),
52+
@ApiResponse(
53+
responseCode = "400",
54+
description = "잘못된 요청 (필수값 누락, 존재하지 않는 상품 등)",
55+
content = @Content(
56+
mediaType = "application/json",
57+
schema = @Schema(
58+
example = """
59+
{
60+
"resultCode": "400",
61+
"msg": "Q&A 카테고리는 필수입니다.",
62+
"data": null
63+
}
64+
"""
65+
)
66+
)
67+
),
68+
@ApiResponse(
69+
responseCode = "403",
70+
description = "권한 없음",
71+
content = @Content(
72+
mediaType = "application/json",
73+
schema = @Schema(
74+
example = """
75+
{
76+
"resultCode": "403",
77+
"msg": "상품 Q&A 등록 권한이 없습니다.",
78+
"data": null
79+
}
80+
"""
81+
)
82+
)
83+
)
84+
}
85+
)
86+
public ResponseEntity<RsData<UUID>> createProductQna(
87+
@PathVariable UUID productUuid,
88+
@Valid @RequestBody ProductQnaRequestDto request,
89+
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
90+
UUID createdProductUuid = productQnaService.createProductQna(productUuid, request, customUserDetails);
91+
return ResponseEntity.ok(RsData.of("200", "상품 Q&A가 성공적으로 등록되었습니다.", createdProductUuid));
92+
}
93+
94+
/** 상품 Q&A 상세 조회 */
95+
@GetMapping("/{productQnaId}")
96+
@Operation(
97+
summary = "상품 Q&A 상세 조회",
98+
responses = {
99+
@ApiResponse(
100+
responseCode = "200",
101+
description = "상품 Q&A 상세 조회 성공",
102+
content = @Content(
103+
mediaType = "application/json",
104+
schema = @Schema(implementation = ProductQnaResponseDto.class)
105+
)
106+
),
107+
@ApiResponse(
108+
responseCode = "404",
109+
description = "해당 상품 Q&A를 찾을 수 없음",
110+
content = @Content(
111+
mediaType = "application/json",
112+
schema = @Schema(
113+
example = """
114+
{
115+
"resultCode": "404",
116+
"msg": "해당 상품 Q&A를 찾을 수 없습니다.",
117+
"data": null
118+
}
119+
"""
120+
)
121+
)
122+
)
123+
}
124+
)
125+
public ResponseEntity<RsData<ProductQnaResponseDto>> getProductQnaDetail(
126+
@PathVariable Long productQnaId) {
127+
ProductQnaResponseDto responseDto = productQnaService.getProductQnaDetail(productQnaId);
128+
return ResponseEntity.ok(RsData.of("200", "상품 Q&A 상세 조회 성공", responseDto));
129+
}
130+
131+
/** 상품 Q&A 목록 조회 (페이지네이션) */
132+
@GetMapping("/list")
133+
@Operation(
134+
summary = "상품 Q&A 목록 조회 (페이지네이션)",
135+
parameters = {
136+
@Parameter(name = "qnaCategory", description = "Q&A 카테고리 (예: 배송, 상품, 교환/환불, 기타. '전체' 또는 미지정 시 전체 카테고리 조회)", example = "배송"),
137+
@Parameter(name = "page", description = "페이지 번호", example = "1"),
138+
@Parameter(name = "size", description = "페이지당 항목 수", example = "10")
139+
},
140+
responses = {
141+
@ApiResponse(
142+
responseCode = "200",
143+
description = "상품 Q&A 목록 조회 성공",
144+
content = @Content(
145+
mediaType = "application/json",
146+
schema = @Schema(implementation = ProductQnaListResponseDto.class)
147+
)
148+
)
149+
}
150+
)
151+
public ResponseEntity<RsData<ProductQnaListResponseDto>> getProductQnaList(
152+
@PathVariable UUID productUuid,
153+
@RequestParam(value = "qnaCategory", required = false) String qnaCategory,
154+
@RequestParam(value = "page", defaultValue = "1") int page,
155+
@RequestParam(value = "size", defaultValue = "10") int size) {
156+
ProductQnaListResponseDto responseDto = productQnaService.getProductQnaList(productUuid, qnaCategory, page, size);
157+
return ResponseEntity.ok(RsData.of("200", "상품 Q&A 목록 조회 성공", responseDto));
158+
}
159+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.back.domain.product.qna.dto.request;
2+
3+
import com.back.global.s3.S3FileRequest;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import jakarta.validation.Valid;
6+
import jakarta.validation.constraints.NotBlank;
7+
8+
import java.util.List;
9+
10+
/**
11+
* 상품 Q&A 작성 요청을 받는 DTO
12+
*/
13+
public record ProductQnaRequestDto(
14+
@Schema(description = "Q&A 카테고리", example = "배송")
15+
@NotBlank(message = "Q&A 카테고리는 필수입니다.")
16+
String qnaCategory,
17+
18+
@Schema(description = "Q&A 제목", example = "배송 문의합니다.")
19+
@NotBlank(message = "Q&A 제목은 필수입니다.")
20+
String qnaTitle,
21+
22+
@Schema(description = "Q&A 내용", example = "상품 배송이 언제쯤 시작될까요?")
23+
@NotBlank(message = "Q&A 내용은 필수입니다.")
24+
String qnaDescription,
25+
26+
@Schema(
27+
description = "첨부 이미지 파일 목록 (null 허용)",
28+
example = "["
29+
+ "{\"url\":\"https://example.com/image1.jpg\",\"type\":\"ADDITIONAL\",\"s3Key\":\"s3-key-1\",\"originalFileName\":\"image1.jpg\"},"
30+
+ "{\"url\":\"https://example.com/image2.jpg\",\"type\":\"ADDITIONAL\",\"s3Key\":\"s3-key-2\",\"originalFileName\":\"image2.jpg\"}"
31+
+ "]"
32+
)
33+
@Valid
34+
List<S3FileRequest> qnaImages
35+
) {
36+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.back.domain.product.qna.dto.response;
2+
3+
import com.back.domain.product.qna.entity.ProductQna;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import org.springframework.data.domain.Page;
6+
7+
import java.util.List;
8+
9+
/**
10+
* 상품 Q&A 목록 조회 응답 DTO
11+
*/
12+
public record ProductQnaListResponseDto(
13+
@Schema(description = "현재 페이지 번호", example = "1")
14+
int currentPage,
15+
@Schema(description = "총 페이지 수", example = "5")
16+
int totalPages,
17+
@Schema(description = "한 페이지당 항목 수", example = "10")
18+
int pageSize,
19+
@Schema(description = "총 항목 수", example = "45")
20+
long totalElements,
21+
@Schema(description = "상품 Q&A 목록")
22+
List<ProductQnaResponseDto> qnaList
23+
) {
24+
public static ProductQnaListResponseDto fromPage(Page<ProductQna> page, List<ProductQnaResponseDto> qnaList) {
25+
return new ProductQnaListResponseDto(
26+
page.getNumber() + 1,
27+
page.getTotalPages(),
28+
page.getSize(),
29+
page.getTotalElements(),
30+
qnaList
31+
);
32+
}
33+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.back.domain.product.qna.dto.response;
2+
3+
import com.back.global.s3.UploadResultResponse;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
6+
import java.util.List;
7+
8+
/**
9+
* 상품 Q&A 상세 조회 응답 DTO
10+
*/
11+
@Schema(name = "ProductQnaResponseDto", description = "상품 Q&A 조회 응답 DTO")
12+
public record ProductQnaResponseDto(
13+
@Schema(description = "상품 Q&A 글 번호", example = "1")
14+
Long id,
15+
@Schema(description = "Q&A 카테고리", example = "배송")
16+
String qnaCategory,
17+
@Schema(description = "Q&A 제목", example = "배송 문의합니다.")
18+
String qnaTitle,
19+
@Schema(description = "Q&A 내용", example = "상품 배송이 언제쯤 시작될까요?")
20+
String qnaDescription,
21+
@Schema(description = "작성자 이름", example = "홍길동")
22+
String authorName,
23+
@Schema(description = "작성일", example = "23.10.26")
24+
String createDate,
25+
@Schema(description = "첨부 이미지 파일 목록", example = "[" + "{\"url\":\"https://example.com/image1.jpg\",\"type\":\"ADDITIONAL\",\"s3Key\":\"product-images/uuid1.png\",\"originalFileName\":\"example.png\"}," + "{\"url\":\"https://example.com/image2.jpg\",\"fileType\":\"ADDITIONAL\",\"s3Key\":\"product-images/uuid2.png\",\"originalFileName\":\"example2.png\"}," + "]" )
26+
List<UploadResultResponse> qnaImages
27+
) {
28+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.back.domain.product.qna.entity;
2+
3+
import com.back.domain.product.product.entity.Product;
4+
import com.back.domain.user.entity.User;
5+
import com.back.global.jpa.entity.BaseEntity;
6+
import jakarta.persistence.*;
7+
import lombok.*;
8+
9+
import java.util.ArrayList;
10+
import java.util.List;
11+
12+
@Builder
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
@Getter
16+
@Setter
17+
@Entity
18+
@Table(name = "product_qna")
19+
public class ProductQna extends BaseEntity {
20+
@ManyToOne(fetch = FetchType.LAZY)
21+
@JoinColumn(name = "product_id", nullable = false)
22+
private Product product; // 상품 FK
23+
24+
@ManyToOne(fetch = FetchType.LAZY)
25+
@JoinColumn(name = "user_id", nullable = false)
26+
private User user; // Q&A 작성자 FK
27+
28+
@Column(nullable = false)
29+
private String qnaCategory; // 카테고리
30+
31+
@Column(nullable = false)
32+
private String qnaTitle; // 제목
33+
34+
@Column(columnDefinition = "TEXT", nullable = false)
35+
private String qnaDescription; // 내용
36+
37+
@Builder.Default
38+
@OneToMany(mappedBy = "productQna", cascade = CascadeType.ALL, orphanRemoval = true)
39+
private List<ProductQnaImage> productQnaImages = new ArrayList<>();
40+
41+
public void addProductQnaImage(ProductQnaImage productQnaImage) {
42+
productQnaImages.add(productQnaImage);
43+
productQnaImage.setProductQna(this);
44+
}
45+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.back.domain.product.qna.entity;
2+
3+
import com.back.global.jpa.entity.BaseEntity;
4+
import com.back.global.s3.FileType;
5+
import jakarta.persistence.*;
6+
import lombok.*;
7+
8+
@Builder
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
@Getter
12+
@Setter
13+
@Entity
14+
@Table(name = "product_qna_image")
15+
public class ProductQnaImage extends BaseEntity {
16+
17+
@ManyToOne(fetch = FetchType.LAZY)
18+
@JoinColumn(name = "product_qna_id", nullable = false)
19+
private ProductQna productQna;
20+
21+
@Column(nullable = false)
22+
private String fileUrl;
23+
24+
@Column(nullable = false)
25+
@Enumerated(EnumType.STRING)
26+
private FileType fileType;
27+
28+
@Column(nullable = false)
29+
private String s3Key;
30+
31+
@Column(nullable = false)
32+
private String originalFileName;
33+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.back.domain.product.qna.repository;
2+
3+
import com.back.domain.product.qna.entity.ProductQnaImage;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface ProductQnaImageRepository extends JpaRepository<ProductQnaImage, Long> {
7+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.back.domain.product.qna.repository;
2+
3+
import com.back.domain.product.product.entity.Product;
4+
import com.back.domain.product.qna.entity.ProductQna;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
7+
import org.springframework.data.jpa.repository.JpaRepository;
8+
9+
public interface ProductQnaRepository extends JpaRepository<ProductQna, Long> {
10+
Page<ProductQna> findByProduct(Product product, Pageable pageable);
11+
Page<ProductQna> findByProductAndQnaCategory(Product product, String qnaCategory, Pageable pageable);
12+
}
13+

0 commit comments

Comments
 (0)