Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] feat: 새로운 티켓, 티켓팅 도메인 추가 및 레거시 코드 마킹 (#1007-1) #1008

Open
wants to merge 3 commits into
base: feat/#1007
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${swaggerVersion}")

// Spring Security
Expand Down
7 changes: 7 additions & 0 deletions backend/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ services:
MYSQL_PASSWORD: festago
TZ: Asia/Seoul
command: [ "mysqld", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_general_ci" ]

redis:
image: redis:alpine
container_name: festago-local-redis
ports:
- "6389:6379"
command: redis-server --port 6379
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ public enum ErrorCode {
TICKET_CANNOT_RESERVE_STAGE_START("공연의 시작 시간 이후로 예매할 수 없습니다."),
INVALID_STUDENT_VERIFICATION_CODE("올바르지 않은 학생 인증 코드입니다."),
DELETE_CONSTRAINT_FESTIVAL("공연이 등록된 축제는 삭제할 수 없습니다."),
DELETE_CONSTRAINT_STAGE("티켓이 등록된 공연은 삭제할 수 없습니다."),
DELETE_CONSTRAINT_SCHOOL("학생 또는 축제에 등록된 학교는 삭제할 수 없습니다."), // @deprecate
DUPLICATE_SCHOOL("이미 존재하는 학교 정보입니다."), // @deprecate
VALIDATION_FAIL("검증이 실패하였습니다."),
Comment on lines 25 to 30
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 ErrorCode 정리가 필요해 보이네요. 😂

Expand All @@ -47,6 +46,12 @@ public enum ErrorCode {
OPEN_ID_INVALID_TOKEN("잘못된 OpenID 토큰입니다."),
NOT_SUPPORT_FILE_EXTENSION("해당 파일의 확장자는 허용되지 않습니다."),
DUPLICATE_ARTIST_NAME("이미 존재하는 아티스트의 이름입니다."),
RESERVE_TICKET_BEFORE_TICKET_OPEN_TIME("티켓 예매 시간 이전에는 예매 할 수 없습니다."),
RESERVE_TICKET_NOT_SCHOOL_STUDENT("해당 티켓의 예매는 소속된 학교를 다니는 재학생만 가능합니다."),
STAGE_UPDATE_CONSTRAINT_EXISTS_TICKET("티켓이 등록된 공연은 수정할 수 없습니다."),
STAGE_DELETE_CONSTRAINT_EXISTS_TICKET("티켓이 등록된 공연은 삭제할 수 없습니다."),
STAGE_TICKET_DELETE_CONSTRAINT_TICKET_OPEN_TIME("티켓 오픈 시간 이후에는 티켓을 삭제할 수 없습니다."),
ONLY_STAGE_TICKETING_SINGLE_TYPE("공연 당 하나의 유형의 티켓에 대해서만 예매가 가능합니다."),

// 401
EXPIRED_AUTH_TOKEN("만료된 로그인 토큰입니다."),
Expand Down Expand Up @@ -84,6 +89,7 @@ public enum ErrorCode {
OAUTH2_INVALID_REQUEST("알 수 없는 OAuth2 에러가 발생했습니다."),
OPEN_ID_PROVIDER_NOT_RESPONSE("OpenID 제공자 서버에 문제가 발생했습니다."),
FILE_UPLOAD_ERROR("파일 업로드 중 에러가 발생했습니다."),
REDIS_ERROR("Redis에 문제가 발생했습니다."),
;

private final String message;
Expand Down
38 changes: 38 additions & 0 deletions backend/src/main/java/com/festago/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.festago.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

private final String host;
private final int port;

public RedisConfig(
@Value("${spring.data.redis.host}") String host,
@Value("${spring.data.redis.port}") int port
) {
this.host = host;
this.port = port;
}

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}

@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
18 changes: 10 additions & 8 deletions backend/src/main/java/com/festago/stage/domain/Stage.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.festago.common.exception.ErrorCode;
import com.festago.common.util.Validator;
import com.festago.festival.domain.Festival;
import com.festago.ticket.domain.Ticket;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
Expand All @@ -18,6 +17,7 @@
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.AccessLevel;
Expand All @@ -41,9 +41,6 @@ public class Stage extends BaseTimeEntity {
@ManyToOne(fetch = FetchType.LAZY)
private Festival festival;

@OneToMany(mappedBy = "stage", fetch = FetchType.LAZY)
private List<Ticket> tickets = new ArrayList<>();

@OneToMany(fetch = FetchType.LAZY, mappedBy = "stageId", orphanRemoval = true,
cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
private List<StageArtist> artists = new ArrayList<>();
Expand Down Expand Up @@ -85,6 +82,10 @@ public boolean isStart(LocalDateTime currentTime) {
return currentTime.isAfter(startTime);
}

public boolean isBeforeTicketOpenTime(LocalDateTime currentTime) {
return currentTime.isBefore(ticketOpenTime);
}

public void changeTime(LocalDateTime startTime, LocalDateTime ticketOpenTime) {
validateTime(startTime, ticketOpenTime, this.festival);
this.startTime = startTime;
Expand All @@ -110,6 +111,11 @@ public List<Long> getArtistIds() {
.toList();
}

// 디미터 법칙에 어긋나지만, n+1을 회피하고, fetch join을 생략하며 주인을 검사하기 위해 getter 체이닝 사용
public boolean isSchoolStage(Long schoolId) {
return Objects.equals(getFestival().getSchool().getId(), schoolId);
}

public Long getId() {
return id;
}
Expand All @@ -125,8 +131,4 @@ public LocalDateTime getTicketOpenTime() {
public Festival getFestival() {
return festival;
}

public List<Ticket> getTickets() {
return tickets;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Deprecated(forRemoval = true)
@Service
@Transactional
@RequiredArgsConstructor
Expand Down
106 changes: 106 additions & 0 deletions backend/src/main/java/com/festago/ticket/domain/NewTicket.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.festago.ticket.domain;

import com.festago.common.domain.BaseTimeEntity;
import com.festago.common.util.Validator;
import com.festago.ticketing.domain.Booker;
import com.festago.ticketing.domain.ReserveTicket;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import java.time.LocalDateTime;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

// TODO NewTicket -> Ticket 이름 변경할 것
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class NewTicket extends BaseTimeEntity {
Comment on lines +22 to +25
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 유형의 티켓을 지원하기 위한 새로운 티켓 엔티티 입니다.

만약 공연이 아닌 행사(Events? Party?)에 티켓팅이 필요하면 해당 클래스를 상속한 구현체를 사용하면 됩니다.


@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;

protected Long schoolId;

@Enumerated(EnumType.STRING)
@Column(columnDefinition = "varchar")
protected TicketExclusive ticketExclusive;

protected int amount = 0;

/**
* 사용자가 최대 예매할 수 있는 티켓의 개수
*/
protected int maxReserveAmount = 1;
Comment on lines +39 to +42
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 필드의 사용으로 통합 테스트 또는 부하 테스트 시 수월하게 테스트가 가능합니다.


protected NewTicket(Long id, Long schoolId, TicketExclusive ticketExclusive) {
Validator.notNull(schoolId, "schoolId");
Validator.notNull(ticketExclusive, "ticketExclusive");
this.id = id;
this.schoolId = schoolId;
this.ticketExclusive = ticketExclusive;
}

protected void changeAmount(int amount) {
Validator.notNegative(amount, "amount");
this.amount = amount;
}

public boolean isStudentOnly() {
return ticketExclusive == TicketExclusive.STUDENT;
}

public boolean isSchoolStudent(Booker booker) {
return Objects.equals(this.schoolId, booker.getSchoolId());
}

public void changeMaxReserveAmount(int maxReserveAmount) {
Validator.minValue(maxReserveAmount, 1, "maxReserveAmount");
this.maxReserveAmount = maxReserveAmount;
}

public abstract void validateReserve(Booker booker, LocalDateTime currentTime);

/**
* 티켓을 예매한다. 해당 메서드를 호출하기 전 반드시 validateReserve() 메서드를 호출해야 한다.<br/> 반환된 ReserveTicket은 영속되지 않았으므로, 반드시 영속시켜야 한다.
*
* @param booker 예매할 사용자
* @param sequence 예매할 티켓의 순번
* @return 영속되지 않은 상태의 ReserveTicket
*/
public abstract ReserveTicket reserve(Booker booker, int sequence);

public abstract LocalDateTime getTicketingEndTime();

public boolean isEmptyAmount() {
return amount <= 0;
}

public Long getId() {
return id;
}

public Long getSchoolId() {
return schoolId;
}

public TicketExclusive getTicketExclusive() {
return ticketExclusive;
}

public int getAmount() {
return amount;
}

public int getMaxReserveAmount() {
return maxReserveAmount;
}
}
11 changes: 11 additions & 0 deletions backend/src/main/java/com/festago/ticket/domain/NewTicketType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.festago.ticket.domain;

// TODO NewTicket -> Ticket 이름 변경할 것

/**
* NewTicket의 구현체의 DiscriminatorValue 어노테이션의 속성의 이름과 반드시 똑같이 할 것!
*/
public enum NewTicketType {
STAGE,
;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;

@Deprecated(forRemoval = true)
public class ReservationSequence {

private static final int MOST_FAST_SEQUENCE = 1;
Expand Down
117 changes: 117 additions & 0 deletions backend/src/main/java/com/festago/ticket/domain/StageTicket.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.festago.ticket.domain;

import com.festago.common.exception.BadRequestException;
import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.UnauthorizedException;
import com.festago.common.util.Validator;
import com.festago.stage.domain.Stage;
import com.festago.ticketing.domain.Booker;
import com.festago.ticketing.domain.ReserveTicket;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
import java.time.LocalDateTime;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@Entity
@DiscriminatorValue("STAGE")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StageTicket extends NewTicket {

private static final int EARLY_ENTRY_LIMIT = 12;

@ManyToOne(fetch = FetchType.LAZY)
private Stage stage;

@Embedded
private StageTicketEntryTimes ticketEntryTimes = new StageTicketEntryTimes();

public StageTicket(Long schoolId, TicketExclusive ticketType, Stage stage) {
this(null, schoolId, ticketType, stage);
}

public StageTicket(Long id, Long schoolId, TicketExclusive ticketType, Stage stage) {
super(id, schoolId, ticketType);
validate(schoolId, stage);
this.stage = stage;
}

private void validate(Long schoolId, Stage stage) {
Validator.notNull(stage, "stage");
if (!stage.isSchoolStage(schoolId)) {
throw new UnauthorizedException(ErrorCode.NOT_ENOUGH_PERMISSION);
}
}

@Override
public LocalDateTime getTicketingEndTime() {
return stage.getStartTime();
}

@Override
public void validateReserve(Booker booker, LocalDateTime currentTime) {
if (isStudentOnly() && !isSchoolStudent(booker)) {
throw new BadRequestException(ErrorCode.RESERVE_TICKET_NOT_SCHOOL_STUDENT);
}
if (stage.isStart(currentTime)) {
throw new BadRequestException(ErrorCode.TICKET_CANNOT_RESERVE_STAGE_START);
}
if (stage.isBeforeTicketOpenTime(currentTime)) {
throw new BadRequestException(ErrorCode.RESERVE_TICKET_BEFORE_TICKET_OPEN_TIME);
}
}

@Override
public ReserveTicket reserve(Booker booker, int sequence) {
LocalDateTime entryTime = ticketEntryTimes.calculateEntryTime(sequence);
return new ReserveTicket(booker.getMemberId(), NewTicketType.STAGE, id, sequence, entryTime);
}

public void addTicketEntryTime(Long schoolId, LocalDateTime currentTime, LocalDateTime entryTime, int amount) {
validateSchoolOwner(schoolId);
validateEntryTime(currentTime, entryTime);
ticketEntryTimes.add(new StageTicketEntryTime(id, entryTime, amount));
changeAmount(ticketEntryTimes.getTotalAmount());
}

private void validateSchoolOwner(Long schoolId) {
if (!Objects.equals(this.schoolId, schoolId)) {
throw new UnauthorizedException(ErrorCode.NOT_ENOUGH_PERMISSION);
}
}

private void validateEntryTime(LocalDateTime currentTime, LocalDateTime entryTime) {
if (!stage.isBeforeTicketOpenTime(currentTime)) {
throw new BadRequestException(ErrorCode.INVALID_TICKET_CREATE_TIME);
}
if (stage.isBeforeTicketOpenTime(entryTime)) {
throw new BadRequestException(ErrorCode.EARLY_TICKET_ENTRY_THAN_OPEN);
}
if (stage.isStart(entryTime)) {
throw new BadRequestException(ErrorCode.LATE_TICKET_ENTRY_TIME);
}
if (!stage.isStart(entryTime.plusHours(EARLY_ENTRY_LIMIT))) {
throw new BadRequestException(ErrorCode.EARLY_TICKET_ENTRY_TIME);
}
}

public boolean deleteTicketEntryTime(Long schoolId, LocalDateTime currentTime, LocalDateTime entryTime) {
validateSchoolOwner(schoolId);
if (!stage.isBeforeTicketOpenTime(currentTime)) {
throw new BadRequestException(ErrorCode.STAGE_TICKET_DELETE_CONSTRAINT_TICKET_OPEN_TIME);
}
boolean isDeleted = ticketEntryTimes.remove(entryTime);
if (isDeleted) {
changeAmount(ticketEntryTimes.getTotalAmount());
}
return isDeleted;
}

public Stage getStage() {
return stage;
}
}
Loading
Loading