Skip to content

Commit b0cabb2

Browse files
feat : Queue 관련 로직 수정, 좌석 선택 시 hasPassed 여부 검증 (#23)
* docs : PR Template 작성 * docs : readmd.md 변경 * feat : entity 수정 * refactor : enum 클래스 분리 및 repo에 JPA 상속 * feat : create(), saveSeatInfo() 기본 틀 구현 * feat : 예매상태 변경하는 메서드 구현 및 생성자 수정 * feat : updatePrice() 메서드 구현 * feat : 일정시간 후 좌석반환, 공통응답 구현 * feat : reservation 도메인 예외처리 * refactor : 다른 api와 중복되는 api 삭제 * feat : redis 연결설정 및 예매 대기 기능 구현 * feat : 소켓 연결 끊겼을 때 sorted set에서 데이터 삭제하는 로직 구현 * refactor : 전체 코드 리팩토링 * feat : 예매취소, 예매조회 api 및 유효성 검증 구현 * refactor : 인터셉터에서 데이터 추출 방식 변경 및 주석 제거 * chore : .gitignore update * feat : 소켓 연결 테스트코드 작성 * chore : 테스트코드 수정 * fix : 테스트코드 오류 수정, SecurityConfig oauth 설정 주석 처리 * chore : 주석 해제 * chore : dev-ci.yml에 redis 설정 추가 * chore : redis 버전 변경, 호스트명 변경 * feat : 티켓팅 가능 시간 체크하는 로직 추가 * fix : seat 테이블 컬럼 수정 반영 * refactor : 결제 도메인이랑 로직 연결 * chore : 사용안하는 클래스 삭제 * refactor : 소켓 연결시 헤더에서 token 추출 * feat : 대기열 TTL을 좌석 선택 화면 접근 후부터 적용하도록 수정 * feat : 예매취소시 좌석 반환 * feat : SeatScheduleInfo Service에 대기열 passed 여부 확인 및 좌석 선택 완료 후 queue에서 삭제 --------- Co-authored-by: pokerbearkr <ogdongwon@gmail.com>
1 parent 525826c commit b0cabb2

13 files changed

Lines changed: 380 additions & 201 deletions

File tree

src/main/java/org/example/siljeun/domain/reservation/controller/WaitingQueueController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public class WaitingQueueController {
1717
public void addQueue(@Valid AddQueueRequest request) {
1818
Long scheduleId = request.scheduleId();
1919
String username = request.username();
20-
waitingQueueService.addQueue(scheduleId, username);
20+
waitingQueueService.addWaitingQueue(scheduleId, username);
2121
System.out.println("연결 성공");
2222
}
2323
}

src/main/java/org/example/siljeun/domain/reservation/dto/request/AddQueueRequest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package org.example.siljeun.domain.reservation.dto.request;
22

33
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
45

56
public record AddQueueRequest(
6-
@NotBlank
7+
@NotNull
78
Long scheduleId,
89
@NotBlank
910
String username

src/main/java/org/example/siljeun/domain/reservation/dto/response/MyQueueInfoResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ public record MyQueueInfoResponse(
44
Long scheduleId,
55
String username,
66
Long rank,
7-
Long acceptedRank
7+
boolean isPassable
88
) {
99

1010
}
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
package org.example.siljeun.domain.reservation.exception;
22

33
import lombok.Getter;
4-
import org.springframework.http.HttpStatus;
54

65
public class CustomException extends RuntimeException {
76

87
@Getter
9-
private HttpStatus errorCode;
8+
private ErrorCode errorCode;
109

1110
public CustomException(ErrorCode errorCode) {
1211
super(errorCode.getMessage());
13-
this.errorCode = errorCode.getCode();
12+
this.errorCode = errorCode;
1413
}
1514
}

src/main/java/org/example/siljeun/domain/reservation/exception/ReservationExceptionHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public class ReservationExceptionHandler {
1515
@ExceptionHandler(CustomException.class)
1616
public ResponseEntity<ResponseDto<Void>> reservationExceptionHandler(
1717
CustomException e) {
18-
return ResponseEntity.status(e.getErrorCode())
18+
return ResponseEntity.status(e.getErrorCode().getCode())
1919
.body(ResponseDto.fail(e.getMessage()));
2020
}
2121

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package org.example.siljeun.domain.reservation.scheduler;
2+
3+
import static org.example.siljeun.domain.reservation.service.WaitingQueueService.prefixKeyForSelecingQueue;
4+
import static org.example.siljeun.domain.reservation.service.WaitingQueueService.prefixKeyForWaitingQueue;
5+
6+
import java.nio.charset.StandardCharsets;
7+
import java.time.LocalDateTime;
8+
import java.util.ArrayList;
9+
import java.util.HashSet;
10+
import java.util.List;
11+
import java.util.Set;
12+
import lombok.RequiredArgsConstructor;
13+
import org.example.siljeun.domain.schedule.entity.Schedule;
14+
import org.example.siljeun.domain.schedule.repository.ScheduleRepository;
15+
import org.springframework.data.redis.core.Cursor;
16+
import org.springframework.data.redis.core.ScanOptions;
17+
import org.springframework.data.redis.core.StringRedisTemplate;
18+
import org.springframework.scheduling.annotation.Scheduled;
19+
import org.springframework.stereotype.Component;
20+
21+
@Component
22+
@RequiredArgsConstructor
23+
public class CheckExpiredScheduler {
24+
25+
private final StringRedisTemplate redisTemplate;
26+
private final ScheduleRepository scheduleRepository;
27+
28+
private final Set<String> keys = new HashSet<>();
29+
30+
// 1시간마다 티켓팅 기간인 schedule을 keys에 저장
31+
@Scheduled(cron = "0 0 * * * *")
32+
public void checkOpenedSchedule() {
33+
34+
keys.clear();
35+
36+
List<Long> openedSchedules = scheduleRepository.findAllByStartTimeAfterAndTicketingStartTimeBefore(
37+
LocalDateTime.now(),
38+
LocalDateTime.now()).stream()
39+
.map(Schedule::getId)
40+
.toList();
41+
42+
try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory().getConnection()
43+
.scan(ScanOptions.scanOptions().match(prefixKeyForSelecingQueue + "*")
44+
.build())) {
45+
while (cursor.hasNext()) {
46+
String key = new String(cursor.next(), StandardCharsets.UTF_8);
47+
String[] parts = key.split(":");
48+
Long scheduleId = Long.valueOf(parts[2]);
49+
50+
if (openedSchedules.contains(scheduleId)) {
51+
keys.add(key);
52+
}
53+
}
54+
}
55+
}
56+
57+
// 1분마다 keys에 저장된 각 schedule의 대기열에서 TTL 만료인 유저 삭제
58+
@Scheduled(cron = "0 * * * * *")
59+
public void checkExpiredUser() {
60+
61+
for (String key : keys) {
62+
redisTemplate.opsForZSet()
63+
.removeRangeByScore(key, 0, System.currentTimeMillis());
64+
}
65+
}
66+
67+
// 1일마다 예매 종료된 공연은 sorted set에서 삭제
68+
@Scheduled(cron = "0 0 0 * * *")
69+
public void deleteExpiredKey() {
70+
71+
Set<Long> scheduleIdForDelete = new HashSet<>();
72+
73+
// sorted set에 저장된 scheduleId 추출
74+
try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory().getConnection()
75+
.scan(ScanOptions.scanOptions().match(prefixKeyForSelecingQueue + "*")
76+
.build())) {
77+
while (cursor.hasNext()) {
78+
String key = new String(cursor.next(), StandardCharsets.UTF_8);
79+
String[] parts = key.split(":");
80+
Long scheduleId = Long.valueOf(parts[2]);
81+
scheduleIdForDelete.add(scheduleId);
82+
}
83+
}
84+
85+
// schedule.startTime < 현재 시각인 schedule 추출
86+
List<Schedule> schedules = scheduleRepository.findByIdInAndStartTimeBefore(
87+
new ArrayList<>(scheduleIdForDelete),
88+
LocalDateTime.now()
89+
);
90+
91+
// sorted set 에서 제거
92+
schedules.stream()
93+
.map(schedule -> prefixKeyForWaitingQueue + schedule.getId())
94+
.forEach(redisTemplate::delete);
95+
schedules.stream()
96+
.map(schedule -> prefixKeyForSelecingQueue + schedule.getId())
97+
.forEach(redisTemplate::delete);
98+
}
99+
}

src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.example.siljeun.domain.reservation.repository.ReservationRepository;
1010
import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository;
1111
import org.example.siljeun.domain.seat.entity.SeatScheduleInfo;
12+
import org.example.siljeun.domain.seat.enums.SeatStatus;
1213
import org.example.siljeun.domain.user.entity.User;
1314
import org.example.siljeun.domain.user.repository.UserRepository;
1415
import org.springframework.stereotype.Service;
@@ -32,7 +33,8 @@ public void save(Long userId, Long seatScheduleInfoId) {
3233

3334
Reservation reservation = new Reservation(user, seatScheduleInfo);
3435
reservationRepository.save(reservation);
35-
waitingQueueService.deleteAtQueue(seatScheduleInfo.getSchedule().getId(), user.getUsername());
36+
waitingQueueService.deleteSelectingUser(seatScheduleInfo.getSchedule().getId(),
37+
user.getUsername());
3638
}
3739

3840
@Transactional
@@ -61,7 +63,7 @@ public void delete(String username, Long reservationId) {
6163
}
6264

6365
reservationRepository.delete(reservation);
64-
// Todo : 좌석 상태 변경
66+
reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE);
6567
}
6668

