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>
This commit is contained in:
unknown
2026-07-03 08:59:41 +09:00
commit f0c30d8005
130 changed files with 8884 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
package com.itcenter.acs;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AcsApplication {
public static void main(String[] args) {
SpringApplication.run(AcsApplication.class, args);
}
}

View File

@@ -0,0 +1,73 @@
package com.itcenter.acs.config;
import com.itcenter.acs.entity.RoleType;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Zone;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.ZoneRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* Seeds initial accounts and zones for local/dev so the app is usable out of the box.
* Idempotent: only inserts when the username/zone code is missing.
* Disabled in prod, where Flyway + the Python CSV seeder own initial data instead.
*/
@Slf4j
@Component
@Profile("!prod")
@RequiredArgsConstructor
public class DataSeeder implements CommandLineRunner {
private final UserRepository userRepository;
private final ZoneRepository zoneRepository;
private final PasswordEncoder passwordEncoder;
private static final String DEFAULT_PASSWORD = "ChangeMe123!";
@Override
public void run(String... args) {
seedUser("admin", "관리자", "IT운영팀", Set.of(RoleType.ADMIN));
seedUser("security", "보안담당", "보안팀", Set.of(RoleType.SECURITY));
seedUser("host", "홍길동", "개발1팀", Set.of(RoleType.HOST));
seedZone("LOBBY", "로비", 1);
seedZone("OFFICE", "사무공간", 2);
seedZone("SERVER_ROOM", "전산실", 3);
}
private void seedUser(String username, String fullName, String department, Set<RoleType> roles) {
if (userRepository.existsByUsername(username)) {
return;
}
User user = new User();
user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(DEFAULT_PASSWORD));
user.setFullName(fullName);
user.setDepartment(department);
user.setEmail(username + "@itcenter.local");
user.setRoles(roles);
user.setMustChangePassword(true);
userRepository.save(user);
log.info("[seed] user '{}' created (default password: {})", username, DEFAULT_PASSWORD);
}
private void seedZone(String code, String name, int level) {
if (zoneRepository.findByCode(code).isPresent()) {
return;
}
Zone zone = new Zone();
zone.setCode(code);
zone.setName(name);
zone.setSecurityLevel(level);
zone.setActive(true);
zoneRepository.save(zone);
log.info("[seed] zone '{}' ({}) created", code, name);
}
}

View File

@@ -0,0 +1,54 @@
package com.itcenter.acs.config;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.exception.ApiException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
/**
* Translates exceptions into the standard ApiResponse envelope so the
* frontend always receives { code, message, data }.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiResponse<?>> handleApi(ApiException ex) {
return ResponseEntity.status(ex.getCode())
.body(ApiResponse.error(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(ApiResponse.error(400, message));
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiResponse<?>> handleBadCredentials(BadCredentialsException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(401, "아이디 또는 비밀번호가 올바르지 않습니다."));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<?>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(403, "접근 권한이 없습니다."));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(500, "서버 오류: " + ex.getMessage()));
}
}

View File

@@ -0,0 +1,12 @@
package com.itcenter.acs.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* Enables @CreatedDate / @LastModifiedDate population on BaseEntity.
*/
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

View File

@@ -0,0 +1,106 @@
package com.itcenter.acs.config;
import com.itcenter.acs.dto.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Session-based authentication with role authorization.
* Roles: ADMIN (all), SECURITY (access console), HOST (requests/approvals).
*/
@Configuration
public class SecurityConfig {
private final ObjectMapper objectMapper = new ObjectMapper();
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityContextRepository securityContextRepository() {
return new HttpSessionSecurityContextRepository();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> {})
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authz -> authz
// public
.requestMatchers("/api/auth/login", "/api/auth/logout").permitAll()
.requestMatchers("/", "/health", "/error").permitAll()
.requestMatchers("/h2-console/**").permitAll()
// public visitor pass (opened from the SMS link, token-guarded)
.requestMatchers("/api/public/**").permitAll()
// staff access console (search + force check-in/out) — 담당자(HOST)/보안/관리자
.requestMatchers("/api/access/**").hasAnyRole("HOST", "SECURITY", "ADMIN")
// approvals — IT센터 관리자(admin) 전용
.requestMatchers("/api/approvals/**").hasRole("ADMIN")
// blacklist & admin management — admin only
.requestMatchers("/api/blacklist/**", "/api/admin/**").hasRole("ADMIN")
// reports — security & admin
.requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN")
// everything else requires a logged-in user
.anyRequest().authenticated()
)
// H2 console renders in a frame
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.securityContext(sc -> sc.securityContextRepository(securityContextRepository()))
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> writeError(res, 401, "로그인이 필요합니다."))
.accessDeniedHandler((req, res, e) -> writeError(res, 403, "접근 권한이 없습니다."))
);
return http.build();
}
private void writeError(HttpServletResponse res, int code, String message) throws java.io.IOException {
res.setStatus(code);
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
res.setCharacterEncoding("UTF-8");
objectMapper.writeValue(res.getWriter(), ApiResponse.error(code, message));
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"http://localhost:5173",
"http://127.0.0.1:5173"
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@@ -0,0 +1,65 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.AccessActionResponse;
import com.itcenter.acs.dto.AccessRecordResponse;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.CheckInRequest;
import com.itcenter.acs.dto.InsideVisitorResponse;
import com.itcenter.acs.dto.VisitRequestResponse;
import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.service.AccessService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Access console for SECURITY/ADMIN: check-in/out and currently-inside list.
* Authorized by SecurityConfig (/api/access/** = SECURITY or ADMIN).
*/
@RestController
@RequestMapping("/api/access")
@RequiredArgsConstructor
public class AccessController {
private final AccessService accessService;
@PostMapping("/check-in")
public ResponseEntity<ApiResponse<AccessActionResponse>> checkIn(@RequestBody CheckInRequest request) {
AccessActionResponse res = accessService.checkIn(request, SecurityUtils.currentUserId());
return ResponseEntity.ok(ApiResponse.success(res.getMessage(), res));
}
@PostMapping("/check-out")
public ResponseEntity<ApiResponse<AccessActionResponse>> checkOut(@RequestBody CheckInRequest request) {
AccessActionResponse res = accessService.checkOut(request, SecurityUtils.currentUserId());
return ResponseEntity.ok(ApiResponse.success(res.getMessage(), res));
}
@GetMapping("/inside")
public ResponseEntity<ApiResponse<List<InsideVisitorResponse>>> inside() {
return ResponseEntity.ok(ApiResponse.success(accessService.listInside()));
}
/** Today's access records (entered today, incl. those who left) for the staff console. */
@GetMapping("/today")
public ResponseEntity<ApiResponse<List<AccessRecordResponse>>> today() {
return ResponseEntity.ok(ApiResponse.success(accessService.listTodayRecords()));
}
/** Search approved requests by visitor name for manual check-in. */
@GetMapping("/search")
public ResponseEntity<ApiResponse<List<VisitRequestResponse>>> search(
@RequestParam(value = "q", required = false) String q) {
List<VisitRequestResponse> items = accessService.searchApproved(q).stream()
.map(VisitRequestResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success(items));
}
}

View File

@@ -0,0 +1,44 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.ApprovalDecisionRequest;
import com.itcenter.acs.dto.VisitRequestResponse;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.service.ApprovalService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Approve/reject visit requests. Restricted to HOST/ADMIN by SecurityConfig.
*/
@RestController
@RequestMapping("/api/approvals")
@RequiredArgsConstructor
public class ApprovalController {
private final ApprovalService approvalService;
@PostMapping("/{visitRequestId}/approve")
public ResponseEntity<ApiResponse<VisitRequestResponse>> approve(
@PathVariable Long visitRequestId,
@RequestBody(required = false) ApprovalDecisionRequest request) {
String comment = request != null ? request.getComment() : null;
VisitRequest vr = approvalService.approve(visitRequestId, SecurityUtils.currentUserId(), comment);
return ResponseEntity.ok(ApiResponse.success("승인되었습니다.", VisitRequestResponse.from(vr)));
}
@PostMapping("/{visitRequestId}/reject")
public ResponseEntity<ApiResponse<VisitRequestResponse>> reject(
@PathVariable Long visitRequestId,
@RequestBody(required = false) ApprovalDecisionRequest request) {
String comment = request != null ? request.getComment() : null;
VisitRequest vr = approvalService.reject(visitRequestId, SecurityUtils.currentUserId(), comment);
return ResponseEntity.ok(ApiResponse.success("반려되었습니다.", VisitRequestResponse.from(vr)));
}
}

View File

@@ -0,0 +1,109 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.ChangePasswordRequest;
import com.itcenter.acs.dto.CurrentUserResponse;
import com.itcenter.acs.dto.LoginRequest;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.security.UserPrincipal;
import com.itcenter.acs.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final SecurityContextRepository securityContextRepository;
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<ApiResponse<CurrentUserResponse>> login(
@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
// Persist the authentication into the session-backed SecurityContext.
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, httpRequest, httpResponse);
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
return ResponseEntity.ok(ApiResponse.success(toCurrentUser(principal)));
}
@PostMapping("/logout")
public ResponseEntity<ApiResponse<String>> logout(
HttpServletRequest request, HttpServletResponse response) {
SecurityContextHolder.clearContext();
if (request.getSession(false) != null) {
request.getSession(false).invalidate();
}
return ResponseEntity.ok(ApiResponse.success("로그아웃되었습니다."));
}
@GetMapping("/me")
public ResponseEntity<ApiResponse<CurrentUserResponse>> me() {
return ResponseEntity.ok(ApiResponse.success(toCurrentUser(SecurityUtils.currentPrincipal())));
}
@PostMapping("/change-password")
public ResponseEntity<ApiResponse<String>> changePassword(
@Valid @RequestBody ChangePasswordRequest request,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
User updated = authService.changePassword(SecurityUtils.currentUserId(),
request.getOldPassword(), request.getNewPassword());
// Refresh the session principal so /me reflects mustChangePassword=false
// (the principal was captured at login and is otherwise stale).
UserPrincipal principal = new UserPrincipal(updated);
Authentication newAuth = new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(newAuth);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, httpRequest, httpResponse);
return ResponseEntity.ok(ApiResponse.success("비밀번호가 변경되었습니다."));
}
private CurrentUserResponse toCurrentUser(UserPrincipal principal) {
Set<String> roles = principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(a -> a.replace("ROLE_", ""))
.collect(Collectors.toSet());
return new CurrentUserResponse(
principal.getId(),
principal.getUsername(),
principal.getFullName(),
roles,
principal.isMustChangePassword());
}
}

