diff --git a/backend/src/main/java/codezap/category/controller/CategoryController.java b/backend/src/main/java/codezap/category/controller/CategoryController.java new file mode 100644 index 000000000..d5e3193e6 --- /dev/null +++ b/backend/src/main/java/codezap/category/controller/CategoryController.java @@ -0,0 +1,65 @@ +package codezap.category.controller; + +import java.net.URI; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.dto.request.UpdateCategoryRequest; +import codezap.category.dto.response.FindAllCategoriesResponse; +import codezap.category.service.CategoryService; +import codezap.global.validation.ValidationSequence; +import codezap.member.configuration.BasicAuthentication; +import codezap.member.dto.MemberDto; + +@RestController +@RequestMapping("/categories") +public class CategoryController implements SpringDocCategoryController { + + private final CategoryService categoryService; + + public CategoryController(CategoryService categoryService) { + this.categoryService = categoryService; + } + + @PostMapping + public ResponseEntity createCategory( + @Validated(ValidationSequence.class) @RequestBody CreateCategoryRequest createCategoryRequest, + @BasicAuthentication MemberDto memberDto + ) { + Long createdCategoryId = categoryService.create(createCategoryRequest, memberDto); + return ResponseEntity.created(URI.create("/categories/" + createdCategoryId)) + .build(); + } + + @GetMapping + public ResponseEntity getCategories(@BasicAuthentication MemberDto memberDto) { + return ResponseEntity.ok(categoryService.findAllByMember(memberDto)); + } + + @PutMapping("/{id}") + public ResponseEntity updateCategory( + @PathVariable Long id, + @Validated(ValidationSequence.class) @RequestBody UpdateCategoryRequest updateCategoryRequest, + @BasicAuthentication MemberDto memberDto + ) { + categoryService.update(id, updateCategoryRequest, memberDto); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCategory(@PathVariable Long id, @BasicAuthentication MemberDto memberDto) { + categoryService.deleteById(id, memberDto); + return ResponseEntity.noContent() + .build(); + } +} diff --git a/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java b/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java new file mode 100644 index 000000000..6c8f552b4 --- /dev/null +++ b/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java @@ -0,0 +1,57 @@ +package codezap.category.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.dto.request.UpdateCategoryRequest; +import codezap.category.dto.response.FindAllCategoriesResponse; +import codezap.global.swagger.error.ApiErrorResponse; +import codezap.global.swagger.error.ErrorCase; +import codezap.member.dto.MemberDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "카테고리 CRUD API", description = "카테고리 생성, 목록 조회, 삭제, 수정 API") +public interface SpringDocCategoryController { + + @Operation(summary = "카테고리 생성", description = """ + 새로운 카테고리를 생성합니다. \n + 새로운 카테고리의 이름이 필요합니다. \n + """) + @ApiResponse(responseCode = "201", description = "카테고리 생성 성공", headers = { + @Header(name = "생성된 카테고리의 API 경로", example = "/categories/1")}) + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories", errorCases = { + @ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "카테고리 이름이 null 입니다."), + @ErrorCase(description = "카테고리 이름이 255자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 255자까지 입력 가능합니다."), + @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다.") + }) + ResponseEntity createCategory(CreateCategoryRequest createCategoryRequest, MemberDto memberDto); + + @Operation(summary = "카테고리 목록 조회", description = "생성된 모든 카테고리를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = {@Content(schema = @Schema(implementation = FindAllCategoriesResponse.class))}) + ResponseEntity getCategories(MemberDto memberDto); + + @Operation(summary = "카테고리 수정", description = "해당하는 식별자의 카테고리를 수정합니다.") + @ApiResponse(responseCode = "200", description = "카테고리 수정 성공") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = { + @ErrorCase(description = "해당하는 id 값인 카테고리가 없는 경우", + exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."), + @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", + exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다.") + }) + ResponseEntity updateCategory(Long id, UpdateCategoryRequest updateCategoryRequest, MemberDto memberDto); + + @Operation(summary = "카테고리 삭제", description = "해당하는 식별자의 카테고리를 삭제합니다.") + @ApiResponse(responseCode = "204", description = "카테고리 삭제 성공") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = { + @ErrorCase(description = "삭제하려는 카테고리에 템플릿이 존재하는 경우", + exampleMessage = "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."), + }) + ResponseEntity deleteCategory(Long id, MemberDto memberDto); +} diff --git a/backend/src/main/java/codezap/category/domain/Category.java b/backend/src/main/java/codezap/category/domain/Category.java new file mode 100644 index 000000000..818b99453 --- /dev/null +++ b/backend/src/main/java/codezap/category/domain/Category.java @@ -0,0 +1,62 @@ +package codezap.category.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import codezap.global.auditing.BaseTimeEntity; +import codezap.member.domain.Member; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Table( + uniqueConstraints={ + @UniqueConstraint( + name="name_with_member", + columnNames={"member_id", "name"} + ) + } +) +public class Category extends BaseTimeEntity { + + private static final String DEFAULT_CATEGORY_NAME = "카테고리 없음"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private Member member; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Boolean isDefault; + + public Category(String name, Member member) { + this.name = name; + this.member = member; + this.isDefault = false; + } + + public static Category createDefaultCategory(Member member) { + return new Category(null, member, DEFAULT_CATEGORY_NAME, true); + } + + public void updateName(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java b/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java new file mode 100644 index 000000000..9587c9d04 --- /dev/null +++ b/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java @@ -0,0 +1,16 @@ +package codezap.category.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateCategoryRequest( + @Schema(description = "카테고리 이름", example = "Spring") + @NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "카테고리 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) + String name +) { +} diff --git a/backend/src/main/java/codezap/category/dto/request/UpdateCategoryRequest.java b/backend/src/main/java/codezap/category/dto/request/UpdateCategoryRequest.java new file mode 100644 index 000000000..52362219b --- /dev/null +++ b/backend/src/main/java/codezap/category/dto/request/UpdateCategoryRequest.java @@ -0,0 +1,16 @@ +package codezap.category.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import io.swagger.v3.oas.annotations.media.Schema; + +public record UpdateCategoryRequest( + @Schema(description = "카테고리 이름", example = "Spring") + @NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "카테고리 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) + String name +) { +} diff --git a/backend/src/main/java/codezap/category/dto/response/FindAllCategoriesResponse.java b/backend/src/main/java/codezap/category/dto/response/FindAllCategoriesResponse.java new file mode 100644 index 000000000..bac6dccf6 --- /dev/null +++ b/backend/src/main/java/codezap/category/dto/response/FindAllCategoriesResponse.java @@ -0,0 +1,19 @@ +package codezap.category.dto.response; + +import java.util.List; + +import codezap.category.domain.Category; +import io.swagger.v3.oas.annotations.media.Schema; + +public record FindAllCategoriesResponse( + @Schema(description = "카테고리 목록") + List categories +) { + public static FindAllCategoriesResponse from(List categories) { + return new FindAllCategoriesResponse( + categories.stream() + .map(FindCategoryResponse::from) + .toList() + ); + } +} diff --git a/backend/src/main/java/codezap/category/dto/response/FindCategoryResponse.java b/backend/src/main/java/codezap/category/dto/response/FindCategoryResponse.java new file mode 100644 index 000000000..699fda8d6 --- /dev/null +++ b/backend/src/main/java/codezap/category/dto/response/FindCategoryResponse.java @@ -0,0 +1,15 @@ +package codezap.category.dto.response; + +import codezap.category.domain.Category; +import io.swagger.v3.oas.annotations.media.Schema; + +public record FindCategoryResponse( + @Schema(description = "카테고리 식별자", example = "1") + Long id, + @Schema(description = "카테고리 이름", example = "Spring") + String name +) { + public static FindCategoryResponse from(Category category) { + return new FindCategoryResponse(category.getId(), category.getName()); + } +} diff --git a/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java b/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java new file mode 100644 index 000000000..b72a37878 --- /dev/null +++ b/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java @@ -0,0 +1,23 @@ +package codezap.category.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.http.HttpStatus; + +import codezap.category.domain.Category; +import codezap.global.exception.CodeZapException; +import codezap.member.domain.Member; + +@SuppressWarnings("unused") +public interface CategoryJpaRepository extends CategoryRepository, JpaRepository { + + default Category fetchById(Long id) { + return findById(id).orElseThrow( + () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 카테고리가 존재하지 않습니다.")); + } + + List findAllByMember(Member member); + + boolean existsByNameAndMember(String categoryName, Member member); +} diff --git a/backend/src/main/java/codezap/category/repository/CategoryRepository.java b/backend/src/main/java/codezap/category/repository/CategoryRepository.java new file mode 100644 index 000000000..ffeef7949 --- /dev/null +++ b/backend/src/main/java/codezap/category/repository/CategoryRepository.java @@ -0,0 +1,21 @@ +package codezap.category.repository; + +import java.util.List; + +import codezap.category.domain.Category; +import codezap.member.domain.Member; + +public interface CategoryRepository { + + Category fetchById(Long id); + + List findAllByMember(Member member); + + List findAll(); + + boolean existsByNameAndMember(String categoryName, Member member); + + Category save(Category category); + + void deleteById(Long id); +} diff --git a/backend/src/main/java/codezap/category/service/CategoryService.java b/backend/src/main/java/codezap/category/service/CategoryService.java new file mode 100644 index 000000000..30318580d --- /dev/null +++ b/backend/src/main/java/codezap/category/service/CategoryService.java @@ -0,0 +1,86 @@ +package codezap.category.service; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import codezap.category.domain.Category; +import codezap.category.dto.request.CreateCategoryRequest; +import codezap.category.dto.request.UpdateCategoryRequest; +import codezap.category.dto.response.FindAllCategoriesResponse; +import codezap.category.repository.CategoryRepository; +import codezap.global.exception.CodeZapException; +import codezap.member.domain.Member; +import codezap.member.dto.MemberDto; +import codezap.member.repository.MemberJpaRepository; +import codezap.member.repository.MemberRepository; +import codezap.template.repository.TemplateRepository; + +@Service +public class CategoryService { + + private final CategoryRepository categoryRepository; + private final TemplateRepository templateRepository; + private final MemberRepository memberJpaRepository; + + public CategoryService(CategoryRepository categoryRepository, TemplateRepository templateRepository, + MemberJpaRepository memberJpaRepository + ) { + this.categoryRepository = categoryRepository; + this.templateRepository = templateRepository; + this.memberJpaRepository = memberJpaRepository; + } + + @Transactional + public Long create(CreateCategoryRequest createCategoryRequest, MemberDto memberDto) { + String categoryName = createCategoryRequest.name(); + Member member = memberJpaRepository.fetchById(memberDto.id()); + validateDuplicatedCategory(categoryName, member); + Category category = new Category(categoryName, member); + return categoryRepository.save(category).getId(); + } + + public FindAllCategoriesResponse findAllByMember(MemberDto memberDto) { + Member member = memberJpaRepository.fetchById(memberDto.id()); + return FindAllCategoriesResponse.from(categoryRepository.findAllByMember(member)); + } + + public FindAllCategoriesResponse findAll() { + return FindAllCategoriesResponse.from(categoryRepository.findAll()); + } + + @Transactional + public void update(Long id, UpdateCategoryRequest updateCategoryRequest, MemberDto memberDto) { + Member member = memberJpaRepository.fetchById(memberDto.id()); + validateDuplicatedCategory(updateCategoryRequest.name(), member); + Category category = categoryRepository.fetchById(id); + validateAuthorizeMember(category, member); + category.updateName(updateCategoryRequest.name()); + } + + private void validateDuplicatedCategory(String categoryName, Member member) { + if (categoryRepository.existsByNameAndMember(categoryName, member)) { + throw new CodeZapException(HttpStatus.CONFLICT, "이름이 " + categoryName + "인 카테고리가 이미 존재합니다."); + } + } + + public void deleteById(Long id, MemberDto memberDto) { + Member member = memberJpaRepository.fetchById(memberDto.id()); + Category category = categoryRepository.fetchById(id); + validateAuthorizeMember(category, member); + + if (templateRepository.existsByCategoryId(id)) { + throw new CodeZapException(HttpStatus.BAD_REQUEST, "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."); + } + if (category.getIsDefault()) { + throw new CodeZapException(HttpStatus.BAD_REQUEST, "기본 카테고리는 삭제할 수 없습니다."); + } + categoryRepository.deleteById(id); + } + + private void validateAuthorizeMember(Category category, Member member) { + if (!category.getMember().equals(member)) { + throw new CodeZapException(HttpStatus.UNAUTHORIZED, "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다."); + } + } +} diff --git a/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java b/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java index b69d3863d..3de4b7d9e 100644 --- a/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java +++ b/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java @@ -9,7 +9,9 @@ public class WebCorsConfiguration implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("*") - .allowedMethods("*"); + .allowCredentials(true) + .allowedOriginPatterns("*") + .allowedMethods("*") + .exposedHeaders("*"); } } diff --git a/backend/src/main/java/codezap/global/logger/MethodExecutionTimeAspect.java b/backend/src/main/java/codezap/global/logger/MethodExecutionTimeAspect.java index dbed5d6de..ecc964bb0 100644 --- a/backend/src/main/java/codezap/global/logger/MethodExecutionTimeAspect.java +++ b/backend/src/main/java/codezap/global/logger/MethodExecutionTimeAspect.java @@ -13,8 +13,9 @@ public class MethodExecutionTimeAspect { @Around("execution(* codezap..*(..)) && " + - "!execution(* codezap.global.logger.MethodExecutionTimeAspect(..))" + - "!execution(* codezap.global.exception.*.*(..))") + "!within(codezap.global.logger.*) && " + + "!within(codezap.global.swagger.error.*) && " + + "!within(codezap.global.exception.*)") public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { if (!log.isInfoEnabled()) { return joinPoint.proceed(); diff --git a/backend/src/main/java/codezap/global/logger/RequestResponseLogger.java b/backend/src/main/java/codezap/global/logger/RequestResponseLogger.java index fd400536d..d29952840 100644 --- a/backend/src/main/java/codezap/global/logger/RequestResponseLogger.java +++ b/backend/src/main/java/codezap/global/logger/RequestResponseLogger.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -29,15 +30,35 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(requestWrapper, responseWrapper); long duration = System.currentTimeMillis() - startTime; - String requestBody = new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8); - String responseBody = new String(responseWrapper.getContentAsByteArray(), StandardCharsets.UTF_8); - - log.info("[Request] {}, {}, 요청 바디: {}", request.getMethod(), request.getRequestURI(), requestBody); - log.info("[Response] Status: {}, Duration: {}ms, 응답 바디: {}", response.getStatus(), duration, responseBody); + log.info("[Request] {} {}, 헤더 값: {} \n 요청 바디: {}", request.getMethod(), request.getRequestURI(), + getHeaderAndValue(requestWrapper), getBodyAsUtf8String(requestWrapper.getContentAsByteArray())); + log.info("[Response] Status: {}, Duration: {}ms, 헤더 값: {} \n 응답 바디: {}", response.getStatus(), duration, + getHeaderAndValue(responseWrapper), getBodyAsUtf8String(responseWrapper.getContentAsByteArray())); responseWrapper.copyBodyToResponse(); } + private String getBodyAsUtf8String(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + private String getHeaderAndValue(ContentCachingRequestWrapper requestWrapper) { + StringBuilder headerAndValue = new StringBuilder(); + requestWrapper.getHeaderNames().asIterator().forEachRemaining(headerName -> { + String headerValue = requestWrapper.getHeader(headerName); + headerAndValue.append(headerName).append(" : ").append(headerValue).append("\n"); + }); + + return headerAndValue.toString(); + } + + private String getHeaderAndValue(ContentCachingResponseWrapper requestWrapper) { + return requestWrapper.getHeaderNames().stream().map(headerName -> { + String headerValue = requestWrapper.getHeader(headerName); + return headerName + " : " + headerValue; + }).collect(Collectors.joining("\n")); + } + @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); diff --git a/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponse.java b/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponse.java index 7af9ad4e4..30db818fa 100644 --- a/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponse.java +++ b/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponse.java @@ -2,6 +2,7 @@ import static java.lang.annotation.ElementType.METHOD; +import java.lang.annotation.Repeatable; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @@ -18,6 +19,7 @@ @Target(value = METHOD) @Retention(RetentionPolicy.RUNTIME) +@Repeatable(ApiErrorResponses.class) public @interface ApiErrorResponse { String type() default "about:blank"; diff --git a/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponses.java b/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponses.java new file mode 100644 index 000000000..2589cf96d --- /dev/null +++ b/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponses.java @@ -0,0 +1,12 @@ +package codezap.global.swagger.error; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorResponses { + ApiErrorResponse[] value(); +} diff --git a/backend/src/main/java/codezap/global/validation/ValidationGroups.java b/backend/src/main/java/codezap/global/validation/ValidationGroups.java new file mode 100644 index 000000000..327e62fbb --- /dev/null +++ b/backend/src/main/java/codezap/global/validation/ValidationGroups.java @@ -0,0 +1,11 @@ +package codezap.global.validation; + +public class ValidationGroups { + public interface NotNullGroup {} + + public interface SnippetOrdinalGroup {} + + public interface SnippetCountGroup {} + + public interface SizeCheckGroup {} +} diff --git a/backend/src/main/java/codezap/global/validation/ValidationSequence.java b/backend/src/main/java/codezap/global/validation/ValidationSequence.java new file mode 100644 index 000000000..c2da469db --- /dev/null +++ b/backend/src/main/java/codezap/global/validation/ValidationSequence.java @@ -0,0 +1,16 @@ +package codezap.global.validation; + +import jakarta.validation.GroupSequence; + +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import codezap.global.validation.ValidationGroups.SnippetCountGroup; +import codezap.global.validation.ValidationGroups.SnippetOrdinalGroup; + +@GroupSequence({ + NotNullGroup.class, + SizeCheckGroup.class, + SnippetCountGroup.class, + SnippetOrdinalGroup.class}) +public interface ValidationSequence { +} diff --git a/backend/src/main/java/codezap/member/configuration/AuthArgumentResolver.java b/backend/src/main/java/codezap/member/configuration/AuthArgumentResolver.java new file mode 100644 index 000000000..8803459c2 --- /dev/null +++ b/backend/src/main/java/codezap/member/configuration/AuthArgumentResolver.java @@ -0,0 +1,34 @@ +package codezap.member.configuration; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import codezap.member.service.AuthService; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(BasicAuthentication.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + return authService.authorizeByCookie(request.getCookies()); + } +} diff --git a/backend/src/main/java/codezap/member/configuration/AuthWebConfiguration.java b/backend/src/main/java/codezap/member/configuration/AuthWebConfiguration.java new file mode 100644 index 000000000..e54e93d35 --- /dev/null +++ b/backend/src/main/java/codezap/member/configuration/AuthWebConfiguration.java @@ -0,0 +1,22 @@ +package codezap.member.configuration; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import codezap.member.service.AuthService; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class AuthWebConfiguration implements WebMvcConfigurer { + + private final AuthService authService; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new AuthArgumentResolver(authService)); + } +} diff --git a/backend/src/main/java/codezap/member/configuration/BasicAuthentication.java b/backend/src/main/java/codezap/member/configuration/BasicAuthentication.java new file mode 100644 index 000000000..184a1eaf5 --- /dev/null +++ b/backend/src/main/java/codezap/member/configuration/BasicAuthentication.java @@ -0,0 +1,11 @@ +package codezap.member.configuration; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface BasicAuthentication { +} diff --git a/backend/src/main/java/codezap/member/controller/MemberController.java b/backend/src/main/java/codezap/member/controller/MemberController.java new file mode 100644 index 000000000..393297b3d --- /dev/null +++ b/backend/src/main/java/codezap/member/controller/MemberController.java @@ -0,0 +1,81 @@ +package codezap.member.controller; + +import java.nio.charset.StandardCharsets; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import codezap.member.dto.LoginRequest; +import codezap.member.dto.MemberDto; +import codezap.member.dto.SignupRequest; +import codezap.member.service.MemberService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class MemberController implements SpringDocMemberController { + + private final MemberService memberService; + + @PostMapping("/signup") + @ResponseStatus(HttpStatus.CREATED) + public void signup(@Valid @RequestBody SignupRequest request) { + memberService.signup(request); + } + + @GetMapping("/check-email") + @ResponseStatus(HttpStatus.OK) + public void checkUniqueEmail(@RequestParam String email) { + memberService.assertUniqueEmail(email); + } + + @GetMapping("/check-username") + @ResponseStatus(HttpStatus.OK) + public void checkUniqueUsername(@RequestParam String username) { + memberService.assertUniqueUsername(username); + } + + @PostMapping("/login") + @ResponseStatus(HttpStatus.OK) + public void login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) { + MemberDto member = memberService.login(request); + String basicAuth = HttpHeaders.encodeBasicAuth(member.email(), member.password(), StandardCharsets.UTF_8); + ResponseCookie cookie = ResponseCookie.from(HttpHeaders.AUTHORIZATION, basicAuth) + .maxAge(-1) + .path("/") + .secure(true) + .httpOnly(true) + .build(); + response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + @GetMapping("/login/check") + @ResponseStatus(HttpStatus.OK) + public void checkLogin(HttpServletRequest request) { + memberService.checkLogin(request.getCookies()); + } + + + @PostMapping("/logout") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void logout(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from(HttpHeaders.AUTHORIZATION, "") + .maxAge(0) + .path("/") + .secure(true) + .httpOnly(true) + .build(); + response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} diff --git a/backend/src/main/java/codezap/member/controller/SpringDocMemberController.java b/backend/src/main/java/codezap/member/controller/SpringDocMemberController.java new file mode 100644 index 000000000..8d5f4a06a --- /dev/null +++ b/backend/src/main/java/codezap/member/controller/SpringDocMemberController.java @@ -0,0 +1,343 @@ +package codezap.member.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import codezap.global.swagger.error.ProblemDetailSchema; +import codezap.member.dto.LoginRequest; +import codezap.member.dto.SignupRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "인증 및 인가 API", description = "회원가입 API") +public interface SpringDocMemberController { + + @Operation(summary = "회원가입") + @ApiResponse( + responseCode = "201", + description = "회원가입 성공" + ) + @ApiResponse( + responseCode = "400", + description = "요청 형식 오류", + content = @Content( + schema = @Schema(implementation = ProblemDetailSchema.class), + examples = { + @ExampleObject(name = "이메일 입력 없음", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "이메일이 입력되지 않았습니다.", + "instance": "/signup" + } + """ + ), + @ExampleObject(name = "이메일 형식 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "이메일 형식이 아닙니다.", + "instance": "/signup" + } + """ + ), + @ExampleObject(name = "이메일 글자수 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "이메일은 255자 이하로 입력해주세요.", + "instance": "/signup" + } + """ + ), + @ExampleObject(name = "비밀번호 입력 없음", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "비밀번호가 입력되지 않았습니다.", + "instance": "/signup" + } + """ + ), + @ExampleObject(name = "비밀번호 형식 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "영어와 숫자를 포함해야합니다.", + "instance": "/signup" + } + """ + ), + @ExampleObject(name = "비밀번호 글자수 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "비밀번호는 8~16자 사이로 입력해주세요.", + "instance": "/signup" + } + """ + ), + @ExampleObject(name = "사용자명 입력 없음", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "사용자명이 입력되지 않았습니다.", + "instance": "/signup" + } + """ + ), + @ExampleObject(name = "사용자명 글자수 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "사용자명이 입력되지 않았습니다.", + "instance": "/signup" + } + """ + ), + } + ) + ) + @ApiResponse( + responseCode = "409", + description = "이메일 또는 사용자명 중복", + content = @Content( + schema = @Schema(implementation = ProblemDetailSchema.class), + examples = { + @ExampleObject(name = "이메일 중복", value = """ + { + "type": "about:blank", + "title": "CONFLICT", + "status": 409, + "detail": "이메일이 이미 존재합니다.", + "instance": "/signup" + } + """ + ), + @ExampleObject(name = "사용자명 중복", value = """ + { + "type": "about:blank", + "title": "CONFLICT", + "status": 409, + "detail": "사용자명이 이미 존재합니다.", + "instance": "/signup" + } + """ + ) + } + ) + ) + void signup(@RequestBody SignupRequest request); + + @Operation(summary = "이메일 중복 확인") + @ApiResponse(responseCode = "200", description = "사용가능한 이메일") + @ApiResponse( + responseCode = "409", + description = "중복된 이메일", + content = @Content( + schema = @Schema(implementation = ProblemDetailSchema.class), + examples = { + @ExampleObject(name = "이메일 중복", value = """ + { + "type": "about:blank", + "title": "CONFLICT", + "status": 409, + "detail": "이메일이 이미 존재합니다.", + "instance": "/check-email" + } + """ + ) + } + ) + ) + void checkUniqueEmail(@RequestParam String email); + + @Operation(summary = "사용자명 중복 확인") + @ApiResponse(responseCode = "200", description = "사용가능한 사용자명") + @ApiResponse( + responseCode = "409", + description = "중복된 사용자명", + content = @Content( + schema = @Schema(implementation = ProblemDetailSchema.class), + examples = { + @ExampleObject(name = "사용자명 중복", value = """ + { + "type": "about:blank", + "title": "CONFLICT", + "status": 409, + "detail": "사용자명이 이미 존재합니다.", + "instance": "/check-username" + } + """ + ) + } + ) + ) + void checkUniqueUsername(@RequestParam String username); + + @Operation(summary = "이메일 로그인") + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + headers = {@Header(name = "Set-Cookie", description = "base64(${email}:${password}); path=\"/\"; HttpOnly; Secure;")} + ) + @ApiResponse( + responseCode = "400", + description = "요청 형식 오류", + content = @Content( + schema = @Schema(implementation = ProblemDetailSchema.class), + examples = { + @ExampleObject(name = "이메일 입력 없음", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "이메일이 입력되지 않았습니다.", + "instance": "/login" + } + """ + ), + @ExampleObject(name = "이메일 형식 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "이메일 형식이 아닙니다.", + "instance": "/login" + } + """ + ), + @ExampleObject(name = "이메일 글자수 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "이메일은 255자 이하로 입력해주세요.", + "instance": "/login" + } + """ + ), + @ExampleObject(name = "비밀번호 입력 없음", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "비밀번호가 입력되지 않았습니다.", + "instance": "/login" + } + """ + ), + @ExampleObject(name = "비밀번호 형식 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "영어와 숫자를 포함해야합니다.", + "instance": "/login" + } + """ + ), + @ExampleObject(name = "비밀번호 글자수 오류", value = """ + { + "type": "about:blank", + "title": "BAD_REQUEST", + "status": 400, + "detail": "비밀번호는 8~16자 사이로 입력해주세요.", + "instance": "/login" + } + """ + ) + } + ) + ) + @ApiResponse( + responseCode = "401", + description = "요청 형식 오류", + content = @Content( + schema = @Schema(implementation = ProblemDetailSchema.class), + examples = { + @ExampleObject(name = "이메일 불일치", value = """ + { + "type": "about:blank", + "title": "UNAUTHORIZED", + "status": 401, + "detail": "이메일이 입력되지 않았습니다.", + "instance": "/login" + } + """ + ), + @ExampleObject(name = "비밀번호 불일치", value = """ + { + "type": "about:blank", + "title": "UNAUTHORIZED", + "status": 401, + "detail": "비밀번호가 입력되지 않았습니다.", + "instance": "/login" + } + """ + ), + } + ) + ) + void login(LoginRequest request, HttpServletResponse response); + + @Operation(summary = "이메일 로그인 후 쿠키 인증") + @ApiResponse(responseCode = "200", description = "쿠키 인증 성공") + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content( + schema = @Schema(implementation = ProblemDetailSchema.class), + examples = { + @ExampleObject(name = "쿠키 값 오류", value = """ + { + "type": "about:blank", + "title": "UNAUTHORIZED", + "status": 401, + "detail": "인증에 실패했습니다.", + "instance": "/login/check" + } + """) + } + ) + ) + void checkLogin(HttpServletRequest request); + + @Operation(summary = "로그아웃") + @ApiResponse(responseCode = "204", description = "인증 성공") + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content( + schema = @Schema(implementation = ProblemDetailSchema.class), + examples = { + @ExampleObject(name = "쿠키 값 오류", value = """ + { + "type": "about:blank", + "title": "UNAUTHORIZED", + "status": 401, + "detail": "인증에 실패했습니다.", + "instance": "/login/check" + } + """) + } + ) + ) + void logout(HttpServletResponse response); +} diff --git a/backend/src/main/java/codezap/member/domain/Member.java b/backend/src/main/java/codezap/member/domain/Member.java index 711092d33..dd144d2c0 100644 --- a/backend/src/main/java/codezap/member/domain/Member.java +++ b/backend/src/main/java/codezap/member/domain/Member.java @@ -1,18 +1,26 @@ package codezap.member.domain; +import java.util.Objects; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor @Getter -@NoArgsConstructor -public class Member { +@EqualsAndHashCode(callSuper = false) +public class Member extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -26,4 +34,12 @@ public class Member { @Column(unique = true, nullable = false) private String username; + + public Member(String email, String password, String username) { + this(null, email, password, username); + } + + public boolean matchPassword(String password) { + return Objects.equals(this.password, password); + } } diff --git a/backend/src/main/java/codezap/member/dto/LoginRequest.java b/backend/src/main/java/codezap/member/dto/LoginRequest.java new file mode 100644 index 000000000..cb406f64e --- /dev/null +++ b/backend/src/main/java/codezap/member/dto/LoginRequest.java @@ -0,0 +1,23 @@ +package codezap.member.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoginRequest( + @Schema(description = "이메일", example = "code@zap.com") + @Email(message = "이메일 형식이 아닙니다.") + @NotBlank(message = "이메일이 입력되지 않았습니다.") + @Size(max = 255, message = "이메일은 255자 이하로 입력해주세요.") + String email, + + @Schema(description = "비밀번호. 영어와 숫자를 반드시 포함해야 합니다.", example = "password1234") + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d).{8,}$", message = "영어와 숫자를 포함해야합니다.") + @NotBlank(message = "비밀번호가 입력되지 않았습니다.") + @Size(min = 8, max = 255, message = "비밀번호는 8~16자 사이로 입력해주세요.") + String password +) { +} diff --git a/backend/src/main/java/codezap/member/dto/MemberDto.java b/backend/src/main/java/codezap/member/dto/MemberDto.java new file mode 100644 index 000000000..9b30ea229 --- /dev/null +++ b/backend/src/main/java/codezap/member/dto/MemberDto.java @@ -0,0 +1,14 @@ +package codezap.member.dto; + +import codezap.member.domain.Member; + +public record MemberDto( + Long id, + String email, + String password, + String username +) { + public static MemberDto from(Member member) { + return new MemberDto(member.getId(), member.getEmail(), member.getPassword(), member.getUsername()); + } +} diff --git a/backend/src/main/java/codezap/member/dto/SignupRequest.java b/backend/src/main/java/codezap/member/dto/SignupRequest.java new file mode 100644 index 000000000..e98848fa1 --- /dev/null +++ b/backend/src/main/java/codezap/member/dto/SignupRequest.java @@ -0,0 +1,28 @@ +package codezap.member.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record SignupRequest( + @Schema(description = "이메일", example = "code@zap.com") + @Email(message = "이메일 형식이 아닙니다.") + @NotBlank(message = "이메일이 입력되지 않았습니다.") + @Size(max = 255, message = "이메일은 255자 이하로 입력해주세요.") + String email, + + @Schema(description = "비밀번호. 영어와 숫자를 반드시 포함해야 합니다.", example = "password1234") + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d).{8,}$", message = "영어와 숫자를 포함해야합니다.") + @NotBlank(message = "비밀번호가 입력되지 않았습니다.") + @Size(min = 8, max = 255, message = "비밀번호는 8~16자 사이로 입력해주세요.") + String password, + + @Schema(description = "사용자명", example = "zappy") + @NotBlank(message = "사용자명이 입력되지 않았습니다.") + @Size(min = 2, max = 255, message = "사용자명은 2~255자 사이로 입력해주세요.") + String username +) { +} diff --git a/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java b/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java new file mode 100644 index 000000000..e7c80125c --- /dev/null +++ b/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java @@ -0,0 +1,24 @@ +package codezap.member.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.http.HttpStatus; + +import codezap.global.exception.CodeZapException; +import codezap.member.domain.Member; + +@SuppressWarnings("unused") +public interface MemberJpaRepository extends MemberRepository, JpaRepository { + + default Member fetchById(Long id) { + return findById(id).orElseThrow( + () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 멤버가 존재하지 않습니다.")); + } + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + Optional findByEmail(String email); +} diff --git a/backend/src/main/java/codezap/member/repository/MemberRepository.java b/backend/src/main/java/codezap/member/repository/MemberRepository.java index b109ac982..b9fcadb81 100644 --- a/backend/src/main/java/codezap/member/repository/MemberRepository.java +++ b/backend/src/main/java/codezap/member/repository/MemberRepository.java @@ -1,8 +1,20 @@ package codezap.member.repository; -import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; import codezap.member.domain.Member; -public interface MemberRepository extends JpaRepository { +public interface MemberRepository { + + Member fetchById(Long id); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + boolean existsById(Long id); + + Optional findByEmail(String email); + + Member save(Member member); } diff --git a/backend/src/main/java/codezap/member/service/AuthService.java b/backend/src/main/java/codezap/member/service/AuthService.java new file mode 100644 index 000000000..61d623d38 --- /dev/null +++ b/backend/src/main/java/codezap/member/service/AuthService.java @@ -0,0 +1,68 @@ +package codezap.member.service; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; + +import jakarta.servlet.http.Cookie; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import codezap.global.exception.CodeZapException; +import codezap.member.domain.Member; +import codezap.member.dto.MemberDto; +import codezap.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private static final String BASIC_AUTH_REGEX = ".+:.+"; + private static final String BASIC_AUTH_DELIMITER = ":"; + + private final MemberRepository memberRepository; + + public MemberDto authorizeByEmailAndPassword(String email, String password) { + Member member = memberRepository.findByEmail(email).orElseThrow(this::throwUnauthorized); + if (!member.matchPassword(password)) { + throwUnauthorized(); + } + return MemberDto.from(member); + } + + public MemberDto authorizeByCookie(Cookie[] cookies) { + String authHeaderValue = getAuthCookieValue(cookies); + String[] credentials = decodeCredentials(authHeaderValue); + String email = credentials[0]; + String password = credentials[1]; + return authorizeByEmailAndPassword(email, password); + } + + private String getAuthCookieValue(Cookie[] cookies) { + if (cookies == null || cookies.length == 0) { + throwUnauthorized(); + } + return Arrays.stream(cookies) + .filter(cookie -> Objects.equals(cookie.getName(), HttpHeaders.AUTHORIZATION)) + .findFirst() + .map(Cookie::getValue) + .orElseThrow(this::throwUnauthorized); + } + + private String[] decodeCredentials(String encodedCredentials) { + byte[] decodedBytes = Base64.getDecoder().decode(encodedCredentials.getBytes(StandardCharsets.UTF_8)); + String decodedString = new String(decodedBytes); + if (!decodedString.matches(BASIC_AUTH_REGEX)) { + throwUnauthorized(); + } + return decodedString.split(BASIC_AUTH_DELIMITER); + } + + private CodeZapException throwUnauthorized() { + throw new CodeZapException(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."); + } +} diff --git a/backend/src/main/java/codezap/member/service/MemberService.java b/backend/src/main/java/codezap/member/service/MemberService.java new file mode 100644 index 000000000..83a358a26 --- /dev/null +++ b/backend/src/main/java/codezap/member/service/MemberService.java @@ -0,0 +1,63 @@ +package codezap.member.service; + +import jakarta.servlet.http.Cookie; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import codezap.category.domain.Category; +import codezap.category.repository.CategoryRepository; +import codezap.global.exception.CodeZapException; +import codezap.member.domain.Member; +import codezap.member.dto.LoginRequest; +import codezap.member.dto.MemberDto; +import codezap.member.dto.SignupRequest; +import codezap.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final AuthService authService; + private final CategoryRepository categoryJpaRepository; + + public Member signup(SignupRequest request) { + assertUniqueEmail(request.email()); + assertUniqueUsername(request.username()); + Member member = new Member(request.email(), request.password(), request.username()); + categoryJpaRepository.save(Category.createDefaultCategory(member)); + return memberRepository.save(member); + } + + public MemberDto login(LoginRequest request) { + return authService.authorizeByEmailAndPassword(request.email(), request.password()); + } + + public void checkLogin(Cookie[] cookies) { + authService.authorizeByCookie(cookies); + } + + public void assertUniqueEmail(String email) { + if (memberRepository.existsByEmail(email)) { + throw new CodeZapException(HttpStatus.CONFLICT, "이메일이 이미 존재합니다."); + } + } + + public void assertUniqueUsername(String username) { + if (memberRepository.existsByUsername(username)) { + throw new CodeZapException(HttpStatus.CONFLICT, "사용자명이 이미 존재합니다."); + } + } + + public void validateMemberId(MemberDto memberDto, Long memberId) { + if (!memberId.equals(memberDto.id())) { + throw new CodeZapException(HttpStatus.UNAUTHORIZED, "다른 사람의 템플릿은 확인할 수 없습니다."); + } + + if(!memberRepository.existsById(memberId)) { + throw new CodeZapException(HttpStatus.UNAUTHORIZED, "로그인 정보가 잘못되었습니다."); + } + } +} diff --git a/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java b/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java index aaf176dc1..44745d05a 100644 --- a/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java +++ b/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java @@ -1,14 +1,20 @@ package codezap.template.controller; +import org.springframework.data.domain.Pageable; +import java.util.List; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import codezap.global.swagger.error.ApiErrorResponse; import codezap.global.swagger.error.ErrorCase; +import codezap.member.dto.MemberDto; import codezap.template.dto.request.CreateTemplateRequest; import codezap.template.dto.request.UpdateTemplateRequest; +import codezap.template.dto.response.FindAllMyTemplatesResponse; +import codezap.template.dto.response.ExploreTemplatesResponse; import codezap.template.dto.response.FindAllTemplatesResponse; -import codezap.template.dto.response.FindTemplateByIdResponse; +import codezap.template.dto.response.FindTemplateResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; @@ -21,7 +27,7 @@ public interface SpringDocTemplateController { @Operation(summary = "템플릿 생성", description = """ 새로운 템플릿을 생성합니다. \n - 템플릿의 제목, 썸네일 스니펫의 순서, 스니펫 목록이 필요합니다. \n + 템플릿의 제목, 썸네일 스니펫의 순서, 스니펫 목록, 카테고리 ID, 태그 목록이 필요합니다. \n 스니펫 목록은 파일 이름, 소스 코드, 해당 스니펫의 순서가 필요합니다. \n * 썸네일 스니펫은 1로 고정입니다. (2024.07.15 기준) \n * 모든 스니펫 순서는 1부터 시작합니다. \n @@ -31,34 +37,52 @@ public interface SpringDocTemplateController { @Header(name = "생성된 템플릿의 API 경로", example = "/templates/1")}) @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates", errorCases = { @ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "템플릿 이름 null 입니다."), - @ErrorCase(description = "제목 또는 스니펫 파일명이 255자를 초과한 경우", exampleMessage = "제목은 최대 255자까지 입력 가능합니다."), + @ErrorCase(description = "제목 또는 스니펫 파일 또는 태그 이름이 255자를 초과한 경우", exampleMessage = "제목은 최대 255자까지 입력 가능합니다."), @ErrorCase(description = "썸네일 스니펫의 순서가 1이 아닌 경우", exampleMessage = "썸네일 스니펫의 순서가 잘못되었습니다."), @ErrorCase(description = "스니펫 순서가 잘못된 경우", exampleMessage = "스니펫 순서가 잘못되었습니다."), @ErrorCase(description = "스니펫 내용 65,535 byte를 초과한 경우", exampleMessage = "파일 내용은 최대 65,535 byte까지 입력 가능합니다.") }) - ResponseEntity create(CreateTemplateRequest createTemplateRequest); + ResponseEntity create(CreateTemplateRequest createTemplateRequest, MemberDto memberDto); - @Operation(summary = "템플릿 목록 조회", description = "작성된 모든 템플릿을 조회합니다.") - @ApiResponse(responseCode = "200", description = "조회 성공", - content = {@Content(schema = @Schema(implementation = FindAllTemplatesResponse.class))}) - ResponseEntity getTemplates(); + @Operation(summary = "템플릿 목록 조회", description = """ + 조건에 맞는 모든 템플릿을 조회합니다. + 필터링 조건은 작성자 Id, 카테고리 Id, 태그 목록을 사용할 수 있습니다. + 조회 조건으로 페이지 인덱스, 한 페이지에 들어갈 최대 템플릿의 개수를 변경할 수 있습니다. + 페이지 인덱스는 1, 템플릿 개수는 20개가 기본 값입니다. + """) + @ApiResponse(responseCode = "200", description = "템플릿 단건 조회 성공", + content = {@Content(schema = @Schema(implementation = ExploreTemplatesResponse.class))}) + ResponseEntity getTemplates( + //Long memberId, + Integer pageNumber, + Integer pageSize, + Long categoryId, + List tagNames + ); @Operation(summary = "템플릿 단건 조회", description = "해당하는 식별자의 템플릿을 조회합니다.") @ApiResponse(responseCode = "200", description = "템플릿 단건 조회 성공", - content = {@Content(schema = @Schema(implementation = FindAllTemplatesResponse.class))}) + content = {@Content(schema = @Schema(implementation = ExploreTemplatesResponse.class))}) @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates/1", errorCases = { @ErrorCase(description = "해당하는 id 값인 템플릿이 없는 경우", exampleMessage = "식별자 1에 해당하는 템플릿이 존재하지 않습니다."), }) - ResponseEntity getTemplateById(Long id); + ResponseEntity getTemplateById(Long id, MemberDto memberDto); + + @Operation(summary = "템플릿 토픽 검색", description = "토픽이 포함된 템플릿들을 검색합니다.") + @ApiResponse(responseCode = "200", description = "템플릿 토픽 검색 성공", + content = {@Content(schema = @Schema(implementation = FindAllTemplatesResponse.class))}) + ResponseEntity getMyTemplatesContainTopic( + MemberDto memberDto, Long memberId, String topic, Pageable pageable + ); @Operation(summary = "템플릿 수정", description = "해당하는 식별자의 템플릿을 수정합니다.") @ApiResponse(responseCode = "200", description = "템플릿 수정 성공") @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates/1", errorCases = { @ErrorCase(description = "해당하는 id 값인 템플릿이 없는 경우", exampleMessage = "식별자 1에 해당하는 템플릿이 존재하지 않습니다."), }) - ResponseEntity updateTemplate(Long id, UpdateTemplateRequest updateTemplateRequest); + ResponseEntity updateTemplate(Long id, UpdateTemplateRequest updateTemplateRequest, MemberDto memberDto); @Operation(summary = "템플릿 삭제", description = "해당하는 식별자의 템플릿을 삭제합니다.") @ApiResponse(responseCode = "204", description = "템플릿 삭제 성공") - ResponseEntity deleteTemplate(Long id); + ResponseEntity deleteTemplate(Long id, MemberDto memberDto); } diff --git a/backend/src/main/java/codezap/template/controller/TemplateController.java b/backend/src/main/java/codezap/template/controller/TemplateController.java index 89c93323a..08c94201a 100644 --- a/backend/src/main/java/codezap/template/controller/TemplateController.java +++ b/backend/src/main/java/codezap/template/controller/TemplateController.java @@ -1,22 +1,34 @@ package codezap.template.controller; import java.net.URI; +import java.util.List; -import jakarta.validation.Valid; - +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.apache.commons.lang3.NotImplementedException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.repository.query.Param; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import codezap.global.validation.ValidationSequence; +import codezap.member.configuration.BasicAuthentication; +import codezap.member.dto.MemberDto; import codezap.template.dto.request.CreateTemplateRequest; import codezap.template.dto.request.UpdateTemplateRequest; +import codezap.template.dto.response.FindAllMyTemplatesResponse; +import codezap.template.dto.response.ExploreTemplatesResponse; import codezap.template.dto.response.FindAllTemplatesResponse; -import codezap.template.dto.response.FindTemplateByIdResponse; +import codezap.template.dto.response.FindTemplateResponse; +import codezap.template.service.MyTemplateFacadeService; import codezap.template.service.TemplateService; @RestController @@ -24,39 +36,70 @@ public class TemplateController implements SpringDocTemplateController { private final TemplateService templateService; + private final MyTemplateFacadeService myTemplateFacadeService; - public TemplateController(TemplateService templateService) { + public TemplateController(TemplateService templateService, MyTemplateFacadeService myTemplateFacadeService) { this.templateService = templateService; + this.myTemplateFacadeService = myTemplateFacadeService; } @PostMapping - public ResponseEntity create(@Valid @RequestBody CreateTemplateRequest createTemplateRequest) { - return ResponseEntity.created(URI.create("/templates/" + templateService.create(createTemplateRequest))) + public ResponseEntity create( + @Validated(ValidationSequence.class) @RequestBody CreateTemplateRequest createTemplateRequest, + @BasicAuthentication MemberDto memberDto + ) { + Long createdTemplateId = templateService.createTemplate(createTemplateRequest, memberDto); + return ResponseEntity.created(URI.create("/templates/" + createdTemplateId)) .build(); } @GetMapping - public ResponseEntity getTemplates() { + public ResponseEntity getTemplates( + //@RequestParam Long memberId, + @RequestParam(required = false, defaultValue = "1") Integer pageNumber, + @RequestParam(required = false, defaultValue = "20") Integer pageSize, + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) List tags + ) { + + return ResponseEntity.ok(templateService.findAllBy(PageRequest.of(pageNumber - 1, pageSize), categoryId, tags)); + } + + @GetMapping("/explore") + public ResponseEntity explore() { return ResponseEntity.ok(templateService.findAll()); } @GetMapping("/{id}") - public ResponseEntity getTemplateById(@PathVariable Long id) { - return ResponseEntity.ok(templateService.findById(id)); + public ResponseEntity getTemplateById(@PathVariable Long id, @BasicAuthentication MemberDto memberDto) { + return ResponseEntity.ok(templateService.findByIdAndMember(id, memberDto)); + } + + @GetMapping("/search") + public ResponseEntity getMyTemplatesContainTopic( + @BasicAuthentication MemberDto memberDto, + @RequestParam("memberId") Long memberId, + @RequestParam("topic") String topic, + @PageableDefault Pageable pageable + ) { + FindAllMyTemplatesResponse response = myTemplateFacadeService + .searchMyTemplatesContainTopic(memberDto, memberId, topic, pageable); + return ResponseEntity.ok(response); } @PostMapping("/{id}") public ResponseEntity updateTemplate( @PathVariable Long id, - @Valid @RequestBody UpdateTemplateRequest updateTemplateRequest + @Validated(ValidationSequence.class) @RequestBody UpdateTemplateRequest updateTemplateRequest, + @BasicAuthentication MemberDto memberDto ) { - templateService.update(id, updateTemplateRequest); + templateService.update(id, updateTemplateRequest, memberDto); return ResponseEntity.ok().build(); } @DeleteMapping("/{id}") - public ResponseEntity deleteTemplate(@PathVariable Long id) { - templateService.deleteById(id); + public ResponseEntity deleteTemplate(@PathVariable Long id, @BasicAuthentication MemberDto memberDto) { + templateService.deleteById(id, memberDto); return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/codezap/template/domain/Snippet.java b/backend/src/main/java/codezap/template/domain/Snippet.java index 8cf0291a5..31974afe8 100644 --- a/backend/src/main/java/codezap/template/domain/Snippet.java +++ b/backend/src/main/java/codezap/template/domain/Snippet.java @@ -12,15 +12,16 @@ import jakarta.persistence.ManyToOne; import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@NoArgsConstructor public class Snippet extends BaseTimeEntity { - private static final String CODE_LINE_BREAK = "\n"; + private static final String LINE_BREAK = "\n"; private static final int THUMBNAIL_SNIPPET_LINE_HEIGHT = 10; @Id @@ -47,9 +48,9 @@ public Snippet(Template template, String filename, String content, Integer ordin } public String getThumbnailContent() { - return Arrays.stream(content.split(CODE_LINE_BREAK)) + return Arrays.stream(content.split(LINE_BREAK)) .limit(THUMBNAIL_SNIPPET_LINE_HEIGHT) - .collect(Collectors.joining(CODE_LINE_BREAK)); + .collect(Collectors.joining(LINE_BREAK)); } public void updateSnippet(String filename, String content, Integer ordinal) { diff --git a/backend/src/main/java/codezap/template/domain/Tag.java b/backend/src/main/java/codezap/template/domain/Tag.java new file mode 100644 index 000000000..7d6eb3d51 --- /dev/null +++ b/backend/src/main/java/codezap/template/domain/Tag.java @@ -0,0 +1,29 @@ +package codezap.template.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Tag extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + public Tag(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/codezap/template/domain/Template.java b/backend/src/main/java/codezap/template/domain/Template.java index 1178d79c1..4e7a9c436 100644 --- a/backend/src/main/java/codezap/template/domain/Template.java +++ b/backend/src/main/java/codezap/template/domain/Template.java @@ -2,31 +2,52 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +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 codezap.category.domain.Category; import codezap.global.auditing.BaseTimeEntity; +import codezap.member.domain.Member; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@NoArgsConstructor public class Template extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private Member member; + @Column(nullable = false) private String title; - public Template(String title) { + @Column(columnDefinition = "TEXT") + private String description; + + @ManyToOne(optional = false) + private Category category; + + public Template(Member member, String title, String description, Category category) { + this.member = member; this.title = title; + this.description = description; + this.category = category; } - public void updateTitle(String title) { + public void updateTemplate(String title, String description, Category category) { this.title = title; + this.description = description; + this.category = category; } } diff --git a/backend/src/main/java/codezap/template/domain/TemplateTag.java b/backend/src/main/java/codezap/template/domain/TemplateTag.java new file mode 100644 index 000000000..02a94916d --- /dev/null +++ b/backend/src/main/java/codezap/template/domain/TemplateTag.java @@ -0,0 +1,52 @@ +package codezap.template.domain; + +import java.io.Serializable; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; + +import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class TemplateTag extends BaseTimeEntity { + + @Embeddable + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Getter + @EqualsAndHashCode + private static class TemplateTagId implements Serializable { + private Long templateId; + private Long tagId; + } + + @EmbeddedId + private TemplateTagId id; + + @ManyToOne + @MapsId("templateId") + @JoinColumn(name = "template_id") + private Template template; + + @ManyToOne + @MapsId("tagId") + @JoinColumn(name = "tag_id") + private Tag tag; + + public TemplateTag(Template template, Tag tag) { + this.id = new TemplateTagId(template.getId(), tag.getId()); + this.template = template; + this.tag = tag; + } +} diff --git a/backend/src/main/java/codezap/template/domain/ThumbnailSnippet.java b/backend/src/main/java/codezap/template/domain/ThumbnailSnippet.java index 8c6b5feea..47664f625 100644 --- a/backend/src/main/java/codezap/template/domain/ThumbnailSnippet.java +++ b/backend/src/main/java/codezap/template/domain/ThumbnailSnippet.java @@ -7,12 +7,13 @@ import jakarta.persistence.OneToOne; import codezap.global.auditing.BaseTimeEntity; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@NoArgsConstructor public class ThumbnailSnippet extends BaseTimeEntity { @Id diff --git a/backend/src/main/java/codezap/template/dto/request/CreateSnippetRequest.java b/backend/src/main/java/codezap/template/dto/request/CreateSnippetRequest.java index 34397eda3..cc527635d 100644 --- a/backend/src/main/java/codezap/template/dto/request/CreateSnippetRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/CreateSnippetRequest.java @@ -1,20 +1,23 @@ package codezap.template.dto.request; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import codezap.global.validation.ByteLength; +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; import io.swagger.v3.oas.annotations.media.Schema; public record CreateSnippetRequest( @Schema(description = "파일 이름", example = "Main.java") - @NotNull(message = "파일 이름이 null 입니다.") - @Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.") + @NotBlank(message = "파일 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String filename, @Schema(description = "소스 코드", example = "public class Main { // ...") - @NotNull(message = "파일 내용이 null 입니다.") - @ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.") + @NotBlank(message = "파일 내용이 null 입니다.", groups = NotNullGroup.class) + @ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.", groups = SizeCheckGroup.class) String content, @Schema(description = "스니펫 순서", example = "1") diff --git a/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java b/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java index 9663ba500..dac09639e 100644 --- a/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java @@ -3,22 +3,40 @@ import java.util.List; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import codezap.global.validation.ByteLength; +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; import codezap.template.dto.request.validation.ValidatedSnippetsOrdinalRequest; import io.swagger.v3.oas.annotations.media.Schema; public record CreateTemplateRequest( @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") - @NotNull(message = "템플릿 이름이 null 입니다.") - @Size(max = 255, message = "템플릿 이름은 최대 255자까지 입력 가능합니다.") + @NotBlank(message = "템플릿 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "템플릿 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String title, + @Schema(description = "템플릿 설명", example = "JWT를 사용하여 로그인 기능을 구현함") + @NotNull(message = "템플릿 설명이 null 입니다.", groups = NotNullGroup.class) + @ByteLength(max = 65_535, message = "템플릿 설명은 최대 65,535 Byte까지 입력 가능합니다.", groups = SizeCheckGroup.class) + String description, + @Schema(description = "템플릿의 스니펫 내역") - @NotNull(message = "스니펫 리스트가 null 입니다.") + @NotNull(message = "스니펫 리스트가 null 입니다.", groups = NotNullGroup.class) + @Size(min = 1, message = "스니펫은 최소 1개 입력해야 합니다.", groups = SizeCheckGroup.class) @Valid - List snippets + List snippets, + + @Schema(description = "카테고리 ID", example = "1") + @NotNull(message = "카테고리 id가 null 입니다.") + Long categoryId, + + @Schema(description = "태그 리스트") + @NotNull(message = "태그 리스트가 null 입니다.") + List tags ) implements ValidatedSnippetsOrdinalRequest { @Override public List extractSnippetsOrdinal() { diff --git a/backend/src/main/java/codezap/template/dto/request/UpdateSnippetRequest.java b/backend/src/main/java/codezap/template/dto/request/UpdateSnippetRequest.java index bf669729e..bce59a6d8 100644 --- a/backend/src/main/java/codezap/template/dto/request/UpdateSnippetRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/UpdateSnippetRequest.java @@ -1,9 +1,12 @@ package codezap.template.dto.request; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import codezap.global.validation.ByteLength; +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; import io.swagger.v3.oas.annotations.media.Schema; public record UpdateSnippetRequest( @@ -12,13 +15,13 @@ public record UpdateSnippetRequest( Long id, @Schema(description = "파일 이름", example = "Main.java") - @NotNull(message = "파일 이름이 null 입니다.") - @Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.") + @NotBlank(message = "파일 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String filename, @Schema(description = "소스 코드", example = "public class Main { // ...") - @NotNull(message = "파일 내용이 null 입니다.") - @ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.") + @NotBlank(message = "파일 내용이 null 입니다.", groups = NotNullGroup.class) + @ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.", groups = SizeCheckGroup.class) String content, @Schema(description = "스니펫 순서", example = "1") diff --git a/backend/src/main/java/codezap/template/dto/request/UpdateTemplateRequest.java b/backend/src/main/java/codezap/template/dto/request/UpdateTemplateRequest.java index 06e2cfcaf..e99232cfe 100644 --- a/backend/src/main/java/codezap/template/dto/request/UpdateTemplateRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/UpdateTemplateRequest.java @@ -3,28 +3,51 @@ import java.util.List; import java.util.stream.Stream; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import codezap.global.validation.ByteLength; +import codezap.global.validation.ValidationGroups.NotNullGroup; +import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import codezap.template.dto.request.validation.ValidatedSnippetsCountRequest; import codezap.template.dto.request.validation.ValidatedSnippetsOrdinalRequest; import io.swagger.v3.oas.annotations.media.Schema; public record UpdateTemplateRequest( @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") - @NotNull(message = "템플릿 이름이 null 입니다.") + @NotBlank(message = "템플릿 이름이 null 입니다.", groups = NotNullGroup.class) + @Size(max = 255, message = "템플릿 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String title, + @Schema(description = "템플릿 설명", example = "JWT를 사용하여 로그인 기능을 구현함") + @NotNull(message = "템플릿 설명이 null 입니다.", groups = NotNullGroup.class) + @ByteLength(max = 65_535, message = "템플릿 설명은 최대 65,535 Byte까지 입력 가능합니다.", groups = SizeCheckGroup.class) + String description, + @Schema(description = "새로 추가한 스니펫 내역") - @NotNull(message = "createSnippets 리스트가 null 입니다.") + @NotNull(message = "createSnippets 리스트가 null 입니다.", groups = NotNullGroup.class) + @Valid List createSnippets, @Schema(description = "삭제, 생성 스니펫을 제외한 모든 스니펫 내역") - @NotNull(message = "updateSnippets 리스트가 null 입니다.") + @NotNull(message = "updateSnippets 리스트가 null 입니다.", groups = NotNullGroup.class) + @Valid List updateSnippets, @Schema(description = "삭제한 스니펫 식별자") @NotNull(message = "deleteSnippetIds 리스트가 null 입니다.") - List deleteSnippetIds -) implements ValidatedSnippetsOrdinalRequest { + List deleteSnippetIds, + + @Schema(description = "카테고리 ID", example = "1") + @NotNull(message = "카테고리 id가 null 입니다.") + Long categoryId, + + @Schema(description = "태그 리스트") + @NotNull(message = "태그 리스트가 null 입니다.") + List tags +) implements ValidatedSnippetsOrdinalRequest, ValidatedSnippetsCountRequest { @Override public List extractSnippetsOrdinal() { return Stream.concat( @@ -32,4 +55,9 @@ public List extractSnippetsOrdinal() { createSnippets.stream().map(CreateSnippetRequest::ordinal) ).toList(); } + + @Override + public Integer countSnippets() { + return updateSnippets.size() + createSnippets.size(); + } } diff --git a/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCount.java b/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCount.java new file mode 100644 index 000000000..86f41a911 --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCount.java @@ -0,0 +1,21 @@ +package codezap.template.dto.request.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = SnippetsCountValidator.class) +public @interface SnippetsCount { + + String message(); + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCountValidator.java b/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCountValidator.java new file mode 100644 index 000000000..a68729f6d --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/request/validation/SnippetsCountValidator.java @@ -0,0 +1,15 @@ +package codezap.template.dto.request.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class SnippetsCountValidator implements ConstraintValidator { + + @Override + public boolean isValid(ValidatedSnippetsCountRequest validatedSnippetsCountRequest, + ConstraintValidatorContext constraintValidatorContext + ) { + Integer snippetsCount = validatedSnippetsCountRequest.countSnippets(); + return snippetsCount > 0; + } +} diff --git a/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsCountRequest.java b/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsCountRequest.java new file mode 100644 index 000000000..2de66a8b7 --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsCountRequest.java @@ -0,0 +1,9 @@ +package codezap.template.dto.request.validation; + +import codezap.global.validation.ValidationGroups.SnippetCountGroup; + +@SnippetsCount(message = "스니펫은 최소 1개 입력해야 합니다.", groups = SnippetCountGroup.class) +public interface ValidatedSnippetsCountRequest { + + Integer countSnippets(); +} diff --git a/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsOrdinalRequest.java b/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsOrdinalRequest.java index 5adb86fc6..7a09a5f06 100644 --- a/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsOrdinalRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/validation/ValidatedSnippetsOrdinalRequest.java @@ -2,7 +2,9 @@ import java.util.List; -@SnippetsOrdinal(message = "스니펫 순서가 잘못되었습니다.") +import codezap.global.validation.ValidationGroups.SnippetOrdinalGroup; + +@SnippetsOrdinal(message = "스니펫 순서가 잘못되었습니다.", groups = SnippetOrdinalGroup.class) public interface ValidatedSnippetsOrdinalRequest { List extractSnippetsOrdinal(); diff --git a/backend/src/main/java/codezap/template/dto/response/ExploreTemplatesResponse.java b/backend/src/main/java/codezap/template/dto/response/ExploreTemplatesResponse.java new file mode 100644 index 000000000..750dfa00c --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/response/ExploreTemplatesResponse.java @@ -0,0 +1,39 @@ +package codezap.template.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import codezap.template.domain.ThumbnailSnippet; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ExploreTemplatesResponse( + @Schema(description = "템플릿 목록") + List templates +) { + public static ExploreTemplatesResponse from(List thumbnailSnippets) { + List templatesBySummaryResponse = thumbnailSnippets.stream() + .map(ItemResponse::from) + .toList(); + return new ExploreTemplatesResponse(templatesBySummaryResponse); + } + + public record ItemResponse( + @Schema(description = "템플릿 식별자", example = "0") + Long id, + @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") + String title, + @Schema(description = "목록 조회 시 보여질 대표 스니펫 정보") + FindThumbnailSnippetResponse thumbnailSnippet, + @Schema(description = "템플릿 수정 시간", example = "2024-11-11 12:00", type = "string") + LocalDateTime modifiedAt + ) { + public static ItemResponse from(ThumbnailSnippet thumbnailSnippet) { + return new ItemResponse( + thumbnailSnippet.getTemplate().getId(), + thumbnailSnippet.getTemplate().getTitle(), + FindThumbnailSnippetResponse.from(thumbnailSnippet.getSnippet()), + thumbnailSnippet.getModifiedAt() + ); + } + } +} diff --git a/backend/src/main/java/codezap/template/dto/response/FindAllMyTemplatesResponse.java b/backend/src/main/java/codezap/template/dto/response/FindAllMyTemplatesResponse.java new file mode 100644 index 000000000..0efa46369 --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/response/FindAllMyTemplatesResponse.java @@ -0,0 +1,20 @@ +package codezap.template.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record FindAllMyTemplatesResponse( + @Schema(description = "총 페이지", example = "5") + long totalPage, + + @Schema(description = "템플릿 목록") + List templates +) { + public static FindAllMyTemplatesResponse of(List myTemplateResponses, long totalPage) { + return new FindAllMyTemplatesResponse( + totalPage, + myTemplateResponses + ); + } +} diff --git a/backend/src/main/java/codezap/template/dto/response/FindAllSnippetByTemplateResponse.java b/backend/src/main/java/codezap/template/dto/response/FindAllSnippetByTemplateResponse.java index 34815a2f7..d2520a934 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindAllSnippetByTemplateResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindAllSnippetByTemplateResponse.java @@ -6,10 +6,13 @@ public record FindAllSnippetByTemplateResponse( @Schema(description = "파일 식별자", example = "0") Long id, + @Schema(description = "파일 이름", example = "Main.java") String filename, + @Schema(description = "소스 코드", example = "public class Main { // ...") String content, + @Schema(description = "스니펫의 순서", example = "1") int ordinal ) { diff --git a/backend/src/main/java/codezap/template/dto/response/FindAllTemplatesResponse.java b/backend/src/main/java/codezap/template/dto/response/FindAllTemplatesResponse.java index c0bb51ac0..ff67477fc 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindAllTemplatesResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindAllTemplatesResponse.java @@ -3,36 +3,40 @@ import java.time.LocalDateTime; import java.util.List; -import codezap.template.domain.ThumbnailSnippet; +import codezap.template.domain.Tag; +import codezap.template.domain.Template; import io.swagger.v3.oas.annotations.media.Schema; public record FindAllTemplatesResponse( + @Schema(description = "전체 페이지 개수", example = "1") + int totalPages, + @Schema(description = "총 템플릿 개수", example = "134") + long totalElements, @Schema(description = "템플릿 목록") List templates ) { - public static FindAllTemplatesResponse from(List thumbnailSnippets) { - List templatesBySummaryResponse = thumbnailSnippets.stream() - .map(ItemResponse::from) - .toList(); - return new FindAllTemplatesResponse(templatesBySummaryResponse); - } public record ItemResponse( @Schema(description = "템플릿 식별자", example = "0") Long id, @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") String title, - @Schema(description = "목록 조회 시 보여질 대표 스니펫 정보") - FindThumbnailSnippetResponse thumbnailSnippet, + @Schema(description = "템플릿 설명", example = "Jwt 토큰을 이용하여 로그인 기능을 구현합니다.") + String description, + @Schema(description = "태그 리스트") + List tags, @Schema(description = "템플릿 수정 시간", example = "2024-11-11 12:00", type = "string") LocalDateTime modifiedAt ) { - public static ItemResponse from(ThumbnailSnippet thumbnailSnippet) { + public static ItemResponse of(Template template, List templateTags) { return new ItemResponse( - thumbnailSnippet.getTemplate().getId(), - thumbnailSnippet.getTemplate().getTitle(), - FindThumbnailSnippetResponse.from(thumbnailSnippet.getSnippet()), - thumbnailSnippet.getModifiedAt() + template.getId(), + template.getTitle(), + template.getDescription(), + templateTags.stream() + .map(tag -> new FindTagResponse(tag.getId(), tag.getName())) + .toList(), + template.getModifiedAt() ); } } diff --git a/backend/src/main/java/codezap/template/dto/response/FindMyTemplateResponse.java b/backend/src/main/java/codezap/template/dto/response/FindMyTemplateResponse.java new file mode 100644 index 000000000..01607c123 --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/response/FindMyTemplateResponse.java @@ -0,0 +1,44 @@ +package codezap.template.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import codezap.template.domain.Tag; +import codezap.template.domain.Template; +import io.swagger.v3.oas.annotations.media.Schema; + +public record FindMyTemplateResponse( + @Schema(description = "템플릿 식별자", example = "0") + Long id, + + @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") + String title, + + @Schema(description = "템플릿 설명", example = "JWT를 사용하여 로그인 기능을 구현함") + String description, + + @Schema(description = "태그 목록") + List tags, + + @Schema(description = "템플릿 수정 시간", example = "2024-11-11 12:00", type = "string") + LocalDateTime modifiedAt +) { + public static FindMyTemplateResponse of(Template template, List tags) { + return new FindMyTemplateResponse( + template.getId(), + template.getTitle(), + template.getDescription(), + mapToFindTagByTemplateResponse(tags), + template.getModifiedAt() + ); + } + + private static List mapToFindTagByTemplateResponse( + List tags + ) { + return tags.stream() + .map(FindTagResponse::from) + .toList(); + } +} + diff --git a/backend/src/main/java/codezap/template/dto/response/FindTagResponse.java b/backend/src/main/java/codezap/template/dto/response/FindTagResponse.java new file mode 100644 index 000000000..08a167a37 --- /dev/null +++ b/backend/src/main/java/codezap/template/dto/response/FindTagResponse.java @@ -0,0 +1,16 @@ +package codezap.template.dto.response; + +import codezap.template.domain.Tag; +import io.swagger.v3.oas.annotations.media.Schema; + +public record FindTagResponse( + @Schema(description = "태그 식별자", example = "1") + Long id, + + @Schema(description = "태그 이름", example = "스프링") + String name +) { + public static FindTagResponse from(Tag tag) { + return new FindTagResponse(tag.getId(), tag.getName()); + } +} diff --git a/backend/src/main/java/codezap/template/dto/response/FindTemplateByIdResponse.java b/backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java similarity index 54% rename from backend/src/main/java/codezap/template/dto/response/FindTemplateByIdResponse.java rename to backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java index 6fccceb7c..42fe1f294 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindTemplateByIdResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java @@ -3,25 +3,42 @@ import java.time.LocalDateTime; import java.util.List; +import codezap.category.dto.response.FindCategoryResponse; import codezap.template.domain.Snippet; +import codezap.template.domain.Tag; import codezap.template.domain.Template; import io.swagger.v3.oas.annotations.media.Schema; -public record FindTemplateByIdResponse( +public record FindTemplateResponse( @Schema(description = "템플릿 식별자", example = "0") Long id, + @Schema(description = "템플릿 이름", example = "스프링 로그인 구현") String title, + + @Schema(description = "템플릿 설명", example = "JWT를 사용하여 로그인 기능을 구현함") + String description, + @Schema(description = "스니펫 목록") List snippets, + + @Schema(description = "카테고리 정보") + FindCategoryResponse category, + + @Schema(description = "태그 목록") + List tags, + @Schema(description = "템플릿 수정 시간", example = "2024-11-11 12:00", type = "string") LocalDateTime modifiedAt ) { - public static FindTemplateByIdResponse of(Template template, List snippets) { - return new FindTemplateByIdResponse( + public static FindTemplateResponse of(Template template, List snippets, List tags) { + return new FindTemplateResponse( template.getId(), template.getTitle(), + template.getDescription(), mapToFindAllSnippetByTemplateResponse(snippets), + FindCategoryResponse.from(template.getCategory()), + mapToFindTagByTemplateResponse(tags), template.getModifiedAt() ); } @@ -33,4 +50,10 @@ private static List mapToFindAllSnippetByTempl .map(FindAllSnippetByTemplateResponse::from) .toList(); } + + private static List mapToFindTagByTemplateResponse(List tags) { + return tags.stream() + .map(FindTagResponse::from) + .toList(); + } } diff --git a/backend/src/main/java/codezap/template/dto/response/FindThumbnailSnippetResponse.java b/backend/src/main/java/codezap/template/dto/response/FindThumbnailSnippetResponse.java index 1cdf7303e..de47fbe4f 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindThumbnailSnippetResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindThumbnailSnippetResponse.java @@ -6,6 +6,7 @@ public record FindThumbnailSnippetResponse( @Schema(description = "파일 이름", example = "Main.java") String filename, + @Schema(description = "목록 조회 시 보여질 코드", example = "public class Main { // ...") String thumbnailContent ) { diff --git a/backend/src/main/java/codezap/template/repository/SnippetRepository.java b/backend/src/main/java/codezap/template/repository/SnippetRepository.java index 898b5d8ba..bfe6da88e 100644 --- a/backend/src/main/java/codezap/template/repository/SnippetRepository.java +++ b/backend/src/main/java/codezap/template/repository/SnippetRepository.java @@ -1,6 +1,7 @@ package codezap.template.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.http.HttpStatus; @@ -17,7 +18,7 @@ default Snippet fetchById(Long id) { List findAllByTemplate(Template template); - Snippet findByTemplateAndOrdinal(Template template, int ordinal); + Optional findByTemplateAndOrdinal(Template template, int ordinal); List findAllByTemplateAndOrdinal(Template template, int ordinal); diff --git a/backend/src/main/java/codezap/template/repository/TagRepository.java b/backend/src/main/java/codezap/template/repository/TagRepository.java new file mode 100644 index 000000000..8ff7f3b85 --- /dev/null +++ b/backend/src/main/java/codezap/template/repository/TagRepository.java @@ -0,0 +1,24 @@ +package codezap.template.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.http.HttpStatus; + +import codezap.global.exception.CodeZapException; +import codezap.template.domain.Tag; + +public interface TagRepository extends JpaRepository { + + default Tag fetchById(Long id) { + return findById(id).orElseThrow( + () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 태그가 존재하지 않습니다.")); + } + + boolean existsByName(String name); + + Optional findByName(String name); + + List findByNameIn(List tagNames); +} diff --git a/backend/src/main/java/codezap/template/repository/TemplateRepository.java b/backend/src/main/java/codezap/template/repository/TemplateRepository.java index 47b00b246..34ddb0f69 100644 --- a/backend/src/main/java/codezap/template/repository/TemplateRepository.java +++ b/backend/src/main/java/codezap/template/repository/TemplateRepository.java @@ -1,6 +1,13 @@ package codezap.template.repository; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.http.HttpStatus; import codezap.global.exception.CodeZapException; @@ -12,4 +19,34 @@ default Template fetchById(Long id) { return findById(id).orElseThrow( () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 템플릿이 존재하지 않습니다.")); } + + boolean existsByCategoryId(Long categoryId); + + @Query(""" + SELECT DISTINCT t + FROM Template t JOIN Snippet s ON t.id = s.template.id + WHERE t.member.id = :memberId AND + ( + t.title LIKE :topic + OR s.filename LIKE :topic + OR s.content LIKE :topic + OR t.description LIKE :topic + ) + """) + Page