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.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class AcsApplication {
|
public class AcsApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(AcsApplication.class, 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.VisitRequest;
|
||||||
import com.itcenter.acs.entity.VisitStatus;
|
import com.itcenter.acs.entity.VisitStatus;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
@@ -16,6 +19,25 @@ public interface VisitRequestRepository extends JpaRepository<VisitRequest, Long
|
|||||||
List<VisitRequest> findAllByOrderByVisitFromDesc();
|
List<VisitRequest> findAllByOrderByVisitFromDesc();
|
||||||
Optional<VisitRequest> findByQrToken(String qrToken);
|
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 countByStatus(VisitStatus status);
|
||||||
long countByVisitFromBetween(LocalDateTime from, LocalDateTime to);
|
long countByVisitFromBetween(LocalDateTime from, LocalDateTime to);
|
||||||
List<VisitRequest> findByVisitFromBetweenOrderByVisitFromAsc(LocalDateTime from, LocalDateTime to);
|
List<VisitRequest> findByVisitFromBetweenOrderByVisitFromAsc(LocalDateTime from, LocalDateTime to);
|
||||||
|
|||||||
@@ -157,13 +157,18 @@ public class AccessService {
|
|||||||
|
|
||||||
// ===== helpers =====
|
// ===== 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) {
|
private VisitRequest resolve(CheckInRequest req) {
|
||||||
if (req.getQrToken() != null && !req.getQrToken().isBlank()) {
|
if (req.getQrToken() != null && !req.getQrToken().isBlank()) {
|
||||||
return visitRequestRepository.findByQrToken(req.getQrToken().trim())
|
return visitRequestRepository.findByQrTokenForUpdate(req.getQrToken().trim())
|
||||||
.orElseThrow(() -> ApiException.notFound("유효하지 않은 QR 코드입니다."));
|
.orElseThrow(() -> ApiException.notFound("유효하지 않은 QR 코드입니다."));
|
||||||
}
|
}
|
||||||
if (req.getVisitRequestId() != null) {
|
if (req.getVisitRequestId() != null) {
|
||||||
return visitRequestRepository.findById(req.getVisitRequestId())
|
return visitRequestRepository.findByIdForUpdate(req.getVisitRequestId())
|
||||||
.orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다."));
|
.orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다."));
|
||||||
}
|
}
|
||||||
throw ApiException.badRequest("QR 코드 또는 방문 신청을 지정하세요.");
|
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