View File

@@ -0,0 +1,50 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.BlacklistRequest;
import com.itcenter.acs.dto.BlacklistResponse;
import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.service.BlacklistService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Blacklist management. Restricted to ADMIN by SecurityConfig (/api/blacklist/**).
*/
@RestController
@RequestMapping("/api/blacklist")
@RequiredArgsConstructor
public class BlacklistController {
private final BlacklistService blacklistService;
@GetMapping
public ResponseEntity<ApiResponse<List<BlacklistResponse>>> list() {
List<BlacklistResponse> items = blacklistService.listActive().stream()
.map(BlacklistResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success(items));
}
@PostMapping
public ResponseEntity<ApiResponse<BlacklistResponse>> add(@Valid @RequestBody BlacklistRequest request) {
return ResponseEntity.ok(ApiResponse.success(
BlacklistResponse.from(blacklistService.add(request, SecurityUtils.currentUserId()))));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<String>> deactivate(@PathVariable Long id) {
blacklistService.deactivate(id);
return ResponseEntity.ok(ApiResponse.success("차단이 해제되었습니다."));
}
}

View File

@@ -0,0 +1,20 @@
package com.itcenter.acs.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HomeController {
@GetMapping("/")
public Map<String, String> root() {
return Map.of("app", "IT Center Access Control System", "status", "ok");
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "UP");
}
}

View File

@@ -0,0 +1,55 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.VisitRequestResponse;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.service.QrService;
import com.itcenter.acs.service.VisitRequestService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Visitor pass/badge: metadata + QR PNG. The QR encodes the approved request's
* qrToken, which the access console scans for check-in.
*/
@RestController
@RequestMapping("/api/passes")
@RequiredArgsConstructor
public class PassController {
private final VisitRequestService visitRequestService;
private final QrService qrService;
/** Badge metadata (reuses the visit-request view, which carries the qrToken). */
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<VisitRequestResponse>> badge(@PathVariable Long id) {
VisitRequest vr = requireApproved(id);
return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(vr)));
}
@GetMapping(value = "/{id}/qr.png", produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> qr(@PathVariable Long id) {
VisitRequest vr = requireApproved(id);
byte[] png = qrService.pngForText(vr.getQrToken(), 240);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.cacheControl(CacheControl.noCache())
.body(png);
}
private VisitRequest requireApproved(Long id) {
VisitRequest vr = visitRequestService.get(id);
if (vr.getStatus() != VisitStatus.APPROVED || vr.getQrToken() == null) {
throw ApiException.badRequest("승인되어 출입증이 발급된 방문만 배지를 볼 수 있습니다.");
}
return vr;
}
}

View File

@@ -0,0 +1,83 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.AccessActionResponse;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.CheckInRequest;
import com.itcenter.acs.dto.PublicPassResponse;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.service.AccessService;
import com.itcenter.acs.service.QrService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Public (no-login) visitor pass + self-service kiosk actions, addressed by the
* unguessable qrToken. The visitor opens the SMS link on their phone (pass view)
* and, at the entrance kiosk, scans the QR to self check-in/out. Possession of
* the token is the authorization; each token only affects its own visit.
*/
@RestController
@RequestMapping("/api/public/passes")
@RequiredArgsConstructor
public class PublicPassController {
private final VisitRequestRepository visitRequestRepository;
private final QrService qrService;
private final AccessService accessService;
@GetMapping("/{token}")
public ResponseEntity<ApiResponse<PublicPassResponse>> pass(@PathVariable String token) {
VisitRequest vr = requireApproved(token);
boolean inside = accessService.isInside(vr.getId());
boolean completedToday = !inside && accessService.hasExitedToday(vr.getId());
return ResponseEntity.ok(ApiResponse.success(PublicPassResponse.from(vr, inside, completedToday)));
}
@GetMapping(value = "/{token}/qr.png", produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> qr(@PathVariable String token) {
VisitRequest vr = requireApproved(token);
byte[] png = qrService.pngForText(vr.getQrToken(), 240);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.cacheControl(CacheControl.noCache())
.body(png);
}
/** Self check-in from the entrance kiosk (no operator). */
@PostMapping("/{token}/check-in")
public ResponseEntity<ApiResponse<AccessActionResponse>> checkIn(@PathVariable String token) {
return ResponseEntity.ok(ApiResponse.success(accessService.checkIn(kioskReq(token), null)));
}
/** Self check-out from the entrance kiosk (no operator). */
@PostMapping("/{token}/check-out")
public ResponseEntity<ApiResponse<AccessActionResponse>> checkOut(@PathVariable String token) {
return ResponseEntity.ok(ApiResponse.success(accessService.checkOut(kioskReq(token), null)));
}
private CheckInRequest kioskReq(String token) {
CheckInRequest req = new CheckInRequest();
req.setQrToken(token);
req.setGateId("KIOSK");
return req;
}
private VisitRequest requireApproved(String token) {
VisitRequest vr = visitRequestRepository.findByQrToken(token)
.orElseThrow(() -> ApiException.notFound("유효하지 않은 출입증입니다."));
if (vr.getStatus() != VisitStatus.APPROVED) {
throw ApiException.badRequest("승인된 출입증만 확인할 수 있습니다.");
}
return vr;
}
}

View File

@@ -0,0 +1,38 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.service.ReportService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
/**
* Excel reports. Restricted to SECURITY/ADMIN by SecurityConfig (/api/reports/**).
*/
@RestController
@RequestMapping("/api/reports")
@RequiredArgsConstructor
public class ReportController {
private final ReportService reportService;
@GetMapping("/visits.xlsx")
public ResponseEntity<byte[]> visitsReport(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
byte[] xlsx = reportService.visitReport(from, to);
String filename = "visit-report_" + from + "_" + to + ".xlsx";
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.body(xlsx);
}
}

View File

@@ -0,0 +1,23 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.StatsSummaryResponse;
import com.itcenter.acs.service.StatsService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final StatsService statsService;
@GetMapping("/summary")
public ResponseEntity<ApiResponse<StatsSummaryResponse>> summary() {
return ResponseEntity.ok(ApiResponse.success(statsService.summary()));
}
}

View File

