fix(access): P1 — 체크인 동시성 경쟁 제거 + 만료 승인 자동 전이
- 체크인/아웃이 VisitRequest 행을 PESSIMISTIC_WRITE 락으로 로드(resolve)하여 동일 방문의 입·출입을 직렬화 → 동시 스캔 시 중복 입장 이벤트 방지(TOCTOU 제거). 스키마 변경 없음(H2/PostgreSQL 이식성 위해 부분 유니크 대신 락 채택). - VisitExpiryScheduler: 매일 00:10(acs.expiry.cron) visit_to가 지난 APPROVED를 EXPIRED로 벌크 전이 → 재실/통계/리포트가 만료건을 유효로 취급하지 않음. - @EnableScheduling 활성화. - 테스트: CheckInConcurrencyTest(2스레드 → 정확히 1건 입장), VisitExpirySchedulerTest(과거 승인만 만료). 전체 3건 통과. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<VisitRequest, Long
|
||||
List<VisitRequest> findAllByOrderByVisitFromDesc();
|
||||
Optional<VisitRequest> 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<VisitRequest> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select vr from VisitRequest vr where vr.qrToken = :qrToken")
|
||||
Optional<VisitRequest> 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<VisitRequest> findByVisitFromBetweenOrderByVisitFromAsc(LocalDateTime from, LocalDateTime to);
|
||||
|
||||
@@ -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 코드 또는 방문 신청을 지정하세요.");
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user