diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 000000000..9842d3d29 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,72 @@ +name: JAVA CI with GRADLE + +on: + push: + branches: [ "yunjunghun0116" ] + pull_request: + branches: [ "yunjunghun0116" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: 시작 + uses: actions/checkout@v4 + + - name: JDK 21로 실행 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: gradle 생성 + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 + + - name: 설정파일 생성 + run: | + touch ./src/main/resources/application.yml + echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application.yml + cat ./src/main/resources/application.yml + + - name: gradlew 권한 추가 + run: chmod +x gradlew + + - name: 빌드 작업 + run: ./gradlew build + + - name: 테스트 작업 + run: ./gradlew test + + - name: 서버에 배포하기 + env: + SSH_KEY: ${{ secrets.SSH_KEY }} + DEPLOY_SERVER: ${{ secrets.DEPLOY_SERVER }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + run: | + echo "$SSH_KEY" > key.pem + chmod 400 key.pem + scp -o StrictHostKeyChecking=no -i key.pem build/libs/*.jar $DEPLOY_USER@$DEPLOY_SERVER:~/spring-gift-point/build/libs/ + scp -o StrictHostKeyChecking=no -i key.pem ./src/main/resources/application.yml $DEPLOY_USER@$DEPLOY_SERVER:~/spring-gift-point/src/main/resources/application.yml + ssh -o StrictHostKeyChecking=no -i key.pem $DEPLOY_USER@$DEPLOY_SERVER " + sudo lsof -t -i:8080 | xargs -r sudo kill -9 + nohup java -jar ~/spring-gift-point/build/libs/spring-gift-0.0.1-SNAPSHOT.jar &" + + dependency-submission: + + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@417ae3ccd767c252f5661f1ace9f835f9654f2b5 diff --git a/.gitignore b/.gitignore index 0caf866b0..a60bf793c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ - +src/main/resources/application.yml ### STS ### .apt_generated .classpath diff --git a/README.md b/README.md index b2202f113..3382e4c0c 100644 --- a/README.md +++ b/README.md @@ -1 +1,64 @@ -# spring-gift-point \ No newline at end of file +# spring-gift-point + +### 과제 진행 요구 사항 + +- 미션은 [포인트](https://github.com/kakao-tech-campus-2nd-step2/spring-gift-point) 저장소를 포크하고 클론하는 것으로 시작한다. +- [온라인 코드 리뷰 요청 1단계 문서](https://github.com/next-step/nextstep-docs/blob/master/codereview/review-step1.md)를 참고하여 실습 환경을 + 구축한다. +- 기능을 구현하기 전 README.md에 구현할 기능 목록을 정리해 추가한다. +- Git의 커밋 단위는 앞 단계에서 README.md에 정리한 기능 목록 단위로 + 추가한다. [AngularJS Git Commit Message Convention](https://gist.github.com/stephenparish/9941e89d80e2bc58a153)을 참고해 커밋 + 메시지를 작성한다. + +### 프로그래밍 요구 사항 + +- 자바 코드 컨벤션을 지키면서 프로그래밍 한다. (들여쓰기는 '4 spaces' 로 한다) +- indent (들여쓰기) depth 를 3이 넘지 않도록 구현한다. +- 3항 연산자를 사용하지 않는다. +- 함수는 한가지 일만 하도록 최대한 작게 만든다. +- 함수의 길이가 15 라인을 넘어가지 않도록 구현한다. +- JUnit 5 와 AssertJ 를 이용하여 정리한 기능 목록이 정상적으로 작동하는지 테스트 코드로 확인한다. +- else 예약어를 사용하지 않는다. +- 도메인 로직에 단위 테스트를 구현해야 한다.(핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 분리해 구현한다.) + +### 기능 요구 사항 (5주차) + +#### 0단계 + +- [X] 주문하기 코드를 옮겨온다. + +### 나만의 HTTP RULE + +| HTTP Method | 사용상황 | 반환(상태코드) | +|-------------|--------------------------------|----------| +| GET | 리소스의 조회 | 200 | +| POST | 새로운 리소스 생성 | 201 | +| PUT | 리소스의 전체 업데이트 또는 ID를 통한 리소스 생성 | 204 | +| PATCH | 리소스의 일부분(일부 필드) 업데이트 | 204 | +| DELETE | 리소스의 삭제 | 204 | + +### 나만의 계층 RULE + +| 계층 | 역할 | +|------------|-------------------------------------------------------------| +| Controller | HTTP 요청을 받아 적절한 Service 호출, 입력 검증, 유효성 검사, HTTP 응답 생성 및 반환 | +| Service | 비즈니스 로직 수행, DTO 와 엔티티 변환, 다수 Repository 를 통한 하나의 트랜잭션 처리 작업 | +| Model | Entity, DTO 가 속하며 데이터구조, 데이터베이스와의 연동되는 객체 | +| Repository | DB 관련 CRUD 작업, DB 의 결과를 Entity 로 변환하는 작업 | + +### 나만의 개행 RULE + +- 지역변수는 사이에 개행을 두지 않는다. 하지만 첫 지역변수 전줄, 마지막 지역변수 다음줄에 개행을 추가한다. +- 생성자 전후에 개행을 추가한다. +- 추상체, 구현체 모두 메서드 전후에 개행을 추가한다. 단 마지막 메서드 후에는 추가하지 않는다. +- 클래스의 마지막 줄에는 개행을 추가한다. + +### 연관관계 매핑 RULE + +- M:1 관계에서는 M 에서 1 에 대한 정보까지 추가한다. ex) setProduct() 는 Product 가 아닌 WishProduct 에서 수행을 하는것 처럼 +- save 를 하는 과정에서 우선 객체를 생성하고 연관관계를 맺어준 후에 repository.save() 를 호출한다. + +### 계층간 의존 RULE + +- M:1 관계에서 M 에서는 1에 대한 조회만을 수행하기에 서비스 계층에서는 레포지토리 계층을 의존한다.(R) +- M:1 관계에서 1 에서는 M에 대한 로직을 수행할 수 있기에(삭제 등) 서비스 계층을 의존한다.(CUD) diff --git a/build.gradle b/build.gradle index df7db9334..19815f19f 100644 --- a/build.gradle +++ b/build.gradle @@ -18,12 +18,22 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-jdbc' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + compileOnly 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' runtimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testCompileOnly 'io.jsonwebtoken:jjwt-api:0.12.6' + testRuntimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + testRuntimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } tasks.named('test') { diff --git a/src/main/java/gift/Application.java b/src/main/java/gift/Application.java index 61603cca0..21cb80877 100644 --- a/src/main/java/gift/Application.java +++ b/src/main/java/gift/Application.java @@ -1,9 +1,15 @@ package gift; +import gift.config.properties.JwtProperties; +import gift.config.properties.KakaoProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing +@EnableConfigurationProperties({JwtProperties.class, KakaoProperties.class}) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); diff --git a/src/main/java/gift/client/KakaoApiClient.java b/src/main/java/gift/client/KakaoApiClient.java new file mode 100644 index 000000000..06d088b8d --- /dev/null +++ b/src/main/java/gift/client/KakaoApiClient.java @@ -0,0 +1,128 @@ +package gift.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import gift.config.properties.KakaoProperties; +import gift.dto.giftorder.GiftOrderResponse; +import gift.dto.kakao.KakaoAuthResponse; +import gift.dto.kakao.KakaoTokenResponse; +import gift.dto.kakao.template.KakaoTemplate; +import gift.dto.kakao.template.KakaoTemplateCommerce; +import gift.dto.kakao.template.KakaoTemplateContent; +import gift.dto.kakao.template.KakaoTemplateLink; +import gift.exception.BadRequestException; +import gift.exception.InvalidKakaoTokenException; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.RestClient; + +import java.net.URI; + +@Component +public class KakaoApiClient { + + private final RestClient restClient; + private final KakaoProperties kakaoProperties; + private final ObjectMapper objectMapper = new ObjectMapper(); + private static final String INVALID_TOKEN_MESSAGE = "유효하지 않은 토큰입니다. 갱신이 필요합니다."; + private static final String TOKEN_BASE_URL = "https://kauth.kakao.com/oauth/token"; + + public KakaoApiClient(KakaoProperties kakaoProperties, RestClient restClient) { + this.kakaoProperties = kakaoProperties; + this.restClient = restClient; + } + + public KakaoTokenResponse getTokenResponse(String code, String redirectUri) { + var body = new LinkedMultiValueMap(); + body.add("grant_type", "authorization_code"); + body.add("client_id", kakaoProperties.restApiKey()); + body.add("redirect_uri", redirectUri); + body.add("code", code); + + var response = restClient.post() + .uri(URI.create(TOKEN_BASE_URL)) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(String.class); + + return convertDtoWithJsonString(response, KakaoTokenResponse.class); + } + + public KakaoTokenResponse getRefreshedTokenResponse(String refreshToken) { + var body = new LinkedMultiValueMap(); + body.add("grant_type", "refresh_token"); + body.add("client_id", kakaoProperties.restApiKey()); + body.add("refresh_token", refreshToken); + + var response = restClient.post() + .uri(URI.create(TOKEN_BASE_URL)) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .onStatus(statusCode -> statusCode.equals(HttpStatus.UNAUTHORIZED), (req, res) -> { + throw new InvalidKakaoTokenException(INVALID_TOKEN_MESSAGE); + }) + .onStatus(statusCode -> statusCode.equals(HttpStatus.BAD_REQUEST), (req, res) -> { + throw new InvalidKakaoTokenException(INVALID_TOKEN_MESSAGE); + }) + .body(String.class); + + return convertDtoWithJsonString(response, KakaoTokenResponse.class); + } + + public KakaoAuthResponse getKakaoAuthResponse(KakaoTokenResponse kakaoTokenResponse) { + var url = "https://kapi.kakao.com/v2/user/me"; + var header = "Bearer " + kakaoTokenResponse.accessToken(); + + var response = restClient.get() + .uri(URI.create(url)) + .header("Authorization", header) + .retrieve() + .body(String.class); + + return convertDtoWithJsonString(response, KakaoAuthResponse.class); + } + + public void sendSelfMessageOrder(String accessToken, GiftOrderResponse giftOrderResponse) { + try { + var url = "https://kapi.kakao.com/v2/api/talk/memo/default/send"; + var header = "Bearer " + accessToken; + + var template = getCommerceTemplate(giftOrderResponse); + var body = new LinkedMultiValueMap(); + body.add("template_object", objectMapper.writeValueAsString(template)); + + restClient.post() + .uri(URI.create(url)) + .header("Authorization", header) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .onStatus(statusCode -> statusCode.equals(HttpStatus.UNAUTHORIZED), (req, res) -> { + throw new InvalidKakaoTokenException(INVALID_TOKEN_MESSAGE); + }) + .body(String.class); + } catch (JsonProcessingException exception) { + throw new BadRequestException("잘못된 입력으로 인해 JSON 파싱에 실패했습니다" + exception.getMessage()); + } + } + + private T convertDtoWithJsonString(String response, Class returnTypeClass) { + try { + return objectMapper.readValue(response, returnTypeClass); + } catch (JsonProcessingException exception) { + throw new RuntimeException(returnTypeClass.getName() + "의 데이터를 DTO 로 변환하는 과정에서 예외가 발생했습니다.", exception); + } + } + + private KakaoTemplate getCommerceTemplate(GiftOrderResponse giftOrderResponse) { + var objectType = "commerce"; + var link = new KakaoTemplateLink("https://gift.kakao.com/product/2370524"); + var content = new KakaoTemplateContent(giftOrderResponse.message(), "https://img1.kakaocdn.net/thumb/C320x320@2x.fwebp.q82/?fname=https%3A%2F%2Fst.kakaocdn.net%2Fproduct%2Fgift%2Fproduct%2F20240417111629_616eccb9d4cd464fa06d3430947dce15.jpg", giftOrderResponse.message(), link); + var commerce = new KakaoTemplateCommerce(giftOrderResponse.optionInformation().productName() + "[" + giftOrderResponse.optionInformation().name() + "]", giftOrderResponse.optionInformation().price() * giftOrderResponse.quantity()); + return new KakaoTemplate(objectType, content, commerce); + } +} diff --git a/src/main/java/gift/config/RestClientConfig.java b/src/main/java/gift/config/RestClientConfig.java new file mode 100644 index 000000000..d848c1273 --- /dev/null +++ b/src/main/java/gift/config/RestClientConfig.java @@ -0,0 +1,20 @@ +package gift.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(5000); + return RestClient.builder() + .requestFactory(factory) + .build(); + } +} diff --git a/src/main/java/gift/config/SwaggerConfig.java b/src/main/java/gift/config/SwaggerConfig.java new file mode 100644 index 000000000..0788aca7b --- /dev/null +++ b/src/main/java/gift/config/SwaggerConfig.java @@ -0,0 +1,84 @@ +package gift.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.parameters.HeaderParameter; +import io.swagger.v3.oas.models.responses.ApiResponse; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Set; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + var info = new Info() + .version("v1.0") + .title("카카오테크캠퍼스-선물하기") + .description("프론트엔드와 협업을 위한 API 문서"); + return new OpenAPI().info(info); + } + + @Bean + public OpenApiCustomizer openApiCustomizer() { + return openApi -> { + openApi.getPaths() + .forEach((path, pathItem) -> { + setBaseOperationResponse(path, pathItem); + setOperationResponse(pathItem.getPut(), path, "PUT"); + setOperationResponse(pathItem.getPost(), path, "POST"); + setOperationResponse(pathItem.getDelete(), path, "DELETE"); + }); + }; + } + + private void setBaseOperationResponse(String path, PathItem pathItem) { + var excludePaths = Set.of("/api/members/oauth/kakao", "/api/members/login", "/api/members/register", "/api/kakao/get-oauth"); + + for (var operation : pathItem.readOperations()) { + var successResponse = new ApiResponse().description("성공"); + operation.getResponses() + .addApiResponse("200", successResponse); + + if (!excludePaths.contains(path)) { + var header = new HeaderParameter() + .name("Authorization") + .required(Boolean.TRUE); + operation.addParametersItem(header); + + var unauthorizedResponse = new ApiResponse().description("잘못된 인증정보"); + operation.getResponses().addApiResponse("401", unauthorizedResponse); + } + } + } + + private void setOperationResponse(Operation operation, String path, String method) { + if (operation == null) return; + if (method.equals("POST") && path.contains("/add")) { + var createdResponse = new ApiResponse().description("생성 성공"); + operation.getResponses() + .addApiResponse("201", createdResponse); + operation.getResponses() + .remove("200"); + } + if (method.equals("PUT")) { + var updatedResponse = new ApiResponse().description("업데이트 성공"); + operation.getResponses() + .addApiResponse("204", updatedResponse); + operation.getResponses() + .remove("200"); + } + if (method.equals("DELETE")) { + var updatedResponse = new ApiResponse().description("삭제 성공"); + operation.getResponses() + .addApiResponse("204", updatedResponse); + operation.getResponses() + .remove("200"); + } + } +} diff --git a/src/main/java/gift/config/WebConfig.java b/src/main/java/gift/config/WebConfig.java new file mode 100644 index 000000000..792560643 --- /dev/null +++ b/src/main/java/gift/config/WebConfig.java @@ -0,0 +1,27 @@ +package gift.config; + +import gift.controller.auth.AuthInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + + public WebConfig(AuthInterceptor authInterceptor) { + this.authInterceptor = authInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/swagger-resources/**", "/swagger-ui/**", "/v3/api-docs/**") + .excludePathPatterns("/api/members/oauth/kakao") + .excludePathPatterns("/api/members/login") + .excludePathPatterns("/api/members/register") + .excludePathPatterns("/api/kakao/get-oauth"); + } +} diff --git a/src/main/java/gift/config/properties/JwtProperties.java b/src/main/java/gift/config/properties/JwtProperties.java new file mode 100644 index 000000000..8cdea422e --- /dev/null +++ b/src/main/java/gift/config/properties/JwtProperties.java @@ -0,0 +1,7 @@ +package gift.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties(String secretKey, Long expiredTime) { +} diff --git a/src/main/java/gift/config/properties/KakaoProperties.java b/src/main/java/gift/config/properties/KakaoProperties.java new file mode 100644 index 000000000..6bd8999e5 --- /dev/null +++ b/src/main/java/gift/config/properties/KakaoProperties.java @@ -0,0 +1,7 @@ +package gift.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "kakao") +public record KakaoProperties(String restApiKey, String redirectUri, String tokenUri) { +} diff --git a/src/main/java/gift/controller/CategoryController.java b/src/main/java/gift/controller/CategoryController.java new file mode 100644 index 000000000..94bd7306b --- /dev/null +++ b/src/main/java/gift/controller/CategoryController.java @@ -0,0 +1,65 @@ +package gift.controller; + +import gift.dto.category.CategoryRequest; +import gift.dto.category.CategoryResponse; +import gift.service.CategoryService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +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 java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/api/categories") +@Tag(name = "CATEGORY") +public class CategoryController { + + private final CategoryService categoryService; + + public CategoryController(CategoryService categoryService) { + this.categoryService = categoryService; + } + + @PostMapping("/add") + public ResponseEntity addCategory(@Valid @RequestBody CategoryRequest categoryRequest) { + var category = categoryService.addCategory(categoryRequest); + return ResponseEntity.created(URI.create("/api/categories/" + category.id())).build(); + } + + @PutMapping("/update/{id}") + public ResponseEntity updateCategory(@PathVariable Long id, @Valid @RequestBody CategoryRequest categoryRequest) { + categoryService.updateCategory(id, categoryRequest); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}") + public ResponseEntity getCategory(@PathVariable Long id) { + var category = categoryService.getCategory(id); + return ResponseEntity.ok(category); + } + + @GetMapping + public ResponseEntity> getCategories( + @PageableDefault(sort = "id", direction = Sort.Direction.DESC) Pageable pageable) { + var categories = categoryService.getCategories(pageable); + return ResponseEntity.ok(categories); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCategory(@PathVariable Long id) { + categoryService.deleteCategory(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gift/controller/GiftOrderController.java b/src/main/java/gift/controller/GiftOrderController.java new file mode 100644 index 000000000..1f016d30a --- /dev/null +++ b/src/main/java/gift/controller/GiftOrderController.java @@ -0,0 +1,47 @@ +package gift.controller; + +import gift.dto.giftorder.GiftOrderResponse; +import gift.service.GiftOrderService; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +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.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/giftOrders") +@Tag(name = "GIFT_ORDER") +public class GiftOrderController { + + private final GiftOrderService giftOrderService; + + public GiftOrderController(GiftOrderService giftOrderService) { + this.giftOrderService = giftOrderService; + } + + @GetMapping("/{id}") + public ResponseEntity getOrder(@PathVariable Long id) { + var order = giftOrderService.getGiftOrder(id); + return ResponseEntity.ok(order); + } + + @GetMapping + public ResponseEntity> getOrders(@RequestAttribute("memberId") Long memberId, @PageableDefault(sort = "id", direction = Sort.Direction.DESC) Pageable pageable) { + var orders = giftOrderService.getGiftOrders(memberId, pageable); + return ResponseEntity.ok(orders); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteOrder(@PathVariable Long id) { + giftOrderService.deleteOrder(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gift/controller/KakaoController.java b/src/main/java/gift/controller/KakaoController.java new file mode 100644 index 000000000..6d21344d7 --- /dev/null +++ b/src/main/java/gift/controller/KakaoController.java @@ -0,0 +1,65 @@ +package gift.controller; + +import gift.config.properties.KakaoProperties; +import gift.service.KakaoService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; + +@RestController +@RequestMapping("/api/kakao") +@Tag(name = "KAKAO") +public class KakaoController { + + private final KakaoService kakaoService; + private final KakaoProperties kakaoProperties; + private static final String OAUTH_BASE_URL = "https://kauth.kakao.com/oauth/authorize?response_type=code&scope=account_email,talk_message"; + + public KakaoController(KakaoService kakaoService, KakaoProperties kakaoProperties) { + this.kakaoService = kakaoService; + this.kakaoProperties = kakaoProperties; + } + + @GetMapping("/set-token") + public ResponseEntity redirectSetToken(@RequestAttribute("memberId") Long memberId) { + var headers = getRedirectHeader(kakaoProperties.tokenUri(), memberId); + return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY); + } + + @Hidden + @GetMapping("/token") + public ResponseEntity setToken(@RequestParam String code, @RequestParam String state) { + var memberId = Long.valueOf(state); + kakaoService.saveKakaoToken(memberId, code); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/get-oauth") + public ResponseEntity redirectOAuth() { + var headers = getRedirectHeader(kakaoProperties.redirectUri()); + return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY); + } + + private HttpHeaders getRedirectHeader(String redirectUri) { + var headers = new HttpHeaders(); + String redirectLocation = OAUTH_BASE_URL + "&client_id=" + kakaoProperties.restApiKey() + "&redirect_uri=" + redirectUri; + headers.setLocation(URI.create(redirectLocation)); + return headers; + } + + private HttpHeaders getRedirectHeader(String redirectUri, Long memberId) { + var headers = new HttpHeaders(); + String redirectLocation = OAUTH_BASE_URL + "&client_id=" + kakaoProperties.restApiKey() + "&redirect_uri=" + redirectUri + "&state=" + memberId; + headers.setLocation(URI.create(redirectLocation)); + return headers; + } +} diff --git a/src/main/java/gift/controller/MemberController.java b/src/main/java/gift/controller/MemberController.java new file mode 100644 index 000000000..3a17f828b --- /dev/null +++ b/src/main/java/gift/controller/MemberController.java @@ -0,0 +1,27 @@ +package gift.controller; + +import gift.service.MemberService; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/members") +@Tag(name = "MEMBER") +public class MemberController { + + private final MemberService memberService; + + public MemberController(MemberService memberService) { + this.memberService = memberService; + } + + @DeleteMapping + public ResponseEntity deleteMember(@RequestAttribute("memberId") Long memberId) { + memberService.deleteMember(memberId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gift/controller/OptionController.java b/src/main/java/gift/controller/OptionController.java new file mode 100644 index 000000000..13b33c8a6 --- /dev/null +++ b/src/main/java/gift/controller/OptionController.java @@ -0,0 +1,73 @@ +package gift.controller; + +import gift.dto.giftorder.GiftOrderRequest; +import gift.dto.option.OptionAddRequest; +import gift.dto.option.OptionResponse; +import gift.dto.option.OptionUpdateRequest; +import gift.service.KakaoService; +import gift.service.OptionService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +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.RequestAttribute; +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 java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/api/options") +@Tag(name = "OPTION") +public class OptionController { + + private final OptionService optionService; + + private final KakaoService kakaoService; + + public OptionController(OptionService optionService, KakaoService kakaoService) { + this.optionService = optionService; + this.kakaoService = kakaoService; + } + + @PostMapping("/add") + public ResponseEntity addOption(@Valid @RequestBody OptionAddRequest optionAddRequest) { + var option = optionService.addOption(optionAddRequest); + return ResponseEntity.created(URI.create("/api/options/" + option.id())).build(); + } + + @PostMapping("/order") + public ResponseEntity orderOption(@RequestAttribute("memberId") Long memberId, @Valid @RequestBody GiftOrderRequest giftOrderRequest) { + var order = optionService.orderOption(memberId, giftOrderRequest); + kakaoService.sendSelfMessageOrder(memberId, order); + return ResponseEntity.created(URI.create("/api/giftOrders/" + order.id())).build(); + } + + @PutMapping("/update/{id}") + public ResponseEntity updateOption(@PathVariable Long id, @Valid @RequestBody OptionUpdateRequest optionUpdateRequest) { + optionService.updateOption(id, optionUpdateRequest); + return ResponseEntity.noContent().build(); + } + + @GetMapping + public ResponseEntity> getOptions(@RequestParam Long productId, @PageableDefault(sort = "id", direction = Sort.Direction.DESC) Pageable pageable) { + var options = optionService.getOptions(productId, pageable); + return ResponseEntity.ok(options); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteOption(@PathVariable Long id) { + optionService.deleteOption(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gift/controller/ProductController.java b/src/main/java/gift/controller/ProductController.java new file mode 100644 index 000000000..7a31c9203 --- /dev/null +++ b/src/main/java/gift/controller/ProductController.java @@ -0,0 +1,66 @@ +package gift.controller; + +import gift.dto.product.ProductRequest; +import gift.dto.product.ProductResponse; +import gift.model.MemberRole; +import gift.service.ProductService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +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.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/api/products") +@Tag(name = "PRODUCT") +public class ProductController { + + private final ProductService productService; + + public ProductController(ProductService productService) { + this.productService = productService; + } + + @PostMapping("/add") + public ResponseEntity addProduct(@Valid @RequestBody ProductRequest productRequest, @RequestAttribute("memberRole") String memberRole) { + var product = productService.addProduct(productRequest, MemberRole.valueOf(memberRole)); + return ResponseEntity.created(URI.create("/api/products/" + product.id())).build(); + } + + @PutMapping("/update/{id}") + public ResponseEntity updateProduct(@PathVariable Long id, @Valid @RequestBody ProductRequest productRequest) { + productService.updateProduct(id, productRequest); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}") + public ResponseEntity getProduct(@PathVariable Long id) { + var product = productService.getProduct(id); + return ResponseEntity.ok(product); + } + + @GetMapping + public ResponseEntity> getProducts(@PageableDefault(sort = "id", direction = Sort.Direction.DESC) Pageable pageable) { + var products = productService.getProducts(pageable); + return ResponseEntity.ok(products); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteProduct(@PathVariable Long id) { + productService.deleteProduct(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gift/controller/WishProductController.java b/src/main/java/gift/controller/WishProductController.java new file mode 100644 index 000000000..143138d96 --- /dev/null +++ b/src/main/java/gift/controller/WishProductController.java @@ -0,0 +1,61 @@ +package gift.controller; + +import gift.dto.wishproduct.WishProductAddRequest; +import gift.dto.wishproduct.WishProductResponse; +import gift.dto.wishproduct.WishProductUpdateRequest; +import gift.service.WishProductService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +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.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/api/wishes") +@Tag(name = "WISH_PRODUCT") +public class WishProductController { + + private final WishProductService wishProductService; + + public WishProductController(WishProductService wishProductService) { + this.wishProductService = wishProductService; + } + + @PostMapping("/add") + public ResponseEntity addWishProduct(@Valid @RequestBody WishProductAddRequest wishProductAddRequest, @RequestAttribute("memberId") Long memberId) { + var wishProduct = wishProductService.addWishProduct(wishProductAddRequest, memberId); + return ResponseEntity.created(URI.create("/api/wishes/" + wishProduct.id())).build(); + } + + @PutMapping("/update/{id}") + public ResponseEntity updateWishProduct(@PathVariable Long id, @Valid @RequestBody WishProductUpdateRequest wishProductUpdateRequest) { + wishProductService.updateWishProduct(id, wishProductUpdateRequest); + return ResponseEntity.noContent().build(); + } + + @GetMapping + public ResponseEntity> getWishProducts(@RequestAttribute("memberId") Long memberId, + @PageableDefault(sort = "id", direction = Sort.Direction.DESC) Pageable pageable) { + var wishProducts = wishProductService.getWishProducts(memberId, pageable); + return ResponseEntity.ok(wishProducts); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteWishProduct(@PathVariable Long id) { + wishProductService.deleteWishProduct(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gift/controller/auth/AuthController.java b/src/main/java/gift/controller/auth/AuthController.java new file mode 100644 index 000000000..dfbea3515 --- /dev/null +++ b/src/main/java/gift/controller/auth/AuthController.java @@ -0,0 +1,47 @@ +package gift.controller.auth; + +import gift.dto.auth.AuthResponse; +import gift.dto.auth.LoginRequest; +import gift.dto.auth.RegisterRequest; +import gift.service.auth.AuthService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/members") +@Tag(name = "MEMBER") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest registerRequest) { + var auth = authService.register(registerRequest); + return ResponseEntity.ok(auth); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest) { + var auth = authService.login(loginRequest); + return ResponseEntity.ok(auth); + } + + @Hidden + @GetMapping("/oauth/kakao") + public ResponseEntity loginWithKakaoAuth(@RequestParam String code) { + var auth = authService.loginWithKakaoAuth(code); + return ResponseEntity.ok(auth); + } +} diff --git a/src/main/java/gift/controller/auth/AuthInterceptor.java b/src/main/java/gift/controller/auth/AuthInterceptor.java new file mode 100644 index 000000000..afda6021c --- /dev/null +++ b/src/main/java/gift/controller/auth/AuthInterceptor.java @@ -0,0 +1,64 @@ +package gift.controller.auth; + +import gift.config.properties.JwtProperties; +import gift.exception.UnauthorizedAccessException; +import gift.model.MemberRole; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.crypto.SecretKey; + +@Component +public class AuthInterceptor implements HandlerInterceptor { + + private final JwtProperties jwtProperties; + + public AuthInterceptor(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + var header = getHeader(request); + var token = getTokenWithAuthorizationHeader(header); + var claims = getClaimsWithToken(token); + setMemberInformationWithClaims(request, claims); + return true; + } + + private Claims getClaimsWithToken(String token) { + return Jwts.parser() + .verifyWith(getSecretKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private void setMemberInformationWithClaims(HttpServletRequest request, Claims claims) { + var memberId = Long.parseLong(claims.getSubject()); + var memberRole = MemberRole.valueOf(claims.get("role").toString()); + request.setAttribute("memberId", memberId); + request.setAttribute("memberRole", memberRole); + } + + private String getTokenWithAuthorizationHeader(String authorizationHeader) { + var header = authorizationHeader.split(" "); + if (header.length != 2) throw new IllegalArgumentException("잘못된 헤더 정보입니다."); + return header[1]; + } + + private String getHeader(HttpServletRequest request) { + var header = request.getHeader("Authorization"); + if (header == null) throw new UnauthorizedAccessException("인가되지 않은 요청입니다."); + return header; + } + + private SecretKey getSecretKey() { + return Keys.hmacShaKeyFor(jwtProperties.secretKey().getBytes()); + } +} diff --git a/src/main/java/gift/dto/auth/AuthResponse.java b/src/main/java/gift/dto/auth/AuthResponse.java new file mode 100644 index 000000000..10dac2a0e --- /dev/null +++ b/src/main/java/gift/dto/auth/AuthResponse.java @@ -0,0 +1,7 @@ +package gift.dto.auth; + +public record AuthResponse(String token) { + public static AuthResponse of(String token) { + return new AuthResponse(token); + } +} diff --git a/src/main/java/gift/dto/auth/LoginRequest.java b/src/main/java/gift/dto/auth/LoginRequest.java new file mode 100644 index 000000000..c0da441ff --- /dev/null +++ b/src/main/java/gift/dto/auth/LoginRequest.java @@ -0,0 +1,11 @@ +package gift.dto.auth; + +import jakarta.validation.constraints.Pattern; + +public record LoginRequest( + @Pattern(regexp = "^[0-9a-z\\-\\_\\+\\w]*@([0-9a-z]+\\.)+[a-z]{2,9}", message = "허용되지 않은 형식의 이메일입니다.") + String email, + @Pattern(regexp = "^[0-9a-zA-Z\\-\\_\\+\\!\\*\\@\\#\\$\\%\\^\\&\\(\\)\\.]{8,}$", message = "허용되지 않은 형식의 패스워드입니다.") + String password +) { +} diff --git a/src/main/java/gift/dto/auth/RegisterRequest.java b/src/main/java/gift/dto/auth/RegisterRequest.java new file mode 100644 index 000000000..75b0451ad --- /dev/null +++ b/src/main/java/gift/dto/auth/RegisterRequest.java @@ -0,0 +1,18 @@ +package gift.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import org.hibernate.validator.constraints.Length; + +public record RegisterRequest( + @Length(max = 8, message = "이름의 길이는 8자를 초과할 수 없습니다.") + @NotBlank(message = "이름의 길이는 최소 1자 이상이어야 합니다.") + String name, + @Pattern(regexp = "^[0-9a-z\\-\\_\\+\\w]*@([0-9a-z]+\\.)+[a-z]{2,9}", message = "허용되지 않은 형식의 이메일입니다.") + String email, + @Pattern(regexp = "^[0-9a-zA-Z\\-\\_\\+\\!\\*\\@\\#\\$\\%\\^\\&\\(\\)\\.]{8,}$", message = "허용되지 않은 형식의 패스워드입니다.") + String password, + @Pattern(regexp = "^(MEMBER|ADMIN)$", message = "존재하지 않는 회원 타입입니다.") + String role +) { +} diff --git a/src/main/java/gift/dto/category/CategoryInformation.java b/src/main/java/gift/dto/category/CategoryInformation.java new file mode 100644 index 000000000..11c6e1c74 --- /dev/null +++ b/src/main/java/gift/dto/category/CategoryInformation.java @@ -0,0 +1,7 @@ +package gift.dto.category; + +public record CategoryInformation(Long id, String name) { + public static CategoryInformation of(Long id, String name) { + return new CategoryInformation(id, name); + } +} diff --git a/src/main/java/gift/dto/category/CategoryRequest.java b/src/main/java/gift/dto/category/CategoryRequest.java new file mode 100644 index 000000000..282ebb38f --- /dev/null +++ b/src/main/java/gift/dto/category/CategoryRequest.java @@ -0,0 +1,16 @@ +package gift.dto.category; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record CategoryRequest( + @NotBlank(message = "이름의 길이는 최소 1자 이상이어야 합니다.") + String name, + @NotBlank(message = "카테고리 설명은 필수로 입력해야 합니다.") + String description, + @Pattern(regexp = "^\\#[0-9a-zA-Z]{6,8}$", message = "허용되지 않은 형식의 색상코드입니다.") + String color, + @NotBlank(message = "카테고리 설명 이미지는 필수로 입력해야 합니다.") + String imageUrl +) { +} diff --git a/src/main/java/gift/dto/category/CategoryResponse.java b/src/main/java/gift/dto/category/CategoryResponse.java new file mode 100644 index 000000000..370a9a4ae --- /dev/null +++ b/src/main/java/gift/dto/category/CategoryResponse.java @@ -0,0 +1,7 @@ +package gift.dto.category; + +public record CategoryResponse(Long id, String name, String description, String color, String imageUrl) { + public static CategoryResponse of(Long id, String name, String description, String color, String imageUrl) { + return new CategoryResponse(id, name, description, color, imageUrl); + } +} diff --git a/src/main/java/gift/dto/giftorder/GiftOrderRequest.java b/src/main/java/gift/dto/giftorder/GiftOrderRequest.java new file mode 100644 index 000000000..fc81a824e --- /dev/null +++ b/src/main/java/gift/dto/giftorder/GiftOrderRequest.java @@ -0,0 +1,17 @@ +package gift.dto.giftorder; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record GiftOrderRequest( + @NotNull(message = "상품 옵션은 반드시 선택되어야 합니다.") + Long optionId, + @Min(value = 1, message = "수량은 최소 1개 이상, 1억개 미만입니다.") + @Max(value = 100_000_000, message = "수량은 최소 1개 이상, 1억개 미만입니다.") + Integer quantity, + @NotBlank(message = "메시지의 길이는 최소 1자 이상이어야 합니다.") + String message +) { +} diff --git a/src/main/java/gift/dto/giftorder/GiftOrderResponse.java b/src/main/java/gift/dto/giftorder/GiftOrderResponse.java new file mode 100644 index 000000000..5bda70e3c --- /dev/null +++ b/src/main/java/gift/dto/giftorder/GiftOrderResponse.java @@ -0,0 +1,11 @@ +package gift.dto.giftorder; + +import gift.dto.option.OptionInformation; + +import java.time.LocalDateTime; + +public record GiftOrderResponse(Long id, OptionInformation optionInformation, Integer quantity, LocalDateTime orderDateTime, String message) { + public static GiftOrderResponse of(Long id, OptionInformation optionInformation, Integer quantity, LocalDateTime orderDateTime, String message) { + return new GiftOrderResponse(id, optionInformation, quantity, orderDateTime, message); + } +} diff --git a/src/main/java/gift/dto/kakao/KakaoAccount.java b/src/main/java/gift/dto/kakao/KakaoAccount.java new file mode 100644 index 000000000..e91025396 --- /dev/null +++ b/src/main/java/gift/dto/kakao/KakaoAccount.java @@ -0,0 +1,14 @@ +package gift.dto.kakao; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoAccount( + @JsonProperty("profile") + KakaoProfile profile, + @JsonProperty("email") + String email +) { +} + diff --git a/src/main/java/gift/dto/kakao/KakaoAuthInformation.java b/src/main/java/gift/dto/kakao/KakaoAuthInformation.java new file mode 100644 index 000000000..4c408e5e7 --- /dev/null +++ b/src/main/java/gift/dto/kakao/KakaoAuthInformation.java @@ -0,0 +1,7 @@ +package gift.dto.kakao; + +public record KakaoAuthInformation(String name, String email) { + public static KakaoAuthInformation of(String name, String email) { + return new KakaoAuthInformation(name, email); + } +} diff --git a/src/main/java/gift/dto/kakao/KakaoAuthResponse.java b/src/main/java/gift/dto/kakao/KakaoAuthResponse.java new file mode 100644 index 000000000..53c7976ce --- /dev/null +++ b/src/main/java/gift/dto/kakao/KakaoAuthResponse.java @@ -0,0 +1,12 @@ +package gift.dto.kakao; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoAuthResponse( + @JsonProperty("kakao_account") + KakaoAccount kakaoAccount +) { +} + diff --git a/src/main/java/gift/dto/kakao/KakaoProfile.java b/src/main/java/gift/dto/kakao/KakaoProfile.java new file mode 100644 index 000000000..d3739178f --- /dev/null +++ b/src/main/java/gift/dto/kakao/KakaoProfile.java @@ -0,0 +1,12 @@ +package gift.dto.kakao; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoProfile( + @JsonProperty("nickname") + String name +) { +} + diff --git a/src/main/java/gift/dto/kakao/KakaoTokenResponse.java b/src/main/java/gift/dto/kakao/KakaoTokenResponse.java new file mode 100644 index 000000000..0eca368e3 --- /dev/null +++ b/src/main/java/gift/dto/kakao/KakaoTokenResponse.java @@ -0,0 +1,17 @@ +package gift.dto.kakao; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoTokenResponse( + @JsonProperty("access_token") + String accessToken, + @JsonProperty("expires_in") + Integer accessTokenExpiresIn, + @JsonProperty("refresh_token") + String refreshToken, + @JsonProperty("refresh_token_expires_in") + Integer refreshTokenExpiresIn +) { +} diff --git a/src/main/java/gift/dto/kakao/template/KakaoTemplate.java b/src/main/java/gift/dto/kakao/template/KakaoTemplate.java new file mode 100644 index 000000000..15d26ab14 --- /dev/null +++ b/src/main/java/gift/dto/kakao/template/KakaoTemplate.java @@ -0,0 +1,16 @@ +package gift.dto.kakao.template; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoTemplate( + @JsonProperty("object_type") + String objectType, + @JsonProperty("content") + KakaoTemplateContent content, + @JsonProperty("commerce") + KakaoTemplateCommerce commerce +) { +} + diff --git a/src/main/java/gift/dto/kakao/template/KakaoTemplateCommerce.java b/src/main/java/gift/dto/kakao/template/KakaoTemplateCommerce.java new file mode 100644 index 000000000..3e3b19fc9 --- /dev/null +++ b/src/main/java/gift/dto/kakao/template/KakaoTemplateCommerce.java @@ -0,0 +1,14 @@ +package gift.dto.kakao.template; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoTemplateCommerce( + @JsonProperty("product_name") + String productName, + @JsonProperty("regular_price") + Integer regularPrice +) { +} + diff --git a/src/main/java/gift/dto/kakao/template/KakaoTemplateContent.java b/src/main/java/gift/dto/kakao/template/KakaoTemplateContent.java new file mode 100644 index 000000000..fe222b9f1 --- /dev/null +++ b/src/main/java/gift/dto/kakao/template/KakaoTemplateContent.java @@ -0,0 +1,18 @@ +package gift.dto.kakao.template; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoTemplateContent( + @JsonProperty("title") + String title, + @JsonProperty("image_url") + String imageUrl, + @JsonProperty("description") + String description, + @JsonProperty("link") + KakaoTemplateLink link +) { +} + diff --git a/src/main/java/gift/dto/kakao/template/KakaoTemplateLink.java b/src/main/java/gift/dto/kakao/template/KakaoTemplateLink.java new file mode 100644 index 000000000..9b9a0b829 --- /dev/null +++ b/src/main/java/gift/dto/kakao/template/KakaoTemplateLink.java @@ -0,0 +1,12 @@ +package gift.dto.kakao.template; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoTemplateLink( + @JsonProperty("web_url") + String webUrl +) { +} + diff --git a/src/main/java/gift/dto/option/OptionAddRequest.java b/src/main/java/gift/dto/option/OptionAddRequest.java new file mode 100644 index 000000000..bb0e3ea62 --- /dev/null +++ b/src/main/java/gift/dto/option/OptionAddRequest.java @@ -0,0 +1,21 @@ +package gift.dto.option; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import org.hibernate.validator.constraints.Length; + +public record OptionAddRequest( + @Pattern(regexp = "^[\s\\-\\&\\(\\)\\[\\]\\+\\/\\_a-zA-z0-9ㄱ-ㅎ가-힣]*$", message = "허용되지 않은 형식의 이름입니다.") + @Length(max = 50, message = "이름의 길이는 50자를 초과할 수 없습니다.") + @NotBlank(message = "이름의 길이는 최소 1자 이상이어야 합니다.") + String name, + @Min(value = 1, message = "수량은 최소 1개 이상, 1억개 미만입니다.") + @Max(value = 100_000_000, message = "수량은 최소 1개 이상, 1억개 미만입니다.") + Integer quantity, + @NotNull(message = "상품은 반드시 선택되어야 합니다.") + Long productId +) { +} diff --git a/src/main/java/gift/dto/option/OptionInformation.java b/src/main/java/gift/dto/option/OptionInformation.java new file mode 100644 index 000000000..6d039dfcb --- /dev/null +++ b/src/main/java/gift/dto/option/OptionInformation.java @@ -0,0 +1,7 @@ +package gift.dto.option; + +public record OptionInformation(Long id, String productName, Integer price, String name) { + public static OptionInformation of(Long id, String productName, Integer price, String name) { + return new OptionInformation(id, productName, price, name); + } +} diff --git a/src/main/java/gift/dto/option/OptionResponse.java b/src/main/java/gift/dto/option/OptionResponse.java new file mode 100644 index 000000000..91b41ced6 --- /dev/null +++ b/src/main/java/gift/dto/option/OptionResponse.java @@ -0,0 +1,7 @@ +package gift.dto.option; + +public record OptionResponse(Long id, String name, Integer quantity) { + public static OptionResponse of(Long id, String name, Integer quantity) { + return new OptionResponse(id, name, quantity); + } +} diff --git a/src/main/java/gift/dto/option/OptionUpdateRequest.java b/src/main/java/gift/dto/option/OptionUpdateRequest.java new file mode 100644 index 000000000..d309e1130 --- /dev/null +++ b/src/main/java/gift/dto/option/OptionUpdateRequest.java @@ -0,0 +1,18 @@ +package gift.dto.option; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import org.hibernate.validator.constraints.Length; + +public record OptionUpdateRequest( + @Pattern(regexp = "^[\s\\-\\&\\(\\)\\[\\]\\+\\/\\_a-zA-z0-9ㄱ-ㅎ가-힣]*$", message = "허용되지 않은 형식의 이름입니다.") + @Length(max = 50, message = "이름의 길이는 50자를 초과할 수 없습니다.") + @NotBlank(message = "이름의 길이는 최소 1자 이상이어야 합니다.") + String name, + @Min(value = 1, message = "수량은 최소 1개 이상, 1억개 미만입니다.") + @Max(value = 100_000_000, message = "수량은 최소 1개 이상, 1억개 미만입니다.") + Integer quantity +) { +} diff --git a/src/main/java/gift/dto/product/ProductBasicInformation.java b/src/main/java/gift/dto/product/ProductBasicInformation.java new file mode 100644 index 000000000..3d8a0ae57 --- /dev/null +++ b/src/main/java/gift/dto/product/ProductBasicInformation.java @@ -0,0 +1,7 @@ +package gift.dto.product; + +public record ProductBasicInformation(Long id, String name, Integer price) { + public static ProductBasicInformation of(Long id, String name, Integer price) { + return new ProductBasicInformation(id, name, price); + } +} diff --git a/src/main/java/gift/dto/product/ProductRequest.java b/src/main/java/gift/dto/product/ProductRequest.java new file mode 100644 index 000000000..70182125c --- /dev/null +++ b/src/main/java/gift/dto/product/ProductRequest.java @@ -0,0 +1,21 @@ +package gift.dto.product; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import org.hibernate.validator.constraints.Length; + +public record ProductRequest( + @Pattern(regexp = "^[\s\\-\\&\\(\\)\\[\\]\\+\\/\\_a-zA-z0-9ㄱ-ㅎ가-힣]*$", message = "허용되지 않은 형식의 이름입니다.") + @Length(max = 15, message = "이름의 길이는 15자를 초과할 수 없습니다.") + @NotBlank(message = "이름의 길이는 최소 1자 이상이어야 합니다.") + String name, + @PositiveOrZero(message = "금액은 0보다 크거나 같아야 합니다.") + Integer price, + @NotBlank(message = "상품 이미지는 필수로 입력해야 합니다.") + String imageUrl, + @NotNull(message = "상품 카테고리는 반드시 선택되어야 합니다.") + Long categoryId +) { +} diff --git a/src/main/java/gift/dto/product/ProductResponse.java b/src/main/java/gift/dto/product/ProductResponse.java new file mode 100644 index 000000000..806988f31 --- /dev/null +++ b/src/main/java/gift/dto/product/ProductResponse.java @@ -0,0 +1,9 @@ +package gift.dto.product; + +import gift.dto.category.CategoryInformation; + +public record ProductResponse(Long id, String name, Integer price, String imageUrl, CategoryInformation categoryInformation) { + public static ProductResponse of(Long id, String name, Integer price, String imageUrl, CategoryInformation categoryInformation) { + return new ProductResponse(id, name, price, imageUrl, categoryInformation); + } +} diff --git a/src/main/java/gift/dto/wishproduct/WishProductAddRequest.java b/src/main/java/gift/dto/wishproduct/WishProductAddRequest.java new file mode 100644 index 000000000..d994bf9af --- /dev/null +++ b/src/main/java/gift/dto/wishproduct/WishProductAddRequest.java @@ -0,0 +1,12 @@ +package gift.dto.wishproduct; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record WishProductAddRequest( + @NotNull(message = "상품은 반드시 선택되어야 합니다.") + Long productId, + @Positive(message = "상품의 수량은 반드시 1개 이상이어야 합니다.") + Integer quantity +) { +} diff --git a/src/main/java/gift/dto/wishproduct/WishProductResponse.java b/src/main/java/gift/dto/wishproduct/WishProductResponse.java new file mode 100644 index 000000000..2eb609312 --- /dev/null +++ b/src/main/java/gift/dto/wishproduct/WishProductResponse.java @@ -0,0 +1,9 @@ +package gift.dto.wishproduct; + +import gift.dto.product.ProductBasicInformation; + +public record WishProductResponse(Long id, ProductBasicInformation productBasicInformation, Integer quantity) { + public static WishProductResponse of(Long id, ProductBasicInformation productBasicInformation, Integer quantity) { + return new WishProductResponse(id, productBasicInformation, quantity); + } +} diff --git a/src/main/java/gift/dto/wishproduct/WishProductUpdateRequest.java b/src/main/java/gift/dto/wishproduct/WishProductUpdateRequest.java new file mode 100644 index 000000000..845b40628 --- /dev/null +++ b/src/main/java/gift/dto/wishproduct/WishProductUpdateRequest.java @@ -0,0 +1,9 @@ +package gift.dto.wishproduct; + +import jakarta.validation.constraints.PositiveOrZero; + +public record WishProductUpdateRequest( + @PositiveOrZero(message = "상품의 수량은 반드시 0개 이상이어야 합니다.") + Integer quantity +) { +} diff --git a/src/main/java/gift/exception/BadRequestException.java b/src/main/java/gift/exception/BadRequestException.java new file mode 100644 index 000000000..257cf9307 --- /dev/null +++ b/src/main/java/gift/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package gift.exception; + +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/gift/exception/DuplicatedEmailException.java b/src/main/java/gift/exception/DuplicatedEmailException.java new file mode 100644 index 000000000..a77e691d8 --- /dev/null +++ b/src/main/java/gift/exception/DuplicatedEmailException.java @@ -0,0 +1,7 @@ +package gift.exception; + +public class DuplicatedEmailException extends RuntimeException { + public DuplicatedEmailException(String message) { + super(message); + } +} diff --git a/src/main/java/gift/exception/DuplicatedNameException.java b/src/main/java/gift/exception/DuplicatedNameException.java new file mode 100644 index 000000000..76083d9b7 --- /dev/null +++ b/src/main/java/gift/exception/DuplicatedNameException.java @@ -0,0 +1,7 @@ +package gift.exception; + +public class DuplicatedNameException extends RuntimeException { + public DuplicatedNameException(String message) { + super(message); + } +} diff --git a/src/main/java/gift/exception/GlobalExceptionHandler.java b/src/main/java/gift/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..aee7e7143 --- /dev/null +++ b/src/main/java/gift/exception/GlobalExceptionHandler.java @@ -0,0 +1,100 @@ +package gift.exception; + +import io.jsonwebtoken.ExpiredJwtException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.mapping.PropertyReferenceException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.net.URI; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final String NOT_FOUND_MESSAGE = "존재하지 않는 리소스에 대한 접근입니다."; + private static final String INVALID_PRODUCT_NAME_WITH_KAKAO_MESSAGE = "카카오가 포함된 문구는 담당 MD와 협의한 경우에만 사용할 수 있습니다."; + private static final String DUPLICATED_EMAIL_MESSAGE = "이미 존재하는 이메일입니다."; + private static final String DUPLICATED_NAME_MESSAGE = "이미 존재하는 이름입니다."; + private static final String INVALID_LOGIN_INFO_MESSAGE = "로그인 정보가 유효하지 않습니다."; + private static final String INVALID_PAGE_REQUEST_MESSAGE = "요청에 담긴 페이지 정보가 유효하지 않습니다."; + private static final String UNAUTHORIZED_ACCESS_MESSAGE = "인가되지 않은 요청입니다."; + private static final String EXPIRED_JWT_MESSAGE = "인증 정보가 만료되었습니다."; + @Value("${kakao.redirect-token-uri}") + private String redirectTokenUri; + + @ExceptionHandler(value = NotFoundElementException.class) + public ResponseEntity notFoundElementExceptionHandling() { + return new ResponseEntity<>(NOT_FOUND_MESSAGE, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(value = InvalidProductNameWithKAKAOException.class) + public ResponseEntity invalidProductNameWithKAKAOExceptionHandling() { + return new ResponseEntity<>(INVALID_PRODUCT_NAME_WITH_KAKAO_MESSAGE, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(value = DuplicatedEmailException.class) + public ResponseEntity duplicatedEmailExceptionHandling() { + return new ResponseEntity<>(DUPLICATED_EMAIL_MESSAGE, HttpStatus.CONFLICT); + } + + @ExceptionHandler(value = DuplicatedNameException.class) + public ResponseEntity duplicatedNameExceptionHandling() { + return new ResponseEntity<>(DUPLICATED_NAME_MESSAGE, HttpStatus.CONFLICT); + } + + @ExceptionHandler(value = InvalidLoginInfoException.class) + public ResponseEntity invalidLoginInfoExceptionHandling() { + return new ResponseEntity<>(INVALID_LOGIN_INFO_MESSAGE, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(value = UnauthorizedAccessException.class) + public ResponseEntity unauthorizedAccessExceptionHandling() { + return new ResponseEntity<>(UNAUTHORIZED_ACCESS_MESSAGE, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(value = ExpiredJwtException.class) + public ResponseEntity expiredJwtExceptionHandling() { + return new ResponseEntity<>(EXPIRED_JWT_MESSAGE, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(value = InvalidKakaoTokenException.class) + public ResponseEntity invalidKakaoTokenExceptionHandling() { + var headers = new HttpHeaders(); + String redirectLocation = redirectTokenUri; + headers.setLocation(URI.create(redirectLocation)); + return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY); + } + + @ExceptionHandler(value = BadRequestException.class) + public ResponseEntity badRequestExceptionHandling(BadRequestException exception) { + return new ResponseEntity<>(exception.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(value = MethodArgumentNotValidException.class) + public ResponseEntity methodArgumentNotValidExceptionHandling(MethodArgumentNotValidException exception) { + BindingResult bindingResult = exception.getBindingResult(); + + StringBuilder builder = new StringBuilder(); + for (FieldError fieldError : bindingResult.getFieldErrors()) { + builder.append(fieldError.getDefaultMessage()); + } + + return ResponseEntity.badRequest().body(builder.toString()); + } + + @ExceptionHandler(value = PropertyReferenceException.class) + public ResponseEntity propertyReferenceExceptionHandling() { + return new ResponseEntity<>(INVALID_PAGE_REQUEST_MESSAGE, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(value = Exception.class) + public ResponseEntity internalServerExceptionHandling(Exception exception) { + return new ResponseEntity<>(exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/gift/exception/InvalidKakaoTokenException.java b/src/main/java/gift/exception/InvalidKakaoTokenException.java new file mode 100644 index 000000000..67511aba9 --- /dev/null +++ b/src/main/java/gift/exception/InvalidKakaoTokenException.java @@ -0,0 +1,7 @@ +package gift.exception; + +public class InvalidKakaoTokenException extends RuntimeException { + public InvalidKakaoTokenException(String message) { + super(message); + } +} diff --git a/src/main/java/gift/exception/InvalidLoginInfoException.java b/src/main/java/gift/exception/InvalidLoginInfoException.java new file mode 100644 index 000000000..0307e1e0e --- /dev/null +++ b/src/main/java/gift/exception/InvalidLoginInfoException.java @@ -0,0 +1,7 @@ +package gift.exception; + +public class InvalidLoginInfoException extends RuntimeException { + public InvalidLoginInfoException(String message) { + super(message); + } +} diff --git a/src/main/java/gift/exception/InvalidProductNameWithKAKAOException.java b/src/main/java/gift/exception/InvalidProductNameWithKAKAOException.java new file mode 100644 index 000000000..3d49b293e --- /dev/null +++ b/src/main/java/gift/exception/InvalidProductNameWithKAKAOException.java @@ -0,0 +1,7 @@ +package gift.exception; + +public class InvalidProductNameWithKAKAOException extends RuntimeException { + public InvalidProductNameWithKAKAOException(String message) { + super(message); + } +} diff --git a/src/main/java/gift/exception/NotFoundElementException.java b/src/main/java/gift/exception/NotFoundElementException.java new file mode 100644 index 000000000..987fb49d3 --- /dev/null +++ b/src/main/java/gift/exception/NotFoundElementException.java @@ -0,0 +1,7 @@ +package gift.exception; + +public class NotFoundElementException extends RuntimeException { + public NotFoundElementException(String message) { + super(message); + } +} diff --git a/src/main/java/gift/exception/UnauthorizedAccessException.java b/src/main/java/gift/exception/UnauthorizedAccessException.java new file mode 100644 index 000000000..fe1badd61 --- /dev/null +++ b/src/main/java/gift/exception/UnauthorizedAccessException.java @@ -0,0 +1,7 @@ +package gift.exception; + +public class UnauthorizedAccessException extends RuntimeException { + public UnauthorizedAccessException(String message) { + super(message); + } +} diff --git a/src/main/java/gift/model/BaseEntity.java b/src/main/java/gift/model/BaseEntity.java new file mode 100644 index 000000000..cdaa73c27 --- /dev/null +++ b/src/main/java/gift/model/BaseEntity.java @@ -0,0 +1,29 @@ +package gift.model; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @CreatedDate + private LocalDateTime createdDate; + + public Long getId() { + return id; + } + + public LocalDateTime getCreatedDate() { + return createdDate; + } +} diff --git a/src/main/java/gift/model/Category.java b/src/main/java/gift/model/Category.java new file mode 100644 index 000000000..274b26b4c --- /dev/null +++ b/src/main/java/gift/model/Category.java @@ -0,0 +1,63 @@ +package gift.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "category") +@SQLDelete(sql = "update category set deleted = true where id = ?") +@SQLRestriction("deleted is false") +public class Category extends BaseEntity { + @NotNull + @Column(name = "name") + private String name; + @NotNull + @Column(name = "description") + private String description; + @NotNull + @Column(name = "color") + private String color; + @NotNull + @Column(name = "image_url") + private String imageUrl; + @NotNull + @Column(name = "deleted") + private Boolean deleted = Boolean.FALSE; + + protected Category() { + } + + public Category(String name, String description, String color, String imageUrl) { + this.name = name; + this.description = description; + this.color = color; + this.imageUrl = imageUrl; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getColor() { + return color; + } + + public String getImageUrl() { + return imageUrl; + } + + public void updateCategory(String name, String description, String color, String imageUrl) { + this.name = name; + this.description = description; + this.color = color; + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/gift/model/GiftOrder.java b/src/main/java/gift/model/GiftOrder.java new file mode 100644 index 000000000..056232396 --- /dev/null +++ b/src/main/java/gift/model/GiftOrder.java @@ -0,0 +1,62 @@ +package gift.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "gift_order") +@SQLDelete(sql = "update gift_order set deleted = true where id = ?") +@SQLRestriction("deleted is false") +public class GiftOrder extends BaseEntity { + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", referencedColumnName = "id") + private Member member; + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id", referencedColumnName = "id") + private Option option; + @NotNull + @Column(name = "quantity") + private Integer quantity; + @NotNull + @Column(name = "message") + private String message; + @NotNull + @Column(name = "deleted") + private Boolean deleted = Boolean.FALSE; + + protected GiftOrder() { + } + + public Member getMember() { + return member; + } + + public Option getOption() { + return option; + } + + public Integer getQuantity() { + return quantity; + } + + public String getMessage() { + return message; + } + + public GiftOrder(Member member, Option option, Integer quantity, String message) { + this.member = member; + this.option = option; + this.quantity = quantity; + this.message = message; + } +} diff --git a/src/main/java/gift/model/Member.java b/src/main/java/gift/model/Member.java new file mode 100644 index 000000000..a3d0dbf7d --- /dev/null +++ b/src/main/java/gift/model/Member.java @@ -0,0 +1,69 @@ +package gift.model; + +import gift.exception.InvalidLoginInfoException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "member") +@SQLDelete(sql = "update member set deleted = true where id = ?") +@SQLRestriction("deleted is false") +public class Member extends BaseEntity { + @NotNull + @Column(name = "name") + private String name; + @NotNull + @Column(name = "email", unique = true) + private String email; + @NotNull + @Column(name = "password") + private String password; + @NotNull + @Enumerated(value = EnumType.STRING) + @Column(name = "role") + private MemberRole role; + @NotNull + @Column(name = "deleted") + private Boolean deleted = Boolean.FALSE; + + protected Member() { + } + + public Member(String name, String email, MemberRole role, OauthType oauthType) { + this.name = name; + this.email = email; + this.password = oauthType.name(); + this.role = role; + } + + public Member(String name, String email, String password, MemberRole role) { + this.name = name; + this.email = email; + this.password = password; + this.role = role; + } + + public String getName() { + return name; + } + + public String getPassword() { + return password; + } + + public MemberRole getRole() { + return role; + } + + public void passwordCheck(String inputPassword) { + if (!password.equals(inputPassword)) { + throw new InvalidLoginInfoException("로그인 정보가 유효하지 않습니다."); + } + } +} diff --git a/src/main/java/gift/model/MemberRole.java b/src/main/java/gift/model/MemberRole.java new file mode 100644 index 000000000..5616538c5 --- /dev/null +++ b/src/main/java/gift/model/MemberRole.java @@ -0,0 +1,6 @@ +package gift.model; + +public enum MemberRole { + MEMBER, + ADMIN +} diff --git a/src/main/java/gift/model/OauthToken.java b/src/main/java/gift/model/OauthToken.java new file mode 100644 index 000000000..dd643a14b --- /dev/null +++ b/src/main/java/gift/model/OauthToken.java @@ -0,0 +1,85 @@ +package gift.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "oauth_token") +public class OauthToken extends BaseEntity { + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", referencedColumnName = "id") + private Member member; + @NotNull + @Column(name = "access_token") + private String accessToken; + @NotNull + @Column(name = "access_token_expires_in") + private Integer accessTokenExpiresIn; + @NotNull + @Column(name = "refresh_token") + private String refreshToken; + @NotNull + @Column(name = "refresh_token_expires_in") + private Integer refreshTokenExpiresIn; + @NotNull + @Enumerated(value = EnumType.STRING) + @Column(name = "oauth_type") + private OauthType oauthType; + + protected OauthToken() { + } + + public OauthToken(Member member, OauthType oauthType, String accessToken, Integer accessTokenExpiresIn, String refreshToken, Integer refreshTokenExpiresIn) { + this.member = member; + this.oauthType = oauthType; + this.accessToken = accessToken; + this.accessTokenExpiresIn = accessTokenExpiresIn; + this.refreshToken = refreshToken; + this.refreshTokenExpiresIn = refreshTokenExpiresIn; + } + + public String getAccessToken() { + return accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public Member getMember() { + return member; + } + + public void updateToken(String accessToken, Integer accessTokenExpiresIn, String refreshToken, Integer refreshTokenExpiresIn) { + this.accessToken = accessToken; + this.accessTokenExpiresIn = accessTokenExpiresIn; + if (refreshToken != null) { + this.refreshToken = refreshToken; + this.refreshTokenExpiresIn = refreshTokenExpiresIn; + } + } + + public Boolean canUseAccessToken() { + if (super.getCreatedDate().plusSeconds(accessTokenExpiresIn).isAfter(LocalDateTime.now())) { + return Boolean.TRUE; + } + return Boolean.FALSE; + } + + public Boolean canUseRefreshToken() { + if (super.getCreatedDate().plusSeconds(refreshTokenExpiresIn).isAfter(LocalDateTime.now())) { + return Boolean.TRUE; + } + return Boolean.FALSE; + } +} diff --git a/src/main/java/gift/model/OauthType.java b/src/main/java/gift/model/OauthType.java new file mode 100644 index 000000000..fbc4bfa17 --- /dev/null +++ b/src/main/java/gift/model/OauthType.java @@ -0,0 +1,5 @@ +package gift.model; + +public enum OauthType { + KAKAO +} diff --git a/src/main/java/gift/model/Option.java b/src/main/java/gift/model/Option.java new file mode 100644 index 000000000..80928bec9 --- /dev/null +++ b/src/main/java/gift/model/Option.java @@ -0,0 +1,65 @@ +package gift.model; + +import gift.exception.BadRequestException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "option") +@SQLDelete(sql = "update option set deleted = true where id = ?") +@SQLRestriction("deleted is false") +public class Option extends BaseEntity { + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", referencedColumnName = "id") + private Product product; + @NotNull + @Column(name = "name") + private String name; + @NotNull + @Column(name = "quantity") + private Integer quantity; + @NotNull + @Column(name = "deleted") + private Boolean deleted = Boolean.FALSE; + + protected Option() { + } + + public Option(Product product, String name, Integer quantity) { + this.product = product; + this.name = name; + this.quantity = quantity; + } + + public Product getProduct() { + return product; + } + + public String getName() { + return name; + } + + public Integer getQuantity() { + return quantity; + } + + public void updateOptionInfo(String newName, Integer newQuantity) { + this.name = newName; + this.quantity = newQuantity; + } + + public void subtract(Integer subQuantity) { + if (subQuantity > quantity) { + throw new BadRequestException("주문량이 옵션의 잔여 갯수를 초과합니다"); + } + this.quantity = quantity - subQuantity; + } +} diff --git a/src/main/java/gift/model/Product.java b/src/main/java/gift/model/Product.java new file mode 100644 index 000000000..898e832ed --- /dev/null +++ b/src/main/java/gift/model/Product.java @@ -0,0 +1,66 @@ +package gift.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "product") +@SQLDelete(sql = "update product set deleted = true where id = ?") +@SQLRestriction("deleted is false") +public class Product extends BaseEntity { + @NotNull + @Column(name = "name") + private String name; + @NotNull + @Column(name = "price") + private Integer price; + @NotNull + @Column(name = "image_url") + private String imageUrl; + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", referencedColumnName = "id") + private Category category; + @NotNull + @Column(name = "deleted") + private Boolean deleted = Boolean.FALSE; + + protected Product() { + } + + public Product(String name, Integer price, String imageUrl, Category category) { + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + this.category = category; + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public String getImageUrl() { + return imageUrl; + } + + public Category getCategory() { + return category; + } + + public void updateProductInfo(String name, Integer price, String imageUrl) { + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/gift/model/WishProduct.java b/src/main/java/gift/model/WishProduct.java new file mode 100644 index 000000000..89a7205d3 --- /dev/null +++ b/src/main/java/gift/model/WishProduct.java @@ -0,0 +1,57 @@ +package gift.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "wish_product") +@SQLDelete(sql = "update wish_product set deleted = true where id = ?") +@SQLRestriction("deleted is false") +public class WishProduct extends BaseEntity { + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", referencedColumnName = "id") + private Product product; + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", referencedColumnName = "id") + private Member member; + @NotNull + @Column(name = "quantity") + private Integer quantity; + @NotNull + @Column(name = "deleted") + private Boolean deleted = Boolean.FALSE; + + protected WishProduct() { + } + + public WishProduct(Product product, Member member, Integer quantity) { + this.product = product; + this.member = member; + this.quantity = quantity; + } + + public Product getProduct() { + return product; + } + + public Member getMember() { + return member; + } + + public Integer getQuantity() { + return quantity; + } + + public void updateQuantity(Integer updateQuantity) { + this.quantity = updateQuantity; + } +} diff --git a/src/main/java/gift/repository/CategoryRepository.java b/src/main/java/gift/repository/CategoryRepository.java new file mode 100644 index 000000000..455eaf73f --- /dev/null +++ b/src/main/java/gift/repository/CategoryRepository.java @@ -0,0 +1,10 @@ +package gift.repository; + +import gift.model.Category; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CategoryRepository extends JpaRepository { + boolean existsByName(String name); +} diff --git a/src/main/java/gift/repository/GiftOrderRepository.java b/src/main/java/gift/repository/GiftOrderRepository.java new file mode 100644 index 000000000..b08789e2f --- /dev/null +++ b/src/main/java/gift/repository/GiftOrderRepository.java @@ -0,0 +1,18 @@ +package gift.repository; + +import gift.model.GiftOrder; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GiftOrderRepository extends JpaRepository { + + void deleteAllByOptionId(Long optionId); + + void deleteAllByMemberId(Long memberId); + + List findAllByMemberId(Long memberId, Pageable pageable); +} diff --git a/src/main/java/gift/repository/MemberRepository.java b/src/main/java/gift/repository/MemberRepository.java new file mode 100644 index 000000000..17df94405 --- /dev/null +++ b/src/main/java/gift/repository/MemberRepository.java @@ -0,0 +1,16 @@ +package gift.repository; + +import gift.model.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + boolean existsByEmailAndPassword(String email, String password); +} diff --git a/src/main/java/gift/repository/OauthTokenRepository.java b/src/main/java/gift/repository/OauthTokenRepository.java new file mode 100644 index 000000000..361059be4 --- /dev/null +++ b/src/main/java/gift/repository/OauthTokenRepository.java @@ -0,0 +1,17 @@ +package gift.repository; + +import gift.model.OauthToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface OauthTokenRepository extends JpaRepository { + + boolean existsByMemberId(Long memberId); + + Optional findByMemberId(Long memberId); + + void deleteByMemberId(Long memberId); +} diff --git a/src/main/java/gift/repository/OptionRepository.java b/src/main/java/gift/repository/OptionRepository.java new file mode 100644 index 000000000..aa8ee4394 --- /dev/null +++ b/src/main/java/gift/repository/OptionRepository.java @@ -0,0 +1,25 @@ +package gift.repository; + +import gift.model.Option; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface OptionRepository extends JpaRepository { + List