방문자 사전신청·승인, 입·출입 체크인/아웃, QR 배지, 재실현황, 블랙리스트, 대시보드 통계, 방문 리포트(엑셀)까지 7단계 전 기능 구현. - backend: Spring Boot 3.4.5 / Java 21 (JDK 26 빌드), 세션 인증, JPA, H2/PostgreSQL, POI, ZXing, Flyway - frontend: React 19 / Vite 6 / TypeScript - infra: Docker Compose (db·app·web nginx), Flyway V1__init, Python 사용자 시드 - docs: 워크플로우 / 시퀀스 다이어그램(Mermaid) / 이슈·유의사항 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
207 lines
9.5 KiB
Java
207 lines
9.5 KiB
Java
package com.itcenter.acs.service;
|
|
|
|
import com.itcenter.acs.dto.AccessActionResponse;
|
|
import com.itcenter.acs.dto.AccessRecordResponse;
|
|
import com.itcenter.acs.dto.CheckInRequest;
|
|
import com.itcenter.acs.dto.InsideVisitorResponse;
|
|
import com.itcenter.acs.entity.AccessEvent;
|
|
import com.itcenter.acs.entity.Direction;
|
|
import com.itcenter.acs.entity.User;
|
|
import com.itcenter.acs.entity.VisitRequest;
|
|
import com.itcenter.acs.entity.VisitStatus;
|
|
import com.itcenter.acs.exception.ApiException;
|
|
import com.itcenter.acs.gateway.AccessControlGateway;
|
|
import com.itcenter.acs.repository.AccessEventRepository;
|
|
import com.itcenter.acs.repository.UserRepository;
|
|
import com.itcenter.acs.repository.VisitRequestRepository;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.util.List;
|
|
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Transactional
|
|
public class AccessService {
|
|
|
|
private final VisitRequestRepository visitRequestRepository;
|
|
private final AccessEventRepository accessEventRepository;
|
|
private final UserRepository userRepository;
|
|
private final AccessControlGateway gateway;
|
|
private final BlacklistService blacklistService;
|
|
|
|
/** Record an entry (IN) and open the gate. */
|
|
public AccessActionResponse checkIn(CheckInRequest req, Long operatorId) {
|
|
VisitRequest vr = resolve(req);
|
|
|
|
if (vr.getStatus() != VisitStatus.APPROVED) {
|
|
throw ApiException.badRequest("승인되지 않은 방문입니다. (상태: " + vr.getStatus() + ")");
|
|
}
|
|
|
|
// 같은 '일자'이면 시간은 엄격히 보지 않고 입장을 허용한다(늦은 도착 허용).
|
|
// 단, 일자가 다르면(신청일이 지났거나 아직 이르면) 재신청이 필요하다.
|
|
// 실제 입장 시각은 access_events(event_at)에 신청 시각과 별도로 기록된다.
|
|
LocalDateTime now = LocalDateTime.now();
|
|
LocalDate today = now.toLocalDate();
|
|
if (today.isAfter(vr.getVisitTo().toLocalDate())) {
|
|
vr.setStatus(VisitStatus.EXPIRED);
|
|
throw ApiException.badRequest("방문 신청일이 지났습니다. 출입 신청을 다시 해주세요.");
|
|
}
|
|
if (today.isBefore(vr.getVisitFrom().toLocalDate())) {
|
|
throw ApiException.badRequest("아직 방문 신청일이 아닙니다. (신청일: " + vr.getVisitFrom().toLocalDate() + ")");
|
|
}
|
|
String blockReason = blacklistService.blockReason(
|
|
vr.getVisitor().getName(), vr.getVisitor().getContact());
|
|
if (blockReason != null) {
|
|
throw new ApiException(403, "차단된 방문자입니다. 사유: " + blockReason);
|
|
}
|
|
|
|
if (isInside(vr.getId())) {
|
|
throw ApiException.conflict("이미 입장 처리된 방문자입니다.");
|
|
}
|
|
if (hasExitedToday(vr.getId())) {
|
|
throw ApiException.badRequest("금일 출입이 이미 완료되었습니다. 재입장은 불가합니다.");
|
|
}
|
|
|
|
User operator = loadOperator(operatorId);
|
|
AccessEvent event = record(vr, Direction.IN, req.getGateId(), operator, now);
|
|
|
|
AccessControlGateway.GateResult gate =
|
|
gateway.openGate(req.getGateId(), "입장: " + vr.getVisitor().getName());
|
|
|
|
return new AccessActionResponse(vr.getId(), vr.getVisitor().getName(),
|
|
Direction.IN.name(), event.getEventAt(), gate.accepted(), "입장 처리되었습니다.");
|
|
}
|
|
|
|
/** Record an exit (OUT). */
|
|
public AccessActionResponse checkOut(CheckInRequest req, Long operatorId) {
|
|
VisitRequest vr = resolve(req);
|
|
|
|
if (!isInside(vr.getId())) {
|
|
throw ApiException.conflict("입장 기록이 없어 퇴장 처리할 수 없습니다.");
|
|
}
|
|
|
|
User operator = loadOperator(operatorId);
|
|
AccessEvent event = record(vr, Direction.OUT, req.getGateId(), operator, LocalDateTime.now());
|
|
|
|
return new AccessActionResponse(vr.getId(), vr.getVisitor().getName(),
|
|
Direction.OUT.name(), event.getEventAt(), true, "퇴장 처리되었습니다.");
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public List<InsideVisitorResponse> listInside() {
|
|
return accessEventRepository.findInsideVisitRequestIds().stream()
|
|
.map(visitRequestRepository::findById)
|
|
.filter(java.util.Optional::isPresent)
|
|
.map(java.util.Optional::get)
|
|
.map(vr -> {
|
|
LocalDateTime checkInAt = accessEventRepository
|
|
.findFirstByVisitRequestIdOrderByEventAtDesc(vr.getId())
|
|
.map(AccessEvent::getEventAt)
|
|
.orElse(null);
|
|
return new InsideVisitorResponse(
|
|
vr.getId(),
|
|
vr.getVisitor().getName(),
|
|
vr.getVisitor().getCompany(),
|
|
vr.getZoneName(),
|
|
vr.getHost().getFullName(),
|
|
checkInAt);
|
|
})
|
|
.toList();
|
|
}
|
|
|
|
/** Today's access records — everyone who entered today, incl. those who left. */
|
|
@Transactional(readOnly = true)
|
|
public List<AccessRecordResponse> listTodayRecords() {
|
|
LocalDateTime start = LocalDate.now().atStartOfDay();
|
|
LocalDateTime end = start.plusDays(1);
|
|
List<AccessRecordResponse> out = new java.util.ArrayList<>();
|
|
for (Long id : accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end)) {
|
|
VisitRequest vr = visitRequestRepository.findById(id).orElse(null);
|
|
if (vr == null) {
|
|
continue;
|
|
}
|
|
List<AccessEvent> events = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(id).stream()
|
|
.filter(e -> !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end))
|
|
.toList();
|
|
LocalDateTime checkInAt = events.stream()
|
|
.filter(e -> e.getDirection() == Direction.IN)
|
|
.map(AccessEvent::getEventAt).findFirst().orElse(null);
|
|
boolean inside = isInside(id);
|
|
LocalDateTime checkOutAt = inside ? null : events.stream()
|
|
.filter(e -> e.getDirection() == Direction.OUT)
|
|
.map(AccessEvent::getEventAt).reduce((a, b) -> b).orElse(null);
|
|
out.add(new AccessRecordResponse(id, vr.getVisitor().getName(), vr.getVisitor().getCompany(),
|
|
vr.getZoneName(), vr.getVisitFrom(), vr.getVisitTo(), checkInAt, checkOutAt, inside));
|
|
}
|
|
out.sort((a, b) -> {
|
|
LocalDateTime x = a.getCheckInAt(), y = b.getCheckInAt();
|
|
if (x == null && y == null) return 0;
|
|
if (x == null) return 1;
|
|
if (y == null) return -1;
|
|
return y.compareTo(x); // most recent entry first
|
|
});
|
|
return out;
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public List<VisitRequest> searchApproved(String q) {
|
|
if (q == null || q.isBlank()) {
|
|
return visitRequestRepository.findByStatusOrderByVisitFromAsc(VisitStatus.APPROVED);
|
|
}
|
|
return visitRequestRepository.searchApprovedByVisitorName(q.trim());
|
|
}
|
|
|
|
// ===== helpers =====
|
|
|
|
private VisitRequest resolve(CheckInRequest req) {
|
|
if (req.getQrToken() != null && !req.getQrToken().isBlank()) {
|
|
return visitRequestRepository.findByQrToken(req.getQrToken().trim())
|
|
.orElseThrow(() -> ApiException.notFound("유효하지 않은 QR 코드입니다."));
|
|
}
|
|
if (req.getVisitRequestId() != null) {
|
|
return visitRequestRepository.findById(req.getVisitRequestId())
|
|
.orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다."));
|
|
}
|
|
throw ApiException.badRequest("QR 코드 또는 방문 신청을 지정하세요.");
|
|
}
|
|
|
|
/** True if the last recorded event for the visit is an entry (currently inside). */
|
|
public boolean isInside(Long visitRequestId) {
|
|
return accessEventRepository.findFirstByVisitRequestIdOrderByEventAtDesc(visitRequestId)
|
|
.map(e -> e.getDirection() == Direction.IN)
|
|
.orElse(false);
|
|
}
|
|
|
|
/** True if the visit already has an exit (OUT) recorded today — blocks same-day re-entry. */
|
|
public boolean hasExitedToday(Long visitRequestId) {
|
|
LocalDateTime start = LocalDate.now().atStartOfDay();
|
|
LocalDateTime end = start.plusDays(1);
|
|
return accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(visitRequestId).stream()
|
|
.anyMatch(e -> e.getDirection() == Direction.OUT
|
|
&& !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end));
|
|
}
|
|
|
|
private AccessEvent record(VisitRequest vr, Direction direction, String gateId, User operator, LocalDateTime at) {
|
|
AccessEvent event = new AccessEvent();
|
|
event.setVisitRequest(vr);
|
|
event.setDirection(direction);
|
|
event.setGateId(gateId);
|
|
event.setOperator(operator);
|
|
event.setEventAt(at);
|
|
return accessEventRepository.save(event);
|
|
}
|
|
|
|
/** Loads the operator, or null for self-service (kiosk) actions. */
|
|
private User loadOperator(Long operatorId) {
|
|
if (operatorId == null) {
|
|
return null;
|
|
}
|
|
return userRepository.findById(operatorId)
|
|
.orElseThrow(() -> ApiException.notFound("처리자(운영자)를 찾을 수 없습니다."));
|
|
}
|
|
}
|