diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/controller/FaqCommandController.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/controller/FaqCommandController.java new file mode 100644 index 0000000..6d5495f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/controller/FaqCommandController.java @@ -0,0 +1,90 @@ +package org.withtime.be.withtimebe.domain.faq.controller; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.faq.converter.FaqConverter; +import org.withtime.be.withtimebe.domain.faq.dto.request.FaqRequestDTO; +import org.withtime.be.withtimebe.domain.faq.dto.response.FaqResponseDTO; +import org.withtime.be.withtimebe.domain.faq.entity.Faq; +import org.withtime.be.withtimebe.domain.faq.service.command.FaqCommandService; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/v1/faqs") +public class FaqCommandController { + + private final FaqCommandService faqCommandService; + + @Operation(summary = "자주 묻는 질문 생성 API by 피우 [Only Admin]", description = "자주 묻는 질문 생성 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "403", + description = """ + - COMMON403 : "Admin 권한이 없음을 의미합니다." + """) + }) + @PostMapping + public DefaultResponse createFaq( + @RequestBody @Valid FaqRequestDTO.CreateFaq request, + @AuthenticatedMember Member member + ) { + Faq result = faqCommandService.createFaq(request, member); + FaqResponseDTO.Faq response = FaqConverter.toFaq(result); + return DefaultResponse.created(response); + } + + @Operation(summary = "자주 묻는 질문 수정 API by 피우 [Only Admin]", description = "자주 묻는 질문 수정 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "403", + description = """ + - COMMON403 : "Admin 권한이 없음을 의미합니다." + """), + @ApiResponse(responseCode = "404", + description = """ + - FAQ404_2 : "해당하는 질문글을 찾을 수 없습니다." + """) + }) + @PutMapping("/{faqId}") + public DefaultResponse updateFaq( + @PathVariable("faqId") Long faqId, + @RequestBody @Valid FaqRequestDTO.UpdateFaq request + ) { + Faq result = faqCommandService.updateFaq(request, faqId); + FaqResponseDTO.Faq response = FaqConverter.toFaq(result); + return DefaultResponse.ok(response); + } + + @Operation(summary = "자주 묻는 질문 삭제 API by 피우 [Only Admin]", description = "자주 묻는 질문 삭제 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "403", + description = """ + - COMMON403 : "Admin 권한이 없음을 의미합니다." + """), + @ApiResponse(responseCode = "404", + description = """ + - FAQ404_2 : "해당하는 질문글을 찾을 수 없습니다." + """) + }) + @DeleteMapping("/{faqId}") + public DefaultResponse deleteFaq(@PathVariable("faqId") Long faqId) { + faqCommandService.deleteFaq(faqId); + return DefaultResponse.noContent(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/controller/FaqQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/controller/FaqQueryController.java new file mode 100644 index 0000000..e16b5ec --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/controller/FaqQueryController.java @@ -0,0 +1,74 @@ +package org.withtime.be.withtimebe.domain.faq.controller; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.faq.converter.FaqConverter; +import org.withtime.be.withtimebe.domain.faq.dto.request.FaqRequestDTO; +import org.withtime.be.withtimebe.domain.faq.dto.response.FaqResponseDTO; +import org.withtime.be.withtimebe.domain.faq.entity.Faq; +import org.withtime.be.withtimebe.domain.faq.entity.enums.FaqCategory; +import org.withtime.be.withtimebe.domain.faq.service.query.FaqQueryService; +import org.withtime.be.withtimebe.global.annotation.SwaggerPageable; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.AllArgsConstructor; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/v1/faqs") +public class FaqQueryController { + + private final FaqQueryService faqQueryService; + + @Operation(summary = "자주 묻는 질문 전체 조회 API by 피우", description = "자주 묻는 질문 전체 조회 API입니다. (검색어 X)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse( + responseCode = "404", + description = """ + - FAQ404_1 : 해당하는 질문 유형을 찾을 수 없습니다. + """) + }) + @Parameter(name = "faqCategory", description = "USAGE / ALGORITHM / FEATURE / SCHEDULE / ERROR / ACCOUNT") + @SwaggerPageable + @GetMapping + public DefaultResponse findFaqList( + @PageableDefault(page = 0, size = 10) Pageable pageable, + @RequestParam FaqCategory faqCategory + ) { + Page result = faqQueryService.findFaqList(pageable, faqCategory); + FaqResponseDTO.FaqList response = FaqConverter.toFaqList(result); + return DefaultResponse.ok(response); + } + + @Operation(summary = "자주 묻는 질문 검색어 전체 조회 API by 피우", description = "자주 묻는 질문 검색어 전체 조회 API입니다. (검색어 O)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse( + responseCode = "404", + description = """ + - FAQ404_1 : 해당하는 질문 유형을 찾을 수 없습니다. + """) + }) + @Parameter(name = "faqCategory", description = "USAGE / ALGORITHM / FEATURE / SCHEDULE / ERROR / ACCOUNT") + @SwaggerPageable + @GetMapping("/search") + public DefaultResponse findFaqListByKeyword( + @PageableDefault(page = 0, size = 10) Pageable pageable, + @RequestParam String keyword, + @RequestParam FaqCategory faqCategory + ) { + Page result = faqQueryService.findFaqListByKeyword(pageable, keyword, faqCategory); + FaqResponseDTO.FaqList response = FaqConverter.toFaqList(result); + return DefaultResponse.ok(response); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/converter/FaqCategoryConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/converter/FaqCategoryConverter.java new file mode 100644 index 0000000..ad5993d --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/converter/FaqCategoryConverter.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.faq.converter; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.util.StringUtils; +import org.withtime.be.withtimebe.domain.faq.entity.enums.FaqCategory; +import org.withtime.be.withtimebe.global.error.code.FaqErrorCode; +import org.withtime.be.withtimebe.global.error.exception.FaqException; + +// @PathVariable, @RequestParam +public class FaqCategoryConverter implements Converter { + + @Override + public FaqCategory convert(String source) { + if(!StringUtils.hasText(source)) throw new FaqException(FaqErrorCode.FAQ_CATEGORY_EMPTY); + return FaqCategory.findFaqCategory(source); + } +} \ No newline at end of file diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/converter/FaqConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/converter/FaqConverter.java new file mode 100644 index 0000000..2bcf834 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/converter/FaqConverter.java @@ -0,0 +1,52 @@ +package org.withtime.be.withtimebe.domain.faq.converter; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.withtime.be.withtimebe.domain.faq.dto.request.FaqRequestDTO; +import org.withtime.be.withtimebe.domain.faq.dto.response.FaqResponseDTO; +import org.withtime.be.withtimebe.domain.faq.entity.Faq; +import org.withtime.be.withtimebe.domain.faq.entity.enums.FaqCategory; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.global.error.code.FaqErrorCode; +import org.withtime.be.withtimebe.global.error.exception.FaqException; + +public class FaqConverter { + + // Response DTO : FaqResponseDTO.FaqList + public static FaqResponseDTO.FaqList toFaqList(Page faqPage) { + + List faqList = faqPage.getContent().stream() + .map(FaqConverter::toFaq) + .toList(); + + return FaqResponseDTO.FaqList.builder() + .faqList(faqList) + .totalPages(faqPage.getTotalPages()) + .currentPage(faqPage.getNumber()) + .currentSize(faqPage.getNumberOfElements()) + .hasNextPage(faqPage.hasNext()) + .build(); + } + + // Response DTO : FaqResponseDTO.Faq + public static FaqResponseDTO.Faq toFaq(Faq faq) { + + return FaqResponseDTO.Faq.builder() + .faqId(faq.getId()) + .title(faq.getTitle()) + .content(faq.getContent()) + .build(); + } + + public static Faq toFaqEntity(FaqRequestDTO.CreateFaq request, Member member) { + + return Faq.builder() + .member(member) + .title(request.title()) + .content(request.content()) + .faqCategory(request.faqCategory()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/dto/request/FaqRequestDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/dto/request/FaqRequestDTO.java new file mode 100644 index 0000000..d899268 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/dto/request/FaqRequestDTO.java @@ -0,0 +1,28 @@ +package org.withtime.be.withtimebe.domain.faq.dto.request; + +import org.springframework.data.domain.Pageable; +import org.withtime.be.withtimebe.domain.faq.entity.enums.FaqCategory; + +import jakarta.validation.constraints.NotBlank; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +public class FaqRequestDTO { + + public record CreateFaq( + @NotBlank(message = "제목을 입력해주세요") + String title, + @NotBlank(message = "내용을 입력해주세요") + String content, + @NotNull(message = "질문 유형을 입력해주세요") + FaqCategory faqCategory + ) {} + + public record UpdateFaq ( + @NotBlank(message = "제목을 입력해주세요") + String title, + @NotBlank(message = "내용을 입력해주세요") + String content + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/dto/response/FaqResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/dto/response/FaqResponseDTO.java new file mode 100644 index 0000000..1e78a12 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/dto/response/FaqResponseDTO.java @@ -0,0 +1,24 @@ +package org.withtime.be.withtimebe.domain.faq.dto.response; + +import java.util.List; + +import lombok.Builder; + +public class FaqResponseDTO { + + @Builder + public record FaqList( + List faqList, + Integer totalPages, // 전체 페이지 개수 + Integer currentPage, // 현재 페이지 번호 + Integer currentSize, // 현재 페이지의 크기 + Boolean hasNextPage // 다음 페이지 존재 여부 + ) {} + + @Builder + public record Faq( + Long faqId, // 자주 묻는 질문글 식별자 값 + String title, // 질문글 제목 + String content // 내용 + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/entity/Faq.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/entity/Faq.java new file mode 100644 index 0000000..cdddb54 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/entity/Faq.java @@ -0,0 +1,61 @@ +package org.withtime.be.withtimebe.domain.faq.entity; + +import java.time.LocalDateTime; + +import org.withtime.be.withtimebe.domain.faq.dto.request.FaqRequestDTO; +import org.withtime.be.withtimebe.domain.faq.entity.enums.FaqCategory; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.global.common.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "faq") +public class Faq extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "faq_id") + private Long id; + + @Column(name = "title") + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "faq_category") + private FaqCategory faqCategory; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + public void updateFields(FaqRequestDTO.UpdateFaq request) { + this.title = request.title(); + this.content = request.content(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/entity/enums/FaqCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/entity/enums/FaqCategory.java new file mode 100644 index 0000000..9e3bde7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/entity/enums/FaqCategory.java @@ -0,0 +1,35 @@ +package org.withtime.be.withtimebe.domain.faq.entity.enums; + +import java.util.Arrays; + +import org.withtime.be.withtimebe.global.error.code.FaqErrorCode; +import org.withtime.be.withtimebe.global.error.exception.FaqException; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum FaqCategory { + USAGE("서비스 이용 방법"), + ALGORITHM("추천 알고리즘 관련"), + FEATURE("기능 및 사용성"), + SCHEDULE("예약/일정 관리"), + ERROR("기타/문의 오류 신고"), + ACCOUNT("계정 및 개인정보"); + + private final String label; + + // @RequestBody + @JsonCreator + public static FaqCategory findFaqCategory(String name) { + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(name)) + .findAny() + .orElseThrow( + () -> new FaqException(FaqErrorCode.FAQ_CATEGORY_NOT_FOUND) + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/repository/FaqRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/repository/FaqRepository.java new file mode 100644 index 0000000..84f80fa --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/repository/FaqRepository.java @@ -0,0 +1,39 @@ +package org.withtime.be.withtimebe.domain.faq.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.faq.entity.Faq; +import org.withtime.be.withtimebe.domain.faq.entity.enums.FaqCategory; + +public interface FaqRepository extends JpaRepository { + + @Query(""" + SELECT f FROM Faq f + WHERE f.faqCategory = :faqCategory + AND f.deletedAt IS NULL + ORDER BY f.createdAt DESC + """) + Page findFaqListByFaqCategory( + @Param("faqCategory") FaqCategory faqCategory, + Pageable pageable + ); + + @Query(""" + SELECT f FROM Faq f + WHERE f.faqCategory = :faqCategory + AND ( + f.title LIKE %:keyword% + OR f.content LIKE %:keyword% + ) + AND f.deletedAt IS NULL + ORDER BY f.createdAt DESC + """) + Page findFaqListByFaqCategoryAndKeyword( + @Param("faqCategory") FaqCategory faqCategory, + @Param("keyword") String keyword, + Pageable pageable + ); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/service/command/FaqCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/service/command/FaqCommandService.java new file mode 100644 index 0000000..cdd99f2 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/service/command/FaqCommandService.java @@ -0,0 +1,14 @@ +package org.withtime.be.withtimebe.domain.faq.service.command; + +import org.withtime.be.withtimebe.domain.faq.dto.request.FaqRequestDTO; +import org.withtime.be.withtimebe.domain.faq.entity.Faq; +import org.withtime.be.withtimebe.domain.member.entity.Member; + +public interface FaqCommandService { + + Faq createFaq(FaqRequestDTO.CreateFaq request, Member member); + + Faq updateFaq(FaqRequestDTO.UpdateFaq request, Long faqId); + + void deleteFaq(Long faqId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/service/command/FaqCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/service/command/FaqCommandServiceImpl.java new file mode 100644 index 0000000..2732092 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/service/command/FaqCommandServiceImpl.java @@ -0,0 +1,44 @@ +package org.withtime.be.withtimebe.domain.faq.service.command; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.faq.converter.FaqConverter; +import org.withtime.be.withtimebe.domain.faq.dto.request.FaqRequestDTO; +import org.withtime.be.withtimebe.domain.faq.entity.Faq; +import org.withtime.be.withtimebe.domain.faq.repository.FaqRepository; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.global.error.code.FaqErrorCode; +import org.withtime.be.withtimebe.global.error.exception.FaqException; + +import lombok.AllArgsConstructor; + +@Service +@AllArgsConstructor +@Transactional(readOnly = false) +public class FaqCommandServiceImpl implements FaqCommandService { + + private FaqRepository faqRepository; + + @Override + public Faq createFaq(FaqRequestDTO.CreateFaq request, Member member) { + Faq faq = FaqConverter.toFaqEntity(request, member); + return faqRepository.save(faq); + } + + @Override + public Faq updateFaq(FaqRequestDTO.UpdateFaq request, Long faqId) { + Faq faq = faqRepository.findById(faqId) + .orElseThrow(() -> new FaqException(FaqErrorCode.FAQ_NOT_FOUND)); + + faq.updateFields(request); + + return faq; + } + + @Override + public void deleteFaq(Long faqId) { + Faq faq = faqRepository.findById(faqId) + .orElseThrow(() -> new FaqException(FaqErrorCode.FAQ_NOT_FOUND)); + faqRepository.delete(faq); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/service/query/FaqQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/service/query/FaqQueryService.java new file mode 100644 index 0000000..b01e29f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/service/query/FaqQueryService.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.domain.faq.service.query; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.withtime.be.withtimebe.domain.faq.dto.request.FaqRequestDTO; +import org.withtime.be.withtimebe.domain.faq.entity.Faq; +import org.withtime.be.withtimebe.domain.faq.entity.enums.FaqCategory; + +public interface FaqQueryService { + Page findFaqList(Pageable pageable, FaqCategory faqCategory); + Page findFaqListByKeyword(Pageable pageable, String keyword, FaqCategory faqCategory); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/faq/service/query/FaqQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/faq/service/query/FaqQueryServiceImpl.java new file mode 100644 index 0000000..aed51f5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/faq/service/query/FaqQueryServiceImpl.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.faq.service.query; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.faq.dto.request.FaqRequestDTO; +import org.withtime.be.withtimebe.domain.faq.entity.Faq; +import org.withtime.be.withtimebe.domain.faq.entity.enums.FaqCategory; +import org.withtime.be.withtimebe.domain.faq.repository.FaqRepository; + +import lombok.AllArgsConstructor; + +@Service +@AllArgsConstructor +@Transactional(readOnly = true) +public class FaqQueryServiceImpl implements FaqQueryService { + + private final FaqRepository faqRepository; + + @Override + public Page findFaqList(Pageable pageable, FaqCategory faqCategory) { + return faqRepository.findFaqListByFaqCategory( + faqCategory, pageable); + } + + @Override + public Page findFaqListByKeyword(Pageable pageable, String keyword, FaqCategory faqCategory) { + return faqRepository.findFaqListByFaqCategoryAndKeyword( + faqCategory, keyword, pageable + ); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/command/NoticeCommandController.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/command/NoticeCommandController.java index 32d1261..0a7d641 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/command/NoticeCommandController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/command/NoticeCommandController.java @@ -1,6 +1,7 @@ package org.withtime.be.withtimebe.domain.notice.controller.command; import org.namul.api.payload.response.DefaultResponse; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -30,7 +31,7 @@ public class NoticeCommandController { private final NoticeCommandService noticeCommandService; - @Operation(summary = "공지사항 생성 API Only Admin by 피우", description = "공지사항 생성 API입니다. 어드민만 사용 가능합니다.") + @Operation(summary = "공지사항 생성 API by 피우 [Only Admin]", description = "공지사항 생성 API입니다. 어드민만 사용 가능합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공입니다."), @ApiResponse(responseCode = "403", @@ -48,7 +49,7 @@ public DefaultResponse createNotice( return DefaultResponse.created(response); } - @Operation(summary = "공지사항 수정 API Only Admin by 피우", description = "공지사항 수정 API입니다. 어드민만 사용 가능합니다.") + @Operation(summary = "공지사항 수정 API by 피우 [Only Admin]", description = "공지사항 수정 API입니다. 어드민만 사용 가능합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공입니다."), @ApiResponse(responseCode = "403", @@ -70,7 +71,7 @@ public DefaultResponse updateNotice( return DefaultResponse.ok(response); } - @Operation(summary = "공지사항 삭제 API Only Admin by 피우", description = "공지사항 삭제 API입니다. 어드민만 사용 가능합니다.") + @Operation(summary = "공지사항 삭제 API by 피우 [Only Admin]", description = "공지사항 삭제 API입니다. 어드민만 사용 가능합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "성공입니다."), @ApiResponse(responseCode = "403", @@ -88,7 +89,7 @@ public DefaultResponse softDeleteNotice(@PathVariable Long noticeId) { return DefaultResponse.noContent(); } - @Operation(summary = "삭제한 공지사항 되돌리기 API Only Admin by 피우", description = "삭제한 공지사항을 되돌리는 API입니다. 어드민만 사용 가능합니다.") + @Operation(summary = "삭제한 공지사항 되돌리기 API by 피우 [Only Admin]", description = "삭제한 공지사항을 되돌리는 API입니다. 어드민만 사용 가능합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공입니다."), @ApiResponse(responseCode = "403", diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java index 423660f..ff7c0df 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,7 +28,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/notices") public class NoticeQueryController { private final NoticeQueryService noticeQueryService; @@ -43,7 +44,7 @@ public class NoticeQueryController { }) @Parameter(name = "noticeCategory", description = "SYSTEM / SERVICE") @SwaggerPageable - @GetMapping("/notices") + @GetMapping public DefaultResponse findNoticeList( @PageableDefault(page = 0, size = 10) Pageable pageable, @RequestParam String noticeCategory @@ -65,7 +66,7 @@ public DefaultResponse findNoticeList( }) @Parameter(name = "noticeCategory", description = "SYSTEM / SERVICE") @SwaggerPageable - @GetMapping("/notices/search") + @GetMapping("/search") public DefaultResponse findNoticeListByKeyword( @PageableDefault(page = 0, size = 10) Pageable pageable, @RequestParam String keyword, @@ -91,7 +92,7 @@ public DefaultResponse findNoticeListByKeyword( - NOTICE404_2 : 해당하는 공지사항을 찾을 수 없습니다. """) }) - @GetMapping("/notices/{noticeId}") + @GetMapping("/{noticeId}") public DefaultResponse findNoticeDetail( @PathVariable("noticeId") Long noticeId, @AuthenticatedMember Member member @@ -102,7 +103,7 @@ public DefaultResponse findNoticeDetail( return DefaultResponse.ok(response); } - @Operation(summary = "삭제된 공지사항 전체 조회 API Only Admin by 피우", description = "삭제된 공지사항 상세 조회 API입니다. 어드민만 사용 가능합니다.") + @Operation(summary = "삭제된 공지사항 전체 조회 API by 피우 [Only Admin]", description = "삭제된 공지사항 상세 조회 API입니다. 어드민만 사용 가능합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공입니다."), @ApiResponse(responseCode = "403", @@ -117,7 +118,7 @@ public DefaultResponse findNoticeDetail( }) @Parameter(name = "noticeCategory", description = "SYSTEM / SERVICE") @SwaggerPageable - @GetMapping("/admin/notices/trash") + @GetMapping("/trash") public DefaultResponse findTrashNoticeList( @PageableDefault(page = 0, size = 10) Pageable pageable, @RequestParam String noticeCategory diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/Notice.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/Notice.java index e343aa7..026b67f 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/Notice.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/Notice.java @@ -25,7 +25,7 @@ public class Notice extends BaseEntity { @Column(name = "title") private String title; - @Column(name = "content") + @Column(name = "content", columnDefinition = "TEXT") private String content; @Enumerated(EnumType.STRING) diff --git a/src/main/java/org/withtime/be/withtimebe/global/annotation/SwaggerPageable.java b/src/main/java/org/withtime/be/withtimebe/global/annotation/SwaggerPageable.java index b14fb22..130955f 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/annotation/SwaggerPageable.java +++ b/src/main/java/org/withtime/be/withtimebe/global/annotation/SwaggerPageable.java @@ -32,4 +32,4 @@ ) }) public @interface SwaggerPageable { -} +} \ No newline at end of file diff --git a/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java b/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java index 05eb654..89b594a 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java @@ -2,8 +2,10 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.withtime.be.withtimebe.domain.faq.converter.FaqCategoryConverter; import org.withtime.be.withtimebe.global.security.annotation.resolver.AuthenticatedMemberResolver; import java.util.List; @@ -18,4 +20,9 @@ public class WebConfig implements WebMvcConfigurer { public void addArgumentResolvers(List resolvers) { resolvers.add(authenticatedMemberResolver); } + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(new FaqCategoryConverter()); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/FaqErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/FaqErrorCode.java new file mode 100644 index 0000000..609ce6a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/FaqErrorCode.java @@ -0,0 +1,30 @@ +package org.withtime.be.withtimebe.global.error.code; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum FaqErrorCode implements BaseErrorCode { + + FAQ_CATEGORY_EMPTY(HttpStatus.BAD_REQUEST, "FAQ400_1", "질문 유형을 입력해주세요."), + + FAQ_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "FAQ404_1", "해당하는 질문 유형을 찾을 수 없습니다."), + FAQ_NOT_FOUND(HttpStatus.NOT_FOUND, "FAQ404_2", "해당하는 질문을 찾을 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public DefaultResponseErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/FaqException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/FaqException.java new file mode 100644 index 0000000..b9b4bd1 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/FaqException.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; + +public class FaqException extends ServerApplicationException { + + public FaqException(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java index e0f9195..adb2c6d 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java @@ -6,6 +6,7 @@ import org.namul.api.payload.writer.FailureResponseWriter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -19,6 +20,8 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -45,17 +48,30 @@ public class SecurityConfig { private String[] allowUrl = { API_PREFIX + "/auth/**", API_PREFIX + "/notices/**", + API_PREFIX + "/faqs/**", "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**" }; + private RequestMatcher[] admin = { + requestMatcher(HttpMethod.GET, API_PREFIX + "/notices/trash"), + requestMatcher(HttpMethod.POST, API_PREFIX + "/notices/**"), + requestMatcher(HttpMethod.PUT, API_PREFIX + "/notices/**"), + requestMatcher(HttpMethod.PATCH, API_PREFIX + "/notices/**"), + requestMatcher(HttpMethod.DELETE, API_PREFIX + "/notices/**"), + + requestMatcher(HttpMethod.POST, API_PREFIX + "/faqs/**"), + requestMatcher(HttpMethod.PUT, API_PREFIX + "/faqs/**"), + requestMatcher(HttpMethod.DELETE, API_PREFIX + "/faqs/**"), + }; + @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(request -> request + .requestMatchers(admin).hasRole("ADMIN") .requestMatchers(allowUrl).permitAll() - .requestMatchers(API_PREFIX + "/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jsonLoginFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class) @@ -120,4 +136,8 @@ private CorsConfigurationSource corsConfigurationSource() { source.registerCorsConfiguration("/**", configuration); return source; } + + private RequestMatcher requestMatcher(HttpMethod method, String url) { + return PathPatternRequestMatcher.withDefaults().matcher(method, url); + } }