6769
public ReservationInfoResponse findById(String username, Long reservationId) {

src/main/java/org/example/siljeun/domain/reservation/service/WaitingQueueService.java

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import jakarta.annotation.PostConstruct;
44
import java.time.LocalDateTime;
5+
import java.util.Set;
56
import lombok.RequiredArgsConstructor;
67
import lombok.extern.slf4j.Slf4j;
78
import org.example.siljeun.domain.reservation.dto.response.MyQueueInfoResponse;
@@ -13,19 +14,22 @@
1314
import org.springframework.data.redis.core.ZSetOperations;
1415
import org.springframework.messaging.simp.SimpMessagingTemplate;
1516
import org.springframework.stereotype.Service;
17+
import org.springframework.transaction.annotation.Transactional;
1618

1719
@Slf4j
1820
@Service
1921
@RequiredArgsConstructor
22+
@Transactional
2023
public class WaitingQueueService {
2124

2225
private final StringRedisTemplate redisTemplate;
2326
private final SimpMessagingTemplate messagingTemplate;
27+
private final ScheduleRepository scheduleRepository;
2428

2529
private static final long ttlMillis = 900000L; // ttl 15분
2630
private static final long acceptedRank = 1000L; // 좌석 선택 최대 수용 인원 1000명
27-
private static final String prefixKey = "queue:schedule:";
28-
private final ScheduleRepository scheduleRepository;
31+
public static final String prefixKeyForWaitingQueue = "waiting:schedule:";
32+
public static final String prefixKeyForSelecingQueue = "selecting:schedule:";
2933

3034
// redis 연결 확인
3135
@PostConstruct
@@ -35,7 +39,8 @@ public void testRedisConnection() {
3539
}
3640

3741
// 예매 대기 시작
38-
public void addQueue(Long scheduleId, String username) {
42+
public void addWaitingQueue(Long scheduleId, String username) {
43+
ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();
3944

4045
Schedule schedule = scheduleRepository.findById(scheduleId)
4146
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE));
@@ -44,49 +49,95 @@ public void addQueue(Long scheduleId, String username) {
4449
throw new CustomException(ErrorCode.NOT_TICKETING_TIME);
4550
}
4651

47-
String key = prefixKey + scheduleId;
48-
long expiredAt = System.currentTimeMillis() + ttlMillis;
49-
ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();
52+
String key = prefixKeyForWaitingQueue + scheduleId;
53+
long createdAt = System.currentTimeMillis();
5054

5155
if (zSet.score(key, username) == null) {
56+
zSet.add(key, username, createdAt);
57+
}
58+
59+
sendWaitingNumber(key, username, scheduleId);
60+
}
61+
62+
// 좌석 선택 중인 유저 큐에 insert (TTL 관리용 큐)
63+
public void addSelectingQueue(Long scheduleId, String username) {
64+
ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();
65+
66+
String key = prefixKeyForSelecingQueue + scheduleId;
67+
Long expiredAt = System.currentTimeMillis() + ttlMillis;
68+
69+
if (zSet.score(prefixKeyForSelecingQueue + scheduleId, username) == null) {
5270
zSet.add(key, username, expiredAt);
5371
}
72+
}
73+
74+
// 대기 끝 or 소켓 연결 해제되면 대기열에서 삭제
75+
public void deleteWaitingUser(Long scheduleId, String username) {
76+
String key = prefixKeyForWaitingQueue + scheduleId;
77+
redisTemplate.opsForZSet().remove(key, username);
78+
}
79+
80+
// 좌석 선택 완료 or 소켓 연결 해제 or TTL 만료되면 큐에서 삭제
81+
public void deleteSelectingUser(Long scheduleId, String username) {
82+
String key = prefixKeyForSelecingQueue + scheduleId;
83+
redisTemplate.opsForZSet().remove(key, username);
84+
sendAllWaitingNumber(scheduleId);
85+
}
86+
87+
// 정상적인 경로로 좌석 선택 api 호출했는지 검증
88+
public boolean hasPassedWaitingQueue(Long scheduleId, String username) {
89+
return
90+
redisTemplate.opsForZSet().score(prefixKeyForSelecingQueue + scheduleId, username) != null;
91+
}
92+
93+
// 대기중인 특정 사용자에게 랭킹 및 대기번호 전송
94+
public void sendWaitingNumber(String key, String username, Long scheduleId) {
95+
ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();
5496

5597
Long rank = zSet.rank(key, username);
98+
5699
if (rank == null) {
57100
throw new CustomException(ErrorCode.QUEUE_INSERT_FAIL);
58101
}
102+
59103
rank = rank + 1;
60104

61-
String destination = "/topic/queue/" + scheduleId + "/" + username;
62-
MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank,
63-
acceptedRank);
64-
messagingTemplate.convertAndSend(destination, response);
65-
}
105+
// 내 순위와 현재 좌석 선택 중인 사용자 수의 합이 수용 인원보다 적으면 대기 X
106+
Long selectingQueueSize = zSet.size(prefixKeyForSelecingQueue + scheduleId);
107+
selectingQueueSize = (selectingQueueSize == null) ? 0 : selectingQueueSize;
66108

