Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions src/main/java/com/beautiflow/chat/domain/ChatRoomRead.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,5 +19,26 @@ public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long>

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<ChatMessage> findOldestUnreadForRecipient(
@Param("room") ChatRoom room,
@Param("lastReadTime") LocalDateTime lastReadTime,
@Param("recipient") User recipient
);

Optional<ChatMessage>
findFirstByChatRoomAndCreatedTimeAfterAndSenderNotOrderByCreatedTimeAsc(
ChatRoom room,
LocalDateTime lastReadTime,
User recipient
);


}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,4 +41,11 @@ Optional<ChatRoom> findByShopAndCustomerAndDesigner(
and cr.customer.id in :customerIds
""")
List<ChatRoom> findByShopIdAndDesignerIdAndCustomerIdIn(Long shopId, Long designerId, Collection<Long> customerIds);

@Query("""
select cr
from ChatRoom cr
where (cr.customerExited = false or cr.designerExited = false)
""")
Page<ChatRoom> findAllActive(Pageable pageable);
}
19 changes: 13 additions & 6 deletions src/main/java/com/beautiflow/chat/service/ChatRoomService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatRoom> 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);
Expand All @@ -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)
Expand All @@ -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);

Expand Down Expand Up @@ -113,8 +121,7 @@ public List<ChatRoomSummaryRes> 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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatRoom> 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<ChatMessage> 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);
}

}


Original file line number Diff line number Diff line change
@@ -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;
}
}
63 changes: 45 additions & 18 deletions src/main/java/com/beautiflow/global/common/sms/SmsService.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,67 @@ public class SmsService {

private final SmsProperties smsProperties;
private DefaultMessageService messageService;
private String normalizedFrom;

@PostConstruct
public void init() {
this.messageService = NurigoApp.INSTANCE.initialize(
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);
}
}
Loading