From 59ca2e2935c53d31a53a08f24f06003039636b18 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 19 May 2025 14:09:00 +0900 Subject: [PATCH 01/10] feat: add QnA model --- .../java/kr/mayb/data/model/UserQuestion.java | 32 +++++++++++++++++++ .../repository/UserQuestionRepository.java | 7 ++++ 2 files changed, 39 insertions(+) create mode 100644 src/main/java/kr/mayb/data/model/UserQuestion.java create mode 100644 src/main/java/kr/mayb/data/repository/UserQuestionRepository.java diff --git a/src/main/java/kr/mayb/data/model/UserQuestion.java b/src/main/java/kr/mayb/data/model/UserQuestion.java new file mode 100644 index 0000000..523dd7f --- /dev/null +++ b/src/main/java/kr/mayb/data/model/UserQuestion.java @@ -0,0 +1,32 @@ +package kr.mayb.data.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Table(schema = "mayb") +@Entity +public class UserQuestion extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false) + private String question; + + @Column + private String answer; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false) + private long productId; + + @Column(nullable = false) + private boolean isSecret; +} diff --git a/src/main/java/kr/mayb/data/repository/UserQuestionRepository.java b/src/main/java/kr/mayb/data/repository/UserQuestionRepository.java new file mode 100644 index 0000000..3498cdb --- /dev/null +++ b/src/main/java/kr/mayb/data/repository/UserQuestionRepository.java @@ -0,0 +1,7 @@ +package kr.mayb.data.repository; + +import kr.mayb.data.model.UserQuestion; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserQuestionRepository extends JpaRepository { +} From c865be4424561ea586d727842acbfd80d5e78f1a Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 19 May 2025 14:57:49 +0900 Subject: [PATCH 02/10] feat: add registerQuestion API --- .../kr/mayb/controller/QnAController.java | 37 ++++++++++++++ .../java/kr/mayb/data/model/UserQuestion.java | 12 ++--- src/main/java/kr/mayb/dto/QnADto.java | 48 +++++++++++++++++++ src/main/java/kr/mayb/facade/QnAFacade.java | 32 +++++++++++++ src/main/java/kr/mayb/service/QnAService.java | 26 ++++++++++ 5 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 src/main/java/kr/mayb/controller/QnAController.java create mode 100644 src/main/java/kr/mayb/dto/QnADto.java create mode 100644 src/main/java/kr/mayb/facade/QnAFacade.java create mode 100644 src/main/java/kr/mayb/service/QnAService.java diff --git a/src/main/java/kr/mayb/controller/QnAController.java b/src/main/java/kr/mayb/controller/QnAController.java new file mode 100644 index 0000000..b064acb --- /dev/null +++ b/src/main/java/kr/mayb/controller/QnAController.java @@ -0,0 +1,37 @@ +package kr.mayb.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import kr.mayb.facade.QnAFacade; +import kr.mayb.security.DenyAll; +import kr.mayb.security.PermitAuthenticated; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "QnA", description = "QnA API") +@DenyAll +@RestController +@RequiredArgsConstructor +public class QnAController { + + private final QnAFacade qnAFacade; + + @Operation(summary = "상품 QnA 등록") + @PermitAuthenticated + @PostMapping("/questions") + public void registerQuestion(@RequestBody @Valid QnARequest qnARequest) { + qnAFacade.registerQuestion(qnARequest.productId(), qnARequest.question(), qnARequest.isSecret()); + } + + private record QnARequest( + long productId, + @NotBlank + String question, + boolean isSecret + ) { + } +} diff --git a/src/main/java/kr/mayb/data/model/UserQuestion.java b/src/main/java/kr/mayb/data/model/UserQuestion.java index 523dd7f..67614de 100644 --- a/src/main/java/kr/mayb/data/model/UserQuestion.java +++ b/src/main/java/kr/mayb/data/model/UserQuestion.java @@ -14,19 +14,19 @@ public class UserQuestion extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; + @Column(nullable = false) + private long productId; + @Column(nullable = false) private String question; @Column private String answer; + @Column(nullable = false) + private boolean isSecret; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; - - @Column(nullable = false) - private long productId; - - @Column(nullable = false) - private boolean isSecret; } diff --git a/src/main/java/kr/mayb/dto/QnADto.java b/src/main/java/kr/mayb/dto/QnADto.java new file mode 100644 index 0000000..61fe63b --- /dev/null +++ b/src/main/java/kr/mayb/dto/QnADto.java @@ -0,0 +1,48 @@ +package kr.mayb.dto; + +import kr.mayb.data.model.Member; +import kr.mayb.data.model.UserQuestion; + +import java.time.OffsetDateTime; +import java.util.Optional; + +public record QnADto( + long userQuestionId, + String question, + String answer, + String authorName, + OffsetDateTime createdAt, + boolean isAnswered, + boolean isSecret, + boolean isMyQuestion +) { + public static QnADto of(UserQuestion userQuestion, long memberId) { + Member author = userQuestion.getMember(); + String maskedName = author.getMaskedName(); + String answer = getAnswer(userQuestion, author.getId(), memberId); + boolean isAnswered = Optional.ofNullable(userQuestion.getAnswer()).isPresent(); + + return new QnADto( + userQuestion.getId(), + userQuestion.getQuestion(), + answer, + maskedName, + userQuestion.getCreatedAt(), + isAnswered, + userQuestion.isSecret(), + isMyQuestion(author.getId(), memberId) + ); + } + + private static String getAnswer(UserQuestion userQuestion, long authorId, long memberId) { + if (userQuestion.isSecret() && !isMyQuestion(authorId, memberId)) { + return null; + } + + return userQuestion.getAnswer(); + } + + private static boolean isMyQuestion(long authorId, long currentMemberId) { + return authorId == currentMemberId; + } +} diff --git a/src/main/java/kr/mayb/facade/QnAFacade.java b/src/main/java/kr/mayb/facade/QnAFacade.java new file mode 100644 index 0000000..dc880aa --- /dev/null +++ b/src/main/java/kr/mayb/facade/QnAFacade.java @@ -0,0 +1,32 @@ +package kr.mayb.facade; + +import kr.mayb.data.model.Member; +import kr.mayb.data.model.Product; +import kr.mayb.data.model.UserQuestion; +import kr.mayb.dto.MemberDto; +import kr.mayb.dto.QnADto; +import kr.mayb.service.MemberService; +import kr.mayb.service.ProductService; +import kr.mayb.service.QnAService; +import kr.mayb.util.ContextUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class QnAFacade { + + private final ProductService productService; + private final MemberService memberService; + private final QnAService qnAService; + + public QnADto registerQuestion(long productId, String question, boolean isSecret) { + MemberDto member = ContextUtils.loadMember(); + + Member author = memberService.getMember(member.getMemberId()); + Product product = productService.getProduct(productId); + + UserQuestion saved = qnAService.registerQuestion(product.getId(), question, isSecret, author); + return QnADto.of(saved, author.getId()); + } +} diff --git a/src/main/java/kr/mayb/service/QnAService.java b/src/main/java/kr/mayb/service/QnAService.java new file mode 100644 index 0000000..90714c1 --- /dev/null +++ b/src/main/java/kr/mayb/service/QnAService.java @@ -0,0 +1,26 @@ +package kr.mayb.service; + +import jakarta.transaction.Transactional; +import kr.mayb.data.model.Member; +import kr.mayb.data.model.UserQuestion; +import kr.mayb.data.repository.UserQuestionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class QnAService { + + private final UserQuestionRepository userQuestionRepository; + + @Transactional + public UserQuestion registerQuestion(long productId, String question, boolean isSecret, Member author) { + UserQuestion userQuestion = new UserQuestion(); + userQuestion.setProductId(productId); + userQuestion.setQuestion(question); + userQuestion.setSecret(isSecret); + userQuestion.setMember(author); + + return userQuestionRepository.save(userQuestion); + } +} From f28efe5adcc454c6cdd32bf033a17319ca2476a9 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 19 May 2025 14:59:26 +0900 Subject: [PATCH 03/10] feat: add registerQuestion API response --- src/main/java/kr/mayb/controller/QnAController.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/kr/mayb/controller/QnAController.java b/src/main/java/kr/mayb/controller/QnAController.java index b064acb..88aa357 100644 --- a/src/main/java/kr/mayb/controller/QnAController.java +++ b/src/main/java/kr/mayb/controller/QnAController.java @@ -4,10 +4,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import kr.mayb.dto.QnADto; import kr.mayb.facade.QnAFacade; import kr.mayb.security.DenyAll; import kr.mayb.security.PermitAuthenticated; +import kr.mayb.util.response.ApiResponse; +import kr.mayb.util.response.Responses; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -23,8 +27,9 @@ public class QnAController { @Operation(summary = "상품 QnA 등록") @PermitAuthenticated @PostMapping("/questions") - public void registerQuestion(@RequestBody @Valid QnARequest qnARequest) { - qnAFacade.registerQuestion(qnARequest.productId(), qnARequest.question(), qnARequest.isSecret()); + public ResponseEntity> registerQuestion(@RequestBody @Valid QnARequest qnARequest) { + QnADto response = qnAFacade.registerQuestion(qnARequest.productId(), qnARequest.question(), qnARequest.isSecret()); + return Responses.ok(response); } private record QnARequest( From 867de4f74472fbd87434bc8fab6561b7bc25bb66 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 19 May 2025 16:01:53 +0900 Subject: [PATCH 04/10] feat: add registerAnswer API --- .../kr/mayb/controller/QnAController.java | 22 +++++++++++++++--- src/main/java/kr/mayb/dto/QnADto.java | 23 +++++++++++++++---- src/main/java/kr/mayb/facade/QnAFacade.java | 10 +++++++- src/main/java/kr/mayb/service/QnAService.java | 12 +++++++++- 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/main/java/kr/mayb/controller/QnAController.java b/src/main/java/kr/mayb/controller/QnAController.java index 88aa357..efe6fe6 100644 --- a/src/main/java/kr/mayb/controller/QnAController.java +++ b/src/main/java/kr/mayb/controller/QnAController.java @@ -7,6 +7,7 @@ import kr.mayb.dto.QnADto; import kr.mayb.facade.QnAFacade; import kr.mayb.security.DenyAll; +import kr.mayb.security.PermitAdmin; import kr.mayb.security.PermitAuthenticated; import kr.mayb.util.response.ApiResponse; import kr.mayb.util.response.Responses; @@ -27,16 +28,31 @@ public class QnAController { @Operation(summary = "상품 QnA 등록") @PermitAuthenticated @PostMapping("/questions") - public ResponseEntity> registerQuestion(@RequestBody @Valid QnARequest qnARequest) { - QnADto response = qnAFacade.registerQuestion(qnARequest.productId(), qnARequest.question(), qnARequest.isSecret()); + public ResponseEntity> registerQuestion(@RequestBody @Valid QuestionRequest request) { + QnADto response = qnAFacade.registerQuestion(request.productId(), request.question(), request.isSecret()); return Responses.ok(response); } - private record QnARequest( + @Operation(summary = "상품 QnA 답변 등록") + @PermitAdmin + @PostMapping("/questions/answers") + public ResponseEntity> registerAnswer(@RequestBody @Valid AnswerRequest request) { + QnADto response = qnAFacade.registerAnswer(request.questionId(), request.answer()); + return Responses.ok(response); + } + + private record QuestionRequest( long productId, @NotBlank String question, boolean isSecret ) { } + + private record AnswerRequest( + long questionId, + @NotBlank + String answer + ) { + } } diff --git a/src/main/java/kr/mayb/dto/QnADto.java b/src/main/java/kr/mayb/dto/QnADto.java index 61fe63b..184241a 100644 --- a/src/main/java/kr/mayb/dto/QnADto.java +++ b/src/main/java/kr/mayb/dto/QnADto.java @@ -1,7 +1,9 @@ package kr.mayb.dto; +import kr.mayb.data.model.Authority; import kr.mayb.data.model.Member; import kr.mayb.data.model.UserQuestion; +import kr.mayb.enums.AuthorityName; import java.time.OffsetDateTime; import java.util.Optional; @@ -16,10 +18,10 @@ public record QnADto( boolean isSecret, boolean isMyQuestion ) { - public static QnADto of(UserQuestion userQuestion, long memberId) { + public static QnADto of(UserQuestion userQuestion, Member member) { Member author = userQuestion.getMember(); String maskedName = author.getMaskedName(); - String answer = getAnswer(userQuestion, author.getId(), memberId); + String answer = getAnswer(userQuestion, author.getId(), member); boolean isAnswered = Optional.ofNullable(userQuestion.getAnswer()).isPresent(); return new QnADto( @@ -30,12 +32,23 @@ public static QnADto of(UserQuestion userQuestion, long memberId) { userQuestion.getCreatedAt(), isAnswered, userQuestion.isSecret(), - isMyQuestion(author.getId(), memberId) + isMyQuestion(author.getId(), member.getId()) ); } - private static String getAnswer(UserQuestion userQuestion, long authorId, long memberId) { - if (userQuestion.isSecret() && !isMyQuestion(authorId, memberId)) { + private static String getAnswer(UserQuestion userQuestion, long authorId, Member member) { + boolean isAdmin = member.getAuthorities() + .stream() + .map(Authority::getName) + .anyMatch(name -> name == AuthorityName.ROLE_ADMIN); + + // Admin can see all answers + if (isAdmin) { + return userQuestion.getAnswer(); + } + + // If the question is secret and the member is not the author, return null + if (userQuestion.isSecret() && !isMyQuestion(authorId, member.getId())) { return null; } diff --git a/src/main/java/kr/mayb/facade/QnAFacade.java b/src/main/java/kr/mayb/facade/QnAFacade.java index dc880aa..897b987 100644 --- a/src/main/java/kr/mayb/facade/QnAFacade.java +++ b/src/main/java/kr/mayb/facade/QnAFacade.java @@ -27,6 +27,14 @@ public QnADto registerQuestion(long productId, String question, boolean isSecret Product product = productService.getProduct(productId); UserQuestion saved = qnAService.registerQuestion(product.getId(), question, isSecret, author); - return QnADto.of(saved, author.getId()); + return QnADto.of(saved, author); + } + + public QnADto registerAnswer(long questionId, String answer) { + MemberDto admin = ContextUtils.loadMember(); + + Member member = memberService.getMember(admin.getMemberId()); + UserQuestion answered = qnAService.registerAnswer(questionId, answer); + return QnADto.of(answered, member); } } diff --git a/src/main/java/kr/mayb/service/QnAService.java b/src/main/java/kr/mayb/service/QnAService.java index 90714c1..9b59abb 100644 --- a/src/main/java/kr/mayb/service/QnAService.java +++ b/src/main/java/kr/mayb/service/QnAService.java @@ -1,11 +1,12 @@ package kr.mayb.service; -import jakarta.transaction.Transactional; import kr.mayb.data.model.Member; import kr.mayb.data.model.UserQuestion; import kr.mayb.data.repository.UserQuestionRepository; +import kr.mayb.error.ResourceNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -23,4 +24,13 @@ public UserQuestion registerQuestion(long productId, String question, boolean is return userQuestionRepository.save(userQuestion); } + + @Transactional + public UserQuestion registerAnswer(long questionId, String answer) { + UserQuestion userQuestion = userQuestionRepository.findById(questionId) + .orElseThrow(() -> new ResourceNotFoundException("Question not found : " + questionId)); + + userQuestion.setAnswer(answer); + return userQuestionRepository.save(userQuestion); + } } From 45c94c97b837bb50e98113c7dfc1d0a30bd8733d Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 19 May 2025 16:09:12 +0900 Subject: [PATCH 05/10] refactor: change @Transactional import package. Jakarta to spring --- src/main/java/kr/mayb/facade/ReviewFacade.java | 2 +- src/main/java/kr/mayb/security/jwt/AuthenticationHelper.java | 2 +- src/main/java/kr/mayb/security/jwt/TokenHelper.java | 2 +- src/main/java/kr/mayb/service/AuthService.java | 2 +- src/main/java/kr/mayb/service/MemberService.java | 2 +- src/main/java/kr/mayb/service/OrderService.java | 2 +- src/main/java/kr/mayb/service/ProductService.java | 2 +- src/main/java/kr/mayb/service/ReviewService.java | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java index 4b906bd..91d5000 100644 --- a/src/main/java/kr/mayb/facade/ReviewFacade.java +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -1,6 +1,5 @@ package kr.mayb.facade; -import jakarta.transaction.Transactional; import jakarta.validation.constraints.NotBlank; import kr.mayb.data.model.Member; import kr.mayb.data.model.Review; @@ -22,6 +21,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.util.List; diff --git a/src/main/java/kr/mayb/security/jwt/AuthenticationHelper.java b/src/main/java/kr/mayb/security/jwt/AuthenticationHelper.java index 536cc50..b7cd399 100644 --- a/src/main/java/kr/mayb/security/jwt/AuthenticationHelper.java +++ b/src/main/java/kr/mayb/security/jwt/AuthenticationHelper.java @@ -1,6 +1,5 @@ package kr.mayb.security.jwt; -import jakarta.transaction.Transactional; import kr.mayb.data.model.Authority; import kr.mayb.data.model.Member; import kr.mayb.data.repository.MemberRepository; @@ -10,6 +9,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; diff --git a/src/main/java/kr/mayb/security/jwt/TokenHelper.java b/src/main/java/kr/mayb/security/jwt/TokenHelper.java index 92a65eb..d6d7e11 100644 --- a/src/main/java/kr/mayb/security/jwt/TokenHelper.java +++ b/src/main/java/kr/mayb/security/jwt/TokenHelper.java @@ -6,7 +6,6 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import jakarta.transaction.Transactional; import kr.mayb.data.model.Authority; import kr.mayb.data.model.Member; import kr.mayb.data.model.RefreshToken; @@ -20,6 +19,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import javax.crypto.SecretKey; import java.util.Date; diff --git a/src/main/java/kr/mayb/service/AuthService.java b/src/main/java/kr/mayb/service/AuthService.java index 3994eca..677a26f 100644 --- a/src/main/java/kr/mayb/service/AuthService.java +++ b/src/main/java/kr/mayb/service/AuthService.java @@ -1,7 +1,6 @@ package kr.mayb.service; import io.jsonwebtoken.Claims; -import jakarta.transaction.Transactional; import kr.mayb.data.model.Member; import kr.mayb.dto.AuthDto; import kr.mayb.dto.MemberDto; @@ -18,6 +17,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor diff --git a/src/main/java/kr/mayb/service/MemberService.java b/src/main/java/kr/mayb/service/MemberService.java index 13cdad2..8cc127e 100644 --- a/src/main/java/kr/mayb/service/MemberService.java +++ b/src/main/java/kr/mayb/service/MemberService.java @@ -1,6 +1,5 @@ package kr.mayb.service; -import jakarta.transaction.Transactional; import kr.mayb.data.model.Authority; import kr.mayb.data.model.Member; import kr.mayb.data.repository.AuthorityRepository; @@ -15,6 +14,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Collections; import java.util.Map; diff --git a/src/main/java/kr/mayb/service/OrderService.java b/src/main/java/kr/mayb/service/OrderService.java index 3b2fd72..562e3c3 100644 --- a/src/main/java/kr/mayb/service/OrderService.java +++ b/src/main/java/kr/mayb/service/OrderService.java @@ -1,6 +1,5 @@ package kr.mayb.service; -import jakarta.transaction.Transactional; import kr.mayb.data.model.Order; import kr.mayb.data.repository.OrderRepository; import kr.mayb.data.repository.specification.OrderSpecification; @@ -15,6 +14,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; diff --git a/src/main/java/kr/mayb/service/ProductService.java b/src/main/java/kr/mayb/service/ProductService.java index d93a1f2..77dcf42 100644 --- a/src/main/java/kr/mayb/service/ProductService.java +++ b/src/main/java/kr/mayb/service/ProductService.java @@ -1,6 +1,5 @@ package kr.mayb.service; -import jakarta.transaction.Transactional; import kr.mayb.data.model.Order; import kr.mayb.data.model.Product; import kr.mayb.data.model.ProductGenderPrice; @@ -16,6 +15,7 @@ import org.apache.commons.lang3.tuple.Pair; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.*; diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java index a4b0d40..f089e25 100644 --- a/src/main/java/kr/mayb/service/ReviewService.java +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -1,6 +1,5 @@ package kr.mayb.service; -import jakarta.transaction.Transactional; import kr.mayb.data.model.Member; import kr.mayb.data.model.Review; import kr.mayb.data.model.ReviewImage; @@ -16,6 +15,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; From ea57915de13dca898fd7419ae409581ad10b7fa3 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Tue, 20 May 2025 05:57:31 +0900 Subject: [PATCH 06/10] feat: add getQnAs API --- .../kr/mayb/controller/QnAController.java | 20 ++++- src/main/java/kr/mayb/data/model/Member.java | 4 +- .../java/kr/mayb/data/model/UserQuestion.java | 11 ++- .../repository/UserQuestionRepository.java | 3 +- .../specification/QnASpecification.java | 36 ++++++++ src/main/java/kr/mayb/dto/QnADto.java | 67 +++++++++++++-- src/main/java/kr/mayb/dto/QnAQuery.java | 34 ++++++++ src/main/java/kr/mayb/enums/QnAStatus.java | 7 ++ src/main/java/kr/mayb/facade/QnAFacade.java | 82 ++++++++++++++++++- .../java/kr/mayb/service/ProductService.java | 4 +- src/main/java/kr/mayb/service/QnAService.java | 18 +++- 11 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 src/main/java/kr/mayb/data/repository/specification/QnASpecification.java create mode 100644 src/main/java/kr/mayb/dto/QnAQuery.java create mode 100644 src/main/java/kr/mayb/enums/QnAStatus.java diff --git a/src/main/java/kr/mayb/controller/QnAController.java b/src/main/java/kr/mayb/controller/QnAController.java index efe6fe6..b9d3958 100644 --- a/src/main/java/kr/mayb/controller/QnAController.java +++ b/src/main/java/kr/mayb/controller/QnAController.java @@ -5,17 +5,19 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import kr.mayb.dto.QnADto; +import kr.mayb.enums.QnAStatus; import kr.mayb.facade.QnAFacade; import kr.mayb.security.DenyAll; import kr.mayb.security.PermitAdmin; +import kr.mayb.security.PermitAll; import kr.mayb.security.PermitAuthenticated; +import kr.mayb.util.request.PageRequest; import kr.mayb.util.response.ApiResponse; +import kr.mayb.util.response.PageResponse; import kr.mayb.util.response.Responses; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "QnA", description = "QnA API") @DenyAll @@ -41,6 +43,18 @@ public ResponseEntity> registerAnswer(@RequestBody @Valid An return Responses.ok(response); } + @Operation(summary = "상품 QnA 조회") + @PermitAll + @GetMapping("/questions") + public ResponseEntity>> getQnAs(@RequestParam("pid") long productId, + @RequestParam("ex_secret") boolean excludeSecret, + @RequestParam("only_mine") boolean onlyMine, + @RequestParam("status") QnAStatus status, + PageRequest pageRequest) { + PageResponse response = qnAFacade.getQnAs(productId, excludeSecret, onlyMine, status, pageRequest); + return Responses.ok(response); + } + private record QuestionRequest( long productId, @NotBlank diff --git a/src/main/java/kr/mayb/data/model/Member.java b/src/main/java/kr/mayb/data/model/Member.java index f3b2234..384e250 100644 --- a/src/main/java/kr/mayb/data/model/Member.java +++ b/src/main/java/kr/mayb/data/model/Member.java @@ -73,8 +73,6 @@ public String getMaskedName() { } String firstChar = this.name.substring(0, 1); - String masked = this.name.substring(1).replaceAll("\\.", "*"); - - return firstChar + masked; + return firstChar + "****"; } } diff --git a/src/main/java/kr/mayb/data/model/UserQuestion.java b/src/main/java/kr/mayb/data/model/UserQuestion.java index 67614de..f328baf 100644 --- a/src/main/java/kr/mayb/data/model/UserQuestion.java +++ b/src/main/java/kr/mayb/data/model/UserQuestion.java @@ -14,9 +14,6 @@ public class UserQuestion extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; - @Column(nullable = false) - private long productId; - @Column(nullable = false) private String question; @@ -26,7 +23,9 @@ public class UserQuestion extends BaseEntity { @Column(nullable = false) private boolean isSecret; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", nullable = false) - private Member member; + @Column(nullable = false) + private long productId; + + @Column(nullable = false) + private long memberId; } diff --git a/src/main/java/kr/mayb/data/repository/UserQuestionRepository.java b/src/main/java/kr/mayb/data/repository/UserQuestionRepository.java index 3498cdb..2d20f42 100644 --- a/src/main/java/kr/mayb/data/repository/UserQuestionRepository.java +++ b/src/main/java/kr/mayb/data/repository/UserQuestionRepository.java @@ -2,6 +2,7 @@ import kr.mayb.data.model.UserQuestion; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -public interface UserQuestionRepository extends JpaRepository { +public interface UserQuestionRepository extends JpaRepository, JpaSpecificationExecutor { } diff --git a/src/main/java/kr/mayb/data/repository/specification/QnASpecification.java b/src/main/java/kr/mayb/data/repository/specification/QnASpecification.java new file mode 100644 index 0000000..0bb584d --- /dev/null +++ b/src/main/java/kr/mayb/data/repository/specification/QnASpecification.java @@ -0,0 +1,36 @@ +package kr.mayb.data.repository.specification; + +import kr.mayb.data.model.UserQuestion; +import kr.mayb.enums.QnAStatus; +import org.springframework.data.jpa.domain.Specification; + +public class QnASpecification { + public static Specification withProductId(Long productId) { + return (root, query, criteriaBuilder) -> + productId != null ? criteriaBuilder.equal(root.get("productId"), productId) : criteriaBuilder.conjunction(); + } + + public static Specification withOnlyMine(Long memberId, boolean onlyMine) { + return (root, query, criteriaBuilder) -> { + if (!onlyMine || memberId == null) { + return criteriaBuilder.conjunction(); + } else { + return criteriaBuilder.equal(root.get("memberId"), memberId); + } + }; + } + + public static Specification withExcludeSecret(boolean excludeSecret) { + return (root, query, criteriaBuilder) -> + excludeSecret ? criteriaBuilder.isFalse(root.get("isSecret")) : criteriaBuilder.conjunction(); + } + + public static Specification withStatus(QnAStatus status) { + return (root, query, criteriaBuilder) -> + switch (status) { + case ALL -> criteriaBuilder.conjunction(); + case ANSWERED -> criteriaBuilder.isNotNull(root.get("answer")); + case UNANSWERED -> criteriaBuilder.isNull(root.get("answer")); + }; + } +} diff --git a/src/main/java/kr/mayb/dto/QnADto.java b/src/main/java/kr/mayb/dto/QnADto.java index 184241a..9690f6e 100644 --- a/src/main/java/kr/mayb/dto/QnADto.java +++ b/src/main/java/kr/mayb/dto/QnADto.java @@ -18,10 +18,9 @@ public record QnADto( boolean isSecret, boolean isMyQuestion ) { - public static QnADto of(UserQuestion userQuestion, Member member) { - Member author = userQuestion.getMember(); + public static QnADto of(UserQuestion userQuestion, Member author, Member reader) { String maskedName = author.getMaskedName(); - String answer = getAnswer(userQuestion, author.getId(), member); + String answer = getAnswer(userQuestion, author.getId(), reader); boolean isAnswered = Optional.ofNullable(userQuestion.getAnswer()).isPresent(); return new QnADto( @@ -32,12 +31,62 @@ public static QnADto of(UserQuestion userQuestion, Member member) { userQuestion.getCreatedAt(), isAnswered, userQuestion.isSecret(), - isMyQuestion(author.getId(), member.getId()) + isMyQuestion(author.getId(), reader.getId()) ); } - private static String getAnswer(UserQuestion userQuestion, long authorId, Member member) { - boolean isAdmin = member.getAuthorities() + public static QnADto of(UserQuestion userQuestion, Member author) { + String maskedName = author.getMaskedName(); + + String question; + if (userQuestion.isSecret()) { + question = null; + } else { + question = userQuestion.getQuestion(); + } + + String answer; + if (userQuestion.isSecret()) { + answer = null; + } else { + answer = userQuestion.getAnswer(); + } + + boolean isAnswered = Optional.ofNullable(userQuestion.getAnswer()).isPresent(); + + return new QnADto( + userQuestion.getId(), + question, + answer, + maskedName, + userQuestion.getCreatedAt(), + isAnswered, + userQuestion.isSecret(), + false + ); + } + + private static String getQuestion(UserQuestion userQuestion, long authorId, Member reader) { + boolean isAdmin = reader.getAuthorities() + .stream() + .map(Authority::getName) + .anyMatch(name -> name == AuthorityName.ROLE_ADMIN); + + // Admin can see all questions + if (isAdmin) { + return userQuestion.getQuestion(); + } + + // If the question is secret and the member is not the author, return null + if (userQuestion.isSecret() && !isMyQuestion(authorId, reader.getId())) { + return null; + } + + return userQuestion.getQuestion(); + } + + private static String getAnswer(UserQuestion userQuestion, long authorId, Member reader) { + boolean isAdmin = reader.getAuthorities() .stream() .map(Authority::getName) .anyMatch(name -> name == AuthorityName.ROLE_ADMIN); @@ -48,14 +97,14 @@ private static String getAnswer(UserQuestion userQuestion, long authorId, Member } // If the question is secret and the member is not the author, return null - if (userQuestion.isSecret() && !isMyQuestion(authorId, member.getId())) { + if (userQuestion.isSecret() && !isMyQuestion(authorId, reader.getId())) { return null; } return userQuestion.getAnswer(); } - private static boolean isMyQuestion(long authorId, long currentMemberId) { - return authorId == currentMemberId; + private static boolean isMyQuestion(long authorId, long readerId) { + return authorId == readerId; } } diff --git a/src/main/java/kr/mayb/dto/QnAQuery.java b/src/main/java/kr/mayb/dto/QnAQuery.java new file mode 100644 index 0000000..e7ef848 --- /dev/null +++ b/src/main/java/kr/mayb/dto/QnAQuery.java @@ -0,0 +1,34 @@ +package kr.mayb.dto; + +import kr.mayb.data.model.UserQuestion; +import kr.mayb.data.repository.specification.QnASpecification; +import kr.mayb.enums.QnAStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.jpa.domain.Specification; + +@ParameterObject +@Getter +@Setter +@RequiredArgsConstructor +public class QnAQuery { + private final long productId; + private final boolean excludeSecret; + private final QnAStatus status; + private final boolean onlyMine; + private Long memberId; + + public static QnAQuery of(long productId, boolean excludeSecret, QnAStatus status, boolean onlyMine) { + return new QnAQuery(productId, excludeSecret, status, onlyMine); + } + + public Specification toSpecQuery() { + return Specification + .where(QnASpecification.withProductId(this.productId) + .and(QnASpecification.withExcludeSecret(excludeSecret)) + .and(QnASpecification.withStatus(this.status)) + .and(QnASpecification.withOnlyMine(this.memberId, this.onlyMine))); + } +} diff --git a/src/main/java/kr/mayb/enums/QnAStatus.java b/src/main/java/kr/mayb/enums/QnAStatus.java new file mode 100644 index 0000000..75420b2 --- /dev/null +++ b/src/main/java/kr/mayb/enums/QnAStatus.java @@ -0,0 +1,7 @@ +package kr.mayb.enums; + +public enum QnAStatus { + ALL, + ANSWERED, + UNANSWERED, +} diff --git a/src/main/java/kr/mayb/facade/QnAFacade.java b/src/main/java/kr/mayb/facade/QnAFacade.java index 897b987..6fa180e 100644 --- a/src/main/java/kr/mayb/facade/QnAFacade.java +++ b/src/main/java/kr/mayb/facade/QnAFacade.java @@ -5,13 +5,26 @@ import kr.mayb.data.model.UserQuestion; import kr.mayb.dto.MemberDto; import kr.mayb.dto.QnADto; +import kr.mayb.dto.QnAQuery; +import kr.mayb.enums.QnAStatus; import kr.mayb.service.MemberService; import kr.mayb.service.ProductService; import kr.mayb.service.QnAService; import kr.mayb.util.ContextUtils; +import kr.mayb.util.request.PageRequest; +import kr.mayb.util.response.PageResponse; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + @Component @RequiredArgsConstructor public class QnAFacade { @@ -26,15 +39,76 @@ public QnADto registerQuestion(long productId, String question, boolean isSecret Member author = memberService.getMember(member.getMemberId()); Product product = productService.getProduct(productId); - UserQuestion saved = qnAService.registerQuestion(product.getId(), question, isSecret, author); - return QnADto.of(saved, author); + UserQuestion saved = qnAService.registerQuestion(product.getId(), question, isSecret, author.getId()); + return QnADto.of(saved, author, author); } public QnADto registerAnswer(long questionId, String answer) { MemberDto admin = ContextUtils.loadMember(); - Member member = memberService.getMember(admin.getMemberId()); + UserQuestion answered = qnAService.registerAnswer(questionId, answer); - return QnADto.of(answered, member); + Member author = memberService.getMember(answered.getMemberId()); + + return QnADto.of(answered, author, member); + } + + public PageResponse getQnAs(long productId, boolean excludeSecret, boolean onlyMine, QnAStatus status, PageRequest pageRequest) { + Optional signInMember = ContextUtils.getCurrentMember(); + + if (signInMember.isEmpty() && onlyMine) { + throw new AccessDeniedException("Only signed-in users can view."); + } + + QnAQuery qnAQuery = QnAQuery.of(productId, excludeSecret, status, onlyMine); + Page userQuestions = signInMember + .map(member -> { + qnAQuery.setMemberId(member.getMemberId()); + return qnAService.findAll(qnAQuery, pageRequest); + }) + .orElseGet(() -> qnAService.findAll(qnAQuery, pageRequest)); + + List qnASimples = convertToQnASimple(userQuestions.getContent()); + + if (signInMember.isPresent()) { + Member reader = memberService.getMember(signInMember.get().getMemberId()); + List qnADtoList = qnASimples + .stream() + .map(qna -> QnADto.of(qna.userQuestion(), qna.author(), reader)) + .toList(); + return PageResponse.of(new PageImpl<>(qnADtoList, userQuestions.getPageable(), userQuestions.getTotalElements())); + } else { + List qnaDtoList = qnASimples + .stream() + .map(qna -> QnADto.of(qna.userQuestion(), qna.author())) + .toList(); + return PageResponse.of(new PageImpl<>(qnaDtoList, userQuestions.getPageable(), userQuestions.getTotalElements())); + } + } + + private List convertToQnASimple(List userQuestions) { + Set memberIds = userQuestions + .stream() + .map(UserQuestion::getMemberId) + .collect(Collectors.toSet()); + Map memberMap = memberService.findAllByIdIn(memberIds); + + return userQuestions + .stream() + .map(qna -> { + Member member = memberMap.get(qna.getMemberId()); + if (member == null) { + return null; + } + + return new QnASimple(qna, member); + }) + .toList(); + } + + private record QnASimple( + UserQuestion userQuestion, + Member author + ) { } } diff --git a/src/main/java/kr/mayb/service/ProductService.java b/src/main/java/kr/mayb/service/ProductService.java index 77dcf42..8eec3db 100644 --- a/src/main/java/kr/mayb/service/ProductService.java +++ b/src/main/java/kr/mayb/service/ProductService.java @@ -10,10 +10,10 @@ import kr.mayb.dto.*; import kr.mayb.enums.GcsBucketPath; import kr.mayb.enums.ProductStatus; +import kr.mayb.error.BadRequestException; import kr.mayb.error.ResourceNotFoundException; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.tuple.Pair; -import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -104,7 +104,7 @@ public ProductDto getProduct(long productId, boolean isAdmin) { } if (product.getStatus() == ProductStatus.INACTIVE) { - throw new AccessDeniedException("Product is inactive.: " + productId); + throw new BadRequestException("Product is inactive.: " + productId); } return ProductDto.of(product); diff --git a/src/main/java/kr/mayb/service/QnAService.java b/src/main/java/kr/mayb/service/QnAService.java index 9b59abb..b711658 100644 --- a/src/main/java/kr/mayb/service/QnAService.java +++ b/src/main/java/kr/mayb/service/QnAService.java @@ -1,10 +1,15 @@ package kr.mayb.service; -import kr.mayb.data.model.Member; import kr.mayb.data.model.UserQuestion; import kr.mayb.data.repository.UserQuestionRepository; +import kr.mayb.dto.QnAQuery; import kr.mayb.error.ResourceNotFoundException; +import kr.mayb.util.request.PageRequest; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,12 +20,12 @@ public class QnAService { private final UserQuestionRepository userQuestionRepository; @Transactional - public UserQuestion registerQuestion(long productId, String question, boolean isSecret, Member author) { + public UserQuestion registerQuestion(long productId, String question, boolean isSecret, long memberId) { UserQuestion userQuestion = new UserQuestion(); userQuestion.setProductId(productId); userQuestion.setQuestion(question); userQuestion.setSecret(isSecret); - userQuestion.setMember(author); + userQuestion.setMemberId(memberId); return userQuestionRepository.save(userQuestion); } @@ -33,4 +38,11 @@ public UserQuestion registerAnswer(long questionId, String answer) { userQuestion.setAnswer(answer); return userQuestionRepository.save(userQuestion); } + + public Page findAll(QnAQuery query, PageRequest pageRequest) { + Pageable pageable = pageRequest.toPageable(Sort.by(Sort.Direction.DESC, "createdAt")); + Specification specQuery = query.toSpecQuery(); + + return userQuestionRepository.findAll(specQuery, pageable); + } } From 902bd1c799b409b6f41fdf556f21056ea4f2d9b3 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Tue, 20 May 2025 22:07:09 +0900 Subject: [PATCH 07/10] feat: add updateQuestion API --- src/main/java/kr/mayb/controller/QnAController.java | 13 +++++++++++++ src/main/java/kr/mayb/facade/QnAFacade.java | 8 ++++++++ src/main/java/kr/mayb/service/QnAService.java | 13 +++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/main/java/kr/mayb/controller/QnAController.java b/src/main/java/kr/mayb/controller/QnAController.java index b9d3958..dcf8390 100644 --- a/src/main/java/kr/mayb/controller/QnAController.java +++ b/src/main/java/kr/mayb/controller/QnAController.java @@ -55,6 +55,14 @@ public ResponseEntity>> getQnAs(@RequestP return Responses.ok(response); } + @Operation + @PermitAuthenticated + @PutMapping("/questions/{questionId}") + public ResponseEntity> updateQuestion(@PathVariable long questionId, @RequestBody @Valid UpdateRequest request) { + QnADto response = qnAFacade.updateQuestion(questionId, request.content()); + return Responses.ok(response); + } + private record QuestionRequest( long productId, @NotBlank @@ -69,4 +77,9 @@ private record AnswerRequest( String answer ) { } + + private record UpdateRequest( + String content + ) { + } } diff --git a/src/main/java/kr/mayb/facade/QnAFacade.java b/src/main/java/kr/mayb/facade/QnAFacade.java index 6fa180e..970d6c0 100644 --- a/src/main/java/kr/mayb/facade/QnAFacade.java +++ b/src/main/java/kr/mayb/facade/QnAFacade.java @@ -86,6 +86,14 @@ public PageResponse getQnAs(long productId, boolean excludeSecret, } } + public QnADto updateQuestion(long questionId, String content) { + MemberDto member = ContextUtils.loadMember(); + UserQuestion updated = qnAService.updateQuestion(questionId, content, member.getMemberId()); + Member author = memberService.getMember(updated.getMemberId()); + + return QnADto.of(updated, author, author); + } + private List convertToQnASimple(List userQuestions) { Set memberIds = userQuestions .stream() diff --git a/src/main/java/kr/mayb/service/QnAService.java b/src/main/java/kr/mayb/service/QnAService.java index b711658..468183a 100644 --- a/src/main/java/kr/mayb/service/QnAService.java +++ b/src/main/java/kr/mayb/service/QnAService.java @@ -10,6 +10,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -45,4 +46,16 @@ public Page findAll(QnAQuery query, PageRequest pageRequest) { return userQuestionRepository.findAll(specQuery, pageable); } + + public UserQuestion updateQuestion(long questionId, String content, long memberId) { + UserQuestion userQuestion = userQuestionRepository.findById(questionId) + .orElseThrow(() -> new ResourceNotFoundException("Question not found : " + questionId)); + + if (userQuestion.getMemberId() != memberId) { + throw new AccessDeniedException("Only author can update the question."); + } + + userQuestion.setQuestion(content); + return userQuestionRepository.save(userQuestion); + } } From e54b1c2069156b3459a48f4b675b9ca1462a1eb4 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Tue, 20 May 2025 22:18:05 +0900 Subject: [PATCH 08/10] feat: add updateAnswer and removeQuestion APIs --- .../kr/mayb/controller/QnAController.java | 20 +++++++++++++++-- src/main/java/kr/mayb/facade/QnAFacade.java | 15 +++++++++++++ src/main/java/kr/mayb/service/QnAService.java | 22 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/main/java/kr/mayb/controller/QnAController.java b/src/main/java/kr/mayb/controller/QnAController.java index dcf8390..41d6e58 100644 --- a/src/main/java/kr/mayb/controller/QnAController.java +++ b/src/main/java/kr/mayb/controller/QnAController.java @@ -55,14 +55,30 @@ public ResponseEntity>> getQnAs(@RequestP return Responses.ok(response); } - @Operation + @Operation(summary = "상품 질문 수정") @PermitAuthenticated - @PutMapping("/questions/{questionId}") + @PatchMapping("/questions/{questionId}") public ResponseEntity> updateQuestion(@PathVariable long questionId, @RequestBody @Valid UpdateRequest request) { QnADto response = qnAFacade.updateQuestion(questionId, request.content()); return Responses.ok(response); } + @Operation(summary = "상품 답변 수정") + @PermitAdmin + @PatchMapping("/questions/{questionId}/answers") + public ResponseEntity> updateAnswer(@PathVariable long questionId, @RequestBody @Valid UpdateRequest request) { + QnADto response = qnAFacade.updateAnswer(questionId, request.content()); + return Responses.ok(response); + } + + @Operation(summary = "상품 질문 삭제") + @PermitAuthenticated + @DeleteMapping("/questions/{questionId}") + public ResponseEntity removeQuestion(@PathVariable long questionId) { + qnAFacade.removeQuestion(questionId); + return Responses.noContent(); + } + private record QuestionRequest( long productId, @NotBlank diff --git a/src/main/java/kr/mayb/facade/QnAFacade.java b/src/main/java/kr/mayb/facade/QnAFacade.java index 970d6c0..b7e09e5 100644 --- a/src/main/java/kr/mayb/facade/QnAFacade.java +++ b/src/main/java/kr/mayb/facade/QnAFacade.java @@ -94,6 +94,21 @@ public QnADto updateQuestion(long questionId, String content) { return QnADto.of(updated, author, author); } + public QnADto updateAnswer(long questionId, String content) { + MemberDto member = ContextUtils.loadMember(); + Member admin = memberService.getMember(member.getMemberId()); + + UserQuestion updated = qnAService.updateAnswer(questionId, content); + Member author = memberService.getMember(updated.getMemberId()); + + return QnADto.of(updated, author, admin); + } + + public void removeQuestion(long questionId) { + MemberDto member = ContextUtils.loadMember(); + qnAService.removeQuestion(questionId, member.getMemberId()); + } + private List convertToQnASimple(List userQuestions) { Set memberIds = userQuestions .stream() diff --git a/src/main/java/kr/mayb/service/QnAService.java b/src/main/java/kr/mayb/service/QnAService.java index 468183a..39410e2 100644 --- a/src/main/java/kr/mayb/service/QnAService.java +++ b/src/main/java/kr/mayb/service/QnAService.java @@ -47,6 +47,7 @@ public Page findAll(QnAQuery query, PageRequest pageRequest) { return userQuestionRepository.findAll(specQuery, pageable); } + @Transactional public UserQuestion updateQuestion(long questionId, String content, long memberId) { UserQuestion userQuestion = userQuestionRepository.findById(questionId) .orElseThrow(() -> new ResourceNotFoundException("Question not found : " + questionId)); @@ -58,4 +59,25 @@ public UserQuestion updateQuestion(long questionId, String content, long memberI userQuestion.setQuestion(content); return userQuestionRepository.save(userQuestion); } + + @Transactional + public UserQuestion updateAnswer(long questionId, String content) { + UserQuestion userQuestion = userQuestionRepository.findById(questionId) + .orElseThrow(() -> new ResourceNotFoundException("Question not found : " + questionId)); + + userQuestion.setAnswer(content); + return userQuestionRepository.save(userQuestion); + } + + @Transactional + public void removeQuestion(long questionId, long memberId) { + UserQuestion userQuestion = userQuestionRepository.findById(questionId) + .orElseThrow(() -> new ResourceNotFoundException("Question not found : " + questionId)); + + if (userQuestion.getMemberId() != memberId) { + throw new AccessDeniedException("Only author can delete the question."); + } + + userQuestionRepository.delete(userQuestion); + } } From 4daf475fb72c93183142f91635f573b9f25517d7 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Thu, 6 Nov 2025 21:30:43 +0900 Subject: [PATCH 09/10] feat: add dev server setting --- .github/workflows/deploy-dev.yml | 178 ++++++++++++++++++++++++ deploy/run/dev.template.yaml | 40 ++++++ src/main/resources/application-dev.yaml | 25 ++++ 3 files changed, 243 insertions(+) create mode 100644 .github/workflows/deploy-dev.yml create mode 100644 deploy/run/dev.template.yaml diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..4859f68 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,178 @@ +on: + issue_comment: + types: [ created ] + +name: "Deploy to Dev Cloud Run" + +env: + PROJECT_ID: mayb-api-458206 + GAR_LOCATION: asia-northeast3 + REPOSITORY: mayb-repo + IMAGE_NAME: mayb-api + REGION: asia-northeast1 + DEV_SERVICE_NAME: mayb-api-dev + +jobs: + deploy-dev: + runs-on: ubuntu-latest + # Only run on PR comments that contain /dev-release + if: | + github.event.issue.pull_request && + contains(github.event.comment.body, '/dev-release') + + permissions: + contents: 'read' + id-token: 'write' + pull-requests: 'write' + + steps: + - name: 'Get PR details' + id: pr + uses: actions/github-script@v6 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('ref', pr.head.ref); + core.setOutput('sha', pr.head.sha); + core.setOutput('title', pr.title); + core.setOutput('html_url', pr.html_url); + + - uses: 'actions/checkout@v4' + with: + ref: ${{ steps.pr.outputs.sha }} + + - uses: 'google-github-actions/auth@v2' + with: + credentials_json: ${{ secrets.GCP_DEPLOY_SA_KEY }} + + - uses: 'google-github-actions/setup-gcloud@v2' + with: + project_id: '${{ env.PROJECT_ID }}' + + - name: 'Set variables' + run: |- + echo "IMAGE=${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.pr.outputs.sha }}" >> $GITHUB_ENV + + - uses: 'actions/setup-java@v3' + with: + distribution: temurin + java-version: 21 + + - uses: gradle/gradle-build-action@v2 + - name: Make gradlew executable + run: chmod +x ./gradlew + - name: Execute Gradle build + run: ./gradlew build + + - name: 'Docker auth' + run: |- + gcloud auth configure-docker ${{ env.GAR_LOCATION }}-docker.pkg.dev + + - name: Build and Push Container + run: |- + docker build --file ./Dockerfile -t "${{ env.IMAGE }}" . + docker push "${{ env.IMAGE }}" + + - name: Create dev manifest + run: |- + export IMAGE="${{ env.IMAGE }}" + export SERVICE="${{ env.DEV_SERVICE_NAME }}" + export DB_URL="${{ secrets.DEV_DB_URL }}" + export DB_USERNAME="${{ secrets.DEV_DB_USERNAME }}" + export DB_PASSWORD="${{ secrets.DEV_DB_PASSWORD }}" + export ENCRYPTION_SECRET_KEY="${{ secrets.DEV_ENCRYPTION_SECRET_KEY }}" + export JWT_SECRET_ACCESS="${{ secrets.DEV_JWT_SECRET_ACCESS }}" + export JWT_SECRET_REFRESH="${{ secrets.DEV_JWT_SECRET_REFRESH }}" + envsubst < ./deploy/run/dev.template.yaml > dev.yaml + + - name: Deploy to Cloud Run + id: deploy + uses: google-github-actions/deploy-cloudrun@v1 + with: + region: ${{ env.REGION }} + metadata: dev.yaml + + - name: Set no-auth policy + run: |- + cat < policy.yaml + bindings: + - members: + - allUsers + role: roles/run.invoker + EOF + gcloud run services set-iam-policy "${{ env.DEV_SERVICE_NAME }}" policy.yaml --region ${{ env.REGION }} --quiet + + - name: 'Comment URL' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '🚀 Dev deploy complete: ${{ steps.deploy.outputs.url }}\n📦 Image: `${{ env.IMAGE }}`\n📝 Branch: `${{ steps.pr.outputs.ref }}`\n💾 Commit: ${{ steps.pr.outputs.sha }}' + }) + + - name: 'Create slack payload' + run: |- + export PR_NAME="${{ steps.pr.outputs.title }}" + export PR_LINK="${{ steps.pr.outputs.html_url }}" + export SERVICE_URL="${{ steps.deploy.outputs.url }}" + export REPO_NAME="${{ github.event.repository.name }}" + export REPO_LINK="${{ github.event.repository.html_url }}" + export BRANCH_NAME="${{ steps.pr.outputs.ref }}" + + COMMIT=${{ steps.pr.outputs.sha }} + SHORT_SHA=${COMMIT::7} + export COMMIT_SHA="${SHORT_SHA}" + + cat < dev-deploy-payload.json + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "🚀 *Dev Environment Deployed*" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:*\n<${REPO_LINK}|${REPO_NAME}>" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n${BRANCH_NAME}" + }, + { + "type": "mrkdwn", + "text": "*PR:*\n<${PR_LINK}|${PR_NAME}>" + }, + { + "type": "mrkdwn", + "text": "*Commit:*\n${COMMIT_SHA}" + }, + { + "type": "mrkdwn", + "text": "*Service URL:*\n<${SERVICE_URL}|Open Dev Service>" + } + ] + } + ] + } + EOF + + - uses: slackapi/slack-github-action@v1.24.0 + with: + channel-id: ${{ vars.SLACK_CHANNEL_ID }} + payload-file-path: ./dev-deploy-payload.json + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file diff --git a/deploy/run/dev.template.yaml b/deploy/run/dev.template.yaml new file mode 100644 index 0000000..5db2f8b --- /dev/null +++ b/deploy/run/dev.template.yaml @@ -0,0 +1,40 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: ${SERVICE} + annotations: + run.googleapis.com/ingress: all + run.googleapis.com/ingress-status: all +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: '0' + autoscaling.knative.dev/maxScale: '1' + run.googleapis.com/startup-cpu-boost: 'true' + spec: + containers: + - env: + - name: SPRING_PROFILES_ACTIVE + value: dev + - name: DB_URL + value: ${DB_URL} + - name: DB_USERNAME + value: ${DB_USERNAME} + - name: DB_PASSWORD + value: ${DB_PASSWORD} + - name: ENCRYPTION_SECRET_KEY + value: ${ENCRYPTION_SECRET_KEY} + - name: JWT_SECRET_ACCESS + value: ${JWT_SECRET_ACCESS} + - name: JWT_SECRET_REFRESH + value: ${JWT_SECRET_REFRESH} + image: ${IMAGE} + ports: + - containerPort: 8080 + name: http1 + resources: + limits: + cpu: 1000m + memory: 1Gi + diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 746b457..4190499 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -1,3 +1,20 @@ +spring: + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration + datasource: + driver-class-name: org.postgresql.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + database: postgresql + hibernate.ddl-auto: none + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + default_schema: mayb + springdoc: packages-to-scan: kr.mayb.controller api-docs: @@ -8,3 +25,11 @@ springdoc: display-operation-id: true syntax-highlight.theme: monokai doc-expansion: none + +encryption: + secret-key: ${ENCRYPTION_SECRET_KEY} + +jwt: + secret: + access: ${JWT_SECRET_ACCESS} + refresh: ${JWT_SECRET_REFRESH} From a3bf5aaac19198dead66a02aee2c249183bfe914 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Thu, 6 Nov 2025 22:17:39 +0900 Subject: [PATCH 10/10] fix: dev deploy command --- .github/workflows/deploy-dev.yml | 6 ++---- deploy/slack/feature-deploy-payload.template.json | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 4859f68..982e914 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -15,10 +15,7 @@ env: jobs: deploy-dev: runs-on: ubuntu-latest - # Only run on PR comments that contain /dev-release - if: | - github.event.issue.pull_request && - contains(github.event.comment.body, '/dev-release') + if: contains(github.event.comment.body, '/dev-release') permissions: contents: 'read' @@ -133,6 +130,7 @@ jobs: cat < dev-deploy-payload.json { + "text": "🚀 Dev Environment Deployed - ${REPO_NAME} (${BRANCH_NAME})", "blocks": [ { "type": "section", diff --git a/deploy/slack/feature-deploy-payload.template.json b/deploy/slack/feature-deploy-payload.template.json index bd8badd..84cf3e5 100644 --- a/deploy/slack/feature-deploy-payload.template.json +++ b/deploy/slack/feature-deploy-payload.template.json @@ -1,4 +1,5 @@ { + "text": "${PR_NAME} deployed successfully - ${BRANCH_NAME}", "username": "API Feature Deploy", "icon_emoji": ":lightning_cloud:", "blocks": [