67-
// 기존 유저가 좌석 선택 완료 or 소켓 연결 종료하면 대기열에서 삭제
68-
public void deleteAtQueue(Long scheduleId, String username) {
69-
redisTemplate.opsForZSet().remove(prefixKey + scheduleId, username);
70-
log.info("Disconnected and removed Schedule: {}, User: {}", scheduleId, username);
109+
if (rank + selectingQueueSize <= acceptedRank) {
110+
String destination = "/topic/queue/" + scheduleId + "/" + username;
111+
MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank,
112+
true);
113+
messagingTemplate.convertAndSend(destination, response);
71114

72-
// TTL 만료된 데이터 삭제
73-
redisTemplate.opsForZSet()
74-
.removeRangeByScore(prefixKey + scheduleId, 0, System.currentTimeMillis());
115+
addSelectingQueue(scheduleId, username);
116+
deleteWaitingUser(scheduleId, username);
75117

76-
// rank() 재실행해서 변경된 대기번호 클라이언트에 전송
77-
Long rank = redisTemplate.opsForZSet().rank(prefixKey + scheduleId, username);
78-
rank = (rank != null) ? rank + 1 : -1;
118+
return;
119+
}
79120

80121
String destination = "/topic/queue/" + scheduleId + "/" + username;
81122
MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank,
82-
acceptedRank);
123+
false);
83124
messagingTemplate.convertAndSend(destination, response);
84125
}
85126