@@ -0,0 +1,80 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.ExcelImportResult;
import com.itcenter.acs.dto.VisitRequestCreateRequest;
import com.itcenter.acs.dto.VisitRequestResponse;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.service.ExcelImportService;
import com.itcenter.acs.service.VisitRequestService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/visit-requests")
@RequiredArgsConstructor
public class VisitRequestController {
private final VisitRequestService visitRequestService;
private final ExcelImportService excelImportService;
/** Create a single pre-registration; current user becomes the host. */
@PostMapping
public ResponseEntity<ApiResponse<VisitRequestResponse>> create(
@Valid @RequestBody VisitRequestCreateRequest request) {
VisitRequest created = visitRequestService.create(request, SecurityUtils.currentUserId());
return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(created)));
}
/** ADMIN/SECURITY see all; HOST sees only their own requests. */
@GetMapping
public ResponseEntity<ApiResponse<List<VisitRequestResponse>>> list() {
List<VisitRequest> items =
(SecurityUtils.hasRole("ADMIN") || SecurityUtils.hasRole("SECURITY"))
? visitRequestService.listAll()
: visitRequestService.listByHost(SecurityUtils.currentUserId());
return ResponseEntity.ok(ApiResponse.success(toResponses(items)));
}
/** Pending queue for the approval screen. */
@GetMapping("/pending")
public ResponseEntity<ApiResponse<List<VisitRequestResponse>>> pending() {
return ResponseEntity.ok(ApiResponse.success(toResponses(visitRequestService.listPending())));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<VisitRequestResponse>> get(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(visitRequestService.get(id))));
}
@PostMapping("/{id}/cancel")
public ResponseEntity<ApiResponse<VisitRequestResponse>> cancel(@PathVariable Long id) {
VisitRequest vr = visitRequestService.cancel(id, SecurityUtils.currentUserId(), SecurityUtils.hasRole("ADMIN"));
return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(vr)));
}
@PostMapping("/upload")
public ResponseEntity<ApiResponse<ExcelImportResult>> upload(
@RequestParam("file") MultipartFile file) throws IOException {
ExcelImportResult result = excelImportService.importVisitRequests(file, SecurityUtils.currentUserId());
return ResponseEntity.ok(ApiResponse.success(result));
}
private List<VisitRequestResponse> toResponses(List<VisitRequest> items) {
return items.stream().map(VisitRequestResponse::from).collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,29 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.ZoneResponse;
import com.itcenter.acs.repository.ZoneRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/zones")
@RequiredArgsConstructor
public class ZoneController {
private final ZoneRepository zoneRepository;
@GetMapping
public ResponseEntity<ApiResponse<List<ZoneResponse>>> list() {
List<ZoneResponse> zones = zoneRepository.findByActiveTrueOrderBySecurityLevelAsc().stream()
.map(ZoneResponse::from)
.collect(Collectors.toList());
return ResponseEntity.ok(ApiResponse.success(zones));
}
}

View File

@@ -0,0 +1,17 @@
package com.itcenter.acs.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
public class AccessActionResponse {
private Long visitRequestId;
private String visitorName;
private String direction;
private LocalDateTime eventAt;
private boolean gateOpened;
private String message;
}

View File

@@ -0,0 +1,25 @@
package com.itcenter.acs.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
/**
* One visitor's access record for today: planned window (신청 입/퇴장) and actual
* times (실제 입/퇴장). Includes those who have already left, so the staff console
* can show the full day's traffic.
*/
@Data
@AllArgsConstructor
public class AccessRecordResponse {
private Long visitRequestId;
private String visitorName;
private String company;
private String zoneName;
private LocalDateTime visitFrom; // 신청(예정) 입장
private LocalDateTime visitTo; // 신청(예정) 퇴장
private LocalDateTime checkInAt; // 실제 입장 (오늘 최초 IN)
private LocalDateTime checkOutAt; // 실제 퇴장 (오늘 최종 OUT, 재실 중이면 null)
private boolean inside;
}

View File

@@ -0,0 +1,29 @@
package com.itcenter.acs.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Standard API envelope: every endpoint returns { code, message, data }.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "Success", data);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(200, message, data);
}
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}

View File

@@ -0,0 +1,9 @@
package com.itcenter.acs.dto;
import lombok.Data;
@Data
public class ApprovalDecisionRequest {
/** Optional comment / reason (recommended for rejections). */
private String comment;
}

View File

@@ -0,0 +1,16 @@
package com.itcenter.acs.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class BlacklistRequest {
@NotBlank(message = "이름을 입력하세요.")
private String name;
private String company;
private String contact;
@NotBlank(message = "차단 사유를 입력하세요.")
private String reason;
}

View File

@@ -0,0 +1,31 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.Blacklist;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class BlacklistResponse {
private Long id;
private String name;
private String company;
private String contact;
private String reason;
private boolean active;
private String createdByName;
private LocalDateTime createdAt;
public static BlacklistResponse from(Blacklist b) {
BlacklistResponse r = new BlacklistResponse();
r.id = b.getId();
r.name = b.getName();
r.company = b.getCompany();
r.contact = b.getContact();
r.reason = b.getReason();
r.active = b.isActive();
r.createdByName = b.getCreatedBy() != null ? b.getCreatedBy().getFullName() : null;
r.createdAt = b.getCreatedAt();
return r;
}
}

View File

@@ -0,0 +1,15 @@
package com.itcenter.acs.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class ChangePasswordRequest {
@NotBlank(message = "현재 비밀번호를 입력하세요.")
private String oldPassword;
@NotBlank(message = "새 비밀번호를 입력하세요.")
@Size(min = 8, message = "새 비밀번호는 8자 이상이어야 합니다.")
private String newPassword;
}

View File

@@ -0,0 +1,14 @@
package com.itcenter.acs.dto;
import lombok.Data;
/**
* Check-in / check-out command. Provide either qrToken (QR scan) or
* visitRequestId (manual selection from search).
*/
@Data
public class CheckInRequest {
private String qrToken;
private Long visitRequestId;
private String gateId;
}

View File

@@ -0,0 +1,16 @@
package com.itcenter.acs.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.Set;
@Data
@AllArgsConstructor
public class CurrentUserResponse {
private Long id;
private String username;
private String fullName;
private Set<String> roles;
private boolean mustChangePassword;
}

View File

@@ -0,0 +1,17 @@
package com.itcenter.acs.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class ExcelImportResult {
private int totalRows;
private int successCount;
private List<String> errors = new ArrayList<>();
public boolean isSuccess() {
return errors.isEmpty();
}
}

View File

@@ -0,0 +1,20 @@
package com.itcenter.acs.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
/**
* A visitor currently inside (checked in, not yet checked out).
*/
@Data
@AllArgsConstructor
public class InsideVisitorResponse {
private Long visitRequestId;
private String visitorName;
private String company;
private String zoneName;
private String hostName;
private LocalDateTime checkInAt;
}

View File

@@ -0,0 +1,13 @@
package com.itcenter.acs.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank(message = "아이디를 입력하세요.")
private String username;
@NotBlank(message = "비밀번호를 입력하세요.")
private String password;
}

View File

@@ -0,0 +1,35 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.VisitRequest;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Minimal, non-sensitive pass view served to the visitor via the public
* token link. Intentionally omits contact/host/internal fields.
*/
@Data
public class PublicPassResponse {
private String visitorName;
private String company;
private String zoneName;
private LocalDateTime visitFrom;
private LocalDateTime visitTo;
/** Whether the visitor is currently inside — drives the kiosk 입장/퇴장 button. */
private boolean inside;
/** Whether the visit already completed (entered & exited) today — blocks re-entry. */
private boolean completedToday;
public static PublicPassResponse from(VisitRequest vr, boolean inside, boolean completedToday) {
PublicPassResponse r = new PublicPassResponse();
r.visitorName = vr.getVisitor().getName();
r.company = vr.getVisitor().getCompany();
r.zoneName = vr.getZoneName();
r.visitFrom = vr.getVisitFrom();
r.visitTo = vr.getVisitTo();
r.inside = inside;
r.completedToday = completedToday;
return r;
}
}

View File

@@ -0,0 +1,14 @@
package com.itcenter.acs.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class StatsSummaryResponse {
private long todayVisits;
private long pending;
private long approved;
private long currentlyInside;
private long total;
}

View File

@@ -0,0 +1,34 @@
package com.itcenter.acs.dto;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class VisitRequestCreateRequest {
@NotBlank(message = "방문자 이름을 입력하세요.")
private String visitorName;
private String company;
@NotBlank(message = "방문자 연락처를 입력하세요.")
private String contact;
private String email;
private String vehicleNo;
/** Access zone label (fixed list value or "기타" free text). */
private String zoneName;
@NotBlank(message = "출입 목적을 입력하세요.")
private String purpose;
@NotNull(message = "방문 시작 일시를 입력하세요.")
private LocalDateTime visitFrom;
@NotNull(message = "방문 종료 일시를 입력하세요.")
private LocalDateTime visitTo;
}

View File

@@ -0,0 +1,45 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.VisitRequest;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class VisitRequestResponse {
private Long id;
private String visitorName;
private String company;
private String contact;
private String vehicleNo;
private Long hostId;
private String hostName;
private String hostDepartment;
private String zoneName;
private String purpose;
private LocalDateTime visitFrom;
private LocalDateTime visitTo;
private String status;
private String qrToken;
private LocalDateTime createdAt;
public static VisitRequestResponse from(VisitRequest vr) {
VisitRequestResponse r = new VisitRequestResponse();
r.id = vr.getId();
r.visitorName = vr.getVisitor().getName();
r.company = vr.getVisitor().getCompany();
r.contact = vr.getVisitor().getContact();
r.vehicleNo = vr.getVisitor().getVehicleNo();
r.hostId = vr.getHost().getId();
r.hostName = vr.getHost().getFullName();
r.hostDepartment = vr.getHost().getDepartment();
r.zoneName = vr.getZoneName();
r.purpose = vr.getPurpose();
r.visitFrom = vr.getVisitFrom();
r.visitTo = vr.getVisitTo();
r.status = vr.getStatus().name();
r.qrToken = vr.getQrToken();
r.createdAt = vr.getCreatedAt();
return r;
}
}

