MessageCommandServiceImpl.java
package com.deveagles.be15_deveagles_be.features.messages.command.application.service.impl;
import com.deveagles.be15_deveagles_be.common.exception.BusinessException;
import com.deveagles.be15_deveagles_be.common.exception.ErrorCode;
import com.deveagles.be15_deveagles_be.features.customers.query.service.CustomerQueryService;
import com.deveagles.be15_deveagles_be.features.messages.command.application.dto.SmsSendUnit;
import com.deveagles.be15_deveagles_be.features.messages.command.application.dto.request.SmsRequest;
import com.deveagles.be15_deveagles_be.features.messages.command.application.dto.request.UpdateReservationRequest;
import com.deveagles.be15_deveagles_be.features.messages.command.application.dto.response.MessageSendResult;
import com.deveagles.be15_deveagles_be.features.messages.command.application.service.MessageCommandService;
import com.deveagles.be15_deveagles_be.features.messages.command.application.service.MessageVariableProcessor;
import com.deveagles.be15_deveagles_be.features.messages.command.domain.aggregate.MessageDeliveryStatus;
import com.deveagles.be15_deveagles_be.features.messages.command.domain.aggregate.MessageSendingType;
import com.deveagles.be15_deveagles_be.features.messages.command.domain.aggregate.MessageSettings;
import com.deveagles.be15_deveagles_be.features.messages.command.domain.aggregate.Sms;
import com.deveagles.be15_deveagles_be.features.messages.command.domain.repository.MessageSettingRepository;
import com.deveagles.be15_deveagles_be.features.messages.command.domain.repository.SmsRepository;
import com.deveagles.be15_deveagles_be.features.messages.command.infrastructure.CoolSmsClient;
import com.deveagles.be15_deveagles_be.features.shops.command.application.service.ShopCommandService;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class MessageCommandServiceImpl implements MessageCommandService {
private final ShopCommandService shopCommandService;
private final CustomerQueryService customerQueryService;
private final MessageSettingRepository messageSettingRepository;
private final CoolSmsClient coolSmsClient;
private final SmsRepository smsRepository;
private final MessageVariableProcessor messageVariableProcessor;
@Override
@Transactional
public List<MessageSendResult> sendSms(Long shopId, SmsRequest smsRequest) {
shopCommandService.validateShopExists(shopId);
LocalDateTime now = LocalDateTime.now();
LocalDateTime scheduledAt = resolveScheduledAt(smsRequest, now);
boolean isReservation = MessageSendingType.RESERVATION.equals(smsRequest.messageSendingType());
// 1. 고객 ID 원본 리스트
List<Long> customerIds = smsRequest.customerIds();
// 2. 중복 제거된 ID로 전화번호 조회 (성공/실패 여부 판단용)
List<Long> distinctCustomerIds = customerIds.stream().distinct().toList();
List<String> phoneNumbers = customerQueryService.getCustomerPhoneNumbers(distinctCustomerIds);
// 3. 메시지 설정 조회 (발신번호 등)
MessageSettings settings =
messageSettingRepository
.findByShopId(shopId)
.orElseThrow(() -> new BusinessException(ErrorCode.MESSAGE_SETTINGS_NOT_FOUND));
String senderNumber = settings.getSenderNumber();
// 4. Sms 리스트 생성 (치환 포함)
List<Sms> smsList =
IntStream.range(0, distinctCustomerIds.size())
.mapToObj(
i -> {
Long customerId = distinctCustomerIds.get(i);
// ✅ 메시지 치환: payload 생성 + 적용
Map<String, String> payload =
messageVariableProcessor.buildPayload(customerId, shopId);
String resolvedContent =
messageVariableProcessor.resolveVariables(
smsRequest.messageContent(), payload);
Sms.SmsBuilder builder =
Sms.builder()
.shopId(shopId)
.customerId(customerId)
.messageContent(resolvedContent)
.messageKind(smsRequest.messageKind())
.messageType(smsRequest.messageType())
.messageSendingType(smsRequest.messageSendingType())
.messageDeliveryStatus(
isReservation
? MessageDeliveryStatus.PENDING
: MessageDeliveryStatus.SENT)
.scheduledAt(scheduledAt)
.templateId(smsRequest.templateId())
.hasLink(Boolean.TRUE.equals(smsRequest.hasLink()))
.customerGradeId(smsRequest.customerGradeId())
.tagId(smsRequest.tagId())
.couponId(smsRequest.couponId())
.workflowId(smsRequest.workflowId());
if (!isReservation) {
builder.sentAt(now);
}
return builder.build();
})
.toList();
// 5. 저장
List<Sms> saved = smsRepository.saveAll(smsList);
smsRepository.flush();
// 6. 즉시 발송이면 CoolSMS 호출
if (!isReservation) {
List<SmsSendUnit> units =
IntStream.range(0, saved.size())
.mapToObj(i -> new SmsSendUnit(saved.get(i).getMessageId(), phoneNumbers.get(i)))
.toList();
List<MessageSendResult> results =
coolSmsClient.sendMany(senderNumber, saved.get(0).getMessageContent(), units);
List<Long> failedIds =
results.stream().filter(r -> !r.success()).map(MessageSendResult::messageId).toList();
if (!failedIds.isEmpty()) {
markSmsAsFailed(failedIds);
}
return results;
}
// 7. 예약 발송이면 등록 완료 응답
return saved.stream()
.map(s -> new MessageSendResult(true, "예약 등록 완료", s.getMessageId()))
.toList();
}
@Override
@Transactional
public void updateReservationMessage(
UpdateReservationRequest updateReservationRequest, Long shopId, Long messageId) {
shopCommandService.validateShopExists(shopId);
Sms sms =
smsRepository
.findById(messageId)
.orElseThrow(() -> new BusinessException(ErrorCode.SMS_NOT_FOUND));
if (!sms.getShopId().equals(shopId)) {
throw new BusinessException(ErrorCode.SMS_SHOP_MISMATCH);
}
if (sms.getScheduledAt().isBefore(LocalDateTime.now())) {
throw new BusinessException(ErrorCode.ALREADY_SENT_OR_CANCELED);
}
if (updateReservationRequest.scheduledAt().isBefore(LocalDateTime.now())) {
throw new BusinessException(ErrorCode.INVALID_SCHEDULED_TIME);
}
sms.updateReservation(
updateReservationRequest.messageContent(),
updateReservationRequest.messageKind(),
updateReservationRequest.customerId(),
updateReservationRequest.scheduledAt());
}
@Override
@Transactional
public void cancelScheduledMessage(Long messageId, Long shopId) {
shopCommandService.validateShopExists(shopId);
Sms sms =
smsRepository
.findById(messageId)
.orElseThrow(() -> new BusinessException(ErrorCode.SMS_NOT_FOUND));
if (!sms.getShopId().equals(shopId)) {
throw new BusinessException(ErrorCode.SMS_SHOP_MISMATCH);
}
if (!sms.isReservable()) {
throw new BusinessException(ErrorCode.INVALID_MESSAGE_CANCEL_CONDITION);
}
sms.cancel();
}
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markSmsAsFailed(Collection<Long> smsIds) {
List<Sms> failedMessages = smsRepository.findAllById(smsIds);
failedMessages.forEach(Sms::markAsFailed);
smsRepository.saveAll(failedMessages);
}
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markSmsAsSent(Collection<Long> smsIds) {
List<Sms> sentMessages = smsRepository.findAllById(smsIds);
sentMessages.forEach(Sms::markAsSent);
smsRepository.saveAll(sentMessages);
}
private LocalDateTime resolveScheduledAt(SmsRequest request, LocalDateTime now) {
if (request.messageSendingType() == MessageSendingType.RESERVATION
&& request.scheduledAt() == null) {
throw new BusinessException(ErrorCode.SCHEDULE_TIME_REQUIRED_FOR_RESERVATION);
}
if (request.messageSendingType() == MessageSendingType.IMMEDIATE
&& request.scheduledAt() != null) {
throw new BusinessException(ErrorCode.SCHEDULE_TIME_NOT_ALLOWED_FOR_IMMEDIATE);
}
return request.messageSendingType() == MessageSendingType.IMMEDIATE
? now
: request.scheduledAt();
}
}