86-
// sorted set에 해당 scheduleId, userId를 가지는 데이터가 존재하는지 확인
87-
// Todo : 좌석 선택 메서드 안에서 호출(정상 경로로 접근했는지 검증 필요)
88-
public boolean checkQueue(Long scheduleId, String username) {
89-
boolean exists = redisTemplate.opsForZSet().score(prefixKey + scheduleId, username) != null;
90-
return exists;
127+
// 대기중인 모든 사용자에게 랭킹 및 대기번호 전송
128+
public void sendAllWaitingNumber(Long scheduleId) {
129+
String key = prefixKeyForWaitingQueue + scheduleId;
130+
131+
// for문이나 stream으로 scheduleId에 해당하는 value값 리스트 추출
132+
Set<String> usernames = redisTemplate.opsForZSet().range(key, 0, -1);
133+
134+
if (usernames == null || usernames.isEmpty()) {
135+
return;
136+
}
137+
138+
// 해당 유저들한테 메세지 전송
139+
for (String username : usernames) {
140+
sendWaitingNumber(key, username, scheduleId);
141+
}
91142
}
92143
}

src/main/java/org/example/siljeun/domain/schedule/controller/SeatScheduleInfoController.java

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,28 @@
1010
import org.springframework.web.bind.annotation.PathVariable;
1111
import org.springframework.web.bind.annotation.PostMapping;
1212

