Files
acs/backend/src/main/java/com/itcenter/acs/service/AccessService.java
unknown f0c30d8005 Initial commit: IT센터 출입자관리시스템 (ACS)
방문자 사전신청·승인, 입·출입 체크인/아웃, 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>
2026-07-03 08:59:41 +09:00

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("처리자(운영자)를 찾을 수 없습니다."));
}
}