diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..5e4e8f3 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,62 @@ +name: CI FOR MVP + +on: + push: +# branches-ignore: +# - main +# - develop + +jobs: + CI: + name: Continuous Integration + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup MySQL + uses: mirromutth/mysql-action@v1.1 + with: + mysql database: 'testDB' + mysql user: 'test' + mysql password: 'testPW' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 + + - name: Get short SHA + id: slug + run: echo "sha7=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Create application.properties + run: | + cat < ./src/main/resources/application.properties + spring.application.name=ureca + + spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + spring.datasource.url=jdbc:mysql://localhost:3306/testDB?characterEncoding=UTF-8&serverTimezone=Asia/Seoul + spring.datasource.username=test + spring.datasource.password=testPW + + # spring jpa + spring.jpa.database=mysql + spring.jpa.properties.hibernate.show_sql=true + spring.jpa.hibernate.ddl-auto=create-drop + spring.jpa.properties.hibernate.format_sql=true + EOT + shell: bash + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew build diff --git a/.gitignore b/.gitignore index c2065bc..39fb52a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +/src/main/resources/application.properties \ No newline at end of file diff --git a/build.gradle b/build.gradle index d903a73..00d8539 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.kafka:spring-kafka' + implementation 'com.fasterxml.jackson.core:jackson-databind' + + testImplementation 'org.springframework.kafka:spring-kafka-test' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/quickpick/ureca/UrecaApplication.java b/src/main/java/com/quickpick/ureca/UrecaApplication.java index 5911fa6..14add35 100644 --- a/src/main/java/com/quickpick/ureca/UrecaApplication.java +++ b/src/main/java/com/quickpick/ureca/UrecaApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableJpaAuditing +@EnableScheduling public class UrecaApplication { - public static void main(String[] args) { SpringApplication.run(UrecaApplication.class, args); } - -} +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v3/common/BaseEntityV3.java b/src/main/java/com/quickpick/ureca/v3/common/BaseEntityV3.java new file mode 100644 index 0000000..ce21bc1 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/common/BaseEntityV3.java @@ -0,0 +1,26 @@ +package com.quickpick.ureca.v3.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntityV3 { + @CreatedDate + @Column(length = 6, name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(length = 6, name = "updated_at") + private LocalDateTime updatedAt; + +} + diff --git a/src/main/java/com/quickpick/ureca/v3/config/KafkaConsumerConfigV3.java b/src/main/java/com/quickpick/ureca/v3/config/KafkaConsumerConfigV3.java new file mode 100644 index 0000000..84ea84c --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/config/KafkaConsumerConfigV3.java @@ -0,0 +1,24 @@ +package com.quickpick.ureca.v3.config; + +import com.quickpick.ureca.v3.ticket.event.TicketPurchaseEventV3; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; + +@Configuration +public class KafkaConsumerConfigV3 { + + @Bean + public ConcurrentKafkaListenerContainerFactory batchFactory( + ConsumerFactory consumerFactory) { + + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + + factory.setConsumerFactory(consumerFactory); + factory.setBatchListener(true); // 핵심 + factory.setConcurrency(3); + return factory; + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/init/InitControllerV3.java b/src/main/java/com/quickpick/ureca/v3/init/InitControllerV3.java new file mode 100644 index 0000000..d3bf536 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/init/InitControllerV3.java @@ -0,0 +1,59 @@ +package com.quickpick.ureca.v3.init; + +import com.quickpick.ureca.v3.ticket.domain.TicketV3; +import com.quickpick.ureca.v3.ticket.repository.RedisStockRepositoryV3; +import com.quickpick.ureca.v3.ticket.repository.TicketRepositoryV3; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@RestController +@RequestMapping("/v3/init") +@RequiredArgsConstructor +public class InitControllerV3 { + + private final TicketRepositoryV3 ticketRepositoryV3; + private final RedisStockRepositoryV3 redisStockRepositoryV3; + private final JdbcTemplate jdbcTemplate; + + @PostMapping + @Transactional + public void init() { + + String sql = "INSERT INTO user (user_id, name, age, gender, password) VALUES (?, ?, ?, ?, ?)"; + List batchArgs = new ArrayList<>(); + + for (long i = 1; i <= 10000; i++) { + batchArgs.add(new Object[]{ + i, + "abc" + i, + i+20, + "MALE", + "password" + i + + }); + } + + jdbcTemplate.batchUpdate(sql, batchArgs); + System.out.println("=== 10만명 유저 생성 완료 ==="); + + + TicketV3 ticketV3 = TicketV3.builder() + .name("ticket") + .quantity(3000L) + .reserveTime(LocalDateTime.now()) + .startDate(LocalDate.now()) + .build(); + + TicketV3 saveTicketV3 = ticketRepositoryV3.save(ticketV3); + redisStockRepositoryV3.setTicket(saveTicketV3.getId(), 3000L); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/init/InitDataLoaderV3.java b/src/main/java/com/quickpick/ureca/v3/init/InitDataLoaderV3.java new file mode 100644 index 0000000..93e9763 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/init/InitDataLoaderV3.java @@ -0,0 +1,57 @@ +//package com.quickpick.ureca.v3.init; +// +//import com.quickpick.ureca.v3.ticket.domain.TicketV3; +//import com.quickpick.ureca.v3.ticket.repository.RedisStockRepositoryV3; +//import com.quickpick.ureca.v3.ticket.repository.TicketRepositoryV3; +//import jakarta.transaction.Transactional; +//import lombok.RequiredArgsConstructor; +//import org.springframework.boot.CommandLineRunner; +//import org.springframework.jdbc.core.JdbcTemplate; +//import org.springframework.stereotype.Component; +// +//import java.time.LocalDate; +//import java.time.LocalDateTime; +//import java.util.ArrayList; +//import java.util.List; +// +//@Component +//@RequiredArgsConstructor +//public class InitDataLoaderV3 implements CommandLineRunner { +// +// private final TicketRepositoryV3 ticketRepositoryV3; +// private final RedisStockRepositoryV3 redisStockRepositoryV3; +// private final JdbcTemplate jdbcTemplate; +// +// @Transactional +// @Override +// public void run(String... args) { +// String sql = "INSERT INTO user (user_id, name, age, gender, password) VALUES (?, ?, ?, ?, ?)"; +// +// List batchArgs = new ArrayList<>(); +// +// for (long i = 1; i <= 10000; i++) { +// batchArgs.add(new Object[]{ +// i, +// "abc" + i, +// i+20, +// "MALE", +// "password" + i +// +// }); +// } +// +// jdbcTemplate.batchUpdate(sql, batchArgs); +// System.out.println("=== 10만명 유저 생성 완료 ==="); +// +// +// TicketV3 ticketV3 = TicketV3.builder() +// .name("ticket") +// .quantity(3000L) +// .reserveTime(LocalDateTime.now()) +// .startDate(LocalDate.now()) +// .build(); +// +// TicketV3 saveTicketV3 = ticketRepositoryV3.save(ticketV3); +// redisStockRepositoryV3.setTicket(saveTicketV3.getId(), 3000L); +// } +//} diff --git a/src/main/java/com/quickpick/ureca/v3/reserve/domain/ReserveStatusV3.java b/src/main/java/com/quickpick/ureca/v3/reserve/domain/ReserveStatusV3.java new file mode 100644 index 0000000..6e87693 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/reserve/domain/ReserveStatusV3.java @@ -0,0 +1,5 @@ +package com.quickpick.ureca.v3.reserve.domain; + +public enum ReserveStatusV3 { + SUCCESS, FAIL +} diff --git a/src/main/java/com/quickpick/ureca/v3/reserve/domain/ReserveV3.java b/src/main/java/com/quickpick/ureca/v3/reserve/domain/ReserveV3.java new file mode 100644 index 0000000..0aaefec --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/reserve/domain/ReserveV3.java @@ -0,0 +1,27 @@ +package com.quickpick.ureca.v3.reserve.domain; + +import com.quickpick.ureca.v3.common.BaseEntityV3; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ReserveV3 extends BaseEntityV3 { + + @Column(name = "reserve_id") + @Id @GeneratedValue + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(nullable = false) + private ReserveStatusV3 status; +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/ExceptionHandlers.java b/src/main/java/com/quickpick/ureca/v3/ticket/ExceptionHandlers.java new file mode 100644 index 0000000..b171ff0 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/ExceptionHandlers.java @@ -0,0 +1,15 @@ +package com.quickpick.ureca.v3.ticket; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class ExceptionHandlers { + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleFileException() { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("티켓팅 실패"); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/controller/TicketControllerV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/controller/TicketControllerV3.java new file mode 100644 index 0000000..16462c9 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/controller/TicketControllerV3.java @@ -0,0 +1,19 @@ +package com.quickpick.ureca.v3.ticket.controller; + +import com.quickpick.ureca.v3.ticket.service.TicketServiceV3; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/v3/tickets") +@RequiredArgsConstructor +@RestController +public class TicketControllerV3 { + + private final TicketServiceV3 ticketServiceV3; + + @PostMapping("/{ticketId}/purchase") + public String purchaseTicket(@PathVariable Long ticketId, @RequestParam Long userId) { + ticketServiceV3.purchaseTicket(ticketId, userId, 1L); + return "success"; + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/domain/TicketV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/domain/TicketV3.java new file mode 100644 index 0000000..a868d65 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/domain/TicketV3.java @@ -0,0 +1,34 @@ +package com.quickpick.ureca.v3.ticket.domain; + +import com.quickpick.ureca.v3.common.BaseEntityV3; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "tickets") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TicketV3 extends BaseEntityV3 { + + @Id + @Column(name = "ticket_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Long quantity; + + @Column(nullable = false) + private LocalDate startDate; + + @Column(nullable = false) + private LocalDateTime reserveTime; +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/event/TicketPurchaseEventV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/event/TicketPurchaseEventV3.java new file mode 100644 index 0000000..906bd18 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/event/TicketPurchaseEventV3.java @@ -0,0 +1,18 @@ +package com.quickpick.ureca.v3.ticket.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class TicketPurchaseEventV3 { + private String uuid; + private Long ticketId; + private Long userId; + private Long quantity; + private String time; +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/kafka/TicketEventConsumerV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/kafka/TicketEventConsumerV3.java new file mode 100644 index 0000000..56b8f50 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/kafka/TicketEventConsumerV3.java @@ -0,0 +1,55 @@ +package com.quickpick.ureca.v3.ticket.kafka; + +import com.quickpick.ureca.v3.reserve.domain.ReserveV3; +import com.quickpick.ureca.v3.reserve.domain.ReserveStatusV3; +import com.quickpick.ureca.v3.ticket.event.TicketPurchaseEventV3; +import com.quickpick.ureca.v3.ticket.repository.ReserveRepositoryV3; +import com.quickpick.ureca.v3.ticket.repository.TicketRepositoryV3; +import com.quickpick.ureca.v3.userticket.domain.UserTicketV3; +import com.quickpick.ureca.v3.userticket.repository.UserTicketRepositoryV3; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TicketEventConsumerV3 { + + private final TicketRepositoryV3 ticketRepositoryV3; + private final UserTicketRepositoryV3 userTicketRepositoryV3; + private final ReserveRepositoryV3 reserveRepositoryV3; + + @Transactional + @KafkaListener(topics = "ticket.purchase", groupId = "ticket-service", concurrency = "3") + public void consume(final TicketPurchaseEventV3 event, @Header(KafkaHeaders.RECEIVED_PARTITION) int partition) { + + log.info("ticker.purchased 이벤트 수신, 수신한 아이디 : {}", event.getUserId()); + log.info("Consumed message from partition [{}] by thread [{}], userId: {}", + partition, + Thread.currentThread().getName(), + event.getUserId()); + + int result = ticketRepositoryV3.decreaseStock(event.getTicketId(), 1); + if(result == 0) { + throw new RuntimeException("티켓 수량 부족 및 존재하지 않음"); + } + + UserTicketV3 userTicketV3 = UserTicketV3.builder() + .ticketId(event.getTicketId()) + .userId(event.getUserId()) + .build(); + userTicketRepositoryV3.save(userTicketV3); + + + ReserveV3 reserveV3 = ReserveV3.builder() + .userId(event.getUserId()) + .status(ReserveStatusV3.SUCCESS) + .build(); + reserveRepositoryV3.save(reserveV3); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/kafka/TicketEventProducerV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/kafka/TicketEventProducerV3.java new file mode 100644 index 0000000..0f69b60 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/kafka/TicketEventProducerV3.java @@ -0,0 +1,18 @@ +package com.quickpick.ureca.v3.ticket.kafka; + +import com.quickpick.ureca.v3.ticket.event.TicketPurchaseEventV3; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TicketEventProducerV3 { + + private final KafkaTemplate kafkaTemplate; + private final String TOPIC = "ticket.purchase"; + + public void send(final TicketPurchaseEventV3 ticketPurchaseEventV3) { + kafkaTemplate.send(TOPIC, ticketPurchaseEventV3); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/repository/EventDuplicationRepositoryV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/repository/EventDuplicationRepositoryV3.java new file mode 100644 index 0000000..bf3fbfc --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/repository/EventDuplicationRepositoryV3.java @@ -0,0 +1,22 @@ +package com.quickpick.ureca.v3.ticket.repository; + + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; + +@Repository +@RequiredArgsConstructor +public class EventDuplicationRepositoryV3 { + + private final RedisTemplate redisTemplate; + private static final String KEY_PREFIX = "processed:"; + + public boolean markIfNotProcessed(final Long userId) { + String key = KEY_PREFIX + userId; + return Boolean.TRUE.equals(redisTemplate.opsForValue() + .setIfAbsent(key, "1", Duration.ofMinutes(5))); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/repository/RedisStockRepositoryV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/repository/RedisStockRepositoryV3.java new file mode 100644 index 0000000..2af6e2a --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/repository/RedisStockRepositoryV3.java @@ -0,0 +1,52 @@ +package com.quickpick.ureca.v3.ticket.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.List; + + +@Component +@RequiredArgsConstructor +public class RedisStockRepositoryV3 { + + private final RedisTemplate redisTemplate; + private final String DECREASE_SCRIPT = """ + local key = KEYS[1] + local decrease = tonumber(ARGV[1]) + local current = tonumber(redis.call('get', key)) + if current and current >= decrease then + return redis.call('decrby', key, decrease) + else + return -1 + end + """; + + private final String KEY_PREFIX = "ticket:"; + + private String getKey(final Long ticketId) { + return KEY_PREFIX + ticketId; + } + + public Long decrease(final Long ticketId, final Long quantity) { + return redisTemplate.execute( + new DefaultRedisScript<>(DECREASE_SCRIPT, Long.class), + List.of(getKey(ticketId)), + String.valueOf(quantity) + ); + } + + public void setTicket(final Long ticketId, final Long quantity) { + redisTemplate.opsForValue().set(getKey(ticketId), String.valueOf(quantity)); + } + + public boolean isExisted(final Long ticketId) { + return Boolean.TRUE.equals(redisTemplate.hasKey(getKey(ticketId))); + } + + public void increase(final Long ticketId, final Long quantity) { + redisTemplate.opsForValue().increment(getKey(ticketId), quantity); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/repository/ReserveRepositoryV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/repository/ReserveRepositoryV3.java new file mode 100644 index 0000000..068c8de --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/repository/ReserveRepositoryV3.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.v3.ticket.repository; + +import com.quickpick.ureca.v3.reserve.domain.ReserveV3; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReserveRepositoryV3 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/repository/TicketRepositoryV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/repository/TicketRepositoryV3.java new file mode 100644 index 0000000..87b5863 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/repository/TicketRepositoryV3.java @@ -0,0 +1,14 @@ +package com.quickpick.ureca.v3.ticket.repository; + +import com.quickpick.ureca.v3.ticket.domain.TicketV3; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TicketRepositoryV3 extends JpaRepository { + + @Modifying + @Query(value = "UPDATE tickets SET quantity = quantity - :amount WHERE ticket_id = :ticketId", nativeQuery = true) + int decreaseStock(@Param("ticketId") Long ticketId, @Param("amount") int amount); +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v3/ticket/service/TicketServiceV3.java b/src/main/java/com/quickpick/ureca/v3/ticket/service/TicketServiceV3.java new file mode 100644 index 0000000..738e7d6 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/ticket/service/TicketServiceV3.java @@ -0,0 +1,49 @@ +package com.quickpick.ureca.v3.ticket.service; + +import com.quickpick.ureca.v3.ticket.event.TicketPurchaseEventV3; +import com.quickpick.ureca.v3.ticket.kafka.TicketEventProducerV3; +import com.quickpick.ureca.v3.ticket.repository.RedisStockRepositoryV3; +import com.quickpick.ureca.v3.user.repository.UserRepositoryV3; +import com.quickpick.ureca.v3.userticket.repository.UserTicketRepositoryV3; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TicketServiceV3 { + + private final UserRepositoryV3 userRepositoryV3; + private final RedisStockRepositoryV3 redisStockRepositoryV3; + private final TicketEventProducerV3 ticketEventProducerV3; + private final UserTicketRepositoryV3 userTicketRepositoryV3; + + public void purchaseTicket(final Long ticketId, final Long userId, final Long quantity) { + + if(!userRepositoryV3.existsById(userId)) { + log.error("존재하지 않는 사용자입니다."); + throw new RuntimeException("존재하지 않는 사용자입니다."); + } + + if(userTicketRepositoryV3.existsByUserId(userId)) { + log.error("이미 예약을 성공한 사용자입니다."); + throw new RuntimeException("이미 예약을 성공한 사용자입니다."); + } + + final Long result = redisStockRepositoryV3.decrease(ticketId, quantity); + if (result == -1L) { + throw new RuntimeException("티켓이 전부 소진되었습니다."); + } + + final TicketPurchaseEventV3 ticketPurchaseEventV3 = TicketPurchaseEventV3.builder() + .uuid(UUID.randomUUID().toString()) + .ticketId(ticketId) + .userId(userId) + .quantity(quantity).build(); + + ticketEventProducerV3.send(ticketPurchaseEventV3); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/user/constants/GenderV3.java b/src/main/java/com/quickpick/ureca/v3/user/constants/GenderV3.java new file mode 100644 index 0000000..2f0c2e7 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/user/constants/GenderV3.java @@ -0,0 +1,5 @@ +package com.quickpick.ureca.v3.user.constants; + +public enum GenderV3 { + MALE, FEMALE +} diff --git a/src/main/java/com/quickpick/ureca/v3/user/controller/UserControllerV3.java b/src/main/java/com/quickpick/ureca/v3/user/controller/UserControllerV3.java new file mode 100644 index 0000000..8885cc4 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/user/controller/UserControllerV3.java @@ -0,0 +1,22 @@ +package com.quickpick.ureca.v3.user.controller; + +import com.quickpick.ureca.v3.user.dto.UserCreateRequestV3; +import com.quickpick.ureca.v3.user.service.UserServiceV3; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RequestMapping("/v3/users") +@RestController +@RequiredArgsConstructor +public class UserControllerV3 { + + private final UserServiceV3 userServiceV3; + + @PostMapping + public void createUser(@RequestBody UserCreateRequestV3 request) { + userServiceV3.save(request); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/user/domain/UserV3.java b/src/main/java/com/quickpick/ureca/v3/user/domain/UserV3.java new file mode 100644 index 0000000..cdd7de7 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/user/domain/UserV3.java @@ -0,0 +1,35 @@ +package com.quickpick.ureca.v3.user.domain; + +import com.quickpick.ureca.v3.common.BaseEntityV3; +import com.quickpick.ureca.v3.user.constants.GenderV3; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserV3 extends BaseEntityV3 { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private int age; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private GenderV3 genderV3; +} diff --git a/src/main/java/com/quickpick/ureca/v3/user/dto/UserCreateRequestV3.java b/src/main/java/com/quickpick/ureca/v3/user/dto/UserCreateRequestV3.java new file mode 100644 index 0000000..7afd24e --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/user/dto/UserCreateRequestV3.java @@ -0,0 +1,10 @@ +package com.quickpick.ureca.v3.user.dto; + +public record UserCreateRequestV3( + String id, + String password, + String name, + int age, + String gender +) { +} diff --git a/src/main/java/com/quickpick/ureca/v3/user/repository/UserRepositoryV3.java b/src/main/java/com/quickpick/ureca/v3/user/repository/UserRepositoryV3.java new file mode 100644 index 0000000..9b5b67e --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/user/repository/UserRepositoryV3.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.v3.user.repository; + +import com.quickpick.ureca.v3.user.domain.UserV3; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepositoryV3 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/v3/user/service/UserServiceV3.java b/src/main/java/com/quickpick/ureca/v3/user/service/UserServiceV3.java new file mode 100644 index 0000000..9c75d5d --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/user/service/UserServiceV3.java @@ -0,0 +1,29 @@ +package com.quickpick.ureca.v3.user.service; + +import com.quickpick.ureca.v3.user.constants.GenderV3; +import com.quickpick.ureca.v3.user.domain.UserV3; +import com.quickpick.ureca.v3.user.dto.UserCreateRequestV3; +import com.quickpick.ureca.v3.user.repository.UserRepositoryV3; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserServiceV3 { + + private final UserRepositoryV3 userRepositoryV3; + + @Transactional + public void save(final UserCreateRequestV3 request) { + + UserV3 userV3 = UserV3.builder() + .age(request.age()) + .name(request.name()) + .genderV3(GenderV3.valueOf(request.gender())) + .password(request.password()) + .build(); + + userRepositoryV3.save(userV3); + } +} diff --git a/src/main/java/com/quickpick/ureca/v3/userticket/domain/UserTicketV3.java b/src/main/java/com/quickpick/ureca/v3/userticket/domain/UserTicketV3.java new file mode 100644 index 0000000..e798f93 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/userticket/domain/UserTicketV3.java @@ -0,0 +1,25 @@ +package com.quickpick.ureca.v3.userticket.domain; + +import com.quickpick.ureca.v3.common.BaseEntityV3; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "user_ticket") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserTicketV3 extends BaseEntityV3 { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_ticket_id") + private Long id; + + @Column(nullable = false) + private Long ticketId; + + @Column(nullable = false) + private Long userId; +} diff --git a/src/main/java/com/quickpick/ureca/v3/userticket/repository/UserTicketRepositoryV3.java b/src/main/java/com/quickpick/ureca/v3/userticket/repository/UserTicketRepositoryV3.java new file mode 100644 index 0000000..7e7feb8 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v3/userticket/repository/UserTicketRepositoryV3.java @@ -0,0 +1,9 @@ +package com.quickpick.ureca.v3.userticket.repository; + +import com.quickpick.ureca.v3.userticket.domain.UserTicketV3; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserTicketRepositoryV3 extends JpaRepository { + + boolean existsByUserId(Long userId); +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 4ab8d35..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=ureca diff --git a/src/main/resources/text b/src/main/resources/text new file mode 100644 index 0000000..945c9b4 --- /dev/null +++ b/src/main/resources/text @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/src/test/java/com/quickpick/ureca/UrecaApplicationTests.java b/src/test/java/com/quickpick/ureca/UrecaApplicationTests.java index 156f2fb..6f2ccff 100644 --- a/src/test/java/com/quickpick/ureca/UrecaApplicationTests.java +++ b/src/test/java/com/quickpick/ureca/UrecaApplicationTests.java @@ -1,13 +1,13 @@ -package com.quickpick.ureca; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class UrecaApplicationTests { - - @Test - void contextLoads() { - } - -} +//package com.quickpick.ureca; +// +//import org.junit.jupiter.api.Test; +//import org.springframework.boot.test.context.SpringBootTest; +// +//@SpringBootTest +//class UrecaApplicationTests { +// +// @Test +// void contextLoads() { +// } +// +//}