Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ gemini:
api:
key: ${GEMINI_KEY}
model: gemini-2.5-flash

slack:
sdk:
bot-token: ${SLACK_BOT_TOKEN}
3 changes: 3 additions & 0 deletions notification/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'

// Slack SDK
implementation "com.slack.api:slack-api-client:1.46.0"

// webflux
implementation 'org.springframework.boot:spring-boot-starter-webflux'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.hubEleven.notification.slack.application.port;

public interface SlackClient {

SlackSendResult sendToChannel(String channel, String text);

SlackSendResult sendDmByEmail(String email, String text);

record SlackSendResult(boolean success, String channelId, String ts, String error) {
public static SlackSendResult ok(String channelId, String ts) {
return new SlackSendResult(true, channelId, ts, null);
}

public static SlackSendResult fail(String error) {
return new SlackSendResult(false, null, null, error);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@
import com.hubEleven.notification.slack.application.dto.response.SlackMessageResult;
import com.hubEleven.notification.slack.application.service.SlackService;
import com.hubEleven.notification.slack.application.validator.SlackValidator;
import com.hubEleven.notification.slack.domain.event.SlackMessageSavedEvent;
import com.hubEleven.notification.slack.domain.model.SlackMessage;
import com.hubEleven.notification.slack.domain.repository.SlackMessageRepository;
import com.hubEleven.notification.slack.domain.service.SlackDomainService;
import com.hubEleven.notification.slack.domain.vo.SlackMessageContext;
import com.hubEleven.notification.slack.domain.vo.SlackMessageItem;
import com.hubEleven.notification.slack.domain.vo.SlackMessageStatus;
import com.hubEleven.notification.slack.exception.SlackErrorCode;
import com.hubEleven.notification.slack.infrastructure.client.SlackWebhookClient;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -37,11 +37,11 @@
public class SlackServiceImpl implements SlackService {

private final SlackMessageRepository slackMessageRepository;
private final SlackWebhookClient slackWebhookClient;
private final SlackDomainService slackDomainService;
private final AiRequestLogRepository aiRequestLogRepository;
private final ObjectMapper objectMapper;
private final SlackValidator slackValidator;
private final ApplicationEventPublisher eventPublisher;

@Override
@Transactional
Expand All @@ -60,7 +60,9 @@ public SlackMessageResult createMessage(CreateSlackMessageCommand command) {

SlackMessage saved = slackMessageRepository.save(slackMessage);

sendToSlackAsync(saved.getId(), formattedMessage);
eventPublisher.publishEvent(SlackMessageSavedEvent.of(saved.getId()));

log.info("슬랙 메시지 저장 완료 - messageId={}", saved.getId());

return SlackMessageResult.from(saved);
}
Expand All @@ -78,6 +80,8 @@ public SlackMessageResult updateMessage(UpdateSlackMessageCommand command) {
slackMessage.updateMessage(command.message());
SlackMessage updated = slackMessageRepository.save(slackMessage);

log.info("슬랙 메시지 수정 완료 - messageId={}", command.messageId());

return SlackMessageResult.from(updated);
}

Expand Down Expand Up @@ -110,50 +114,58 @@ public CommonPageResponse<SlackMessageResult> searchMessages(
SearchSlackMessageCommand command, CommonPageRequest pageReq) {
slackValidator.slackRead();

var page =
return PagingUtils.convert(
slackMessageRepository.search(
command.status(),
command.channel(),
command.dateFrom(),
command.dateTo(),
pageReq.toPageable());

return PagingUtils.convert(page, SlackMessageResult::from);
pageReq.toPageable()),
SlackMessageResult::from);
}

private ResponsePayload findAiPayloadOrThrow(UUID orderId) {
var logEntry =
aiRequestLogRepository
.findByOrderId(orderId)
.orElseThrow(() -> new GlobalException(AiErrorCode.AI_RESPONSE_PARSE_FAIL));

String raw = logEntry.getRawResponse();
String cleaned = cleanJsonResponse(raw);

try {
var payload = objectMapper.readValue(cleaned, ResponsePayload.class);

if (payload.finalDispatchDeadline == null || payload.finalDispatchDeadline.isBlank()) {
throw new GlobalException(AiErrorCode.AI_RESPONSE_PARSE_FAIL);
}
if (payload.messageBody == null || payload.messageBody.isBlank()) {
throw new GlobalException(AiErrorCode.AI_RESPONSE_PARSE_FAIL);
}
return payload;
} catch (Exception e) {
throw new GlobalException(AiErrorCode.AI_RESPONSE_PARSE_FAIL);
}
return aiRequestLogRepository
.findByOrderId(orderId)
.map(
logEntry -> {
String raw = logEntry.getRawResponse();
String cleaned = cleanJsonResponse(raw);

try {
ResponsePayload payload = objectMapper.readValue(cleaned, ResponsePayload.class);

if (payload.finalDispatchDeadline == null
|| payload.finalDispatchDeadline.isBlank()) {
throw new GlobalException(AiErrorCode.AI_RESPONSE_PARSE_FAIL);
}
if (payload.messageBody == null || payload.messageBody.isBlank()) {
throw new GlobalException(AiErrorCode.AI_RESPONSE_PARSE_FAIL);
}
return payload;
} catch (Exception e) {
throw new GlobalException(AiErrorCode.AI_RESPONSE_PARSE_FAIL);
}
})
.orElseThrow(() -> new GlobalException(AiErrorCode.AI_RESPONSE_PARSE_FAIL));
}

private String cleanJsonResponse(String rawJson) {
if (rawJson == null || rawJson.isBlank()) return rawJson;
if (rawJson == null || rawJson.isBlank()) {
return rawJson;
}

String cleaned = rawJson.trim();

if (cleaned.startsWith("```json")) cleaned = cleaned.substring(7);
else if (cleaned.startsWith("```")) cleaned = cleaned.substring(3);
if (cleaned.startsWith("```json")) {
cleaned = cleaned.substring(7);
} else if (cleaned.startsWith("```")) {
cleaned = cleaned.substring(3);
}

if (cleaned.endsWith("```")) cleaned = cleaned.substring(0, cleaned.length() - 3);
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}

return cleaned.trim();
}
Expand Down Expand Up @@ -189,7 +201,9 @@ private SlackMessageContext buildSlackMessageContext(UUID orderId, ResponsePaylo
}

private LocalDateTime parseLocalDateTime(String value) {
if (value == null || value.isBlank()) return null;
if (value == null || value.isBlank()) {
return null;
}
try {
return LocalDateTime.parse(value.trim());
} catch (Exception ignore) {
Expand All @@ -205,42 +219,6 @@ private String nullToEmpty(String s) {
return (s == null) ? "" : s;
}

private void sendToSlackAsync(UUID messageId, String messageText) {
String title = "배송 예상 시간 알림";

slackWebhookClient
.sendMessage(title, messageText)
.subscribe(
ok -> {
if (Boolean.TRUE.equals(ok)) {
updateMessageStatus(messageId, SlackMessageStatus.SENT);
log.info("슬랙 메시지 전송 성공 messageId={}", messageId);
} else {
updateMessageStatus(messageId, SlackMessageStatus.FAILED);
log.error("슬랙 웹훅 응답이 실패 messageId={}", messageId);
}
},
error -> {
updateMessageStatus(messageId, SlackMessageStatus.FAILED);
log.error("슬랙 메시지 전송 중 오류가 발생 messageId={}", messageId, error);
});
}

@Transactional
protected void updateMessageStatus(UUID messageId, SlackMessageStatus status) {
slackMessageRepository
.findById(messageId)
.ifPresent(
message -> {
if (status == SlackMessageStatus.SENT) {
message.markAsSent();
} else if (status == SlackMessageStatus.FAILED) {
message.markAsFailed();
}
slackMessageRepository.save(message);
});
}

private static final class ResponsePayload {
public String finalDispatchDeadline;
public String messageBody;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.hubEleven.notification.slack.domain.event;

import java.time.LocalDateTime;
import java.util.UUID;

/*
SlackMessage가 생성된 후, 커밋 이후 전송 처리를 트리거하기 위한 도메인 이벤트
실제 전송은 AFTER_COMMIT 핸들러에서 수행
*/
public record SlackMessageSavedEvent(UUID messageId, LocalDateTime occurredAt) {
public static SlackMessageSavedEvent of(UUID messageId) {
return new SlackMessageSavedEvent(messageId, LocalDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.hubEleven.notification.slack.domain.event.handler;

import com.commonLib.common.exception.GlobalException;
import com.hubEleven.notification.slack.application.port.SlackClient;
import com.hubEleven.notification.slack.domain.event.SlackMessageSavedEvent;
import com.hubEleven.notification.slack.domain.model.SlackMessage;
import com.hubEleven.notification.slack.domain.repository.SlackMessageRepository;
import com.hubEleven.notification.slack.domain.vo.SlackMessageStatus;
import com.hubEleven.notification.slack.exception.SlackErrorCode;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class SlackDomainEventHandler {

private final SlackMessageRepository slackMessageRepository;
private final SlackClient slackClient;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(SlackMessageSavedEvent event) {
UUID messageId = event.messageId();

try {
SlackMessage slackMessage =
slackMessageRepository
.findById(messageId)
.orElseThrow(() -> new GlobalException(SlackErrorCode.SLACK_MESSAGE_NOT_FOUND));

if (slackMessage.getStatus() == SlackMessageStatus.SENT) {
log.info("슬랙 전송 스킵 - messageId={}", messageId);
return;
}

String channel = slackMessage.getChannel();
String text = slackMessage.getMessage();

SlackClient.SlackSendResult result;
if (channel != null && !channel.isBlank()) {
result = slackClient.sendToChannel(channel, text);
} else {
String email = slackMessage.getRecipientId();
result = slackClient.sendDmByEmail(email, text);
}

if (result.success()) {
slackMessage.markAsSent();
slackMessageRepository.save(slackMessage);
log.info(
"슬랙 전송 성공 - messageId={}, channelId={}, ts={}",
messageId,
result.channelId(),
result.ts());
} else {
slackMessage.markAsFailed();
slackMessageRepository.save(slackMessage);
log.warn("슬랙 전송 실패 - messageId={}, error={}", messageId, result.error());
}

} catch (Exception e) {
slackMessageRepository
.findById(messageId)
.ifPresent(
m -> {
m.markAsFailed();
slackMessageRepository.save(m);
});
log.error("슬랙 전송 처리 중 예외 발생 - messageId={}", messageId, e);
}
}
}

This file was deleted.

This file was deleted.

Loading