View File

@@ -0,0 +1,18 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.Zone;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ZoneResponse {
private Long id;
private String code;
private String name;
private int securityLevel;
public static ZoneResponse from(Zone zone) {
return new ZoneResponse(zone.getId(), zone.getCode(), zone.getName(), zone.getSecurityLevel());
}
}

View File

@@ -0,0 +1,52 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* A single check-in (IN) or check-out (OUT) at a gate, tied to an approved
* visit request. The source of truth for the currently-inside list and stats.
*/
@Entity
@Table(name = "access_events", indexes = {
@Index(name = "idx_ae_visit_request", columnList = "visit_request_id"),
@Index(name = "idx_ae_event_at", columnList = "eventAt"),
@Index(name = "idx_ae_direction", columnList = "direction")
})
@Getter
@Setter
@NoArgsConstructor
public class AccessEvent extends BaseEntity {
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "visit_request_id", nullable = false)
private VisitRequest visitRequest;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 8)
private Direction direction;
/** Logical gate/reader identifier (e.g. LOBBY-1). Free-form for now. */
@Column(length = 40)
private String gateId;
/** Security/reception user who processed the event (null if device-driven). */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "operator_id")
private User operator;
@Column(nullable = false)
private LocalDateTime eventAt;
}

View File

@@ -0,0 +1,44 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* An approve/reject decision recorded against a visit request.
*/
@Entity
@Table(name = "approvals")
@Getter
@Setter
@NoArgsConstructor
public class Approval extends BaseEntity {
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "visit_request_id", nullable = false)
private VisitRequest visitRequest;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "approver_id", nullable = false)
private User approver;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ApprovalDecision decision;
@Column(length = 500)
private String comment;
@Column(nullable = false)
private LocalDateTime decidedAt;
}

View File

@@ -0,0 +1,6 @@
package com.itcenter.acs.entity;
public enum ApprovalDecision {
APPROVED,
REJECTED
}

View File

@@ -0,0 +1,38 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* Shared identity + auditing columns. createdAt/updatedAt are populated
* automatically by Spring Data JPA auditing (see JpaConfig).
*/
@Getter
@Setter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,45 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A blocked person. Matched on name (+ optional contact) at check-in.
*/
@Entity
@Table(name = "blacklist", indexes = {
@Index(name = "idx_bl_name", columnList = "name"),
@Index(name = "idx_bl_contact", columnList = "contact")
})
@Getter
@Setter
@NoArgsConstructor
public class Blacklist extends BaseEntity {
@Column(nullable = false, length = 80)
private String name;
@Column(length = 120)
private String company;
@Column(length = 40)
private String contact;
@Column(nullable = false, length = 255)
private String reason;
@Column(nullable = false)
private boolean active = true;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "created_by")
private User createdBy;
}

View File

@@ -0,0 +1,9 @@
package com.itcenter.acs.entity;
/**
* Direction of an access event at a gate.
*/
public enum Direction {
IN,
OUT
}

View File

@@ -0,0 +1,13 @@
package com.itcenter.acs.entity;
/**
* Application roles. Spring Security authorities are derived as "ROLE_" + name().
* - ADMIN : 전체 관리(사용자/블랙리스트/리포트)
* - SECURITY : 입·출입 콘솔(체크인/아웃), 재실현황, 배지 발급
* - HOST : 방문 사전신청 등록, 본인 담당 방문 승인
*/
public enum RoleType {
ADMIN,
SECURITY,
HOST
}

View File

@@ -0,0 +1,59 @@
package com.itcenter.acs.entity;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.HashSet;
import java.util.Set;
/**
* A system account (ADMIN / SECURITY / HOST). Visitors are NOT users.
*/
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User extends BaseEntity {
@Column(unique = true, nullable = false, length = 50)
private String username;
@Column(nullable = false)
private String passwordHash;
@Column(nullable = false, length = 80)
private String fullName;
@Column(length = 120)
private String email;
/** Organizational department/team; also used as the host's team label. */
@Column(length = 80)
private String department;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private Set<RoleType> roles = new HashSet<>();
@Column(nullable = false)
private boolean mustChangePassword = true;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private boolean locked = false;
}

View File

@@ -0,0 +1,62 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* A request for a visitor to access a zone during a time window.
* This is the unit of access authorization; on approval it receives a qrToken.
*/
@Entity
@Table(name = "visit_requests", indexes = {
@Index(name = "idx_vr_status", columnList = "status"),
@Index(name = "idx_vr_visit_from", columnList = "visitFrom"),
@Index(name = "idx_vr_qr_token", columnList = "qrToken")
})
// zone is stored as free text (fixed list + "기타" custom input) rather than an FK.
@Getter
@Setter
@NoArgsConstructor
public class VisitRequest extends BaseEntity {
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "visitor_id", nullable = false)
private Visitor visitor;
/** The employee (HOST) responsible for this visit. */
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "host_id", nullable = false)
private User host;
@Column(name = "zone_name", length = 80)
private String zoneName;
@Column(nullable = false, length = 255)
private String purpose;
@Column(nullable = false)
private LocalDateTime visitFrom;
@Column(nullable = false)
private LocalDateTime visitTo;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private VisitStatus status = VisitStatus.PENDING;
/** UUID issued on approval; encoded into the QR pass/badge. Null until approved. */
@Column(length = 64)
private String qrToken;
}

View File

@@ -0,0 +1,14 @@
package com.itcenter.acs.entity;
/**
* Lifecycle of a visit request.
* DRAFT -> PENDING -> (APPROVED | REJECTED); APPROVED/PENDING -> CANCELLED; APPROVED -> EXPIRED.
*/
public enum VisitStatus {
DRAFT,
PENDING,
APPROVED,
REJECTED,
CANCELLED,
EXPIRED
}

View File

@@ -0,0 +1,39 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Personal details of a visiting person. Reused across repeat visits.
*/
@Entity
@Table(name = "visitors", indexes = {
@Index(name = "idx_visitor_name", columnList = "name"),
@Index(name = "idx_visitor_contact", columnList = "contact")
})
@Getter
@Setter
@NoArgsConstructor
public class Visitor extends BaseEntity {
@Column(nullable = false, length = 80)
private String name;
@Column(length = 120)
private String company;
@Column(length = 40)
private String contact;
@Column(length = 120)
private String email;
/** Vehicle plate number, if entering by car. */
@Column(length = 20)
private String vehicleNo;
}

View File

@@ -0,0 +1,36 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A physical access area within the IT center (e.g. LOBBY, OFFICE, SERVER_ROOM).
* A visit request grants access to one zone.
*/
@Entity
@Table(name = "zones")
@Getter
@Setter
@NoArgsConstructor
public class Zone extends BaseEntity {
@Column(unique = true, nullable = false, length = 30)
private String code;
@Column(nullable = false, length = 80)
private String name;
@Column(length = 255)
private String description;
/** Higher = more restricted (e.g. server room). Drives approval policy later. */
@Column(nullable = false)
private int securityLevel = 1;
@Column(nullable = false)
private boolean active = true;
}

View File

@@ -0,0 +1,29 @@
package com.itcenter.acs.exception;
import lombok.Getter;
/**
* Business/validation error carrying an HTTP status code, surfaced by
* GlobalExceptionHandler as an ApiResponse.error(code, message).
*/
@Getter
public class ApiException extends RuntimeException {
private final int code;
public ApiException(int code, String message) {
super(message);
this.code = code;
}
public static ApiException badRequest(String message) {
return new ApiException(400, message);
}
public static ApiException notFound(String message) {
return new ApiException(404, message);
}
public static ApiException conflict(String message) {
return new ApiException(409, message);
}
}

View File

@@ -0,0 +1,29 @@
package com.itcenter.acs.gateway;
/**
* Abstraction over physical access-control hardware (card readers, speed gates).
* The current system is software-only; {@link MockAccessControlGateway} is the
* default implementation. A real driver can be added later as another bean
* without touching the service layer.
*/
public interface AccessControlGateway {
/**
* Command a gate to open for a verified credential.
*
* @param gateId logical gate identifier (e.g. "LOBBY-1")
* @param reason human-readable reason for the audit trail
* @return result describing whether the hardware accepted the command
*/
GateResult openGate(String gateId, String reason);
/** Whether the gate/device is reachable and online. */
boolean isOnline(String gateId);
/** Outcome of a gate command. */
record GateResult(boolean accepted, String message) {
public static GateResult ok(String gateId) {
return new GateResult(true, "게이트 개방: " + gateId);
}
}
}

View File

