Skip to content

Commit 02a18da

Browse files
committed
feat: 채팅방 입장 정책 검증, Lazy 생성 구현
1 parent b8b72c4 commit 02a18da

9 files changed

Lines changed: 216 additions & 24 deletions

File tree

src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatController.java

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.controller;
2+
3+
import org.springframework.web.bind.annotation.PathVariable;
4+
import org.springframework.web.bind.annotation.PostMapping;
5+
import org.springframework.web.bind.annotation.RequestMapping;
6+
import org.springframework.web.bind.annotation.RestController;
7+
8+
import com.back.web7_9_codecrete_be.domain.chats.service.ChatRoomService;
9+
import com.back.web7_9_codecrete_be.global.rq.Rq;
10+
import com.back.web7_9_codecrete_be.global.rsData.RsData;
11+
12+
import io.swagger.v3.oas.annotations.Operation;
13+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
14+
import io.swagger.v3.oas.annotations.tags.Tag;
15+
import lombok.RequiredArgsConstructor;
16+
17+
@Tag(name = "Chat Room", description = "공연 채팅방 입장 및 퇴장 API")
18+
@RestController
19+
@RequestMapping("/api/v1/chat-room")
20+
@RequiredArgsConstructor
21+
public class ChatRoomController {
22+
23+
private final ChatRoomService chatRoomService;
24+
private final Rq rq;
25+
26+
@Operation(
27+
summary = "채팅방 입장",
28+
description = """
29+
공연 채팅방에 입장합니다.
30+
- 정책 기간(예매일 3일 전 ~ 예매일 3일 후) 안에서만 입장이 가능합니다.
31+
- 채팅방이 존재하지 않으면 최초 1회 Lazy 생성됩니다.
32+
33+
입장 성공 후 프론트엔드는 WebSocket(STOMP)에 연결하여
34+
Redis Stream 기반 과거 채팅과 실시간 채팅을 수신합니다.
35+
""" )
36+
@ApiResponse( responseCode = "200", description = "채팅방 입장에 성공하였습니다." )
37+
@ApiResponse( responseCode = "403", description = "로그인이 필요합니다." )
38+
@ApiResponse( responseCode = "403", description = "채팅 가능한 기간이 아닙니다." )
39+
@ApiResponse( responseCode = "404", description = "콘서트가 존재하지 않습니다." )
40+
@PostMapping("/concert/{concertId}/join")
41+
public RsData<Void> joinChatRoom(@PathVariable Long concertId) {
42+
43+
chatRoomService.joinChatRoom(concertId);
44+
return RsData.success(null);
45+
}
46+
47+
}

src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/dummy.txt

Whitespace-only changes.

src/main/java/com/back/web7_9_codecrete_be/domain/chats/entity/ChatRoom.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,48 @@
22

33
import java.time.LocalDateTime;
44

5+
import org.springframework.data.annotation.CreatedDate;
6+
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
7+
58
import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert;
69

710
import jakarta.persistence.Column;
811
import jakarta.persistence.Entity;
12+
import jakarta.persistence.EntityListeners;
913
import jakarta.persistence.FetchType;
1014
import jakarta.persistence.GeneratedValue;
1115
import jakarta.persistence.GenerationType;
1216
import jakarta.persistence.Id;
1317
import jakarta.persistence.JoinColumn;
1418
import jakarta.persistence.OneToOne;
19+
import lombok.AccessLevel;
1520
import lombok.Getter;
21+
import lombok.NoArgsConstructor;
1622

