diff --git a/app/build.gradle b/app/build.gradle index 8f9d2fc96..8e3b3e574 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.1' id 'io.spring.dependency-management' version '1.1.7' + id "jacoco" } group = 'com.movie' @@ -29,6 +30,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.43.0' + implementation 'com.google.guava:guava:33.4.0-jre' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' runtimeOnly 'com.mysql:mysql-connector-j' @@ -39,4 +43,85 @@ dependencies { tasks.named('test') { useJUnitPlatform() + finalizedBy 'jacocoTestReport' } + +// jacoco 정보 +jacoco { + toolVersion = "0.8.11" + layout.buildDirectory.dir("reports/jacoco") +} + +// jacoco Report 생성 +jacocoTestReport { + dependsOn test // test 종속성 추가 + + reports { + xml.required = true + csv.required = false + html.required = true + } + + def QDomainList = [] + for (qPattern in '**/QA'..'**/QZ') { // QClass 대응 + QDomainList.add(qPattern + '*') + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + '**/dto/**', + '**/event/**', + '**/*InitData*', + '**/*Application*', + '**/exception/**', + '**/service/alarm/**', + '**/aop/**', + '**/config/**', + '**/MemberRole*' + ] + QDomainList) + })) + } + + finalizedBy 'jacocoTestCoverageVerification' // jacocoTestReport 태스크가 끝난 후 실행 +} + +// jacoco Test 유효성 확인 +jacocoTestCoverageVerification { + def QDomainList = [] + for (qPattern in '*.QA'..'*.QZ') { // QClass 대응 + QDomainList.add(qPattern + '*') + } + + violationRules { + rule { + enabled = true // 규칙 활성화 여부 + element = 'CLASS' // 커버리지를 체크할 단위 설정 + + // 코드 커버리지를 측정할 때 사용되는 지표 + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.30 + } + + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.30 + } + + excludes = [ + '**.dto.**', + '**.event.**', + '**.*InitData*', + '**.*Application*', + '**.exception.**', + '**.service.alarm.**', + '**.aop.**', + '**.config.**', + '**.MemberRole*' + ] + QDomainList + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/movie/app/controller/MovieRestController.java b/app/src/main/java/com/movie/app/controller/MovieRestController.java index 0faa91463..94a654976 100644 --- a/app/src/main/java/com/movie/app/controller/MovieRestController.java +++ b/app/src/main/java/com/movie/app/controller/MovieRestController.java @@ -1,12 +1,18 @@ package com.movie.app.controller; import java.util.List; +import com.google.common.util.concurrent.RateLimiter; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import com.movie.app.domain.Movie; -import com.movie.app.domain.MovieRepository; import com.movie.app.domain.MovieRequestDto; +import com.movie.app.domain.TicketingRequestDto; +import com.movie.app.service.MovieService; +import jakarta.annotation.PostConstruct; import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; @@ -15,27 +21,46 @@ @RestController public class MovieRestController { - private final MovieRepository movieRepository; + private final MovieService movieService; + private static RateLimiter moviesRateLimiter; + private static RateLimiter ticketingLimiter; + + @PostConstruct + public void init() { + moviesRateLimiter = RateLimiter.create(0.8);//50permits/60sec = 0.8permits/1sec + ticketingLimiter = RateLimiter.create(0.003);//1permits/5min = 0.003permits/1sec + } @GetMapping("/api/movies") - public List getMovies( + public ResponseEntity> getMovies( @Size(max = 100, message = "title length should not exceed 100 characters") @RequestParam(required=false) String title, @RequestParam(required=false) String genre) { + + if(!moviesRateLimiter.tryAcquire()) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); + } if(genre != null) { - return movieRepository.findByGenre(genre); + return ResponseEntity.ok(movieService.getMoviesByGenre(genre)); } else if (title != null) { - return movieRepository.findByTitle(title); + return ResponseEntity.ok(movieService.getMoviesByTitle(title)); } else { - return movieRepository.findAll(); + return ResponseEntity.ok(movieService.getMoviesAll()); } } @PostMapping("/api/movies") public Movie postMovies(@RequestBody MovieRequestDto requestDto) { - Movie movie = new Movie(requestDto); - movieRepository.save(movie); - return movie; + return movieService.postMovie(requestDto); + } + + @PutMapping("/api/ticketing/{id}") + public ResponseEntity ticketingMovie(@PathVariable Long id, @RequestBody TicketingRequestDto requestDto) { + if(!ticketingLimiter.tryAcquire()) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); + } + + return ResponseEntity.ok(movieService.ticketing(id ,requestDto)); } } diff --git a/app/src/main/java/com/movie/app/domain/Movie.java b/app/src/main/java/com/movie/app/domain/Movie.java index 2aea0382a..3f1cdb5ac 100644 --- a/app/src/main/java/com/movie/app/domain/Movie.java +++ b/app/src/main/java/com/movie/app/domain/Movie.java @@ -1,5 +1,6 @@ package com.movie.app.domain; +import java.time.LocalTime; import java.util.List; import jakarta.persistence.Column; @@ -14,6 +15,9 @@ @NoArgsConstructor @Entity(name="Movie") public class Movie extends Timestamped{ + + private static final int MAX_SEATS = 25; + @GeneratedValue(strategy = GenerationType.AUTO) @Id private Long id; @@ -40,7 +44,14 @@ public class Movie extends Timestamped{ private String theater; @Column - private List screeningSchedule; + private LocalTime screenStartTime; + + @Column + private LocalTime screenEndTime; + + @Column + private Boolean[] seats = new Boolean[MAX_SEATS]; + public Movie(MovieRequestDto requestDto) { this.title = requestDto.getTitle(); @@ -50,6 +61,16 @@ public Movie(MovieRequestDto requestDto) { this.runningTime = requestDto.getRunningTime(); this.genre = requestDto.getGenre(); this.theater = requestDto.getTheater(); - this.screeningSchedule = requestDto.getScreeningSchedule(); + } + + public void updateSeats(List wantedSeats) { + if(this.seats == null) { + this.seats = new Boolean[MAX_SEATS]; + } + + for (int i=0; i seats; +} diff --git a/app/src/main/java/com/movie/app/domain/Timestamped.java b/app/src/main/java/com/movie/app/domain/Timestamped.java index 416a84e7c..b1306a6ef 100644 --- a/app/src/main/java/com/movie/app/domain/Timestamped.java +++ b/app/src/main/java/com/movie/app/domain/Timestamped.java @@ -8,6 +8,11 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import lombok.Getter; @@ -17,9 +22,13 @@ @EntityListeners(AuditingEntityListener.class) public class Timestamped { + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) @CreatedDate private LocalDateTime createdAt; + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) @LastModifiedDate private LocalDateTime modifiedAt; diff --git a/app/src/main/java/com/movie/app/service/MovieService.java b/app/src/main/java/com/movie/app/service/MovieService.java index 897c5b7d5..2d72f6dc1 100644 --- a/app/src/main/java/com/movie/app/service/MovieService.java +++ b/app/src/main/java/com/movie/app/service/MovieService.java @@ -1,12 +1,18 @@ package com.movie.app.service; import java.util.List; +import java.util.concurrent.TimeUnit; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.movie.app.domain.Movie; import com.movie.app.domain.MovieRepository; +import com.movie.app.domain.MovieRequestDto; +import com.movie.app.domain.TicketingRequestDto; import lombok.RequiredArgsConstructor; @@ -15,6 +21,7 @@ public class MovieService { private final MovieRepository movieRepository; + private final RedissonClient redissonClient; @Cacheable(value = "Movies", key = "#title", cacheManager = "contentCacheManager") public List getMoviesByTitle(String title) { @@ -26,9 +33,41 @@ public List getMoviesByGenre(String genre) { return movieRepository.findByGenre(genre); } - @Cacheable(value = "Movies", key = "all", cacheManager = "contentCacheManager") - public List getMoviesByGenre() { + @Cacheable(value = "Movies", key = "'all'", cacheManager = "contentCacheManager") + public List getMoviesAll() { return movieRepository.findAll(); } + public Movie postMovie(MovieRequestDto requestDto) { + Movie movie = new Movie(requestDto); + movieRepository.save(movie); + return movie; + } + + @Transactional + public Movie ticketing(Long id, TicketingRequestDto requestDto) { + Movie movie = movieRepository.findById(id).orElseThrow( + () -> new NullPointerException("There is no id at DB.") + ); + + if(movie==null) { + return movie; + } + + RLock lock = redissonClient.getLock(id.toString()); + try { + boolean acquireLock = lock.tryLock(10, 1, TimeUnit.SECONDS); + if (!acquireLock) { + System.out.println("Lock get fail"); + return movie; + } + movie.updateSeats(requestDto.getSeats()); + } catch (InterruptedException e) { + } finally { + lock.unlock(); + } + + return movie; + } + } diff --git a/app/src/main/resources/application.properties b/app/src/main/resources/application.properties index 48743fc7a..606875365 100644 --- a/app/src/main/resources/application.properties +++ b/app/src/main/resources/application.properties @@ -1,15 +1,14 @@ -spring.datasource.url=jdbc:mysql://mysql:3306/redis1st -spring.datasource.username=ID -spring.datasource.password=PASSWORD +spring.datasource.url=jdbc:mysql://localhost:3306/redis1st +spring.datasource.username=redis1st +spring.datasource.password=password spring.application.name=app -spring.jpa.hibernate.ddl-auto= update spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.MySQLDialect spring.jpa.defer-datasource-initialization=true spring.sql.init.mode=always -spring.data.redis.host=redis +spring.data.redis.host=localhost spring.data.redis.port=6379 spring.docker.compose.enabled=true \ No newline at end of file diff --git a/app/src/test/java/com/movie/app/AppApplicationTests.java b/app/src/test/java/com/movie/app/AppApplicationTests.java index c652844d3..3b2f35d61 100644 --- a/app/src/test/java/com/movie/app/AppApplicationTests.java +++ b/app/src/test/java/com/movie/app/AppApplicationTests.java @@ -8,6 +8,7 @@ class AppApplicationTests { @Test void contextLoads() { + System.out.println("Hello Test!!!!!!!!!!"); } }