@@ -0,0 +1,26 @@
package com.itcenter.acs.gateway;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* Software-only stub: logs the command and reports success. Default gateway
* until real hardware integration is added. Replace/augment by providing
* another {@link AccessControlGateway} bean marked @Primary.
*/
@Slf4j
@Component
public class MockAccessControlGateway implements AccessControlGateway {
@Override
public GateResult openGate(String gateId, String reason) {
String gate = (gateId == null || gateId.isBlank()) ? "(unspecified)" : gateId;
log.info("[gate] OPEN gate={} reason={}", gate, reason);
return GateResult.ok(gate);
}
@Override
public boolean isOnline(String gateId) {
return true;
}
}

View File

@@ -0,0 +1,94 @@
package com.itcenter.acs.notification;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.Visitor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.format.DateTimeFormatter;
import java.util.Map;
/**
* Sends the visitor pass via the in-house Bank-of-Korea DMZ message API
* (SMS/LMS). The API cannot attach images, so the message carries a link to the
* public pass page ({@code /pass/{token}}) where the visitor views the QR.
*
* <p>Active only when {@code acs.sms.provider=hanbank}; otherwise the dev
* {@link LoggingPassNotifier} is used. The API takes no auth key — it is reached
* over the trusted internal network, so the ACS server must have connectivity to
* {@code acs.sms.api-url}.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "acs.sms.provider", havingValue = "hanbank")
public class HanbankMessagePassNotifier implements PassNotifier {
private static final DateTimeFormatter WINDOW_FMT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
private final RestClient restClient;
private final String publicBaseUrl;
public HanbankMessagePassNotifier(
@Value("${acs.sms.api-url}") String apiUrl,
@Value("${acs.public-base-url}") String publicBaseUrl) {
this.restClient = RestClient.create(apiUrl);
this.publicBaseUrl = publicBaseUrl.endsWith("/")
? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
: publicBaseUrl;
}
@Override
public void sendPass(VisitRequest visitRequest, byte[] qrPng) {
Visitor visitor = visitRequest.getVisitor();
String phone = visitor != null ? digitsOnly(visitor.getContact()) : "";
if (phone.isBlank()) {
log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId());
return;
}
Map<String, String> body = Map.of(
"receive_number", phone,
"content", buildContent(visitRequest),
"msg_type", "LMS",
"reserve_time", "");
try {
SmsResponse res = restClient.post()
.uri("/sens/sms")
.body(body)
.retrieve()
.body(SmsResponse.class);
if (res != null && "202".equals(res.statusCode())) {
log.info("[sms] 출입증 LMS 발송 성공 → {} (requestId={})", phone, res.requestId());
} else {
log.warn("[sms] 출입증 LMS 발송 실패 → {} (statusCode={}, statusName={})",
phone, res != null ? res.statusCode() : "null", res != null ? res.statusName() : "null");
}
} catch (Exception e) {
log.warn("[sms] 출입증 LMS 발송 호출 오류 → {} : {}", phone, e.getMessage());
}
}
private String buildContent(VisitRequest vr) {
Visitor visitor = vr.getVisitor();
String name = visitor != null ? visitor.getName() : "방문자";
String zone = vr.getZoneName() != null ? vr.getZoneName() : "-";
String window = WINDOW_FMT.format(vr.getVisitFrom()) + " ~ " + WINDOW_FMT.format(vr.getVisitTo());
String link = publicBaseUrl + "/pass/" + vr.getQrToken();
return String.format(
"[IT센터 출입증]\n%s님, 출입 신청이 승인되었습니다.\n출입구역: %s\n출입기간: %s\n아래 링크에서 출입증(QR)을 확인하세요.\n%s\n전산실 입장 시 QR을 출입관리시스템에 입력하세요.",
name, zone, window, link);
}
private static String digitsOnly(String s) {
return s == null ? "" : s.replaceAll("[^0-9]", "");
}
/** Response body of POST /sens/sms (statusCode "202" = success). */
private record SmsResponse(String requestId, String requestTime, String statusCode, String statusName) {
}
}

View File

@@ -0,0 +1,78 @@
package com.itcenter.acs.notification;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.Visitor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.format.DateTimeFormatter;
/**
* Dev/default {@link PassNotifier}: does not call a real SMS/MMS gateway.
* Instead it logs the intended send and writes the QR PNG to an outbox
* directory so the approval → delivery flow can be exercised without a paid
* gateway account or a pre-registered sender number. Swap in a real provider
* by adding another {@link PassNotifier} bean marked {@code @Primary}.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "acs.sms.provider", havingValue = "dev", matchIfMissing = true)
public class LoggingPassNotifier implements PassNotifier {
private static final DateTimeFormatter WINDOW_FMT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
/** Directory where the QR image "sent" to the visitor is written (dev only). */
private final Path outboxDir;
public LoggingPassNotifier(@Value("${acs.sms.outbox-dir:./sms-outbox}") String outboxDir) {
this.outboxDir = Path.of(outboxDir);
}
@Override
public void sendPass(VisitRequest visitRequest, byte[] qrPng) {
Visitor visitor = visitRequest.getVisitor();
String phone = visitor != null ? visitor.getContact() : null;
if (phone == null || phone.isBlank()) {
log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId());
return;
}
String message = buildMessage(visitRequest);
Path saved = writeOutbox(visitRequest, qrPng);
log.info("[sms] (DEV) 출입증 MMS 발송 → {} ({}자, QR {} bytes){}\n----- 메시지 -----\n{}\n------------------",
phone, message.length(), qrPng.length,
saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message);
}
private String buildMessage(VisitRequest vr) {
Visitor visitor = vr.getVisitor();
String name = visitor != null ? visitor.getName() : "방문자";
String zone = vr.getZoneName() != null ? vr.getZoneName() : "-";
String window = WINDOW_FMT.format(vr.getVisitFrom()) + " ~ " + WINDOW_FMT.format(vr.getVisitTo());
return String.format(
"[IT센터 출입증]\n%s님, 출입 신청이 승인되었습니다.\n출입구역: %s\n출입기간: %s\n첨부된 QR을 입장 시 출입관리시스템에 입력하세요.",
name, zone, window);
}
/** Writes the QR image to the outbox dir for inspection. Returns null on failure. */
private Path writeOutbox(VisitRequest vr, byte[] qrPng) {
try {
Files.createDirectories(outboxDir);
String safeName = vr.getVisitor() != null && vr.getVisitor().getName() != null
? vr.getVisitor().getName().replaceAll("[^\\p{L}\\p{N}]", "_")
: "visitor";
Path file = outboxDir.resolve("pass-" + vr.getId() + "-" + safeName + ".png");
Files.write(file, qrPng);
return file.toAbsolutePath();
} catch (IOException e) {
log.warn("[sms] 출입증 이미지 저장 실패: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,23 @@
package com.itcenter.acs.notification;
import com.itcenter.acs.entity.VisitRequest;
/**
* Delivers an approved visitor pass (QR image) to the visitor, typically as an
* MMS to their registered phone number. The current system is software-only;
* {@link LoggingPassNotifier} is the default (dev) implementation that logs the
* send and writes the QR image to an outbox directory instead of calling a real
* SMS/MMS gateway. A real provider (Aligo, NHN Cloud, …) can be added later as
* another {@link PassNotifier} bean marked {@code @Primary} without touching the
* service layer.
*/
public interface PassNotifier {
/**
* Send the pass to the visitor.
*
* @param visitRequest the approved request (carries visitor, phone, window)
* @param qrPng PNG bytes of the pass QR to attach/send
*/
void sendPass(VisitRequest visitRequest, byte[] qrPng);
}

View File

@@ -0,0 +1,32 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.AccessEvent;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface AccessEventRepository extends JpaRepository<AccessEvent, Long> {
Optional<AccessEvent> findFirstByVisitRequestIdOrderByEventAtDesc(Long visitRequestId);
List<AccessEvent> findByVisitRequestIdOrderByEventAtAsc(Long visitRequestId);
/** Distinct visit-request ids that had an entry (IN) within the window (e.g. today). */
@Query("select distinct e.visitRequest.id from AccessEvent e " +
"where e.direction = com.itcenter.acs.entity.Direction.IN " +
"and e.eventAt >= :start and e.eventAt < :end")
List<Long> findVisitRequestIdsCheckedInBetween(
@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
/** Visit request ids that are currently inside (more INs than OUTs). */
@Query("select e.visitRequest.id from AccessEvent e group by e.visitRequest.id " +
"having sum(case when e.direction = com.itcenter.acs.entity.Direction.IN then 1 else -1 end) > 0")
List<Long> findInsideVisitRequestIds();
long countByDirectionAndEventAtBetween(
com.itcenter.acs.entity.Direction direction, LocalDateTime from, LocalDateTime to);
}

View File

@@ -0,0 +1,10 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.Approval;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ApprovalRepository extends JpaRepository<Approval, Long> {
List<Approval> findByVisitRequestIdOrderByDecidedAtDesc(Long visitRequestId);
}

View File

@@ -0,0 +1,18 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.Blacklist;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface BlacklistRepository extends JpaRepository<Blacklist, Long> {
List<Blacklist> findByActiveTrueOrderByUpdatedAtDesc();
/** Active entries matching a visitor's name (contact optional). */
@Query("select b from Blacklist b where b.active = true and lower(b.name) = lower(:name) " +
"and (b.contact is null or b.contact = '' or :contact is null or b.contact = :contact)")
List<Blacklist> findActiveMatches(@Param("name") String name, @Param("contact") String contact);
}

View File

@@ -0,0 +1,11 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}

View File

@@ -0,0 +1,37 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface VisitRequestRepository extends JpaRepository<VisitRequest, Long> {
List<VisitRequest> findByStatusOrderByVisitFromAsc(VisitStatus status);
List<VisitRequest> findByHostIdOrderByVisitFromDesc(Long hostId);
List<VisitRequest> findAllByOrderByVisitFromDesc();
Optional<VisitRequest> findByQrToken(String qrToken);
long countByStatus(VisitStatus status);
long countByVisitFromBetween(LocalDateTime from, LocalDateTime to);
List<VisitRequest> findByVisitFromBetweenOrderByVisitFromAsc(LocalDateTime from, LocalDateTime to);
/** Approved requests whose visitor name matches, for the check-in console search. */
@Query("select vr from VisitRequest vr where vr.status = com.itcenter.acs.entity.VisitStatus.APPROVED " +
"and lower(vr.visitor.name) like lower(concat('%', :q, '%')) order by vr.visitFrom asc")
List<VisitRequest> searchApprovedByVisitorName(@Param("q") String q);
/** Active (PENDING/APPROVED) duplicate: same visitor, same window, same zone. */
@Query("select count(vr) > 0 from VisitRequest vr where vr.visitor.id = :visitorId " +
"and vr.visitFrom = :from and vr.visitTo = :to " +
"and ((:zone is null and vr.zoneName is null) or vr.zoneName = :zone) " +
"and vr.status in (com.itcenter.acs.entity.VisitStatus.PENDING, com.itcenter.acs.entity.VisitStatus.APPROVED)")
boolean existsActiveDuplicate(@Param("visitorId") Long visitorId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("zone") String zone);
}

View File

@@ -0,0 +1,12 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.Visitor;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface VisitorRepository extends JpaRepository<Visitor, Long> {
Optional<Visitor> findFirstByNameAndContact(String name, String contact);
List<Visitor> findByNameContainingIgnoreCase(String name);
}

View File

@@ -0,0 +1,12 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.Zone;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ZoneRepository extends JpaRepository<Zone, Long> {
Optional<Zone> findByCode(String code);
List<Zone> findByActiveTrueOrderBySecurityLevelAsc();
}

View File

@@ -0,0 +1,23 @@
package com.itcenter.acs.security;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
return new UserPrincipal(user);
}
}

View File

@@ -0,0 +1,31 @@
package com.itcenter.acs.security;
import com.itcenter.acs.exception.ApiException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* Helpers for reading the authenticated principal from the SecurityContext.
*/
public final class SecurityUtils {
private SecurityUtils() {
}
public static UserPrincipal currentPrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof UserPrincipal principal)) {
throw new ApiException(401, "로그인이 필요합니다.");
}
return principal;
}
public static Long currentUserId() {
return currentPrincipal().getId();
}
public static boolean hasRole(String role) {
return SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_" + role));
}
}

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs.security;
import com.itcenter.acs.entity.User;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Spring Security principal backed by our User entity. Roles become
* "ROLE_ADMIN" / "ROLE_SECURITY" / "ROLE_HOST" authorities.
*/
@Getter
public class UserPrincipal implements UserDetails {
private final Long id;
private final String username;
private final String password;
private final String fullName;
private final boolean mustChangePassword;
private final boolean enabled;
private final boolean locked;
private final Set<GrantedAuthority> authorities;
public UserPrincipal(User user) {
this.id = user.getId();
this.username = user.getUsername();
this.password = user.getPasswordHash();
this.fullName = user.getFullName();
this.mustChangePassword = user.isMustChangePassword();
this.enabled = user.isEnabled();
this.locked = user.isLocked();
this.authorities = user.getRoles().stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.name()))
.collect(Collectors.toUnmodifiableSet());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return !locked;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}