1723
@Getter
1824
@Entity
25+
@EntityListeners(AuditingEntityListener.class)
26+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
1927
public class ChatRoom {
2028

2129
@Id
2230
@GeneratedValue(strategy = GenerationType.IDENTITY)
2331
@Column(name = "chat_room_id")
24-
private long id;
32+
private Long id;
2533

26-
@Column(nullable = false)
34+
@CreatedDate
35+
@Column(name = "created_date", nullable = false)
2736
private LocalDateTime createdDate;
2837

29-
@Column(nullable = false)
30-
private LocalDateTime expiresDate;
31-
3238
@OneToOne(fetch = FetchType.LAZY)
3339
@JoinColumn(name = "concert_id", nullable = false, unique = true)
3440
private Concert concert;
3541

42+
private ChatRoom(Concert concert) {
43+
this.concert = concert;
44+
}
45+
46+
public static ChatRoom create(Concert concert) {
47+
return new ChatRoom(concert);
48+
}
3649
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package com.back.web7_9_codecrete_be.domain.chats.repository;
22

3+
import java.util.Optional;
4+
35
import org.springframework.data.jpa.repository.JpaRepository;
46

57
import com.back.web7_9_codecrete_be.domain.chats.entity.ChatRoom;
68

79
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
10+
11+
Optional<ChatRoom> findByConcert_ConcertId(Long concertId);
12+
813
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.service;
2+
3+
import java.time.Duration;
4+
import java.time.LocalDateTime;
5+
6+
import org.springframework.data.redis.core.RedisTemplate;
7+
import org.springframework.stereotype.Service;
8+
9+
import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert;
10+
import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertRepository;
11+
import com.back.web7_9_codecrete_be.global.error.code.ConcertErrorCode;
12+
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
13+
14+
import lombok.RequiredArgsConstructor;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class ChatPolicyService {
19+
20+
private final RedisTemplate<String, Object> redisTemplate;
21+
private final ConcertRepository concertRepository;
22+
23+
private static final String CHAT_POLICY_KEY_PREFIX = "chat:policy:concert:";
24+
25+
public boolean isChatAvailable(Long concertId) {
26+
27+
String key = CHAT_POLICY_KEY_PREFIX + concertId;
28+
29+
// redis 캐시 조회
30+
Boolean cached = (Boolean)redisTemplate.opsForValue().get(key);
31+
if (cached != null) {
32+
return cached;
33+
}
34+
35+
// 캐시 미스 -> Concert db 조회
36+
Concert concert = concertRepository.findById(concertId)
37+
.orElseThrow(() -> new BusinessException(ConcertErrorCode.CONCERT_NOT_FOUND));
38+
39+
LocalDateTime ticketTime = concert.getTicketTime();
40+
if (ticketTime == null) {
41+
return false;
42+
}
43+
44+
// 정책 기간 계산
45+
LocalDateTime now = LocalDateTime.now();
46+
boolean available =
47+
now.isAfter(ticketTime.minusDays(3)) &&
48+
now.isBefore(ticketTime.plusDays(3));
49+
50+
// TTL 계산 후 캐싱
51+
Duration ttl = calculateTtl(now, ticketTime);
52+
redisTemplate.opsForValue().set(key, available, ttl);
53+
54+
return available;
55+
}
56+
57+
private Duration calculateTtl(LocalDateTime now, LocalDateTime ticketTime) {
58+
59+
LocalDateTime policyStart = ticketTime.minusDays(3);
60+
LocalDateTime policyEnd = ticketTime.plusDays(3);
61+
62+
// 아직 정책 시작 전
63+
if (now.isBefore(policyStart)) {
64+
return Duration.between(now, policyStart);
65+
}
66+
67+
// 정책 기간 중
68+
if (now.isBefore(policyEnd)) {
69+
return Duration.between(now, policyEnd);
70+
}
71+
72+
// 정책 기간 종료 후
73+
return Duration.ofMinutes(10);
74+
}
75+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,62 @@
11
package com.back.web7_9_codecrete_be.domain.chats.service;
22

3+
import org.springframework.dao.DataIntegrityViolationException;
34
import org.springframework.stereotype.Service;
5+
import org.springframework.transaction.annotation.Transactional;
46

7+
import com.back.web7_9_codecrete_be.domain.chats.entity.ChatRoom;
58
import com.back.web7_9_codecrete_be.domain.chats.repository.ChatRoomRepository;
9+
import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert;
10+
import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertRepository;
11+
import com.back.web7_9_codecrete_be.global.error.code.ChatErrorCode;
12+
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
613

714
import lombok.RequiredArgsConstructor;
815

916
@Service
1017
@RequiredArgsConstructor
18+
@Transactional
1119
public class ChatRoomService {
1220

1321
private final ChatRoomRepository chatRoomRepository;
22+
private final ChatPolicyService chatPolicyService;
23+
private final ConcertRepository concertRepository;
24+
25+
/**
26+
* 채팅방 입장
27+
* - 정책 기간 검증 (Redis 캐시 우선)
28+
* - 채팅방 Lazy 생성
29+
*/
30+
public void joinChatRoom(Long concertId) {
31+
32+
// 채팅 가능 정책 검증
33+
if (!chatPolicyService.isChatAvailable(concertId)) {
34+
throw new BusinessException(ChatErrorCode.CHAT_NOT_AVAILABLE);
35+
}
36+
37+
// 채팅방 조회 or Lazy 생성
38+
chatRoomRepository.findByConcert_ConcertId(concertId)
39+
.orElseGet(() -> createChatRoomSafely(concertId));
40+
41+
// TODO: 입장 처리 (WebSocket)
42+
}
43+
44+
/**
45+
* 채팅방 Lazy 생성
46+
* - DB UNIQUE(concert_id)로 동시성 처리
47+
*/
48+
private ChatRoom createChatRoomSafely(Long concertId) {
49+
50+
try {
51+
Concert concert = concertRepository.getReferenceById(concertId);
52+
53+
return chatRoomRepository.save(ChatRoom.create(concert));
54+
55+
} catch (DataIntegrityViolationException e) {
56+
// 동시에 다른 요청이 먼저 생성한 경우
57+
return chatRoomRepository.findByConcert_ConcertId(concertId)
58+
.orElseThrow(() ->
59+
new IllegalStateException("채팅방 생성 충돌 후 재조회 실패"));
60+
}
61+
}
1462
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.back.web7_9_codecrete_be.global.error.code;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import lombok.Getter;
6+
import lombok.RequiredArgsConstructor;
7+
8+
@Getter
9+
@RequiredArgsConstructor
10+
public enum ChatErrorCode implements ErrorCode {
11+
12+
CHAT_NOT_AVAILABLE(HttpStatus.FORBIDDEN, "CH-100", "채팅 가능 기간이 아닙니다.");
13+
14+
private final HttpStatus status;
15+
private final String code;
16+
private final String message;
17+
}

src/main/java/com/back/web7_9_codecrete_be/global/error/code/ConcertErrorCode.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package com.back.web7_9_codecrete_be.global.error.code;
22

3+
import org.springframework.http.HttpStatus;
4+
35
import lombok.Getter;
46
import lombok.RequiredArgsConstructor;
5-
import org.springframework.http.HttpStatus;
67

78
@Getter
89
@RequiredArgsConstructor
910
public enum ConcertErrorCode implements ErrorCode {
1011

1112
LIKE_CONFLICT(HttpStatus.CONFLICT,"C131","이미 좋아요를 누른 공연입니다."),
12-
NOT_FOUND_CONCERTLIKE(HttpStatus.NOT_FOUND,"C130","좋아요를 누르지 않은 공연입니다.")
13+
NOT_FOUND_CONCERTLIKE(HttpStatus.NOT_FOUND,"C130","좋아요를 누르지 않은 공연입니다."),
14+
15+
CONCERT_NOT_FOUND(HttpStatus.NOT_FOUND, "C-103", "콘서트를 찾을 수 없습니다."),
16+
TICKET_TIME_NOT_FOUND(HttpStatus.NOT_FOUND, "C-104", "예매일자가 존재하지 않습니다.")
1317
;
1418

1519
private final HttpStatus status;

0 commit comments

Comments
 (0)