diff --git a/src/main/java/com/beautiflow/chat/domain/ChatRoomRead.java b/src/main/java/com/beautiflow/chat/domain/ChatRoomRead.java index 4f589b21..c4db0936 100644 --- a/src/main/java/com/beautiflow/chat/domain/ChatRoomRead.java +++ b/src/main/java/com/beautiflow/chat/domain/ChatRoomRead.java @@ -36,7 +36,15 @@ public class ChatRoomRead { private LocalDateTime lastReadTime; + private LocalDateTime lastAlertSentAt; + private Long lastAlertFromMessageId; + public void updateReadTime(LocalDateTime time) { this.lastReadTime = time; } + + public void markAlerted(Long messageId,LocalDateTime now){ + this.lastAlertFromMessageId=messageId; + this.lastAlertSentAt=now; + } } \ No newline at end of file diff --git a/src/main/java/com/beautiflow/chat/repository/ChatMessageRepository.java b/src/main/java/com/beautiflow/chat/repository/ChatMessageRepository.java index 9a610c35..f13bce99 100644 --- a/src/main/java/com/beautiflow/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/beautiflow/chat/repository/ChatMessageRepository.java @@ -5,6 +5,8 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.beautiflow.chat.domain.ChatMessage; import com.beautiflow.chat.domain.ChatRoom; @@ -17,5 +19,26 @@ public interface ChatMessageRepository extends JpaRepository int countByChatRoomAndSenderNotAndCreatedTimeAfter(ChatRoom chatRoom, User sender, LocalDateTime time); + @Query(""" + select m + from ChatMessage m + where m.chatRoom = :room + and m.createdTime > :lastReadTime + and m.sender <> :recipient + order by m.createdTime asc + """) + Optional findOldestUnreadForRecipient( + @Param("room") ChatRoom room, + @Param("lastReadTime") LocalDateTime lastReadTime, + @Param("recipient") User recipient + ); + + Optional + findFirstByChatRoomAndCreatedTimeAfterAndSenderNotOrderByCreatedTimeAsc( + ChatRoom room, + LocalDateTime lastReadTime, + User recipient + ); + } \ No newline at end of file diff --git a/src/main/java/com/beautiflow/chat/repository/ChatRoomRepository.java b/src/main/java/com/beautiflow/chat/repository/ChatRoomRepository.java index 8a99c4af..2f2b80bc 100644 --- a/src/main/java/com/beautiflow/chat/repository/ChatRoomRepository.java +++ b/src/main/java/com/beautiflow/chat/repository/ChatRoomRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -39,4 +41,11 @@ Optional findByShopAndCustomerAndDesigner( and cr.customer.id in :customerIds """) List findByShopIdAndDesignerIdAndCustomerIdIn(Long shopId, Long designerId, Collection customerIds); + + @Query(""" + select cr + from ChatRoom cr + where (cr.customerExited = false or cr.designerExited = false) + """) + Page findAllActive(Pageable pageable); } diff --git a/src/main/java/com/beautiflow/chat/service/ChatRoomService.java b/src/main/java/com/beautiflow/chat/service/ChatRoomService.java index 4c36b3cf..c4577d59 100644 --- a/src/main/java/com/beautiflow/chat/service/ChatRoomService.java +++ b/src/main/java/com/beautiflow/chat/service/ChatRoomService.java @@ -41,15 +41,15 @@ public class ChatRoomService { private final SmsService smsService; public RoomCreateRes createRoom(Long requesterId, RoomCreateReq roomCreateReq){ + User requester = userRepository.findById(requesterId) + .orElseThrow(() -> new BeautiFlowException(UserErrorCode.USER_NOT_FOUND)); + Optional optional = chatRoomRepository .findByShopIdAndCustomerIdAndDesignerId(roomCreateReq.shopId(), roomCreateReq.customerId(), roomCreateReq.designerId()); if (optional.isPresent()) { ChatRoom room = optional.get(); - // 재입장 처리 - User requester = userRepository.findById(requesterId) - .orElseThrow(() -> new BeautiFlowException(UserErrorCode.USER_NOT_FOUND)); room.reEnterBy(requester); return RoomCreateRes.of(room); @@ -61,6 +61,10 @@ public RoomCreateRes createRoom(Long requesterId, RoomCreateReq roomCreateReq){ User designer = userRepository.findById(roomCreateReq.designerId()) .orElseThrow(() -> new BeautiFlowException(UserErrorCode.USER_NOT_FOUND)); + if (!requester.getId().equals(customer.getId()) && !requester.getId().equals(designer.getId())) { + throw new BeautiFlowException(ChatRoomErrorCode.INVALID_CHATROOM_PARAMETER); + } + ChatRoom newRoom = ChatRoom.builder() .shop(shop) .customer(customer) @@ -69,8 +73,12 @@ public RoomCreateRes createRoom(Long requesterId, RoomCreateReq roomCreateReq){ chatRoomRepository.save(newRoom); - smsService.sendNewContactAlert(designer.getContact()); + User recipient = requester.getId().equals(customer.getId()) ? designer : customer; + String to = recipient != null ? recipient.getContact() : null; + if (to != null && !to.isBlank()) { + smsService.sendNewContactAlert(to); + } return RoomCreateRes.of(newRoom); @@ -113,8 +121,7 @@ public List getMyChatRooms(Long userId) { LocalDateTime lastReadTime = chatRoomReadRepository .findByChatRoomAndUser(room,me) .map(ChatRoomRead::getLastReadTime) - .orElse(LocalDateTime.MIN); - + .orElse(LocalDateTime.of(1970, 1, 1, 0, 0)); int unreadCount = chatMessageRepository.countByChatRoomAndSenderNotAndCreatedTimeAfter(room, me, lastReadTime); diff --git a/src/main/java/com/beautiflow/global/common/UnreadMessageSmsScheduler.java b/src/main/java/com/beautiflow/global/common/UnreadMessageSmsScheduler.java new file mode 100644 index 00000000..feb4960e --- /dev/null +++ b/src/main/java/com/beautiflow/global/common/UnreadMessageSmsScheduler.java @@ -0,0 +1,147 @@ +package com.beautiflow.global.common; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.beautiflow.chat.domain.ChatMessage; +import com.beautiflow.chat.domain.ChatRoom; +import com.beautiflow.chat.domain.ChatRoomRead; +import com.beautiflow.chat.repository.ChatMessageRepository; +import com.beautiflow.chat.repository.ChatRoomReadRepository; +import com.beautiflow.chat.repository.ChatRoomRepository; +import com.beautiflow.global.common.sms.SmsService; +import com.beautiflow.user.domain.User; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UnreadMessageSmsScheduler { + private final ChatRoomRepository chatRoomRepository; + private final ChatRoomReadRepository readRepository; + private final ChatMessageRepository messageRepository; + private final SmsService smsService; + + @Value("${alert.unread.threshold-minutes:60}") + private int thresholdMinutes; + + @Value("${alert.unread.page-size:200}") + private int pageSize; + + @Value("${alert.unread.enable-night-block:false}") + private boolean enableNightBlock; + + @Value("${alert.unread.night-start:21:00}") + private String nightStart; + + @Value("${alert.unread.night-end:08:00}") + private String nightEnd; + + @Value("${app.chat.deep-link-prefix:https://beautiflow.co.kr/chat/rooms/}") + private String deepLinkPrefix; + + private final AtomicBoolean running = new AtomicBoolean(false); + + private static final LocalDateTime FLOOR = LocalDateTime.of(1970, 1, 1, 0, 0); + + @Scheduled(cron = "0 */5 * * * *", zone = "Asia/Seoul") + @Transactional + public void run() { + if (!running.compareAndSet(false, true)) { + // 이전 작업이 아직 끝나지 않았으면 이번 턴은 스킵 + return; + } + try { + processInBatches(); + } finally { + running.set(false); + } + } + + @Transactional + protected void processInBatches() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime threshold = now.minusMinutes(thresholdMinutes); + + if (enableNightBlock && isInNightHours(now.toLocalTime())) { + log.info("야간시간대이므로 미확인 SMS 전송 건너뜀"); + return; + } + + int page = 0; + Page rooms; + do { + rooms = chatRoomRepository.findAllActive(PageRequest.of(page++, pageSize)); + for (ChatRoom room : rooms) { + if (room.getCustomer() != null && !room.isCustomerExited()) { + checkAndNotify(room, room.getCustomer(), threshold, now); + } + if (room.getDesigner() != null && !room.isDesignerExited()) { + checkAndNotify(room, room.getDesigner(), threshold, now); + } + } + } while (rooms.hasNext()); + } + + private boolean isInNightHours(LocalTime now) { + LocalTime start = LocalTime.parse(nightStart); + LocalTime end = LocalTime.parse(nightEnd); + if (start.isBefore(end)) { + return now.isAfter(start) && now.isBefore(end); + } else { // 21:00 ~ 다음날 08:00 형태 + return now.isAfter(start) || now.isBefore(end); + } + } + + private void checkAndNotify(ChatRoom room, User recipient, LocalDateTime threshold, LocalDateTime now) { + ChatRoomRead read = readRepository.findByChatRoomAndUser(room, recipient) + .orElseGet(() -> readRepository.save(ChatRoomRead.builder() + .chatRoom(room) + .user(recipient) + .lastReadTime(FLOOR) + .build())); + + LocalDateTime lastRead = Optional.ofNullable(read.getLastReadTime()).orElse(FLOOR); + if (lastRead.isBefore(FLOOR)) lastRead = FLOOR; + + Optional oldestUnread = + messageRepository.findFirstByChatRoomAndCreatedTimeAfterAndSenderNotOrderByCreatedTimeAsc( + room, lastRead, recipient + ); + if (oldestUnread.isEmpty()) return; + + ChatMessage target = oldestUnread.get(); + + if (read.getLastAlertFromMessageId() != null && + read.getLastAlertFromMessageId() >= target.getId()) { + return; + } + + // 1시간(설정값) 이상 경과 + if (target.getCreatedTime().isAfter(threshold)) return; + + String phone = recipient.getContact(); // 실제 필드명에 맞게 + if (phone == null || phone.isBlank()) return; + + String link = deepLinkPrefix + room.getId(); + String shopName = room.getShop() != null ? room.getShop().getShopName() : "매장"; + smsService.sendUnreadReminder(phone, shopName, link); + + read.markAlerted(target.getId(), now); + readRepository.save(read); + } + +} + + diff --git a/src/main/java/com/beautiflow/global/common/sms/PhoneNormalizerKR.java b/src/main/java/com/beautiflow/global/common/sms/PhoneNormalizerKR.java new file mode 100644 index 00000000..b863e414 --- /dev/null +++ b/src/main/java/com/beautiflow/global/common/sms/PhoneNormalizerKR.java @@ -0,0 +1,32 @@ +package com.beautiflow.global.common.sms; + +public final class PhoneNormalizerKR { + private PhoneNormalizerKR() {} + + public static String toE164(String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + String digits = trimmed.replaceAll("\\D+", ""); // 숫자만 + if (digits.isEmpty()) return null; + + // '+'로 시작한 경우에도 하이픈 등 제거 후 +digits로 통일 + if (trimmed.startsWith("+")) { + return "+" + digits; // 예: "+82-10-1234-5678" -> "+821012345678" + } + + // "82..." 국제형(한국) 입력을 +82로 + if (digits.startsWith("82")) { + String rest = digits.substring(2); + if (rest.startsWith("0")) rest = rest.substring(1); // "82010..." 보정 + return "+82" + rest; + } + + // 국내형 "0..." 은 +82로 치환 + if (digits.startsWith("0")) { + return "+82" + digits.substring(1); + } + + // 그 외는 그냥 국제형으로 가정 + return "+" + digits; + } +} \ No newline at end of file diff --git a/src/main/java/com/beautiflow/global/common/sms/SmsService.java b/src/main/java/com/beautiflow/global/common/sms/SmsService.java index bce8ab48..d1c3116d 100644 --- a/src/main/java/com/beautiflow/global/common/sms/SmsService.java +++ b/src/main/java/com/beautiflow/global/common/sms/SmsService.java @@ -20,6 +20,7 @@ public class SmsService { private final SmsProperties smsProperties; private DefaultMessageService messageService; + private String normalizedFrom; @PostConstruct public void init() { @@ -27,33 +28,59 @@ public void init() { smsProperties.getApiKey(), smsProperties.getApiSecret(), "https://api.solapi.com" ); + this.normalizedFrom = digitsOnly(smsProperties.getFromNumber()); + if (this.normalizedFrom == null || this.normalizedFrom.isBlank()) { + log.warn("발신번호(from-number)가 유효하지 않습니다. yml을 확인하세요. from={}", smsProperties.getFromNumber()); + } + } + private static String digitsOnly(String raw) { + if (raw == null) return null; + String d = raw.replaceAll("\\D+", ""); + if (d.isEmpty()) return null; + if (d.startsWith("82")) { + String rest = d.substring(2); + if (!rest.startsWith("0")) rest = "0" + rest; + return rest; + } + return d; } - public void sendNewContactAlert(String toPhoneNumber) { + private void send(String to, String text) { + String normalizedTo = PhoneNormalizerKR.toE164(to); + if (normalizedFrom == null || normalizedTo == null) { + log.warn("발신/수신 번호가 유효하지 않아 전송 취소. from={}, to={}", masked(normalizedFrom), masked(normalizedTo)); + return; + } try { Message message = new Message(); - message.setFrom(smsProperties.getFromNumber()); - message.setTo(toPhoneNumber); - message.setText("새로운 문의를 시작했습니다. BeautiFlow에서 확인해주세요."); - - SingleMessageSentResponse response = messageService.sendOne(new SingleMessageSendingRequest(message)); - log.info("📤 SMS 발송 성공: {}", response.getMessageId()); + message.setFrom(normalizedFrom); + message.setTo(normalizedTo); + message.setText(text); + SingleMessageSentResponse res = messageService.sendOne(new SingleMessageSendingRequest(message)); + log.info("SMS 전송 성공: {}", res.getMessageId()); } catch (Exception e) { - log.error("❌ SMS 발송 실패: {}", e.getMessage(), e); + log.error("SMS 전송 실패: {}", e.getMessage(), e); } } + private String masked(String v) { + if (v == null) return null; + if (v.length() <= 4) return "****"; + return v.substring(0, v.length()-4).replaceAll("\\d", "*") + v.substring(v.length()-4); + } + + public void sendNewContactAlert(String toPhoneNumber) { + send(toPhoneNumber, "새로운 문의를 시작했습니다. BeautiFlow에서 확인해주세요."); + } + public void sendAuthCode(String toPhoneNumber, String code) { - try { - Message message = new Message(); - message.setFrom(smsProperties.getFromNumber()); - message.setTo(toPhoneNumber); - message.setText("[BeautiFlow] 인증번호: " + code); + send(toPhoneNumber, "[BeautiFlow] 인증번호: " + code); + } - SingleMessageSentResponse response = messageService.sendOne(new SingleMessageSendingRequest(message)); - log.info("📤 인증번호 발송 성공: {}", response.getMessageId()); - } catch (Exception e) { - log.error("❌ 인증번호 발송 실패: {}", e.getMessage(), e); - } + public void sendUnreadReminder(String toPhoneNumber, String shopName, String roomDeepLink) { + String body = "[BeautiFlow] " + shopName + + "에서 새로운 메시지가 1시간 이상 확인되지 않았습니다.\n" + + "바로 확인: " + roomDeepLink; + send(toPhoneNumber, body); } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2fb81907..caf3a841 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -57,4 +57,16 @@ spring: coolsms: api-key: ${COOLSMS_API_KEY} api-secret: ${COOLSMS_API_SECRET} - from-number: ${COOLSMS_FROM_NUMBER} \ No newline at end of file + from-number: ${COOLSMS_FROM_NUMBER} + +alert: + unread: + threshold-minutes: 60 + page-size: 200 + enable-night-block: true + night-start: "21:00" + night-end: "08:00" + +app: + chat: + deep-link-prefix: "https://www.beautiflow.co.kr/chat/rooms/" \ No newline at end of file