View File

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

View File

@@ -0,0 +1,90 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.Approval;
import com.itcenter.acs.entity.ApprovalDecision;
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.notification.PassNotifier;
import com.itcenter.acs.repository.ApprovalRepository;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class ApprovalService {
/** QR pixel size for the pass image sent to the visitor. */
private static final int PASS_QR_SIZE = 240;
private final VisitRequestRepository visitRequestRepository;
private final ApprovalRepository approvalRepository;
private final UserRepository userRepository;
private final QrService qrService;
private final PassNotifier passNotifier;
public VisitRequest approve(Long visitRequestId, Long approverId, String comment) {
return decide(visitRequestId, approverId, comment, ApprovalDecision.APPROVED);
}
public VisitRequest reject(Long visitRequestId, Long approverId, String comment) {
return decide(visitRequestId, approverId, comment, ApprovalDecision.REJECTED);
}
private VisitRequest decide(Long visitRequestId, Long approverId, String comment, ApprovalDecision decision) {
VisitRequest vr = visitRequestRepository.findById(visitRequestId)
.orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다."));
if (vr.getStatus() != VisitStatus.PENDING) {
throw ApiException.conflict("이미 처리된 신청입니다. (현재 상태: " + vr.getStatus() + ")");
}
User approver = userRepository.findById(approverId)
.orElseThrow(() -> ApiException.notFound("승인자를 찾을 수 없습니다."));
if (decision == ApprovalDecision.APPROVED) {
vr.setStatus(VisitStatus.APPROVED);
vr.setQrToken(UUID.randomUUID().toString());
} else {
vr.setStatus(VisitStatus.REJECTED);
}
Approval approval = new Approval();
approval.setVisitRequest(vr);
approval.setApprover(approver);
approval.setDecision(decision);
approval.setComment(comment);
approval.setDecidedAt(LocalDateTime.now());
approvalRepository.save(approval);
if (decision == ApprovalDecision.APPROVED) {
notifyVisitor(vr);
}
return vr;
}
/**
* Sends the freshly issued pass to the visitor. A delivery failure must not
* roll back the approval, so it is caught and logged rather than propagated.
*/
private void notifyVisitor(VisitRequest vr) {
try {
byte[] qrPng = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE);
passNotifier.sendPass(vr, qrPng);
} catch (Exception e) {
log.warn("[approval] 출입증 발송 실패 (승인은 정상 처리됨). visitRequestId={} err={}",
vr.getId(), e.getMessage());
}
}
}

View File

@@ -0,0 +1,34 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public User changePassword(Long userId, String oldPassword, String newPassword) {
User user = userRepository.findById(userId)
.orElseThrow(() -> ApiException.notFound("사용자를 찾을 수 없습니다."));
if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) {
throw ApiException.badRequest("현재 비밀번호가 올바르지 않습니다.");
}
if (passwordEncoder.matches(newPassword, user.getPasswordHash())) {
throw ApiException.badRequest("새 비밀번호가 기존 비밀번호와 동일합니다.");
}
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setMustChangePassword(false);
return user;
}
}

View File

@@ -0,0 +1,59 @@
package com.itcenter.acs.service;
import com.itcenter.acs.dto.BlacklistRequest;
import com.itcenter.acs.entity.Blacklist;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.repository.BlacklistRepository;
import com.itcenter.acs.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional
public class BlacklistService {
private final BlacklistRepository blacklistRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public List<Blacklist> listActive() {
return blacklistRepository.findByActiveTrueOrderByUpdatedAtDesc();
}
public Blacklist add(BlacklistRequest req, Long createdByUserId) {
User creator = userRepository.findById(createdByUserId)
.orElseThrow(() -> ApiException.notFound("등록자를 찾을 수 없습니다."));
Blacklist b = new Blacklist();
b.setName(req.getName());
b.setCompany(req.getCompany());
b.setContact(req.getContact());
b.setReason(req.getReason());
b.setActive(true);
b.setCreatedBy(creator);
return blacklistRepository.save(b);
}
/** Soft-deactivate (lift) a block. */
public void deactivate(Long id) {
Blacklist b = blacklistRepository.findById(id)
.orElseThrow(() -> ApiException.notFound("차단 항목을 찾을 수 없습니다."));
b.setActive(false);
}
/** Returns the matching block reason, or null if not blacklisted. */
@Transactional(readOnly = true)
public String blockReason(String name, String contact) {
if (name == null) {
return null;
}
return blacklistRepository.findActiveMatches(name, contact).stream()
.findFirst()
.map(Blacklist::getReason)
.orElse(null);
}
}

View File

