diff --git a/src/main/java/Gotcha/domain/inquiry/api/AdminInquiryApi.java b/src/main/java/Gotcha/domain/inquiry/api/AdminInquiryApi.java new file mode 100644 index 00000000..53f510a2 --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/api/AdminInquiryApi.java @@ -0,0 +1,61 @@ +package Gotcha.domain.inquiry.api; + +import Gotcha.common.jwt.auth.SecurityUserDetails; +import Gotcha.domain.inquiry.dto.AnswerReq; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "[관리자 QnA API]", description = "관리자 QnA 처리 관련 API") +public interface AdminInquiryApi { + + @Operation(summary = "QnA 답변 생성", description = "QnA 답변 생성 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "QnA 답변 생성 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "OK", + "message": "QnA 답변 생성에 성공했습니다." + } + """) + }) + ), + @ApiResponse(responseCode = "400", description = "필드 검증 오류", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "BAD_REQUEST", + "message": "필드 검증 오류입니다.", + "fields": { + "content": "내용은 필수 입력 사항입니다." + } + } + """) + }) + ), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "FORBIDDEN", + "message": "권한이 없습니다." + } + """) + }) + ) + }) + ResponseEntity createAnswer(@Valid @RequestBody AnswerReq answerReq, @PathVariable(value = "inquiryId")Long inquiryId, + @AuthenticationPrincipal SecurityUserDetails userDetails); + + + +} diff --git a/src/main/java/Gotcha/domain/inquiry/api/InquiryApi.java b/src/main/java/Gotcha/domain/inquiry/api/InquiryApi.java new file mode 100644 index 00000000..2fc4490f --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/api/InquiryApi.java @@ -0,0 +1,304 @@ +package Gotcha.domain.inquiry.api; + +import Gotcha.common.jwt.auth.SecurityUserDetails; +import Gotcha.domain.inquiry.dto.InquiryReq; +import Gotcha.domain.inquiry.dto.InquirySortType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[QnA API]", description = "QnA 문의 관련 API") +public interface InquiryApi { + + @Operation(summary = "QnA 목록 조회", description = "QnA 목록을 조회하는 API, Keyword로 검색 및 날짜순 정렬 가능.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "QnA 목록 조회 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "totalPages": 1, + "totalElements": 4, + "first": true, + "last": true, + "size": 10, + "content": [ + { + "inquiryId": 3, + "title" : "사랑이 식었다고 말해도 돼", + "writer": "T없e맑은i", + "isPrivate": false + "createdAt": "2025-04-14T18:24:13", + "isSolved": true + }, + { + "inquiryId": 2, + "title" : "게임이 너무 재밌는 것 같아요!", + "writer": "S2형준S2", + "isPrivate": false + "createdAt": "2025-04-14T16:13:32", + "isSolved": true + }, + { + "inquiryId": 1, + "title" : "비밀번호를 잊어버렸어요 ㅠㅠ", + "writer": "zx늑대xz" + "isPrivate": false + "createdAt": "2025-04-04T16:05:35", + "isSolved": false + }, + { + "inquiryId": 0, + "title" : "상점 기능도 추가해주세요", + "writer": "T없e맑은i" + "isPrivate": false + "createdAt": "2025-03-31T10:12:15", + "isSolved": false + } + ], + "pageable": { + "pageNumber": 0, + "pageSize": 10, + "sort": { + "empty": false, + "unsorted": false, + "sorted": true + }, + "offset": 0, + "unpaged": false, + "paged": true + }, + "numberOfElements": 4, + "sort": { + "empty": false, + "sorted": true, + "unsorted": false + }, + "number": 0, + "empty": false + } + """) + }) + ) + }) + ResponseEntity getInquiries(@RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", defaultValue = "0") @Min(0) Integer page, + @RequestParam(value = "sort", defaultValue = "DATE_DESC")InquirySortType sort, + @RequestParam(value = "isSolved", required = false) Boolean isSolved); + + @Operation(summary = "내가 쓴 QnA 목록 조회", description = "내가 쓴 QnA(질문) 목록 조회 API, Keyword 검색 및 날짜순 정렬 가능") + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "나의 QnA(질문) 목록 조회 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "totalPages": 1, + "totalElements": 2, + "first": true, + "last": true, + "size": 10, + "content": [ + { + "inquiryId": 3, + "title" : "사랑이 식었다고 말해도 돼", + "writer": "T없e맑은i", + "isPrivate": false + "createdAt": "2025-04-14T18:24:13", + "isSolved": true + }, + { + "inquiryId": 0, + "title" : "상점 기능도 추가해주세요", + "writer": "T없e맑은i" + "isPrivate": false + "createdAt": "2025-03-31T10:12:15", + "isSolved": false + } + ], + "pageable": { + "pageNumber": 0, + "pageSize": 10, + "sort": { + "empty": false, + "unsorted": false, + "sorted": true + }, + "offset": 0, + "unpaged": false, + "paged": true + }, + "numberOfElements": 2, + "sort": { + "empty": false, + "sorted": true, + "unsorted": false + }, + "number": 0, + "empty": false + } + """) + }) + ) + }) + ResponseEntity getMyInquiries(@RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", defaultValue = "0") @Min(0) Integer page, + @RequestParam(value = "sort", defaultValue = "DATE_DESC")InquirySortType sort, + @RequestParam(value = "isSolved", required = false) Boolean isSolved, + @AuthenticationPrincipal SecurityUserDetails userDetails); + + @Operation(summary = "QnA 조회", description = "QnA ID를 받아 해당 QnA 정보를 조회하는 API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", description = "QnA 조회 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "inquiryId": 2, + "title" : "게임이 너무 재밌는 것 같아요!", + "writer": "S2형준S2", + "content': "이 편지는 영국에서 최초로 시작되어 일년에 한바퀴를 돌면서 받는 사람에게 행운을 주었고 지금은 당신에게로 옮겨진 이 편지는 4일 안에 당신 곁을 ...더보기", + "isPrivate": false, + "createdAt": "2025-04-14T16:13:32", + "isSolved": true, + "answer":{ + "writer": "묘묘", + "content": "감사합니묘! 더 발전하는 갓챠가 되겠습니묘!", + "createdAt": "2025-04-14T18:00:00" + } + } + """ + ) + }) + ), + @ApiResponse( + responseCode = "404", description = "존재하지 않는 QnA", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "NOT_FOUND", + "message": "존재하지 않는 QnA입니다." + } + """ + ) + }) + ) + }) + ResponseEntity getInquiryById(@PathVariable(value = "inquiryId") Long inquiryId); + + @Operation(summary = "QnA 생성", description = "QnA 생성 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "QnA 생성 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "OK", + "message": "QnA 생성에 성공했습니다." + } + """) + }) + ), + @ApiResponse(responseCode = "422", description = "필드 검증 오류", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "title": "제목은 필수 입력 사항입니다.", + "content": "내용은 필수 입력 사항입니다." + } + """) + }) + ) + }) + ResponseEntity createInquiry(@Valid @RequestBody InquiryReq inquiryReq, + @AuthenticationPrincipal SecurityUserDetails userDetails); + + @Operation(summary = "QnA 수정", description = "QnA 수정 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "QnA 수정 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "OK", + "message": "QnA 수정에 성공했습니다." + } + """) + }) + ), + @ApiResponse(responseCode = "400", description = "필드 검증 오류", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "BAD_REQUEST", + "message": "필드 검증 오류입니다.", + "fields": { + "title": "제목은 필수 입력 사항입니다.", + "content": "내용은 필수 입력 사항입니다." + } + } + """) + }) + ), + @ApiResponse(responseCode = "403", description = "작성자 불일치 또는 수정 권한 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "FORBIDDEN", + "message": "권한이 없습니다." + } + """) + }) + ), + @ApiResponse(responseCode = "404", description = "존재하지 않는 QnA", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "NOT_FOUND", + "message": "존재하지 않는 QnA입니다." + } + """) + }) + ) + }) + ResponseEntity updateInquiry( + @PathVariable(value = "inquiryId") Long inquiryId, + @Valid @RequestBody InquiryReq inquiryReq, + @AuthenticationPrincipal SecurityUserDetails userDetails); + + @Operation(summary = "QnA 삭제", description = "QnA 삭제 API") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "QnA 삭제 성공"), + @ApiResponse(responseCode = "403", description = "작성자 불일치 또는 삭제 권한 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "FORBIDDEN", + "message": "권한이 없습니다." + } + """) + }) + ), + @ApiResponse(responseCode = "404", description = "존재하지 않는 QnA", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "status": "NOT_FOUND", + "message": "존재하지 않는 QnA 입니다." + } + """) + }) + ) + }) + ResponseEntity deleteInquiry( + @PathVariable(value = "inquiryId") Long inquiryId, + @AuthenticationPrincipal SecurityUserDetails userDetails); +} diff --git a/src/main/java/Gotcha/domain/inquiry/controller/AdminInquiryController.java b/src/main/java/Gotcha/domain/inquiry/controller/AdminInquiryController.java new file mode 100644 index 00000000..ac71b82c --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/controller/AdminInquiryController.java @@ -0,0 +1,38 @@ +package Gotcha.domain.inquiry.controller; + +import Gotcha.common.api.SuccessRes; +import Gotcha.common.jwt.auth.SecurityUserDetails; +import Gotcha.domain.inquiry.api.AdminInquiryApi; +import Gotcha.domain.inquiry.dto.AnswerReq; +import Gotcha.domain.inquiry.entity.Inquiry; +import Gotcha.domain.inquiry.service.AnswerService; +import Gotcha.domain.inquiry.service.InquiryService; +import Gotcha.domain.user.entity.User; +import Gotcha.domain.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/qnas/") +@RequiredArgsConstructor +public class AdminInquiryController implements AdminInquiryApi { + + private final AnswerService answerService; + + private final InquiryService inquiryService; + + private final UserService userService; + + @Override + @PostMapping("/{inquiryId}/answer") + public ResponseEntity createAnswer(@Valid @RequestBody AnswerReq answerReq, @PathVariable(value = "inquiryId") Long inquiryId, + @AuthenticationPrincipal SecurityUserDetails securityUserDetails) { + Inquiry inquiry = inquiryService.findInquiryById(inquiryId); + User writer = userService.findUserByUserId(securityUserDetails.getId()); + answerService.createAnswer(answerReq, inquiry, writer); + return ResponseEntity.ok(SuccessRes.from("QnA 답변 생성에 성공했습니다.")); + } +} diff --git a/src/main/java/Gotcha/domain/inquiry/controller/InquiryController.java b/src/main/java/Gotcha/domain/inquiry/controller/InquiryController.java new file mode 100644 index 00000000..bced87a5 --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/controller/InquiryController.java @@ -0,0 +1,86 @@ +package Gotcha.domain.inquiry.controller; + +import Gotcha.common.api.SuccessRes; +import Gotcha.common.jwt.auth.SecurityUserDetails; +import Gotcha.domain.inquiry.api.InquiryApi; +import Gotcha.domain.inquiry.dto.InquiryReq; +import Gotcha.domain.inquiry.dto.InquiryRes; +import Gotcha.domain.inquiry.dto.InquirySortType; +import Gotcha.domain.inquiry.dto.InquirySummaryRes; +import Gotcha.domain.inquiry.service.InquiryService; +import Gotcha.domain.user.entity.User; +import Gotcha.domain.user.service.UserService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/qnas") +@RequiredArgsConstructor +public class InquiryController implements InquiryApi { + + private final InquiryService inquiryService; + + private final UserService userService; + + @Override + @GetMapping + public ResponseEntity getInquiries(@RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", defaultValue = "0") @Min(0) Integer page, + @RequestParam(value = "sort", defaultValue = "DATE_DESC")InquirySortType sort, + @RequestParam(value = "isSolved", required = false) Boolean isSolved) { + Page inquiries = inquiryService.getInquiries(keyword, page, sort, isSolved); + + return ResponseEntity.status(HttpStatus.OK).body(inquiries); + } + + @Override + @GetMapping("/mine") + public ResponseEntity getMyInquiries(@RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", defaultValue = "0") @Min(0) Integer page, + @RequestParam(value = "sort", defaultValue = "DATE_DESC") InquirySortType sort, + @RequestParam(value = "isSolved", required = false) Boolean isSolved, + @AuthenticationPrincipal SecurityUserDetails userDetails) { + Page myInquiries = inquiryService.getMyInquiries(keyword, page, sort, isSolved, userDetails.getId()); + + return ResponseEntity.status(HttpStatus.OK).body(myInquiries); + } + + @Override + @GetMapping("/{inquiryId}") + public ResponseEntity getInquiryById(@PathVariable(value = "inquiryId") Long inquiryId) { + InquiryRes inquiryRes = inquiryService.getInquiryById(inquiryId); + return ResponseEntity.status(HttpStatus.OK).body(inquiryRes); + } + + @Override + @PostMapping + public ResponseEntity createInquiry(@Valid @RequestBody InquiryReq inquiryReq, + @AuthenticationPrincipal SecurityUserDetails userDetails) { + User writer = userService.findUserByUserId(userDetails.getId()); + inquiryService.createInquiry(inquiryReq, writer); + return ResponseEntity.ok(SuccessRes.from("QnA 생성에 성공했습니다.")); + } + + @Override + @PutMapping("/{inquiryId}") + public ResponseEntity updateInquiry(@PathVariable(value = "inquiryId") Long inquiryId, + @Valid @RequestBody InquiryReq inquiryReq, + @AuthenticationPrincipal SecurityUserDetails userDetails) { + inquiryService.updateInquiry(inquiryReq, inquiryId, userDetails.getId()); + return ResponseEntity.ok(SuccessRes.from("QnA 수정에 성공했습니다.")); + } + + @Override + @DeleteMapping("/{inquiryId}") + public ResponseEntity deleteInquiry(@PathVariable(value = "inquiryId") Long inquiryId, + @AuthenticationPrincipal SecurityUserDetails userDetails) { + inquiryService.deleteInquiry(inquiryId, userDetails.getId()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/Gotcha/domain/inquiry/dto/AnswerReq.java b/src/main/java/Gotcha/domain/inquiry/dto/AnswerReq.java new file mode 100644 index 00000000..f6434ba3 --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/dto/AnswerReq.java @@ -0,0 +1,19 @@ +package Gotcha.domain.inquiry.dto; + +import Gotcha.domain.inquiry.entity.Answer; +import Gotcha.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record AnswerReq( + @Schema(description = "답변 내용", example = "감사합니묘! 더 발전하는 갓챠가 되겠습니묘!") + @NotBlank(message = "답변 내용은 필수 입력 사항입니다.") + String content +) { + public Answer toEntity(User writer){ + return Answer.builder(). + content(content) + .writer(writer) + .build(); + } +} diff --git a/src/main/java/Gotcha/domain/inquiry/dto/AnswerRes.java b/src/main/java/Gotcha/domain/inquiry/dto/AnswerRes.java new file mode 100644 index 00000000..8441338e --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/dto/AnswerRes.java @@ -0,0 +1,20 @@ +package Gotcha.domain.inquiry.dto; + +import Gotcha.domain.inquiry.entity.Answer; + +import java.time.LocalDateTime; + +public record AnswerRes( + String writer, + String content, + LocalDateTime createdAt +) { + public static AnswerRes fromEntity(Answer answer){ + if (answer == null) return null; + return new AnswerRes( + answer.getWriter().getNickname(), + answer.getContent(), + answer.getCreatedAt() + ); + } +} diff --git a/src/main/java/Gotcha/domain/inquiry/dto/InquiryReq.java b/src/main/java/Gotcha/domain/inquiry/dto/InquiryReq.java new file mode 100644 index 00000000..ba8f5554 --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/dto/InquiryReq.java @@ -0,0 +1,33 @@ +package Gotcha.domain.inquiry.dto; + +import Gotcha.domain.inquiry.entity.Inquiry; +import Gotcha.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record InquiryReq( + @Schema(description = "QnA 제목", example = "게임이 너무 재밌는 것 같아요!") + @NotBlank(message = "제목은 필수 입력 사항입니다.") + String title, + + @Schema(description = "QnA 질문 내용", example = "이 편지는 영국에서 최초로 시작되어 일년에 한바퀴를 돌면서 받는 사람에게 행운을 주었고 지금은 당신에게로 옮겨진 이 편지는 4일 안에 당신 곁을 ...더보기") + @NotBlank(message = "내용은 필수 입력 사항입니다.") + String content, + + @Schema(description = "비공개 여부", example = "false", defaultValue = "false") + Boolean isPrivate +) { + public InquiryReq { + if (isPrivate == null) { + isPrivate = false; // default value + } + } + public Inquiry toEntity(User writer){ + return Inquiry.builder(). + title(title). + content(content). + writer(writer). + isSecret(isPrivate). + build(); + } +} diff --git a/src/main/java/Gotcha/domain/inquiry/dto/InquiryRes.java b/src/main/java/Gotcha/domain/inquiry/dto/InquiryRes.java new file mode 100644 index 00000000..08924edc --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/dto/InquiryRes.java @@ -0,0 +1,29 @@ +package Gotcha.domain.inquiry.dto; + +import Gotcha.domain.inquiry.entity.Inquiry; + +import java.time.LocalDateTime; + +public record InquiryRes( + Long inquiryId, + String title, + String writer, + String content, + Boolean isPrivate, + LocalDateTime createdAt, + Boolean isSolved, + AnswerRes answer +) { + public static InquiryRes fromEntity(Inquiry inquiry){ + return new InquiryRes( + inquiry.getId(), + inquiry.getTitle(), + inquiry.getWriter().getNickname(), + inquiry.getContent(), + inquiry.getIsSecret(), + inquiry.getCreatedAt(), + inquiry.getIsSolved(), + AnswerRes.fromEntity(inquiry.getAnswer()) + ); + } +} diff --git a/src/main/java/Gotcha/domain/inquiry/dto/InquirySortType.java b/src/main/java/Gotcha/domain/inquiry/dto/InquirySortType.java new file mode 100644 index 00000000..982e1a4c --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/dto/InquirySortType.java @@ -0,0 +1,21 @@ +package Gotcha.domain.inquiry.dto; + +import org.springframework.data.domain.Sort; + +public enum InquirySortType { + DATE_DESC("createdAt", Sort.Direction.DESC), + DATE_ASC("createdAt", Sort.Direction.ASC); + + private final String type; + private final Sort.Direction direction; + + InquirySortType(String type, Sort.Direction direction){ + this.type = type; + this.direction = direction; + } + + public Sort getSort(){ + return Sort.by(direction, type); + } + +} diff --git a/src/main/java/Gotcha/domain/inquiry/dto/InquirySummaryRes.java b/src/main/java/Gotcha/domain/inquiry/dto/InquirySummaryRes.java new file mode 100644 index 00000000..964b88b4 --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/dto/InquirySummaryRes.java @@ -0,0 +1,25 @@ +package Gotcha.domain.inquiry.dto; + +import Gotcha.domain.inquiry.entity.Inquiry; + +import java.time.LocalDateTime; + +public record InquirySummaryRes( + Long inquiryId, + String title, + String writer, + Boolean isPrivate, + LocalDateTime createdAt, + Boolean isSolved +) { + public static InquirySummaryRes fromEntity(Inquiry inquiry){ + return new InquirySummaryRes( + inquiry.getId(), + inquiry.getTitle(), + inquiry.getWriter().getNickname(), + inquiry.getIsSecret(), + inquiry.getCreatedAt(), + inquiry.getIsSolved() + ); + } +} diff --git a/src/main/java/Gotcha/domain/inquiry/entity/Answer.java b/src/main/java/Gotcha/domain/inquiry/entity/Answer.java index dcdcb6f5..fa0e142e 100644 --- a/src/main/java/Gotcha/domain/inquiry/entity/Answer.java +++ b/src/main/java/Gotcha/domain/inquiry/entity/Answer.java @@ -10,10 +10,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToOne; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Getter @Entity @@ -29,11 +26,13 @@ public class Answer extends BaseTimeEntity { @JoinColumn(name = "user_id") private User writer; + @Setter @OneToOne(mappedBy = "answer", fetch = FetchType.LAZY) private Inquiry inquiry; @Builder - public Answer(String content){ + public Answer(String content, User writer){ this.content = content; + this.writer = writer; } } diff --git a/src/main/java/Gotcha/domain/inquiry/entity/Inquiry.java b/src/main/java/Gotcha/domain/inquiry/entity/Inquiry.java index b97d42c2..37067292 100644 --- a/src/main/java/Gotcha/domain/inquiry/entity/Inquiry.java +++ b/src/main/java/Gotcha/domain/inquiry/entity/Inquiry.java @@ -1,6 +1,8 @@ package Gotcha.domain.inquiry.entity; import Gotcha.common.entity.BaseTimeEntity; +import Gotcha.common.exception.CustomException; +import Gotcha.domain.inquiry.exception.InquiryExceptionCode; import Gotcha.domain.user.entity.User; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -36,14 +38,36 @@ public class Inquiry extends BaseTimeEntity { private Boolean isSecret; + private Boolean isSolved = false; + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "answer_id") private Answer answer; @Builder - public Inquiry(String title, String content, Boolean isSecret){ + public Inquiry(String title, String content, Boolean isSecret, User writer){ this.title = title; this.content = content; this.isSecret = isSecret; + this.writer = writer; + } + + public void update(String title, String content, Boolean isPrivate){ + this.title = title; + this.content = content; + this.isSecret = isPrivate; } + + public void solve(Answer answer){ + validateSolved(); + this.answer = answer; + isSolved = true; + answer.setInquiry(this); + } + + private void validateSolved(){ + if(this.isSolved) + throw new CustomException(InquiryExceptionCode.ALREADY_SOLVED); + } + } diff --git a/src/main/java/Gotcha/domain/inquiry/exception/InquiryExceptionCode.java b/src/main/java/Gotcha/domain/inquiry/exception/InquiryExceptionCode.java new file mode 100644 index 00000000..c0e3bf67 --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/exception/InquiryExceptionCode.java @@ -0,0 +1,26 @@ +package Gotcha.domain.inquiry.exception; + +import Gotcha.common.exception.exceptionCode.ExceptionCode; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +public enum InquiryExceptionCode implements ExceptionCode { + + INVALID_INQUIRYID(HttpStatus.NOT_FOUND, "존재하지 않는 QnA 입니다."), + UNAUTHORIZED_ACTION(HttpStatus.FORBIDDEN, "권한이 없습니다."), + ALREADY_SOLVED(HttpStatus.CONFLICT, "이미 답변이 작성되었습니다."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus getStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/Gotcha/domain/inquiry/repository/InquiryRepository.java b/src/main/java/Gotcha/domain/inquiry/repository/InquiryRepository.java index 0aabbc0a..dbb3864e 100644 --- a/src/main/java/Gotcha/domain/inquiry/repository/InquiryRepository.java +++ b/src/main/java/Gotcha/domain/inquiry/repository/InquiryRepository.java @@ -1,7 +1,39 @@ package Gotcha.domain.inquiry.repository; import Gotcha.domain.inquiry.entity.Inquiry; +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; + public interface InquiryRepository extends JpaRepository { + @Query(""" + SELECT i FROM Inquiry i + WHERE + (:keyword IS NULL OR + LOWER(i.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR + LOWER(i.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) + AND (:isSolved IS NULL OR i.isSolved = :isSolved) + """) + Page findInquiries( + @Param("keyword") String keyword, + @Param("isSolved") Boolean isSolved, + Pageable pageable + ); + @Query(""" + SELECT i FROM Inquiry i + WHERE + (:keyword IS NULL OR + LOWER(i.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR + LOWER(i.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) + AND (:isSolved IS NULL OR i.isSolved = :isSolved) + AND (i.writer.id = :userId) + """) + Page findMyInquiries( + @Param("keyword") String keyword, + @Param("isSolved") Boolean isSolved, + Pageable pageable, + @Param("userId") Long userId); } diff --git a/src/main/java/Gotcha/domain/inquiry/service/AnswerService.java b/src/main/java/Gotcha/domain/inquiry/service/AnswerService.java new file mode 100644 index 00000000..51833b14 --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/service/AnswerService.java @@ -0,0 +1,26 @@ +package Gotcha.domain.inquiry.service; + +import Gotcha.common.exception.CustomException; +import Gotcha.domain.inquiry.dto.AnswerReq; +import Gotcha.domain.inquiry.entity.Answer; +import Gotcha.domain.inquiry.entity.Inquiry; +import Gotcha.domain.inquiry.exception.InquiryExceptionCode; +import Gotcha.domain.inquiry.repository.AnswerRepository; +import Gotcha.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AnswerService { + + private final AnswerRepository answerRepository; + + @Transactional + public void createAnswer(AnswerReq answerReq, Inquiry inquiry, User writer) { + Answer answer = answerReq.toEntity(writer); + inquiry.solve(answer); + answerRepository.save(answer); + } +} \ No newline at end of file diff --git a/src/main/java/Gotcha/domain/inquiry/service/InquiryService.java b/src/main/java/Gotcha/domain/inquiry/service/InquiryService.java new file mode 100644 index 00000000..e9f72ef5 --- /dev/null +++ b/src/main/java/Gotcha/domain/inquiry/service/InquiryService.java @@ -0,0 +1,83 @@ +package Gotcha.domain.inquiry.service; + +import Gotcha.common.exception.CustomException; +import Gotcha.domain.inquiry.dto.InquiryReq; +import Gotcha.domain.inquiry.dto.InquiryRes; +import Gotcha.domain.inquiry.dto.InquirySortType; +import Gotcha.domain.inquiry.dto.InquirySummaryRes; +import Gotcha.domain.inquiry.entity.Inquiry; +import Gotcha.domain.inquiry.exception.InquiryExceptionCode; +import Gotcha.domain.inquiry.repository.InquiryRepository; +import Gotcha.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class InquiryService { + + private final InquiryRepository inquiryRepository; + + private static final Integer INQUIRIES_PER_PAGE = 10; + + @Transactional(readOnly = true) + public Page getInquiries(String keyword, Integer page, InquirySortType sort, Boolean isSolved) { + Pageable pageable = PageRequest.of(page, INQUIRIES_PER_PAGE, sort.getSort()); + + Page inquiries = inquiryRepository.findInquiries(keyword, isSolved, pageable); + + return inquiries.map(InquirySummaryRes::fromEntity); + } + + @Transactional(readOnly = true) + public Page getMyInquiries(String keyword, Integer page, InquirySortType sort, Boolean isSolved, Long userId) { + Pageable pageable = PageRequest.of(page, INQUIRIES_PER_PAGE, sort.getSort()); + + Page myInquiries = inquiryRepository.findMyInquiries(keyword, isSolved, pageable, userId); + + return myInquiries.map(InquirySummaryRes::fromEntity); + } + + @Transactional(readOnly = true) + public InquiryRes getInquiryById(Long inquiryId) { + Inquiry inquiry = findInquiryById(inquiryId); + return InquiryRes.fromEntity(inquiry); + } + + @Transactional + public void createInquiry(InquiryReq inquiryReq, User writer) { + Inquiry inquiry = inquiryReq.toEntity(writer); + inquiryRepository.save(inquiry); + } + + + @Transactional + public void updateInquiry(InquiryReq inquiryReq, Long inquiryId, Long userId) { + Inquiry inquiry = findInquiryById(inquiryId); + validateInquiryOwner(inquiry, userId); + inquiry.update(inquiryReq.title(), inquiryReq.content(), inquiryReq.isPrivate()); + } + + @Transactional + public void deleteInquiry(Long inquiryId, Long userId) { + Inquiry inquiry = findInquiryById(inquiryId); + validateInquiryOwner(inquiry, userId); + inquiryRepository.delete(inquiry); + } + + @Transactional(readOnly = true) + public Inquiry findInquiryById(Long inquiryId){ + return inquiryRepository.findById(inquiryId) + .orElseThrow(() -> new CustomException(InquiryExceptionCode.INVALID_INQUIRYID)); + } + + private void validateInquiryOwner(Inquiry inquiry, Long userId){ + if (!inquiry.getWriter().getId().equals(userId)) { + throw new CustomException(InquiryExceptionCode.UNAUTHORIZED_ACTION); + } + } +} diff --git a/src/main/java/Gotcha/domain/notification/dto/NotificationReq.java b/src/main/java/Gotcha/domain/notification/dto/NotificationReq.java index ba688ca1..ad492234 100644 --- a/src/main/java/Gotcha/domain/notification/dto/NotificationReq.java +++ b/src/main/java/Gotcha/domain/notification/dto/NotificationReq.java @@ -2,12 +2,15 @@ import Gotcha.domain.notification.entity.Notification; import Gotcha.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; public record NotificationReq( + @Schema(description = "공지사항 제목", example = "걍 공지사항이다") @NotBlank(message = "제목은 필수 입력 사항입니다.") String title, + @Schema(description = "공지사항 내용", example = "걍 공지사항이다 인마") @NotBlank(message = "내용은 필수 입력 사항입니다.") String content ) { diff --git a/src/main/java/Gotcha/domain/user/service/UserService.java b/src/main/java/Gotcha/domain/user/service/UserService.java index 76324364..1fd75163 100644 --- a/src/main/java/Gotcha/domain/user/service/UserService.java +++ b/src/main/java/Gotcha/domain/user/service/UserService.java @@ -53,7 +53,8 @@ public UserInfoRes getUserInfo(Long userId, Role role){ return UserInfoRes.fromEntity(user); } - private User findUserByUserId(Long userId){ + @Transactional(readOnly = true) + public User findUserByUserId(Long userId){ return userRepository.findById(userId) .orElseThrow(()->new CustomException(UserExceptionCode.INVALID_USERID)); }