13-
import java.util.List;
1413
import java.util.Map;
1514

1615
@Controller
1716
@RequiredArgsConstructor
1817
public class SeatScheduleInfoController {
1918

20-
private final SeatScheduleInfoService seatScheduleInfoService;
19+
private final SeatScheduleInfoService seatScheduleInfoService;
2120

22-
@PostMapping("/seat-schedule-info/{seatScheduleInfoId}")
23-
public ResponseEntity<String> selectSeat(
24-
@PathVariable Long seatScheduleInfoId,
25-
@AuthenticationPrincipal PrincipalDetails userDetails
26-
){
27-
seatScheduleInfoService.selectSeat(userDetails.getUserId(), seatScheduleInfoId);
28-
return ResponseEntity.ok("좌석이 선택되었습니다.");
29-
}
21+
@PostMapping("/seat-schedule-info/{seatScheduleInfoId}")
22+
public ResponseEntity<String> selectSeat(
23+
@PathVariable Long seatScheduleInfoId,
24+
@AuthenticationPrincipal PrincipalDetails userDetails
25+
) {
26+
seatScheduleInfoService.selectSeat(userDetails.getUserId(), userDetails.getUsername(),
27+
seatScheduleInfoId);
28+
return ResponseEntity.ok("좌석이 선택되었습니다.");
29+
}
3030

31-
@GetMapping("/schedule/{scheduleId}/seat-schedule-info")
32-
public ResponseEntity<Map<String, String>> getSeatScheduleInfos(
33-
@PathVariable Long scheduleId
34-
){
35-
return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId));
36-
}
31+
@GetMapping("/schedule/{scheduleId}/seat-schedule-info")
32+
public ResponseEntity<Map<String, String>> getSeatScheduleInfos(
33+
@PathVariable Long scheduleId
34+
) {
35+
return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId));
36+
}
3737
}

0 commit comments

Comments
 (0)