@@ -0,0 +1,138 @@
package com.itcenter.acs.service;
import com.itcenter.acs.dto.ExcelImportResult;
import com.itcenter.acs.dto.VisitRequestCreateRequest;
import lombok.RequiredArgsConstructor;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
/**
* Bulk-imports visit requests from an .xlsx file.
* Columns (row 1 = header, skipped):
* 0 visitorName | 1 company | 2 contact | 3 email | 4 vehicleNo
* 5 zoneName | 6 purpose | 7 visitFrom | 8 visitTo
* Dates accept Excel date cells or "yyyy-MM-dd HH:mm" / "yyyy-MM-dd" text.
*/
@Service
@RequiredArgsConstructor
public class ExcelImportService {
private final VisitRequestService visitRequestService;
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd[ HH:mm]");
// Not @Transactional: each row imports in its own transaction (create() is
// @Transactional), so a duplicate/invalid row fails independently without
// poisoning the others' transaction.
public ExcelImportResult importVisitRequests(MultipartFile file, Long hostUserId) throws IOException {
ExcelImportResult result = new ExcelImportResult();
int dataRows = 0;
try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) {
Sheet sheet = workbook.getSheetAt(0);
for (Row row : sheet) {
if (row.getRowNum() == 0) {
continue; // header
}
if (isEmptyRow(row)) {
continue;
}
dataRows++;
try {
VisitRequestCreateRequest req = parseRow(row);
visitRequestService.create(req, hostUserId);
result.setSuccessCount(result.getSuccessCount() + 1);
} catch (Exception e) {
result.getErrors().add("" + (row.getRowNum() + 1) + ": " + e.getMessage());
}
}
}
result.setTotalRows(dataRows);
return result;
}
private VisitRequestCreateRequest parseRow(Row row) {
VisitRequestCreateRequest req = new VisitRequestCreateRequest();
req.setVisitorName(requireString(row.getCell(0), "방문자 이름"));
req.setCompany(getString(row.getCell(1)));
req.setContact(requireString(row.getCell(2), "연락처"));
req.setEmail(getString(row.getCell(3)));
req.setVehicleNo(getString(row.getCell(4)));
req.setZoneName(getString(row.getCell(5)));
req.setPurpose(requireString(row.getCell(6), "출입 목적"));
req.setVisitFrom(requireDateTime(row.getCell(7), "출입 일시"));
req.setVisitTo(requireDateTime(row.getCell(8), "퇴실 일시"));
return req;
}
private boolean isEmptyRow(Row row) {
for (int c = 0; c <= 8; c++) {
String v = getString(row.getCell(c));
if (v != null && !v.isBlank()) {
return false;
}
}
return true;
}
private String getString(Cell cell) {
if (cell == null) {
return null;
}
return switch (cell.getCellType()) {
case STRING -> cell.getStringCellValue().trim();
case NUMERIC -> DateUtil.isCellDateFormatted(cell)
? cell.getLocalDateTimeCellValue().toString()
: stripDecimal(cell.getNumericCellValue());
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
case FORMULA -> cell.getCellFormula();
default -> null;
};
}
private String stripDecimal(double d) {
if (d == Math.floor(d) && !Double.isInfinite(d)) {
return String.valueOf((long) d);
}
return String.valueOf(d);
}
private String requireString(Cell cell, String field) {
String v = getString(cell);
if (v == null || v.isBlank()) {
throw new IllegalArgumentException(field + "은(는) 필수입니다.");
}
return v;
}
private LocalDateTime requireDateTime(Cell cell, String field) {
if (cell == null) {
throw new IllegalArgumentException(field + "은(는) 필수입니다.");
}
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue();
}
String text = requireString(cell, field);
try {
if (text.length() <= 10) {
return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay();
}
return LocalDateTime.parse(text.replace('T', ' '), DT);
} catch (Exception e) {
throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다 (yyyy-MM-dd HH:mm): " + text);
}
}
}

View File

@@ -0,0 +1,38 @@
package com.itcenter.acs.service;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import com.itcenter.acs.exception.ApiException;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.util.Map;
/**
* Generates QR-code PNG images for visitor passes/badges (ZXing).
*/
@Service
public class QrService {
public byte[] pngForText(String text, int size) {
if (text == null || text.isBlank()) {
throw ApiException.badRequest("QR로 인코딩할 값이 없습니다.");
}
try {
Map<EncodeHintType, Object> hints = Map.of(
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M,
EncodeHintType.MARGIN, 1,
EncodeHintType.CHARACTER_SET, "UTF-8");
BitMatrix matrix = new QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size, hints);
ByteArrayOutputStream out = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(matrix, "PNG", out);
return out.toByteArray();
} catch (Exception e) {
throw new ApiException(500, "QR 생성 실패: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,108 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.repository.VisitRequestRepository;
import lombok.RequiredArgsConstructor;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
/**
* Builds an Excel (.xlsx) report of visit requests in a date range (by visitFrom).
*/
@Service
@RequiredArgsConstructor
public class ReportService {
private final VisitRequestRepository visitRequestRepository;
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final Map<VisitStatus, String> STATUS_KO = Map.of(
VisitStatus.DRAFT, "임시저장",
VisitStatus.PENDING, "승인대기",
VisitStatus.APPROVED, "승인완료",
VisitStatus.REJECTED, "반려",
VisitStatus.CANCELLED, "취소",
VisitStatus.EXPIRED, "만료");
@Transactional(readOnly = true)
public byte[] visitReport(LocalDate from, LocalDate to) {
if (from == null || to == null) {
throw ApiException.badRequest("조회 기간(from, to)을 지정하세요.");
}
if (to.isBefore(from)) {
throw ApiException.badRequest("종료일이 시작일보다 빠를 수 없습니다.");
}
List<VisitRequest> rows = visitRequestRepository
.findByVisitFromBetweenOrderByVisitFromAsc(from.atStartOfDay(), to.plusDays(1).atStartOfDay());
String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "출입일시", "퇴실일시", "상태"};
try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Sheet sheet = wb.createSheet("출입기록");
CellStyle headerStyle = headerStyle(wb);
Row head = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell c = head.createCell(i);
c.setCellValue(headers[i]);
c.setCellStyle(headerStyle);
}
int r = 1;
for (VisitRequest vr : rows) {
Row row = sheet.createRow(r++);
row.createCell(0).setCellValue(vr.getVisitor().getName());
row.createCell(1).setCellValue(nv(vr.getVisitor().getCompany()));
row.createCell(2).setCellValue(nv(vr.getVisitor().getContact()));
row.createCell(3).setCellValue(nv(vr.getZoneName()));
row.createCell(4).setCellValue(vr.getHost().getFullName());
row.createCell(5).setCellValue(nv(vr.getPurpose()));
row.createCell(6).setCellValue(fmt(vr.getVisitFrom()));
row.createCell(7).setCellValue(fmt(vr.getVisitTo()));
row.createCell(8).setCellValue(STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()));
}
for (int i = 0; i < headers.length; i++) {
sheet.autoSizeColumn(i);
}
wb.write(out);
return out.toByteArray();
} catch (Exception e) {
throw new ApiException(500, "리포트 생성 실패: " + e.getMessage());
}
}
private CellStyle headerStyle(Workbook wb) {
CellStyle style = wb.createCellStyle();
Font font = wb.createFont();
font.setBold(true);
style.setFont(font);
return style;
}
private String fmt(LocalDateTime t) {
return t != null ? t.format(DT) : "";
}
private String nv(String s) {
return s != null ? s : "";
}
}

View File

@@ -0,0 +1,32 @@
package com.itcenter.acs.service;
import com.itcenter.acs.dto.StatsSummaryResponse;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.repository.AccessEventRepository;
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;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StatsService {
private final VisitRequestRepository visitRequestRepository;
private final AccessEventRepository accessEventRepository;
public StatsSummaryResponse summary() {
LocalDateTime dayStart = LocalDate.now().atStartOfDay();
LocalDateTime dayEnd = dayStart.plusDays(1);
return new StatsSummaryResponse(
visitRequestRepository.countByVisitFromBetween(dayStart, dayEnd),
visitRequestRepository.countByStatus(VisitStatus.PENDING),
visitRequestRepository.countByStatus(VisitStatus.APPROVED),
accessEventRepository.findInsideVisitRequestIds().size(),
visitRequestRepository.count());
}
}

View File

