Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5e22823
docs : PR Template 작성
pokerbearkr May 16, 2025
eba9b06
docs : readmd.md 변경
pokerbearkr May 16, 2025
b92dabd
Merge branch 'dev' of https://github.com/pokerbearkr/nullnullTicket
kmchaejin May 16, 2025
4e3b58e
feat : entity 수정
kmchaejin May 16, 2025
f8583b0
refactor : enum 클래스 분리 및 repo에 JPA 상속
kmchaejin May 18, 2025
d4d168e
feat : create(), saveSeatInfo() 기본 틀 구현
kmchaejin May 18, 2025
fe596bb
Merge branch 'dev'
kmchaejin May 18, 2025
8a39d8a
feat : 예매상태 변경하는 메서드 구현 및 생성자 수정
kmchaejin May 18, 2025
6fa4c41
Merge branch 'dev' of https://github.com/pokerbearkr/nullnullTicket i…
kmchaejin May 18, 2025
313afde
feat : updatePrice() 메서드 구현
kmchaejin May 19, 2025
35df0c4
feat : 일정시간 후 좌석반환, 공통응답 구현
kmchaejin May 19, 2025
8a92d52
feat : reservation 도메인 예외처리
kmchaejin May 19, 2025
cd648b6
refactor : 다른 api와 중복되는 api 삭제
kmchaejin May 19, 2025
f6d925a
feat : redis 연결설정 및 예매 대기 기능 구현
kmchaejin May 20, 2025
b907826
Merge branch 'dev'
kmchaejin May 20, 2025
279b555
feat : 소켓 연결 끊겼을 때 sorted set에서 데이터 삭제하는 로직 구현
kmchaejin May 20, 2025
bbf9103
refactor : 전체 코드 리팩토링
kmchaejin May 20, 2025
c5d8529
feat : 예매취소, 예매조회 api 및 유효성 검증 구현
kmchaejin May 21, 2025
d508180
refactor : 인터셉터에서 데이터 추출 방식 변경 및 주석 제거
kmchaejin May 21, 2025
ccc5362
chore : .gitignore update
kmchaejin May 21, 2025
4dd97ca
feat : 소켓 연결 테스트코드 작성
kmchaejin May 21, 2025
ebf38d2
Merge branch 'dev'
kmchaejin May 21, 2025
d302215
chore : 테스트코드 수정
kmchaejin May 21, 2025
390cc9b
fix : 테스트코드 오류 수정, SecurityConfig oauth 설정 주석 처리
kmchaejin May 21, 2025
615ef29
Merge branch 'dev'
kmchaejin May 21, 2025
3454fba
chore : 주석 해제
kmchaejin May 21, 2025
e28ede0
chore : dev-ci.yml에 redis 설정 추가
kmchaejin May 22, 2025
a45935b
chore : redis 버전 변경, 호스트명 변경
kmchaejin May 22, 2025
0e6dc93
feat : 티켓팅 가능 시간 체크하는 로직 추가
kmchaejin May 22, 2025
1292365
Merge branch 'dev'
kmchaejin May 22, 2025
087f585
fix : seat 테이블 컬럼 수정 반영
kmchaejin May 22, 2025
68e8de8
refactor : 결제 도메인이랑 로직 연결
kmchaejin May 22, 2025
3b2a11f
Merge branch 'dev'
kmchaejin May 22, 2025
a9e8846
chore : 사용안하는 클래스 삭제
kmchaejin May 22, 2025
d56f615
refactor : 소켓 연결시 헤더에서 token 추출
kmchaejin May 22, 2025
d3bf2c3
Merge branch 'dev' of https://github.com/pokerbearkr/nullnullTicket i…
kmchaejin May 22, 2025
8196b4b
Merge branch 'dev' of https://github.com/pokerbearkr/nullnullTicket i…
kmchaejin May 22, 2025
0bb6fda
feat : 대기열 TTL을 좌석 선택 화면 접근 후부터 적용하도록 수정
kmchaejin May 22, 2025
07c3a31
feat : 예매취소시 좌석 반환
kmchaejin May 22, 2025
45ace48
feat : SeatScheduleInfo Service에 대기열 passed 여부 확인 및 좌석 선택 완료 후 queue에…
kmchaejin May 23, 2025
3466e97
Merge branch 'dev'
kmchaejin May 23, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class WaitingQueueController {
public void addQueue(@Valid AddQueueRequest request) {
Long scheduleId = request.scheduleId();
String username = request.username();
waitingQueueService.addQueue(scheduleId, username);
waitingQueueService.addWaitingQueue(scheduleId, username);
System.out.println("연결 성공");
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package org.example.siljeun.domain.reservation.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record AddQueueRequest(
@NotBlank
@NotNull
Long scheduleId,
@NotBlank
String username
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public record MyQueueInfoResponse(
Long scheduleId,
String username,
Long rank,
Long acceptedRank
boolean isPassable
) {

}
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package org.example.siljeun.domain.reservation.exception;

import lombok.Getter;
import org.springframework.http.HttpStatus;

public class CustomException extends RuntimeException {

@Getter
private HttpStatus errorCode;
private ErrorCode errorCode;

public CustomException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode.getCode();
this.errorCode = errorCode;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class ReservationExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ResponseDto<Void>> reservationExceptionHandler(
CustomException e) {
return ResponseEntity.status(e.getErrorCode())
return ResponseEntity.status(e.getErrorCode().getCode())
.body(ResponseDto.fail(e.getMessage()));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package org.example.siljeun.domain.reservation.scheduler;

import static org.example.siljeun.domain.reservation.service.WaitingQueueService.prefixKeyForSelecingQueue;
import static org.example.siljeun.domain.reservation.service.WaitingQueueService.prefixKeyForWaitingQueue;

import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.example.siljeun.domain.schedule.entity.Schedule;
import org.example.siljeun.domain.schedule.repository.ScheduleRepository;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CheckExpiredScheduler {

private final StringRedisTemplate redisTemplate;
private final ScheduleRepository scheduleRepository;

private final Set<String> keys = new HashSet<>();

// 1시간마다 티켓팅 기간인 schedule을 keys에 저장
@Scheduled(cron = "0 0 * * * *")
public void checkOpenedSchedule() {

keys.clear();

List<Long> openedSchedules = scheduleRepository.findAllByStartTimeAfterAndTicketingStartTimeBefore(
LocalDateTime.now(),
LocalDateTime.now()).stream()
.map(Schedule::getId)
.toList();

try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory().getConnection()
.scan(ScanOptions.scanOptions().match(prefixKeyForSelecingQueue + "*")
.build())) {
while (cursor.hasNext()) {
String key = new String(cursor.next(), StandardCharsets.UTF_8);
String[] parts = key.split(":");
Long scheduleId = Long.valueOf(parts[2]);

if (openedSchedules.contains(scheduleId)) {
keys.add(key);
}
}
}
}

// 1분마다 keys에 저장된 각 schedule의 대기열에서 TTL 만료인 유저 삭제
@Scheduled(cron = "0 * * * * *")
public void checkExpiredUser() {

for (String key : keys) {
redisTemplate.opsForZSet()
.removeRangeByScore(key, 0, System.currentTimeMillis());
}
}

// 1일마다 예매 종료된 공연은 sorted set에서 삭제
@Scheduled(cron = "0 0 0 * * *")
public void deleteExpiredKey() {

Set<Long> scheduleIdForDelete = new HashSet<>();

// sorted set에 저장된 scheduleId 추출
try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory().getConnection()
.scan(ScanOptions.scanOptions().match(prefixKeyForSelecingQueue + "*")
.build())) {
while (cursor.hasNext()) {
String key = new String(cursor.next(), StandardCharsets.UTF_8);
String[] parts = key.split(":");
Long scheduleId = Long.valueOf(parts[2]);
scheduleIdForDelete.add(scheduleId);
}
}

// schedule.startTime < 현재 시각인 schedule 추출
List<Schedule> schedules = scheduleRepository.findByIdInAndStartTimeBefore(
new ArrayList<>(scheduleIdForDelete),
LocalDateTime.now()
);

// sorted set 에서 제거
schedules.stream()
.map(schedule -> prefixKeyForWaitingQueue + schedule.getId())
.forEach(redisTemplate::delete);
schedules.stream()
.map(schedule -> prefixKeyForSelecingQueue + schedule.getId())
.forEach(redisTemplate::delete);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.example.siljeun.domain.reservation.repository.ReservationRepository;
import org.example.siljeun.domain.schedule.repository.SeatScheduleInfoRepository;
import org.example.siljeun.domain.seat.entity.SeatScheduleInfo;
import org.example.siljeun.domain.seat.enums.SeatStatus;
import org.example.siljeun.domain.user.entity.User;
import org.example.siljeun.domain.user.repository.UserRepository;
import org.springframework.stereotype.Service;
Expand All @@ -32,7 +33,8 @@ public void save(Long userId, Long seatScheduleInfoId) {

Reservation reservation = new Reservation(user, seatScheduleInfo);
reservationRepository.save(reservation);
waitingQueueService.deleteAtQueue(seatScheduleInfo.getSchedule().getId(), user.getUsername());
waitingQueueService.deleteSelectingUser(seatScheduleInfo.getSchedule().getId(),
user.getUsername());
}

@Transactional
Expand Down Expand Up @@ -61,7 +63,7 @@ public void delete(String username, Long reservationId) {
}

reservationRepository.delete(reservation);
// Todo : 좌석 상태 변경
reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE);
}

public ReservationInfoResponse findById(String username, Long reservationId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.annotation.PostConstruct;
import java.time.LocalDateTime;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.siljeun.domain.reservation.dto.response.MyQueueInfoResponse;
Expand All @@ -13,19 +14,22 @@
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class WaitingQueueService {

private final StringRedisTemplate redisTemplate;
private final SimpMessagingTemplate messagingTemplate;
private final ScheduleRepository scheduleRepository;

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

// redis 연결 확인
@PostConstruct
Expand All @@ -35,7 +39,8 @@ public void testRedisConnection() {
}

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

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

String key = prefixKey + scheduleId;
long expiredAt = System.currentTimeMillis() + ttlMillis;
ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();
String key = prefixKeyForWaitingQueue + scheduleId;
long createdAt = System.currentTimeMillis();

if (zSet.score(key, username) == null) {
zSet.add(key, username, createdAt);
}

sendWaitingNumber(key, username, scheduleId);
}

// 좌석 선택 중인 유저 큐에 insert (TTL 관리용 큐)
public void addSelectingQueue(Long scheduleId, String username) {
ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();

String key = prefixKeyForSelecingQueue + scheduleId;
Long expiredAt = System.currentTimeMillis() + ttlMillis;

if (zSet.score(prefixKeyForSelecingQueue + scheduleId, username) == null) {
zSet.add(key, username, expiredAt);
}
}

// 대기 끝 or 소켓 연결 해제되면 대기열에서 삭제
public void deleteWaitingUser(Long scheduleId, String username) {
String key = prefixKeyForWaitingQueue + scheduleId;
redisTemplate.opsForZSet().remove(key, username);
}

// 좌석 선택 완료 or 소켓 연결 해제 or TTL 만료되면 큐에서 삭제
public void deleteSelectingUser(Long scheduleId, String username) {
String key = prefixKeyForSelecingQueue + scheduleId;
redisTemplate.opsForZSet().remove(key, username);
sendAllWaitingNumber(scheduleId);
}

// 정상적인 경로로 좌석 선택 api 호출했는지 검증
public boolean hasPassedWaitingQueue(Long scheduleId, String username) {
return
redisTemplate.opsForZSet().score(prefixKeyForSelecingQueue + scheduleId, username) != null;
}

// 대기중인 특정 사용자에게 랭킹 및 대기번호 전송
public void sendWaitingNumber(String key, String username, Long scheduleId) {
ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();

Long rank = zSet.rank(key, username);

if (rank == null) {
throw new CustomException(ErrorCode.QUEUE_INSERT_FAIL);
}

rank = rank + 1;

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

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

// TTL 만료된 데이터 삭제
redisTemplate.opsForZSet()
.removeRangeByScore(prefixKey + scheduleId, 0, System.currentTimeMillis());
addSelectingQueue(scheduleId, username);
deleteWaitingUser(scheduleId, username);

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

String destination = "/topic/queue/" + scheduleId + "/" + username;
MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank,
acceptedRank);
false);
messagingTemplate.convertAndSend(destination, response);
}

// sorted set에 해당 scheduleId, userId를 가지는 데이터가 존재하는지 확인
// Todo : 좌석 선택 메서드 안에서 호출(정상 경로로 접근했는지 검증 필요)
public boolean checkQueue(Long scheduleId, String username) {
boolean exists = redisTemplate.opsForZSet().score(prefixKey + scheduleId, username) != null;
return exists;
// 대기중인 모든 사용자에게 랭킹 및 대기번호 전송
public void sendAllWaitingNumber(Long scheduleId) {
String key = prefixKeyForWaitingQueue + scheduleId;

// for문이나 stream으로 scheduleId에 해당하는 value값 리스트 추출
Set<String> usernames = redisTemplate.opsForZSet().range(key, 0, -1);

if (usernames == null || usernames.isEmpty()) {
return;
}

// 해당 유저들한테 메세지 전송
for (String username : usernames) {
sendWaitingNumber(key, username, scheduleId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,28 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;

import java.util.List;
import java.util.Map;

@Controller
@RequiredArgsConstructor
public class SeatScheduleInfoController {

private final SeatScheduleInfoService seatScheduleInfoService;
private final SeatScheduleInfoService seatScheduleInfoService;

@PostMapping("/seat-schedule-info/{seatScheduleInfoId}")
public ResponseEntity<String> selectSeat(
@PathVariable Long seatScheduleInfoId,
@AuthenticationPrincipal PrincipalDetails userDetails
){
seatScheduleInfoService.selectSeat(userDetails.getUserId(), seatScheduleInfoId);
return ResponseEntity.ok("좌석이 선택되었습니다.");
}
@PostMapping("/seat-schedule-info/{seatScheduleInfoId}")
public ResponseEntity<String> selectSeat(
@PathVariable Long seatScheduleInfoId,
@AuthenticationPrincipal PrincipalDetails userDetails
) {
seatScheduleInfoService.selectSeat(userDetails.getUserId(), userDetails.getUsername(),
seatScheduleInfoId);
return ResponseEntity.ok("좌석이 선택되었습니다.");
}

@GetMapping("/schedule/{scheduleId}/seat-schedule-info")
public ResponseEntity<Map<String, String>> getSeatScheduleInfos(
@PathVariable Long scheduleId
){
return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId));
}
@GetMapping("/schedule/{scheduleId}/seat-schedule-info")
public ResponseEntity<Map<String, String>> getSeatScheduleInfos(
@PathVariable Long scheduleId
) {
return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId));
}
}
Loading