feat(notify): P2 — 출입증 발송 outbox + 실패 자동/수동 재발송
- PassDelivery(pass_deliveries) outbox 엔티티: 발송 결과(SENT/FAILED)·채널·수신처·시도횟수·오류 기록.
- PassNotifier 계약 변경: 실패 시 예외 throw(+ channel()/recipient()). Hanbank/Email이 실패를
더 이상 삼키지 않고 throw → PassDeliveryService가 결과를 outbox에 기록(승인은 롤백 안 됨).
- PassDeliveryRetryScheduler: 매 10분(acs.delivery.retry-cron) FAILED & attempts<max(기본5) 재발송.
- 관리 API: GET /api/admin/deliveries?status=FAILED, POST /api/admin/deliveries/{id}/retry (ADMIN).
- ApprovalService는 passDeliveryService.deliver(vr)로 위임(QR생성·발송·기록 일원화).
- Flyway V3__pass_delivery.sql. FlywayValidationTest가 V1+V2+V3를 엔티티와 대조.
- 테스트: PassDeliveryTest(발송 실패→FAILED 기록→재시도→SENT). 전체 8건 통과.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
package com.itcenter.acs.controller;
|
||||||
|
|
||||||
|
import com.itcenter.acs.dto.ApiResponse;
|
||||||
|
import com.itcenter.acs.dto.PassDeliveryResponse;
|
||||||
|
import com.itcenter.acs.entity.DeliveryStatus;
|
||||||
|
import com.itcenter.acs.service.PassDeliveryService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass-delivery outbox admin view + manual resend. Restricted to ADMIN by
|
||||||
|
* SecurityConfig (/api/admin/**).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/deliveries")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DeliveryController {
|
||||||
|
|
||||||
|
private final PassDeliveryService passDeliveryService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<PassDeliveryResponse>>> list(
|
||||||
|
@RequestParam(value = "status", required = false) DeliveryStatus status) {
|
||||||
|
List<PassDeliveryResponse> items = passDeliveryService.listByStatus(status).stream()
|
||||||
|
.map(PassDeliveryResponse::from)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/retry")
|
||||||
|
public ResponseEntity<ApiResponse<PassDeliveryResponse>> retry(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
PassDeliveryResponse.from(passDeliveryService.retryOne(id))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.itcenter.acs.dto;
|
||||||
|
|
||||||
|
import com.itcenter.acs.entity.PassDelivery;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Read model for a pass-delivery outbox record. */
|
||||||
|
public record PassDeliveryResponse(
|
||||||
|
Long id,
|
||||||
|
Long visitRequestId,
|
||||||
|
String channel,
|
||||||
|
String recipient,
|
||||||
|
String status,
|
||||||
|
int attempts,
|
||||||
|
String lastError,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime updatedAt) {
|
||||||
|
|
||||||
|
public static PassDeliveryResponse from(PassDelivery d) {
|
||||||
|
return new PassDeliveryResponse(
|
||||||
|
d.getId(),
|
||||||
|
d.getVisitRequestId(),
|
||||||
|
d.getChannel(),
|
||||||
|
d.getRecipient(),
|
||||||
|
d.getStatus() != null ? d.getStatus().name() : null,
|
||||||
|
d.getAttempts(),
|
||||||
|
d.getLastError(),
|
||||||
|
d.getCreatedAt(),
|
||||||
|
d.getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.itcenter.acs.entity;
|
||||||
|
|
||||||
|
/** Delivery outcome of a visitor pass notification. */
|
||||||
|
public enum DeliveryStatus {
|
||||||
|
SENT,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.itcenter.acs.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbox record of a visitor-pass delivery attempt. A FAILED row is retried by
|
||||||
|
* {@code PassDeliveryRetryScheduler} until it succeeds or hits the attempt cap.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "pass_deliveries", indexes = {
|
||||||
|
@Index(name = "idx_pd_status", columnList = "status"),
|
||||||
|
@Index(name = "idx_pd_visit_request", columnList = "visit_request_id")
|
||||||
|
})
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class PassDelivery extends BaseEntity {
|
||||||
|
|
||||||
|
@Column(name = "visit_request_id", nullable = false)
|
||||||
|
private Long visitRequestId;
|
||||||
|
|
||||||
|
/** Delivery channel: "dev" / "hanbank" / "email". */
|
||||||
|
@Column(length = 20)
|
||||||
|
private String channel;
|
||||||
|
|
||||||
|
@Column(length = 120)
|
||||||
|
private String recipient;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private DeliveryStatus status;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int attempts;
|
||||||
|
|
||||||
|
@Column(name = "last_error", length = 500)
|
||||||
|
private String lastError;
|
||||||
|
}
|
||||||
@@ -50,8 +50,7 @@ public class EmailPassNotifier implements PassNotifier {
|
|||||||
Visitor visitor = visitRequest.getVisitor();
|
Visitor visitor = visitRequest.getVisitor();
|
||||||
String to = visitor != null ? visitor.getEmail() : null;
|
String to = visitor != null ? visitor.getEmail() : null;
|
||||||
if (to == null || to.isBlank()) {
|
if (to == null || to.isBlank()) {
|
||||||
log.warn("[email] 방문자 이메일이 없어 출입증 메일을 발송하지 못했습니다. visitRequestId={}", visitRequest.getId());
|
throw new IllegalStateException("방문자 이메일이 없어 출입증 메일을 발송할 수 없습니다.");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -66,10 +65,22 @@ public class EmailPassNotifier implements PassNotifier {
|
|||||||
mailSender.send(message);
|
mailSender.send(message);
|
||||||
log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length);
|
log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[email] 출입증 메일 발송 실패 → {} : {}", to, e.getMessage());
|
// wrap so the caller records a retryable failure
|
||||||
|
throw new IllegalStateException("메일 발송 실패 → " + to + " : " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String channel() {
|
||||||
|
return "email";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String recipient(VisitRequest visitRequest) {
|
||||||
|
Visitor visitor = visitRequest.getVisitor();
|
||||||
|
return visitor != null ? visitor.getEmail() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private String buildBody(VisitRequest vr) {
|
private String buildBody(VisitRequest vr) {
|
||||||
Visitor visitor = vr.getVisitor();
|
Visitor visitor = vr.getVisitor();
|
||||||
String name = visitor != null ? visitor.getName() : "방문자";
|
String name = visitor != null ? visitor.getName() : "방문자";
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ public class HanbankMessagePassNotifier implements PassNotifier {
|
|||||||
Visitor visitor = visitRequest.getVisitor();
|
Visitor visitor = visitRequest.getVisitor();
|
||||||
String phone = visitor != null ? digitsOnly(visitor.getContact()) : "";
|
String phone = visitor != null ? digitsOnly(visitor.getContact()) : "";
|
||||||
if (phone.isBlank()) {
|
if (phone.isBlank()) {
|
||||||
log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId());
|
throw new IllegalStateException("방문자 연락처가 없어 출입증 문자를 발송할 수 없습니다.");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, String> body = Map.of(
|
Map<String, String> body = Map.of(
|
||||||
@@ -55,22 +54,29 @@ public class HanbankMessagePassNotifier implements PassNotifier {
|
|||||||
"msg_type", "LMS",
|
"msg_type", "LMS",
|
||||||
"reserve_time", "");
|
"reserve_time", "");
|
||||||
|
|
||||||
try {
|
|
||||||
SmsResponse res = restClient.post()
|
SmsResponse res = restClient.post()
|
||||||
.uri("/sens/sms")
|
.uri("/sens/sms")
|
||||||
.body(body)
|
.body(body)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(SmsResponse.class);
|
.body(SmsResponse.class);
|
||||||
|
|
||||||
if (res != null && "202".equals(res.statusCode())) {
|
if (res == null || !"202".equals(res.statusCode())) {
|
||||||
|
throw new IllegalStateException("LMS 발송 실패 (statusCode="
|
||||||
|
+ (res != null ? res.statusCode() : "null")
|
||||||
|
+ ", statusName=" + (res != null ? res.statusName() : "null") + ")");
|
||||||
|
}
|
||||||
log.info("[sms] 출입증 LMS 발송 성공 → {} (requestId={})", phone, res.requestId());
|
log.info("[sms] 출입증 LMS 발송 성공 → {} (requestId={})", phone, res.requestId());
|
||||||
} else {
|
|
||||||
log.warn("[sms] 출입증 LMS 발송 실패 → {} (statusCode={}, statusName={})",
|
|
||||||
phone, res != null ? res.statusCode() : "null", res != null ? res.statusName() : "null");
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[sms] 출입증 LMS 발송 호출 오류 → {} : {}", phone, e.getMessage());
|
@Override
|
||||||
|
public String channel() {
|
||||||
|
return "hanbank";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String recipient(VisitRequest visitRequest) {
|
||||||
|
Visitor visitor = visitRequest.getVisitor();
|
||||||
|
return visitor != null ? visitor.getContact() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildContent(VisitRequest vr) {
|
private String buildContent(VisitRequest vr) {
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ public class LoggingPassNotifier implements PassNotifier {
|
|||||||
saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message);
|
saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String channel() {
|
||||||
|
return "dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String recipient(VisitRequest visitRequest) {
|
||||||
|
Visitor visitor = visitRequest.getVisitor();
|
||||||
|
return visitor != null ? visitor.getContact() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private String buildMessage(VisitRequest vr) {
|
private String buildMessage(VisitRequest vr) {
|
||||||
Visitor visitor = vr.getVisitor();
|
Visitor visitor = vr.getVisitor();
|
||||||
String name = visitor != null ? visitor.getName() : "방문자";
|
String name = visitor != null ? visitor.getName() : "방문자";
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ public interface PassNotifier {
|
|||||||
*
|
*
|
||||||
* @param visitRequest the approved request (carries visitor, phone, window)
|
* @param visitRequest the approved request (carries visitor, phone, window)
|
||||||
* @param qrPng PNG bytes of the pass QR to attach/send
|
* @param qrPng PNG bytes of the pass QR to attach/send
|
||||||
|
* @throws RuntimeException if delivery fails — the caller ({@code PassDeliveryService})
|
||||||
|
* records the failure so it can be retried. Implementations
|
||||||
|
* must NOT swallow delivery errors.
|
||||||
*/
|
*/
|
||||||
void sendPass(VisitRequest visitRequest, byte[] qrPng);
|
void sendPass(VisitRequest visitRequest, byte[] qrPng);
|
||||||
|
|
||||||
|
/** Short channel identifier for the delivery record (e.g. "dev", "hanbank", "email"). */
|
||||||
|
String channel();
|
||||||
|
|
||||||
|
/** The address this channel delivers to for the given request (phone or email); may be null. */
|
||||||
|
String recipient(VisitRequest visitRequest);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.itcenter.acs.repository;
|
||||||
|
|
||||||
|
import com.itcenter.acs.entity.DeliveryStatus;
|
||||||
|
import com.itcenter.acs.entity.PassDelivery;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PassDeliveryRepository extends JpaRepository<PassDelivery, Long> {
|
||||||
|
|
||||||
|
/** Failed deliveries still under the retry cap, oldest first. */
|
||||||
|
List<PassDelivery> findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus status, int maxAttempts);
|
||||||
|
|
||||||
|
List<PassDelivery> findByStatusOrderByCreatedAtDesc(DeliveryStatus status);
|
||||||
|
|
||||||
|
List<PassDelivery> findTop200ByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import com.itcenter.acs.entity.User;
|
|||||||
import com.itcenter.acs.entity.VisitRequest;
|
import com.itcenter.acs.entity.VisitRequest;
|
||||||
import com.itcenter.acs.entity.VisitStatus;
|
import com.itcenter.acs.entity.VisitStatus;
|
||||||
import com.itcenter.acs.exception.ApiException;
|
import com.itcenter.acs.exception.ApiException;
|
||||||
import com.itcenter.acs.notification.PassNotifier;
|
|
||||||
import com.itcenter.acs.repository.ApprovalRepository;
|
import com.itcenter.acs.repository.ApprovalRepository;
|
||||||
import com.itcenter.acs.repository.UserRepository;
|
import com.itcenter.acs.repository.UserRepository;
|
||||||
import com.itcenter.acs.repository.VisitRequestRepository;
|
import com.itcenter.acs.repository.VisitRequestRepository;
|
||||||
@@ -24,14 +23,10 @@ import java.util.UUID;
|
|||||||
@Transactional
|
@Transactional
|
||||||
public class ApprovalService {
|
public class ApprovalService {
|
||||||
|
|
||||||
/** QR pixel size for the pass image sent to the visitor. */
|
|
||||||
private static final int PASS_QR_SIZE = 240;
|
|
||||||
|
|
||||||
private final VisitRequestRepository visitRequestRepository;
|
private final VisitRequestRepository visitRequestRepository;
|
||||||
private final ApprovalRepository approvalRepository;
|
private final ApprovalRepository approvalRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final QrService qrService;
|
private final PassDeliveryService passDeliveryService;
|
||||||
private final PassNotifier passNotifier;
|
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
|
||||||
public VisitRequest approve(Long visitRequestId, Long approverId, String comment) {
|
public VisitRequest approve(Long visitRequestId, Long approverId, String comment) {
|
||||||
@@ -77,23 +72,11 @@ public class ApprovalService {
|
|||||||
"방문자=" + visitorName + (comment != null && !comment.isBlank() ? ", 의견=" + comment : ""));
|
"방문자=" + visitorName + (comment != null && !comment.isBlank() ? ", 의견=" + comment : ""));
|
||||||
|
|
||||||
if (decision == ApprovalDecision.APPROVED) {
|
if (decision == ApprovalDecision.APPROVED) {
|
||||||
notifyVisitor(vr);
|
// Delivery records its own outcome (SENT/FAILED) and never throws, so a
|
||||||
|
// delivery problem cannot roll back the approval; failures are retried later.
|
||||||
|
passDeliveryService.deliver(vr);
|
||||||
}
|
}
|
||||||
|
|
||||||
return vr;
|
return vr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends the freshly issued pass to the visitor. A delivery failure must not
|
|
||||||
* roll back the approval, so it is caught and logged rather than propagated.
|
|
||||||
*/
|
|
||||||
private void notifyVisitor(VisitRequest vr) {
|
|
||||||
try {
|
|
||||||
byte[] qrPng = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE);
|
|
||||||
passNotifier.sendPass(vr, qrPng);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[approval] 출입증 발송 실패 (승인은 정상 처리됨). visitRequestId={} err={}",
|
|
||||||
vr.getId(), e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.itcenter.acs.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodically retries failed pass deliveries recorded in the outbox, up to
|
||||||
|
* {@code acs.delivery.max-attempts}. Thin wrapper — the send/record logic lives
|
||||||
|
* in {@link PassDeliveryService}.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PassDeliveryRetryScheduler {
|
||||||
|
|
||||||
|
private final PassDeliveryService passDeliveryService;
|
||||||
|
|
||||||
|
@Value("${acs.delivery.max-attempts:5}")
|
||||||
|
private int maxAttempts;
|
||||||
|
|
||||||
|
/** Every 10 minutes by default; override with acs.delivery.retry-cron. */
|
||||||
|
@Scheduled(cron = "${acs.delivery.retry-cron:0 */10 * * * *}")
|
||||||
|
public void retryFailed() {
|
||||||
|
passDeliveryService.retryFailed(maxAttempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.itcenter.acs.service;
|
||||||
|
|
||||||
|
import com.itcenter.acs.entity.DeliveryStatus;
|
||||||
|
import com.itcenter.acs.entity.PassDelivery;
|
||||||
|
import com.itcenter.acs.entity.VisitRequest;
|
||||||
|
import com.itcenter.acs.exception.ApiException;
|
||||||
|
import com.itcenter.acs.notification.PassNotifier;
|
||||||
|
import com.itcenter.acs.repository.PassDeliveryRepository;
|
||||||
|
import com.itcenter.acs.repository.VisitRequestRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the visitor pass and records the outcome in the {@code pass_deliveries}
|
||||||
|
* outbox. A failed send is persisted (status=FAILED) so it can be retried later
|
||||||
|
* by {@code PassDeliveryRetryScheduler} or an admin, rather than being lost.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PassDeliveryService {
|
||||||
|
|
||||||
|
/** QR pixel size for the pass image. */
|
||||||
|
private static final int PASS_QR_SIZE = 240;
|
||||||
|
|
||||||
|
private final PassDeliveryRepository passDeliveryRepository;
|
||||||
|
private final VisitRequestRepository visitRequestRepository;
|
||||||
|
private final QrService qrService;
|
||||||
|
private final PassNotifier notifier;
|
||||||
|
|
||||||
|
/** First delivery attempt, invoked right after approval. Never throws. */
|
||||||
|
@Transactional
|
||||||
|
public PassDelivery deliver(VisitRequest vr) {
|
||||||
|
PassDelivery d = new PassDelivery();
|
||||||
|
d.setVisitRequestId(vr.getId());
|
||||||
|
d.setChannel(notifier.channel());
|
||||||
|
d.setRecipient(notifier.recipient(vr));
|
||||||
|
d.setAttempts(1);
|
||||||
|
attempt(d, vr);
|
||||||
|
return passDeliveryRepository.save(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retries every failed delivery still under the attempt cap. Returns how many now succeeded. */
|
||||||
|
@Transactional
|
||||||
|
public int retryFailed(int maxAttempts) {
|
||||||
|
List<PassDelivery> failed =
|
||||||
|
passDeliveryRepository.findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus.FAILED, maxAttempts);
|
||||||
|
int recovered = 0;
|
||||||
|
for (PassDelivery d : failed) {
|
||||||
|
if (retry(d)) {
|
||||||
|
recovered++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!failed.isEmpty()) {
|
||||||
|
log.info("[delivery] 재발송 시도 {}건 중 {}건 성공", failed.size(), recovered);
|
||||||
|
}
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually retry a single delivery (admin action). */
|
||||||
|
@Transactional
|
||||||
|
public PassDelivery retryOne(Long deliveryId) {
|
||||||
|
PassDelivery d = passDeliveryRepository.findById(deliveryId)
|
||||||
|
.orElseThrow(() -> ApiException.notFound("발송 기록을 찾을 수 없습니다."));
|
||||||
|
retry(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<PassDelivery> listByStatus(DeliveryStatus status) {
|
||||||
|
return status != null
|
||||||
|
? passDeliveryRepository.findByStatusOrderByCreatedAtDesc(status)
|
||||||
|
: passDeliveryRepository.findTop200ByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-attempts a managed delivery record (dirty-checked). Returns true if it now succeeded. */
|
||||||
|
private boolean retry(PassDelivery d) {
|
||||||
|
VisitRequest vr = visitRequestRepository.findById(d.getVisitRequestId()).orElse(null);
|
||||||
|
d.setAttempts(d.getAttempts() + 1);
|
||||||
|
if (vr == null) {
|
||||||
|
d.setStatus(DeliveryStatus.FAILED);
|
||||||
|
d.setLastError("방문 신청을 찾을 수 없습니다. (id=" + d.getVisitRequestId() + ")");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
attempt(d, vr);
|
||||||
|
return d.getStatus() == DeliveryStatus.SENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates the QR and sends via the notifier, setting status/lastError on the record. */
|
||||||
|
private void attempt(PassDelivery d, VisitRequest vr) {
|
||||||
|
try {
|
||||||
|
byte[] qr = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE);
|
||||||
|
notifier.sendPass(vr, qr);
|
||||||
|
d.setStatus(DeliveryStatus.SENT);
|
||||||
|
d.setLastError(null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
d.setStatus(DeliveryStatus.FAILED);
|
||||||
|
d.setLastError(truncate(e.getMessage()));
|
||||||
|
log.warn("[delivery] 출입증 발송 실패 (visitRequestId={}, attempts={}): {}",
|
||||||
|
d.getVisitRequestId(), d.getAttempts(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s.length() <= 500 ? s : s.substring(0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 출입증 발송 outbox: 발송 결과 기록 + 실패건 재발송 추적
|
||||||
|
-- Hibernate ddl-auto=validate 가 검증하므로 PassDelivery 엔티티와 컬럼명·타입이 일치해야 한다.
|
||||||
|
|
||||||
|
CREATE TABLE pass_deliveries (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
visit_request_id BIGINT NOT NULL,
|
||||||
|
channel VARCHAR(20),
|
||||||
|
recipient VARCHAR(120),
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL,
|
||||||
|
last_error VARCHAR(500)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_pd_status ON pass_deliveries (status);
|
||||||
|
CREATE INDEX idx_pd_visit_request ON pass_deliveries (visit_request_id);
|
||||||
@@ -64,7 +64,7 @@ class EmailPassNotifierTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void skipsWhenVisitorHasNoEmail() {
|
void throwsWhenVisitorHasNoEmail() {
|
||||||
JavaMailSender sender = mock(JavaMailSender.class);
|
JavaMailSender sender = mock(JavaMailSender.class);
|
||||||
EmailPassNotifier notifier =
|
EmailPassNotifier notifier =
|
||||||
new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173");
|
new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173");
|
||||||
@@ -77,9 +77,9 @@ class EmailPassNotifierTest {
|
|||||||
vr.setVisitTo(LocalDateTime.now().plusHours(1));
|
vr.setVisitTo(LocalDateTime.now().plusHours(1));
|
||||||
vr.setQrToken("t");
|
vr.setQrToken("t");
|
||||||
|
|
||||||
notifier.sendPass(vr, new byte[]{1});
|
// no email → delivery failure is signalled (recorded/retried by PassDeliveryService)
|
||||||
|
org.assertj.core.api.Assertions.assertThatThrownBy(() -> notifier.sendPass(vr, new byte[]{1}))
|
||||||
// no email → never touches the mail sender
|
.isInstanceOf(RuntimeException.class);
|
||||||
verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class));
|
verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
backend/src/test/java/com/itcenter/acs/PassDeliveryTest.java
Normal file
76
backend/src/test/java/com/itcenter/acs/PassDeliveryTest.java
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package com.itcenter.acs;
|
||||||
|
|
||||||
|
import com.itcenter.acs.entity.DeliveryStatus;
|
||||||
|
import com.itcenter.acs.entity.PassDelivery;
|
||||||
|
import com.itcenter.acs.entity.User;
|
||||||
|
import com.itcenter.acs.entity.Visitor;
|
||||||
|
import com.itcenter.acs.entity.VisitRequest;
|
||||||
|
import com.itcenter.acs.entity.VisitStatus;
|
||||||
|
import com.itcenter.acs.notification.PassNotifier;
|
||||||
|
import com.itcenter.acs.repository.PassDeliveryRepository;
|
||||||
|
import com.itcenter.acs.repository.UserRepository;
|
||||||
|
import com.itcenter.acs.repository.VisitRequestRepository;
|
||||||
|
import com.itcenter.acs.repository.VisitorRepository;
|
||||||
|
import com.itcenter.acs.service.PassDeliveryService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doNothing;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/** A failed send is recorded as FAILED and later flipped to SENT by the retry batch. */
|
||||||
|
@SpringBootTest
|
||||||
|
class PassDeliveryTest {
|
||||||
|
|
||||||
|
@MockitoBean PassNotifier notifier;
|
||||||
|
|
||||||
|
@Autowired PassDeliveryService passDeliveryService;
|
||||||
|
@Autowired VisitRequestRepository visitRequestRepository;
|
||||||
|
@Autowired VisitorRepository visitorRepository;
|
||||||
|
@Autowired UserRepository userRepository;
|
||||||
|
@Autowired PassDeliveryRepository passDeliveryRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedDeliveryIsRecordedThenRetriedToSent() {
|
||||||
|
when(notifier.channel()).thenReturn("test");
|
||||||
|
|
||||||
|
User host = userRepository.findByUsername("host").orElseThrow();
|
||||||
|
Visitor v = new Visitor();
|
||||||
|
v.setName("발송테스트-" + System.nanoTime());
|
||||||
|
v.setEmail("visitor@example.com");
|
||||||
|
visitorRepository.save(v);
|
||||||
|
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
VisitRequest vr = new VisitRequest();
|
||||||
|
vr.setVisitor(v);
|
||||||
|
vr.setHost(host);
|
||||||
|
vr.setPurpose("delivery");
|
||||||
|
vr.setVisitFrom(today.atStartOfDay());
|
||||||
|
vr.setVisitTo(today.atTime(23, 59));
|
||||||
|
vr.setStatus(VisitStatus.APPROVED);
|
||||||
|
vr.setQrToken(UUID.randomUUID().toString());
|
||||||
|
visitRequestRepository.save(vr);
|
||||||
|
|
||||||
|
// 1) send fails → recorded FAILED
|
||||||
|
doThrow(new RuntimeException("relay down")).when(notifier).sendPass(any(), any());
|
||||||
|
PassDelivery d = passDeliveryService.deliver(vr);
|
||||||
|
assertThat(d.getStatus()).isEqualTo(DeliveryStatus.FAILED);
|
||||||
|
assertThat(d.getLastError()).contains("relay down");
|
||||||
|
Long deliveryId = d.getId();
|
||||||
|
|
||||||
|
// 2) relay recovers → retry batch flips it to SENT
|
||||||
|
doNothing().when(notifier).sendPass(any(), any());
|
||||||
|
int recovered = passDeliveryService.retryFailed(5);
|
||||||
|
assertThat(recovered).isGreaterThanOrEqualTo(1);
|
||||||
|
assertThat(passDeliveryRepository.findById(deliveryId).orElseThrow().getStatus())
|
||||||
|
.isEqualTo(DeliveryStatus.SENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user