Skip to content

Commit cb4d583

Browse files
feat: 찜한 공연 예매 알림 기능 추가
1 parent 89c0df0 commit cb4d583

8 files changed

Lines changed: 740 additions & 0 deletions

File tree

src/main/java/com/back/web7_9_codecrete_be/domain/concerts/controller/ConcertAdminController.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.back.web7_9_codecrete_be.domain.concerts.dto.concert.ConcertItem;
88
import com.back.web7_9_codecrete_be.domain.concerts.dto.concert.ConcertTicketTimeSetRequest;
99
import com.back.web7_9_codecrete_be.domain.concerts.dto.concert.ConcertUpdateRequest;
10+
import com.back.web7_9_codecrete_be.domain.concerts.service.ConcertNotifyService;
1011
import com.back.web7_9_codecrete_be.domain.concerts.service.ConcertService;
1112
import com.back.web7_9_codecrete_be.domain.concerts.service.KopisApiService;
1213
import com.back.web7_9_codecrete_be.global.rsData.RsData;
@@ -27,6 +28,7 @@
2728
public class ConcertAdminController { // todo : 인증 권한 추가하기
2829
private final ConcertService concertService;
2930
private final KopisApiService kopisApiService;
31+
private final ConcertNotifyService concertNotifyService;
3032

3133

3234
@Operation(summary = "초기 공연 정보 저장", description = "25년 12월부터 앞으로 6개월 이후까지의 전체 공연의 정보를 가져와서 저장합니다. 대략 10~12분 정도 시간이 소요됩니다.")
@@ -118,4 +120,12 @@ public RsData<ConcertDetailResponse> updateConcertByKopisAPI(
118120
public RsData<SetResultResponse> updateConcert() throws InterruptedException {
119121
return RsData.success(kopisApiService.updateConcertData());
120122
}
123+
124+
@Operation(summary = "알림 이메일 전송", description = "예매일이 오늘인 공연에 대해 알림 이메일을 전송합니다.")
125+
@PostMapping("sendTicketingEmail")
126+
public RsData<String> sendTicketingEmail(){
127+
String resultMessage = concertNotifyService.sendTodayTicketingConcertsNotifyingEmail();
128+
return RsData.success(resultMessage,null);
129+
}
130+
121131
}

src/main/java/com/back/web7_9_codecrete_be/domain/concerts/repository/ConcertLikeRepository.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,37 @@
44
import com.back.web7_9_codecrete_be.domain.concerts.entity.ConcertLike;
55
import com.back.web7_9_codecrete_be.domain.users.entity.User;
66
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
79
import org.springframework.stereotype.Repository;
810

11+
import java.time.LocalDateTime;
12+
import java.util.List;
13+
914
@Repository
1015
public interface ConcertLikeRepository extends JpaRepository<ConcertLike, Long> {
1116
ConcertLike findConcertLikeByConcertAndUser(Concert concert, User user);
1217

1318
boolean existsConcertLikeByConcertAndUser(Concert concert, User user);
19+
20+
List<ConcertLike> getConcertLikesByConcert(Concert concert);
21+
22+
@Query("""
23+
SELECT
24+
cl
25+
FROM
26+
ConcertLike cl
27+
JOIN
28+
cl.concert c
29+
WHERE
30+
c.ticketTime
31+
BETWEEN
32+
:startDate
33+
AND
34+
:endDate
35+
""")
36+
List<ConcertLike> getTodayConcertTicketingLikes(
37+
@Param("startDate") LocalDateTime startDate,
38+
@Param("endDate") LocalDateTime endDate);
1439
}
1540

src/main/java/com/back/web7_9_codecrete_be/domain/concerts/repository/ConcertRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,4 +302,8 @@ List<ConcertItem> getConcertItemsByKeyword(
302302

303303

304304
Concert getConcertByConcertId(Long concertId);
305+
306+
List<Concert> getConcertByTicketTimeAfterAndTicketTimeBefore(LocalDateTime ticketTimeAfter, LocalDateTime ticketTimeBefore);
307+
308+
List<Concert> getConcertByTicketTimeBetween(LocalDateTime ticketTimeAfter, LocalDateTime ticketTimeBefore);
305309
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package com.back.web7_9_codecrete_be.domain.concerts.service;
2+
3+
import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert;
4+
import com.back.web7_9_codecrete_be.domain.concerts.entity.ConcertLike;
5+
import com.back.web7_9_codecrete_be.domain.concerts.entity.TicketOffice;
6+
import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertLikeRepository;
7+
import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertRepository;
8+
import com.back.web7_9_codecrete_be.domain.concerts.repository.TicketOfficeRepository;
9+
import com.back.web7_9_codecrete_be.domain.email.service.EmailService;
10+
import com.back.web7_9_codecrete_be.domain.users.entity.User;
11+
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.stereotype.Service;
14+
15+
import java.time.LocalDate;
16+
import java.time.LocalDateTime;
17+
import java.time.LocalTime;
18+
import java.time.format.DateTimeFormatter;
19+
import java.util.ArrayList;
20+
import java.util.HashMap;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
@Service
25+
@RequiredArgsConstructor
26+
public class ConcertNotifyService {
27+
private final UserRepository userRepository;
28+
private final ConcertRepository concertRepository;
29+
private final TicketOfficeRepository ticketOfficeRepository;
30+
private final ConcertLikeRepository concertLikeRepository;
31+
private final EmailService emailService;
32+
33+
34+
private List<Concert> getTodayTicketingConcerts() {
35+
LocalDateTime startOfToday = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
36+
LocalDateTime endOfToday = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);
37+
38+
// 오늘의 시작과 끝 사이에 있는 공연 전부 가져오기
39+
List<Concert> concerts = concertRepository.getConcertByTicketTimeBetween(startOfToday, endOfToday);
40+
return concerts;
41+
}
42+
43+
44+
private Map<Long, List<TicketOffice>> getAllTicketOfficesMapFromConcerts(List<Concert> concerts) {
45+
Map<Long, List<TicketOffice>> ticketOfficeMap = new HashMap<>();
46+
List<TicketOffice> ticketOffices;
47+
// 줄일 수 없긴 개뿔, 가능하네.
48+
for (Concert concert : concerts) {
49+
ticketOffices = ticketOfficeRepository.getTicketOfficesByConcert(concert);
50+
ticketOfficeMap.put(concert.getConcertId(), ticketOffices);
51+
}
52+
// 예매처 맵 반환
53+
return ticketOfficeMap;
54+
}
55+
56+
private Map<String, List<Long>> getSendingEmailFromLikeUser(List<Concert> concerts) {
57+
// 이메일 값을 키 값으로 해서 전송할 concert Id를 추가?
58+
Map<String, List<Long>> emailMap = new HashMap<>();
59+
60+
LocalDateTime startOfToday = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
61+
LocalDateTime endOfToday = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);
62+
63+
List<ConcertLike> concertLikes = concertLikeRepository.getTodayConcertTicketingLikes(startOfToday, endOfToday);
64+
for (ConcertLike concertLike : concertLikes) {
65+
// map에 해당 사용자 email의 ConcertId list 가져오기, 없다면 새로은 arraylist 사용
66+
List<Long> tempList = emailMap.getOrDefault(concertLike.getUser().getEmail(), new ArrayList<>());
67+
// 임시 리스트에 concertId 추가
68+
tempList.add(concertLike.getConcert().getConcertId());
69+
// map에 유저 email 기준으로 해당 리스트 바꾸기;
70+
emailMap.put(concertLike.getUser().getEmail(), tempList);
71+
}
72+
/*
73+
for (Concert concert : concerts){
74+
// 오늘 예매 예정인 공연의 좋아요 목록을 전부 가져오기 // 쿼리 써서 개선 가능할 것 같은데?
75+
List<ConcertLike> concertLikes = concertLikeRepository.getConcertLikesByConcert(concert);
76+
for (ConcertLike concertLike : concertLikes){
77+
// map에 해당 사용자 email의 ConcertId list 가져오기, 없다면 새로은 arraylist 사용
78+
List<Long> tempList = emailMap.getOrDefault(concertLike.getUser().getEmail(),new ArrayList<>());
79+
// 임시 리스트에 concertId 추가
80+
tempList.add(concertLike.getConcert().getConcertId());
81+
// map에 유저 email 기준으로 해당 리스트 바꾸기;
82+
emailMap.put(concertLike.getUser().getEmail(),tempList);
83+
}
84+
}
85+
*/
86+
return emailMap;
87+
}
88+
89+
public String sendTodayTicketingConcertsNotifyingEmail() {
90+
List<Concert> concerts = getTodayTicketingConcerts();
91+
// 빠른 조회를 위해 Map으로 변환
92+
Map<Long, Concert> concertMap = new HashMap<>();
93+
for (Concert concert : concerts) {
94+
concertMap.put(concert.getConcertId(), concert);
95+
}
96+
97+
// 예매처 map 가져오기
98+
Map<Long, List<TicketOffice>> ticketOfficesMap = getAllTicketOfficesMapFromConcerts(concerts);
99+
// email에 따른 ConcertId 맵 가져오기
100+
Map<String, List<Long>> emailMap = getSendingEmailFromLikeUser(concerts);
101+
102+
LocalDate today = LocalDate.now();
103+
104+
int totalConcertsCount = concerts.size();
105+
int totalEmailCount = emailMap.size();
106+
107+
for (String targetEmail : emailMap.keySet()) {
108+
109+
StringBuilder sb = new StringBuilder();
110+
111+
//위 타이틀 부분
112+
sb.append("""
113+
<!doctype html>
114+
<html lang="ko">
115+
<body style="margin:0;padding:0;background-color:#fafafa;
116+
font-family:-apple-system,BlinkMacSystemFont,system-ui,Roboto,Helvetica Neue,Segoe UI,Apple SD Gothic Neo,Noto Sans KR,Malgun Gothic,sans-serif;">
117+
118+
<div style="max-width:680px;margin:40px auto;background:#ffffff;
119+
border-radius:16px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
120+
121+
<!-- Header -->
122+
<div style="padding:32px;background:#1a1a1a;color:#ffffff;">
123+
<h1 style="margin:0 0 8px 0;font-size:26px;font-weight:700;">
124+
🎟 %s 오늘의 공연 예매 알림
125+
</h1>
126+
<div style="font-size:14px;opacity:0.85;">
127+
예매 시작 공연을 알려드립니다.
128+
</div>
129+
</div>
130+
131+
<div style="padding:32px;">
132+
""".formatted(today));
133+
// 개별 공연 내용 작성
134+
for (Long concertId : emailMap.get(targetEmail)) {
135+
136+
Concert concert = concertMap.get(concertId);
137+
138+
String posterImage = concert.getPosterUrl();
139+
if (posterImage == null || posterImage.isBlank()) {
140+
posterImage = "https://via.placeholder.com/640x360?text=No+Image";
141+
}
142+
143+
sb.append("""
144+
<div style="border:1px solid #e8e8e8;border-radius:12px;
145+
padding:24px;margin-bottom:20px;">
146+
<img
147+
src="%s"
148+
alt="공연 포스터"
149+
style="width:100%%;height:200px;
150+
object-fit:cover;border-radius:8px;
151+
margin-bottom:16px;"
152+
/>
153+
<div style="font-size:16px;font-weight:600;color:#1a1a1a;margin-bottom:8px;">
154+
%s
155+
</div>
156+
<div style="font-size:13px;color:#666;margin-bottom:12px;">
157+
⏰ 예매 시간 :
158+
<strong>%s</strong>
159+
</div>
160+
""".formatted(
161+
posterImage,
162+
concert.getName(),
163+
concert.getTicketTime()
164+
.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분"))
165+
));
166+
for (TicketOffice ticketOffice : ticketOfficesMap.get(concertId)) {
167+
sb.append("""
168+
<div style="background:#f8f8f8;padding:12px 16px;
169+
border-radius:8px;margin-bottom:8px;font-size:13px;">
170+
171+
<div style="font-weight:600;color:#1a1a1a;">
172+
%s
173+
</div>
174+
175+
<a href="%s" target="_blank"
176+
style="color:#1a1a1a;text-decoration:underline;font-size:12px;">
177+
예매 페이지 바로가기
178+
</a>
179+
</div>
180+
""".formatted(
181+
ticketOffice.getTicketOfficeName(),
182+
ticketOffice.getTicketOfficeUrl()
183+
));
184+
}
185+
186+
sb.append("</div>");
187+
}
188+
sb.append("""
189+
<div style="background:#f8f8f8;padding:20px;border-radius:8px;margin-top:24px;">
190+
<div style="font-size:14px;font-weight:bold;margin-bottom:6px;">
191+
ℹ️ 유의사항
192+
</div>
193+
<div style="font-size:12px;color:#666;">
194+
공연 정보는 각 공연의 상황에 따라 변경될 수 있으니
195+
예매 전 반드시 확인해주세요.
196+
</div>
197+
</div>
198+
199+
</div>
200+
201+
<div style="text-align:center;padding:32px;background:#fafafa;
202+
color:#999;font-size:12px;line-height:1.6;">
203+
이 메일은 자동으로 발송되었습니다.<br/>
204+
© 2025 Concert Notification Service
205+
</div>
206+
207+
</div>
208+
</body>
209+
</html>
210+
""");
211+
212+
String contents = sb.toString();
213+
emailService.sendNotifyEmail(targetEmail, contents);
214+
}
215+
return totalConcertsCount + "건의 공연을" + totalEmailCount + "명의 사용자에게 전송했습니다.";
216+
}
217+
}

0 commit comments

Comments
 (0)