@@ -0,0 +1,97 @@
package com.itcenter.acs.service;
import com.itcenter.acs.dto.VisitRequestCreateRequest;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional
public class VisitRequestService {
private final VisitRequestRepository visitRequestRepository;
private final VisitorRepository visitorRepository;
private final UserRepository userRepository;
/**
* Create a pre-registration. Reuses an existing visitor (matched by name+contact)
* or creates a new one. The created request starts in PENDING.
*/
public VisitRequest create(VisitRequestCreateRequest req, Long hostUserId) {
if (req.getVisitTo().isBefore(req.getVisitFrom())) {
throw ApiException.badRequest("방문 종료 일시가 시작 일시보다 빠를 수 없습니다.");
}
User host = userRepository.findById(hostUserId)
.orElseThrow(() -> ApiException.notFound("호스트 사용자를 찾을 수 없습니다."));
Visitor visitor = visitorRepository
.findFirstByNameAndContact(req.getVisitorName(), req.getContact())
.orElseGet(Visitor::new);
visitor.setName(req.getVisitorName());
visitor.setCompany(req.getCompany());
visitor.setContact(req.getContact());
visitor.setEmail(req.getEmail());
visitor.setVehicleNo(req.getVehicleNo());
visitor = visitorRepository.save(visitor);
if (visitRequestRepository.existsActiveDuplicate(
visitor.getId(), req.getVisitFrom(), req.getVisitTo(), req.getZoneName())) {
throw ApiException.conflict("이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복)");
}
VisitRequest vr = new VisitRequest();
vr.setVisitor(visitor);
vr.setHost(host);
vr.setZoneName(req.getZoneName());
vr.setPurpose(req.getPurpose());
vr.setVisitFrom(req.getVisitFrom());
vr.setVisitTo(req.getVisitTo());
vr.setStatus(VisitStatus.PENDING);
return visitRequestRepository.save(vr);
}
@Transactional(readOnly = true)
public List<VisitRequest> listAll() {
return visitRequestRepository.findAllByOrderByVisitFromDesc();
}
@Transactional(readOnly = true)
public List<VisitRequest> listByHost(Long hostUserId) {
return visitRequestRepository.findByHostIdOrderByVisitFromDesc(hostUserId);
}
@Transactional(readOnly = true)
public List<VisitRequest> listPending() {
return visitRequestRepository.findByStatusOrderByVisitFromAsc(VisitStatus.PENDING);
}
@Transactional(readOnly = true)
public VisitRequest get(Long id) {
return visitRequestRepository.findById(id)
.orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다."));
}
public VisitRequest cancel(Long id, Long requesterId, boolean isAdmin) {
VisitRequest vr = get(id);
if (!isAdmin && !vr.getHost().getId().equals(requesterId)) {
throw new ApiException(403, "본인이 등록한 신청만 취소할 수 있습니다.");
}
if (vr.getStatus() != VisitStatus.PENDING && vr.getStatus() != VisitStatus.APPROVED) {
throw ApiException.badRequest("취소할 수 없는 상태입니다: " + vr.getStatus());
}
vr.setStatus(VisitStatus.CANCELLED);
return vr;
}
}

View File

@@ -0,0 +1,31 @@
spring.application.name=acs
server.port=8080
# ===== PostgreSQL (prod) =====
spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:acs}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.username=${POSTGRES_USER:postgres}
spring.datasource.password=${POSTGRES_PASSWORD:change_me}
# ===== JPA / Hibernate =====
# Schema is owned by Flyway migrations; Hibernate only validates.
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.open-in-view=false
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
# ===== Flyway =====
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
# ===== SMS/LMS pass delivery =====
# provider=dev -> logs + writes QR to outbox-dir; provider=hanbank -> sends LMS
# with the public pass link via the in-house DMZ message API.
acs.sms.provider=${ACS_SMS_PROVIDER:dev}
acs.sms.api-url=${ACS_SMS_API_URL:http://210.104.132.59:8000}
acs.sms.outbox-dir=${ACS_SMS_OUTBOX_DIR:/app/sms-outbox}
# Base URL the SMS link points to — must be reachable from the visitor's phone.
acs.public-base-url=${ACS_PUBLIC_BASE_URL:http://localhost}
logging.level.root=WARN
logging.level.com.itcenter.acs=INFO

View File

@@ -0,0 +1,40 @@
spring.application.name=acs
server.port=8080
# ===== Logging =====
logging.level.root=INFO
logging.level.com.itcenter.acs=DEBUG
# ===== H2 in-memory (local dev, default) =====
spring.datasource.url=jdbc:h2:mem:acsdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# ===== JPA / Hibernate =====
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.format_sql=false
# H2 console (dev only): http://localhost:8080/h2-console (JDBC URL above)
spring.h2.console.enabled=true
# Flyway is for the prod (PostgreSQL) profile; schema in dev comes from ddl-auto
spring.flyway.enabled=false
# Multipart (Excel upload)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# ===== SMS/LMS pass delivery =====
# provider=dev -> LoggingPassNotifier: logs + writes QR to outbox-dir (no network)
# provider=hanbank -> HanbankMessagePassNotifier: sends an LMS with the public
# pass link via the in-house DMZ message API (requires network reachability).
acs.sms.provider=dev
acs.sms.outbox-dir=./sms-outbox
acs.sms.api-url=http://210.104.132.59:8000
# Base URL the SMS link points to (the visitor's public pass page).
acs.public-base-url=http://localhost:5173
spring.profiles.active=local

View File

@@ -0,0 +1,115 @@
-- IT센터 출입자관리시스템 — 초기 스키마 (PostgreSQL / prod)
-- Hibernate ddl-auto=validate 가 검증하므로 엔티티와 컬럼명·타입이 일치해야 한다.
-- ===== users =====
CREATE TABLE users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(80) NOT NULL,
email VARCHAR(120),
department VARCHAR(80),
must_change_password BOOLEAN NOT NULL,
enabled BOOLEAN NOT NULL,
locked BOOLEAN NOT NULL
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL REFERENCES users (id),
role VARCHAR(20) NOT NULL
);
CREATE INDEX idx_user_roles_user ON user_roles (user_id);
-- ===== zones =====
CREATE TABLE zones (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
code VARCHAR(30) NOT NULL UNIQUE,
name VARCHAR(80) NOT NULL,
description VARCHAR(255),
security_level INTEGER NOT NULL,
active BOOLEAN NOT NULL
);
-- ===== visitors =====
CREATE TABLE visitors (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
name VARCHAR(80) NOT NULL,
company VARCHAR(120),
contact VARCHAR(40),
email VARCHAR(120),
vehicle_no VARCHAR(20)
);
CREATE INDEX idx_visitor_name ON visitors (name);
CREATE INDEX idx_visitor_contact ON visitors (contact);
-- ===== visit_requests =====
CREATE TABLE visit_requests (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
visitor_id BIGINT NOT NULL REFERENCES visitors (id),
host_id BIGINT NOT NULL REFERENCES users (id),
zone_name VARCHAR(80),
purpose VARCHAR(255) NOT NULL,
visit_from TIMESTAMP NOT NULL,
visit_to TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL,
qr_token VARCHAR(64)
);
CREATE INDEX idx_vr_status ON visit_requests (status);
CREATE INDEX idx_vr_visit_from ON visit_requests (visit_from);
CREATE INDEX idx_vr_qr_token ON visit_requests (qr_token);
-- ===== approvals =====
CREATE TABLE approvals (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
visit_request_id BIGINT NOT NULL REFERENCES visit_requests (id),
approver_id BIGINT NOT NULL REFERENCES users (id),
decision VARCHAR(20) NOT NULL,
comment VARCHAR(500),
decided_at TIMESTAMP NOT NULL
);
-- ===== access_events =====
CREATE TABLE access_events (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
visit_request_id BIGINT NOT NULL REFERENCES visit_requests (id),
direction VARCHAR(8) NOT NULL,
gate_id VARCHAR(40),
operator_id BIGINT REFERENCES users (id),
event_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_ae_visit_request ON access_events (visit_request_id);
CREATE INDEX idx_ae_event_at ON access_events (event_at);
CREATE INDEX idx_ae_direction ON access_events (direction);
-- ===== blacklist =====
CREATE TABLE blacklist (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
name VARCHAR(80) NOT NULL,
company VARCHAR(120),
contact VARCHAR(40),
reason VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL,
created_by BIGINT REFERENCES users (id)
);
CREATE INDEX idx_bl_name ON blacklist (name);
CREATE INDEX idx_bl_contact ON blacklist (contact);
-- ===== seed: 출입 구역 (users 는 scripts/seed-load.py 로 적재) =====
INSERT INTO zones (created_at, updated_at, code, name, description, security_level, active) VALUES
(now(), now(), 'LOBBY', '로비', NULL, 1, TRUE),
(now(), now(), 'OFFICE', '사무공간', NULL, 2, TRUE),
(now(), now(), 'SERVER_ROOM', '전산실', NULL, 3, TRUE);

View File

@@ -0,0 +1,21 @@
package com.itcenter.acs;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* Boots the full context with Flyway running V1__init.sql on H2 (PostgreSQL mode)
* and Hibernate ddl-auto=validate. Fails if the migration and the JPA entities
* drift apart (missing/renamed column, wrong type), which is the most likely way
* a prod boot would break. Acts as a regression guard for the migration.
*/
@SpringBootTest
@ActiveProfiles("fwtest")
class FlywayValidationTest {
@Test
void migrationMatchesEntities() {
// Context load = Flyway migrate + Hibernate validate both succeeded.
}
}

View File

@@ -0,0 +1,11 @@
# Temporary profile: run Flyway V1__init.sql on H2 (PostgreSQL mode) and let
# Hibernate ddl-auto=validate check the migration matches the entities.
spring.datasource.url=jdbc:h2:mem:fwtest;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.h2.console.enabled=false