diff --git a/backend/src/main/java/com/itcenter/acs/AcsApplication.java b/backend/src/main/java/com/itcenter/acs/AcsApplication.java index 85f6d7c..3b0757a 100644 --- a/backend/src/main/java/com/itcenter/acs/AcsApplication.java +++ b/backend/src/main/java/com/itcenter/acs/AcsApplication.java @@ -2,8 +2,10 @@ package com.itcenter.acs; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class AcsApplication { public static void main(String[] args) { SpringApplication.run(AcsApplication.class, args); diff --git a/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java b/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java index 78e1766..4e9d243 100644 --- a/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java +++ b/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java @@ -2,7 +2,10 @@ package com.itcenter.acs.repository; import com.itcenter.acs.entity.VisitRequest; import com.itcenter.acs.entity.VisitStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,6 +19,25 @@ public interface VisitRequestRepository extends JpaRepository findAllByOrderByVisitFromDesc(); Optional findByQrToken(String qrToken); + /** + * Check-in/out load the visit under a row-level write lock so the concurrent + * "already inside? / exited today?" checks and the event insert are serialized + * per visit (prevents duplicate-entry TOCTOU races). See AccessService.resolve. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select vr from VisitRequest vr where vr.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select vr from VisitRequest vr where vr.qrToken = :qrToken") + Optional findByQrTokenForUpdate(@Param("qrToken") String qrToken); + + /** Bulk-expire approved visits whose window has passed (visit_to before the cutoff). */ + @Modifying(clearAutomatically = true) + @Query("update VisitRequest vr set vr.status = com.itcenter.acs.entity.VisitStatus.EXPIRED " + + "where vr.status = com.itcenter.acs.entity.VisitStatus.APPROVED and vr.visitTo < :cutoff") + int expireApprovedBefore(@Param("cutoff") LocalDateTime cutoff); + long countByStatus(VisitStatus status); long countByVisitFromBetween(LocalDateTime from, LocalDateTime to); List findByVisitFromBetweenOrderByVisitFromAsc(LocalDateTime from, LocalDateTime to); diff --git a/backend/src/main/java/com/itcenter/acs/service/AccessService.java b/backend/src/main/java/com/itcenter/acs/service/AccessService.java index 2ef3641..8920b44 100644 --- a/backend/src/main/java/com/itcenter/acs/service/AccessService.java +++ b/backend/src/main/java/com/itcenter/acs/service/AccessService.java @@ -157,13 +157,18 @@ public class AccessService { // ===== helpers ===== + /** + * Loads the visit under a pessimistic write lock so concurrent check-in/out of the + * same visit are serialized — the second caller blocks until the first commits and + * then sees its event, preventing duplicate-entry races. + */ private VisitRequest resolve(CheckInRequest req) { if (req.getQrToken() != null && !req.getQrToken().isBlank()) { - return visitRequestRepository.findByQrToken(req.getQrToken().trim()) + return visitRequestRepository.findByQrTokenForUpdate(req.getQrToken().trim()) .orElseThrow(() -> ApiException.notFound("유효하지 않은 QR 코드입니다.")); } if (req.getVisitRequestId() != null) { - return visitRequestRepository.findById(req.getVisitRequestId()) + return visitRequestRepository.findByIdForUpdate(req.getVisitRequestId()) .orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다.")); } throw ApiException.badRequest("QR 코드 또는 방문 신청을 지정하세요."); diff --git a/backend/src/main/java/com/itcenter/acs/service/VisitExpiryScheduler.java b/backend/src/main/java/com/itcenter/acs/service/VisitExpiryScheduler.java new file mode 100644 index 0000000..1232c35 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/VisitExpiryScheduler.java @@ -0,0 +1,37 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.repository.VisitRequestRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * Transitions APPROVED visits whose window has fully passed to EXPIRED. + *

+ * Check-in already expires a stale visit lazily on access, but nobody may ever + * attempt to enter — this batch keeps the in-progress lists, stats and reports + * from treating long-past approvals as still valid. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VisitExpiryScheduler { + + private final VisitRequestRepository visitRequestRepository; + + /** Daily at 00:10 by default; override with acs.expiry.cron. */ + @Scheduled(cron = "${acs.expiry.cron:0 10 0 * * *}") + @Transactional + public void expireStaleApprovals() { + LocalDateTime cutoff = LocalDate.now().atStartOfDay(); + int expired = visitRequestRepository.expireApprovedBefore(cutoff); + if (expired > 0) { + log.info("[expiry] 방문일이 지난 승인 {}건을 EXPIRED로 전이했습니다.", expired); + } + } +} diff --git a/backend/src/test/java/com/itcenter/acs/CheckInConcurrencyTest.java b/backend/src/test/java/com/itcenter/acs/CheckInConcurrencyTest.java new file mode 100644 index 0000000..1ab5d32 --- /dev/null +++ b/backend/src/test/java/com/itcenter/acs/CheckInConcurrencyTest.java @@ -0,0 +1,95 @@ +package com.itcenter.acs; + +import com.itcenter.acs.dto.CheckInRequest; +import com.itcenter.acs.entity.Direction; +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.repository.AccessEventRepository; +import com.itcenter.acs.repository.UserRepository; +import com.itcenter.acs.repository.VisitRequestRepository; +import com.itcenter.acs.repository.VisitorRepository; +import com.itcenter.acs.service.AccessService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Two operators scanning the same visit at the same instant must not both succeed. + * The pessimistic lock in AccessService.resolve serializes them: exactly one entry. + */ +@SpringBootTest +class CheckInConcurrencyTest { + + @Autowired AccessService accessService; + @Autowired VisitRequestRepository visitRequestRepository; + @Autowired VisitorRepository visitorRepository; + @Autowired UserRepository userRepository; + @Autowired AccessEventRepository accessEventRepository; + + @Test + void concurrentCheckInsProduceExactlyOneEntry() throws Exception { + User host = userRepository.findByUsername("host").orElseThrow(); + + Visitor v = new Visitor(); + v.setName("동시성테스트-" + System.nanoTime()); + visitorRepository.save(v); + + LocalDate today = LocalDate.now(); + VisitRequest vr = new VisitRequest(); + vr.setVisitor(v); + vr.setHost(host); + vr.setPurpose("test"); + vr.setVisitFrom(today.atStartOfDay()); + vr.setVisitTo(today.atTime(23, 59)); + vr.setStatus(VisitStatus.APPROVED); + Long vrId = visitRequestRepository.save(vr).getId(); + + int threads = 2; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + AtomicInteger success = new AtomicInteger(); + AtomicInteger failure = new AtomicInteger(); + + for (int i = 0; i < threads; i++) { + pool.submit(() -> { + CheckInRequest req = new CheckInRequest(); + req.setVisitRequestId(vrId); + req.setGateId("TEST-GATE"); + ready.countDown(); + try { + go.await(); + accessService.checkIn(req, null); + success.incrementAndGet(); + } catch (Exception e) { + failure.incrementAndGet(); + } + return null; + }); + } + + ready.await(5, TimeUnit.SECONDS); + go.countDown(); // fire both at once + pool.shutdown(); + assertThat(pool.awaitTermination(15, TimeUnit.SECONDS)).isTrue(); + + long entryEvents = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(vrId).stream() + .filter(e -> e.getDirection() == Direction.IN) + .count(); + + assertThat(success.get()).as("exactly one check-in succeeds").isEqualTo(1); + assertThat(failure.get()).as("the other is rejected").isEqualTo(1); + assertThat(entryEvents).as("only one IN event recorded").isEqualTo(1); + } +} diff --git a/backend/src/test/java/com/itcenter/acs/VisitExpirySchedulerTest.java b/backend/src/test/java/com/itcenter/acs/VisitExpirySchedulerTest.java new file mode 100644 index 0000000..cc49e73 --- /dev/null +++ b/backend/src/test/java/com/itcenter/acs/VisitExpirySchedulerTest.java @@ -0,0 +1,59 @@ +package com.itcenter.acs; + +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.repository.UserRepository; +import com.itcenter.acs.repository.VisitRequestRepository; +import com.itcenter.acs.repository.VisitorRepository; +import com.itcenter.acs.service.VisitExpiryScheduler; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Verifies the scheduled batch expires past-window approvals and leaves current ones alone. */ +@SpringBootTest +class VisitExpirySchedulerTest { + + @Autowired VisitExpiryScheduler scheduler; + @Autowired VisitRequestRepository visitRequestRepository; + @Autowired VisitorRepository visitorRepository; + @Autowired UserRepository userRepository; + + @Test + void expiresApprovalsWhoseWindowHasPassed_keepsCurrentOnes() { + User host = userRepository.findByUsername("host").orElseThrow(); + + LocalDate today = LocalDate.now(); + Long staleId = saveApproved(host, today.minusDays(3).atTime(9, 0), today.minusDays(3).atTime(18, 0)); + Long currentId = saveApproved(host, today.atStartOfDay(), today.atTime(23, 59)); + + scheduler.expireStaleApprovals(); + + assertThat(visitRequestRepository.findById(staleId).orElseThrow().getStatus()) + .isEqualTo(VisitStatus.EXPIRED); + assertThat(visitRequestRepository.findById(currentId).orElseThrow().getStatus()) + .isEqualTo(VisitStatus.APPROVED); + } + + private Long saveApproved(User host, LocalDateTime from, LocalDateTime to) { + Visitor v = new Visitor(); + v.setName("만료테스트-" + System.nanoTime()); + visitorRepository.save(v); + + VisitRequest vr = new VisitRequest(); + vr.setVisitor(v); + vr.setHost(host); + vr.setPurpose("test"); + vr.setVisitFrom(from); + vr.setVisitTo(to); + vr.setStatus(VisitStatus.APPROVED); + return visitRequestRepository.save(vr).getId(); + } +}