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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Java / Maven
|
||||
backend/target/
|
||||
*.class
|
||||
|
||||
# Node / Vite
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local env
|
||||
infra/.env
|
||||
|
||||
# Editor/EDR leftover temp files (see docs/issues-and-guidelines.md §2-3)
|
||||
*.tmp.*
|
||||
|
||||
# Generated pass images (dev SMS outbox)
|
||||
backend/sms-outbox/
|
||||
110
README.md
Normal file
110
README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# IT센터 출입자관리시스템 (Access Control System)
|
||||
|
||||
IT센터 방문자/출입자 관리 웹 시스템. 방문 사전신청·승인, 입·출입 체크인, QR/배지 발급,
|
||||
통계·리포트·블랙리스트를 목표로 하며, 출입통제 하드웨어는 추후 연동 가능하도록 추상화한다.
|
||||
|
||||
## 기술 스택
|
||||
- **백엔드**: Spring Boot 3.4.5 / Java 21 (JDK 26 빌드) · Spring Security(세션) · JPA · H2(dev)·PostgreSQL(prod) · POI · ZXing · Flyway
|
||||
- **프론트엔드**: React 19 · Vite 6 · TypeScript · react-router 7
|
||||
- **포트**: API `8080`, 웹 `5173`
|
||||
|
||||
## 역할
|
||||
| 역할 | 권한 |
|
||||
|------|------|
|
||||
| `ADMIN` | 전체 관리, 사용자/블랙리스트/리포트, 모든 승인 |
|
||||
| `SECURITY` | 입·출입 콘솔(체크인/아웃), 재실현황, 배지 발급 |
|
||||
| `HOST` | 방문 사전신청 등록, 담당 방문 승인 |
|
||||
|
||||
## 실행 (로컬 개발, H2)
|
||||
```cmd
|
||||
:: 백엔드 (http://localhost:8080)
|
||||
run-backend.cmd
|
||||
|
||||
:: 프론트엔드 (http://localhost:5173)
|
||||
run-frontend.cmd
|
||||
```
|
||||
> 두 스크립트는 `C:\ai-dev\scripts\env.cmd`(포터블 JDK/Maven/Node)를 먼저 로드한다.
|
||||
|
||||
### 초기 계정 (DataSeeder, dev 전용)
|
||||
| 아이디 | 비밀번호 | 역할 |
|
||||
|--------|----------|------|
|
||||
| admin | ChangeMe123! | ADMIN |
|
||||
| security | ChangeMe123! | SECURITY |
|
||||
| host | ChangeMe123! | HOST |
|
||||
|
||||
최초 로그인 시 비밀번호 변경이 요구된다.
|
||||
|
||||
## 주요 API
|
||||
- `POST /api/auth/login` · `POST /api/auth/logout` · `GET /api/auth/me` · `POST /api/auth/change-password`
|
||||
- `GET /api/zones`
|
||||
- `GET/POST /api/visit-requests` · `GET /api/visit-requests/pending` · `POST /api/visit-requests/{id}/cancel` · `POST /api/visit-requests/upload`(엑셀)
|
||||
- `POST /api/approvals/{id}/approve` · `POST /api/approvals/{id}/reject`
|
||||
- `POST /api/access/check-in` · `POST /api/access/check-out` · `GET /api/access/inside` · `GET /api/access/search?q=`
|
||||
- `GET /api/passes/{id}` · `GET /api/passes/{id}/qr.png`(배지 QR)
|
||||
- `GET /api/stats/summary` · `GET /api/reports/visits.xlsx?from=&to=`
|
||||
- `GET/POST /api/blacklist` · `DELETE /api/blacklist/{id}` (ADMIN)
|
||||
|
||||
API 스모크 테스트는 [backend/test-api.http](backend/test-api.http) 참고.
|
||||
|
||||
## 구현 현황
|
||||
- [x] **1단계** 스캐폴딩 (pom, 설정, 공통 클래스, 전역 예외/응답 래퍼, JPA auditing)
|
||||
- [x] **2단계** 인증·인가 (세션 로그인, 역할 기반 권한, 시드)
|
||||
- [x] **3단계** 방문 사전신청 + 승인 (신청/취소/엑셀 업로드, 승인/반려 시 qrToken 발급) + 프론트 화면
|
||||
- [x] **4단계** 입·출입 체크인/체크아웃 + 재실현황 (`AccessEvent`, `AccessControlGateway`+Mock, QR/이름 체크인, 재실 목록, 중복입장·만료 검증) + 출입콘솔 화면
|
||||
- [x] **5단계** QR/배지 발급 (ZXing PNG `/api/passes/{id}/qr.png`, 배지 인쇄 화면)
|
||||
- [x] **6단계** 블랙리스트(체크인 시 차단) + 대시보드 통계 집계 + 방문 리포트 엑셀(POI) 다운로드
|
||||
- [x] **7단계** Flyway `V1__init.sql`(prod) + Docker Compose(db·app·web nginx) + Python 사용자 시드
|
||||
|
||||
## Docker 실행 (prod, PostgreSQL)
|
||||
```bash
|
||||
cd infra
|
||||
cp .env.example .env # 값 수정 (아래 참고)
|
||||
docker compose up -d --build # db + app(:8080) + web(nginx :80)
|
||||
docker compose run --rm seed # 사용자 시드 적재 (admin/security/host)
|
||||
docker compose logs -f app # 기동/마이그레이션 로그
|
||||
```
|
||||
- 백엔드는 `prod` 프로파일로 기동, Flyway가 스키마를 생성/검증한다.
|
||||
- 웹(nginx)이 정적 파일 + `/api` 리버스 프록시를 담당한다.
|
||||
- DB는 named volume(`db_data`)에 영속 — 재기동해도 데이터 유지(dev H2와 다름).
|
||||
|
||||
### .env 주요 값
|
||||
| 변수 | 설명 |
|
||||
|------|------|
|
||||
| `POSTGRES_PASSWORD` | DB 비밀번호 (실제값으로 변경) |
|
||||
| `WEB_PORT` | 웹 공개 포트 (기본 80) |
|
||||
| `ACS_SMS_PROVIDER` | `dev`(로그만) / `hanbank`(사내 API로 LMS 실발송) |
|
||||
| `ACS_PUBLIC_BASE_URL` | 문자 링크가 가리키는 주소 — **방문자 휴대폰에서 접속 가능한 실제 URL** (localhost 금지) |
|
||||
| `ACS_SMS_API_URL` | 사내 메시지 API 주소 (기본 `http://210.104.132.59:8000`) |
|
||||
|
||||
### 문자 실발송(hanbank) 전제
|
||||
1. 서버에서 메시지 API 도달 확인: `nc -vz 210.104.132.59 8000`
|
||||
2. `ACS_SMS_PROVIDER=hanbank`, `ACS_PUBLIC_BASE_URL`=외부 접속 URL 로 설정 후 재기동
|
||||
|
||||
### HTTPS / 카메라 스캔 (선택, 접속 방식에 따라 필요)
|
||||
- 출입콘솔의 **웹캠 QR 스캔**은 secure context(HTTPS 또는 localhost)에서만 동작한다.
|
||||
서버를 `http://내부IP`로 접속하면 카메라가 차단된다.
|
||||
- 방문자 공개 링크(`/pass/{token}`)도 HTTPS 도메인이면 휴대폰에서 안전하게 열린다.
|
||||
- 적용하려면: 사내 도메인/인증서 확보 → `frontend/nginx.conf`에 443 TLS server 블록 추가,
|
||||
`docker-compose.yml` web 서비스에 `"443:443"` 매핑 + 인증서 볼륨 마운트,
|
||||
`ACS_PUBLIC_BASE_URL`을 `https://...`로 설정. (도메인/인증서 확정 후 진행)
|
||||
|
||||
### 사내망 빌드 참고
|
||||
`docker compose build`는 컨테이너 안에서 npm/maven 의존성을 받는다. dev 서버는 공개망 직접
|
||||
접근이 되어 대개 그대로 성공하지만, 사내 SSL 인스펙션에 걸려 인증서 오류가 나면 Dockerfile에
|
||||
사내 CA 주입 또는 내부 미러 설정이 필요하다(그때 별도 반영).
|
||||
|
||||
## 디렉터리
|
||||
```
|
||||
backend/ Spring Boot (com.itcenter.acs)
|
||||
frontend/ React + Vite
|
||||
docs/ 워크플로우·시퀀스 다이어그램·이슈/유의사항 문서
|
||||
```
|
||||
|
||||
## 문서
|
||||
- [docs/workflow.md](docs/workflow.md) — 전체 업무 워크플로우(서술형)
|
||||
- [docs/workflow-sequence.md](docs/workflow-sequence.md) — 시퀀스/상태 다이어그램(Mermaid)
|
||||
- [docs/issues-and-guidelines.md](docs/issues-and-guidelines.md) — 이슈 정리 및 유의사항(규칙)
|
||||
|
||||
## 빌드 노트
|
||||
- JDK 26 환경에서 Lombok은 **1.18.46** + maven-compiler-plugin `annotationProcessorPaths` 설정이 필요하다
|
||||
(Spring Boot가 핀한 1.18.38은 JDK 26에서 애너테이션 처리가 동작하지 않음). `backend/pom.xml`에 반영됨.
|
||||
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
*.iml
|
||||
.idea/
|
||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# ===== build =====
|
||||
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /workspace
|
||||
COPY pom.xml ./
|
||||
RUN mvn -B -ntp dependency:go-offline || true
|
||||
COPY src ./src
|
||||
RUN mvn -B -ntp -DskipTests package
|
||||
|
||||
# ===== run =====
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
COPY --from=build /workspace/target/*.jar /app/app.jar
|
||||
EXPOSE 8080
|
||||
ENV SPRING_PROFILES_ACTIVE=prod
|
||||
ENTRYPOINT ["java","-jar","/app/app.jar"]
|
||||
138
backend/pom.xml
Normal file
138
backend/pom.xml
Normal file
@@ -0,0 +1,138 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.itcenter</groupId>
|
||||
<artifactId>acs</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>acs</name>
|
||||
<description>IT Center Access Control System (visitor / access management)</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<!-- Spring Boot 3.4.5 pins Lombok 1.18.38, which cannot run its annotation
|
||||
processor on JDK 26 (this machine's only JDK). 1.18.46 adds JDK 26 support. -->
|
||||
<lombok.version>1.18.46</lombok.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- DB migrations (prod profile only; disabled for H2 dev) -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Excel reports / bulk import -->
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi</artifactId>
|
||||
<version>5.2.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- QR code generation for visitor passes / badges -->
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- JDK 23+ no longer runs classpath-discovered annotation processors by
|
||||
default, so Lombok must be on an explicit annotationProcessorPath. -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
11
backend/src/main/java/com/itcenter/acs/AcsApplication.java
Normal file
11
backend/src/main/java/com/itcenter/acs/AcsApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
12
backend/src/main/java/com/itcenter/acs/config/JpaConfig.java
Normal file
12
backend/src/main/java/com/itcenter/acs/config/JpaConfig.java
Normal 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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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("차단이 해제되었습니다."));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
29
backend/src/main/java/com/itcenter/acs/dto/ApiResponse.java
Normal file
29
backend/src/main/java/com/itcenter/acs/dto/ApiResponse.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
13
backend/src/main/java/com/itcenter/acs/dto/LoginRequest.java
Normal file
13
backend/src/main/java/com/itcenter/acs/dto/LoginRequest.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
18
backend/src/main/java/com/itcenter/acs/dto/ZoneResponse.java
Normal file
18
backend/src/main/java/com/itcenter/acs/dto/ZoneResponse.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
44
backend/src/main/java/com/itcenter/acs/entity/Approval.java
Normal file
44
backend/src/main/java/com/itcenter/acs/entity/Approval.java
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.itcenter.acs.entity;
|
||||
|
||||
public enum ApprovalDecision {
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
45
backend/src/main/java/com/itcenter/acs/entity/Blacklist.java
Normal file
45
backend/src/main/java/com/itcenter/acs/entity/Blacklist.java
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.itcenter.acs.entity;
|
||||
|
||||
/**
|
||||
* Direction of an access event at a gate.
|
||||
*/
|
||||
public enum Direction {
|
||||
IN,
|
||||
OUT
|
||||
}
|
||||
13
backend/src/main/java/com/itcenter/acs/entity/RoleType.java
Normal file
13
backend/src/main/java/com/itcenter/acs/entity/RoleType.java
Normal 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
|
||||
}
|
||||
59
backend/src/main/java/com/itcenter/acs/entity/User.java
Normal file
59
backend/src/main/java/com/itcenter/acs/entity/User.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
39
backend/src/main/java/com/itcenter/acs/entity/Visitor.java
Normal file
39
backend/src/main/java/com/itcenter/acs/entity/Visitor.java
Normal 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;
|
||||
}
|
||||
36
backend/src/main/java/com/itcenter/acs/entity/Zone.java
Normal file
36
backend/src/main/java/com/itcenter/acs/entity/Zone.java
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("처리자(운영자)를 찾을 수 없습니다."));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
31
backend/src/main/resources/application-prod.properties
Normal file
31
backend/src/main/resources/application-prod.properties
Normal 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
|
||||
40
backend/src/main/resources/application.properties
Normal file
40
backend/src/main/resources/application.properties
Normal 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
|
||||
115
backend/src/main/resources/db/migration/V1__init.sql
Normal file
115
backend/src/main/resources/db/migration/V1__init.sql
Normal 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);
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
11
backend/src/test/resources/application-fwtest.properties
Normal file
11
backend/src/test/resources/application-fwtest.properties
Normal 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
|
||||
53
backend/test-api.http
Normal file
53
backend/test-api.http
Normal file
@@ -0,0 +1,53 @@
|
||||
### IT센터 출입자관리시스템 — API smoke test
|
||||
### 세션 쿠키 기반. 로그인 후 같은 클라이언트로 호출하세요.
|
||||
@base = http://localhost:8080
|
||||
|
||||
### 1) 로그인 (호스트)
|
||||
POST {{base}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{ "username": "host", "password": "ChangeMe123!" }
|
||||
|
||||
### 2) 현재 사용자
|
||||
GET {{base}}/api/auth/me
|
||||
|
||||
### 3) 출입 구역 목록
|
||||
GET {{base}}/api/zones
|
||||
|
||||
### 4) 방문 사전 신청
|
||||
POST {{base}}/api/visit-requests
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"visitorName": "김방문",
|
||||
"company": "ACME",
|
||||
"contact": "010-1234-5678",
|
||||
"zoneId": 1,
|
||||
"purpose": "정기 미팅",
|
||||
"visitFrom": "2026-07-01T10:00",
|
||||
"visitTo": "2026-07-01T12:00"
|
||||
}
|
||||
|
||||
### 5) 방문 신청 목록
|
||||
GET {{base}}/api/visit-requests
|
||||
|
||||
### 6) 승인 대기 목록
|
||||
GET {{base}}/api/visit-requests/pending
|
||||
|
||||
### 7) 로그인 (관리자) — 승인 권한
|
||||
POST {{base}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{ "username": "admin", "password": "ChangeMe123!" }
|
||||
|
||||
### 8) 승인 (qrToken 발급됨)
|
||||
POST {{base}}/api/approvals/1/approve
|
||||
Content-Type: application/json
|
||||
|
||||
{ "comment": "확인 완료" }
|
||||
|
||||
### 9) 반려
|
||||
POST {{base}}/api/approvals/2/reject
|
||||
Content-Type: application/json
|
||||
|
||||
{ "comment": "방문 목적 불명확" }
|
||||
11
docs/README.md
Normal file
11
docs/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# ACS 문서
|
||||
|
||||
IT센터 출입자관리시스템 관련 문서 모음.
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| [workflow.md](workflow.md) | 전체 업무 워크플로우 (서술형) — 역할, 상태 머신, 신청→승인→입·출입→통계 흐름, 배포/운영 |
|
||||
| [workflow-sequence.md](workflow-sequence.md) | 워크플로우 시퀀스/상태 다이어그램 (Mermaid) |
|
||||
| [issues-and-guidelines.md](issues-and-guidelines.md) | 기획·개발·테스트·수정 단계 이슈 정리 및 유의사항(규칙) |
|
||||
|
||||
> 프로젝트 개요·실행 방법·API 요약은 상위 [README.md](../README.md) 참조.
|
||||
175
docs/issues-and-guidelines.md
Normal file
175
docs/issues-and-guidelines.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# ACS 개발 이슈 정리 및 유의사항(규칙)
|
||||
|
||||
> 문서 작성일: 2026-07-03
|
||||
> 대상: IT센터 출입자관리시스템 (`C:\ai-dev\workspace\access-control-system`)
|
||||
> 목적: 기획·개발·테스트·수정 단계에서 실제로 겪은 이슈를 정리하고, 재발 방지를 위한 **규칙**으로 제안한다.
|
||||
> 표기: 각 항목은 **[이슈] → [규칙]** 형태. 규칙 요약은 문서 끝 §6 체크리스트 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 기획 단계
|
||||
|
||||
### 1-1. 출입 하드웨어 미확정
|
||||
- **[이슈]** 실제 출입통제 게이트 장비가 확정되지 않은 상태에서 개발을 시작해야 했다.
|
||||
- **[규칙]** 외부 의존(장비·SMS·인증서 등)은 **인터페이스로 추상화**하고 Mock 구현을 기본 제공한다. ACS는 `AccessControlGateway` + `MockAccessControlGateway`로 이 원칙을 지켰다. 실장비는 구현체 교체만으로 붙일 수 있어야 한다.
|
||||
|
||||
### 1-2. 상태(라이프사이클) 정의를 코드보다 먼저
|
||||
- **[이슈]** 방문 신청의 상태(승인 대기/승인/반려/취소/만료)가 모호하면 서비스 곳곳의 분기가 흐트러진다.
|
||||
- **[규칙]** 도메인 상태 머신을 **먼저 확정**하고 enum(`VisitStatus`)으로 고정한다. 상태 전이 규칙(예: `PENDING`만 승인 가능, 방문일 경과 시 `EXPIRED`)을 문서/주석에 남긴다.
|
||||
|
||||
### 1-3. 역할·권한 경계
|
||||
- **[이슈]** ADMIN/SECURITY/HOST의 화면·API 접근 범위가 불명확하면 권한 누수가 생긴다.
|
||||
- **[규칙]** 역할별 접근 표를 기획 단계에서 확정하고, 프론트 라우팅 가드(`Protected roles=[...]`)와 백엔드 인가(`SecurityConfig`)에서 **이중으로** 강제한다. 프론트 가드만 믿지 않는다.
|
||||
|
||||
### 1-4. 방문자 개인정보·공개 링크
|
||||
- **[이슈]** 방문자에게 보내는 출입증 링크(`/pass/:token`)는 비로그인 공개 페이지다.
|
||||
- **[규칙]** 공개 식별자는 **추측 불가능한 토큰**(UUID `qrToken`)만 사용하고, 순번 ID를 공개 URL에 노출하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 개발 단계 — 빌드/환경
|
||||
|
||||
> 이 프로젝트는 JDK 26 + Maven + 사내 보안환경(EDR·선택적 SSL 인스펙션)이라는 특수 조합에서 개발되었다. 아래는 재현성이 확인된 함정들이다.
|
||||
|
||||
### 2-1. JDK 26에서 Lombok이 조용히 동작 안 함
|
||||
- **[이슈]** `cannot find symbol: method getX/setX` 수백 개. Lombok 애너테이션 프로세서가 침묵 실패.
|
||||
- **[규칙]** 두 가지를 **모두** 적용한다 (하나만으론 부족):
|
||||
1. `pom.xml <properties>`에 `<lombok.version>1.18.46</lombok.version>` (Spring Boot가 핀한 1.18.38은 JDK 26 미지원).
|
||||
2. `maven-compiler-plugin`에 명시적 `annotationProcessorPaths`로 lombok 등록 (JDK 23+는 classpath 자동 발견을 하지 않음).
|
||||
- 참고: `backend/pom.xml`이 정상 레퍼런스.
|
||||
|
||||
### 2-2. 콜드 Maven 빌드가 `.lastUpdated` 파일잠금으로 죽음
|
||||
- **[이슈]** 처음 빌드 시 `FileSystemException: ....lastUpdated: 다른 프로세스가 파일 사용 중` → reactor 전체 중단. 원인은 사내 EDR의 실시간 파일 잠금.
|
||||
- **[규칙]**
|
||||
- `MAVEN_OPTS`에 `-Dmaven.legacyLocalRepo=true` (env.cmd에 반영됨). 직접 mvn 실행 시에도 포함.
|
||||
- 콜드 빌드는 실패하면 `find .m2 -name '*.lastUpdated' -delete` 후 **재시도 루프**로 repo를 데운다. 한번 캐시가 따뜻해지면 이후엔 깨끗하게 빌드된다.
|
||||
- 검증 실행은 `mvn package`로 fat jar를 만들어 **`java -jar`로 구동**하는 것이 가장 안전(런타임에 Maven 불필요 → 잠금 회피).
|
||||
|
||||
### 2-3. 편집 중 잔여 `.tmp.*` 파일
|
||||
- **[이슈]** 소스 곳곳에 `*.java.tmp.PID.hash`, `*.tsx.tmp...` 같은 잔여 파일이 남아 있다(현재도 7개 존재). 에디터/툴이 원자적 교체를 하는 중 EDR 잠금으로 임시본이 정리되지 못한 흔적.
|
||||
- **[규칙]**
|
||||
- 커밋/빌드 전 `find . -name "*.tmp.*" -not -path '*/target/*' -delete`로 정리.
|
||||
- `.gitignore`에 `*.tmp.*` 패턴을 추가해 저장소 오염을 막는다.
|
||||
|
||||
### 2-4. 사내 SSL 인스펙션 (선택적)
|
||||
- **[이슈]** 명시적 프록시는 없지만 일부 도메인은 사내 장비가 인증서를 재서명(Bank of Korea CA). curl/git은 폐기검사 hard-fail(`CRYPT_E_NO_REVOCATION_CHECK`)로 끊김.
|
||||
- **[규칙]**
|
||||
- **검증을 끄지 않는다.** `verify=False`, 사내 CA 단독 번들 교체(`REQUESTS_CA_BUNDLE`=사내단독) 금지 — 공인 CA 호스트(npm/pypi/github)가 깨진다.
|
||||
- **추가형/저장소형 신뢰**만 사용: Windows 저장소(공인+사내 CA)를 쓰고, 폐기검사만 건너뛴다(curl `ssl-no-revoke`, git `schannelCheckRevoke=false`).
|
||||
- TLS/인증서 오류가 나면 임의 대응 대신 **`corporate-cert-fix` 스킬**을 사용한다.
|
||||
|
||||
### 2-5. `.cmd` 스크립트 인코딩
|
||||
- **[이슈]** `.cmd` 편집 시 LF 혼입 → cmd.exe 파싱 깨짐. 한글 주석이 든 .cmd는 CP949 콘솔에서 바이트 desync로 줄이 깨져 엉뚱한 명령 실행.
|
||||
- **[규칙]** `scripts\*.cmd`는 **ASCII 전용 + CRLF 줄바꿈**. 한글 설명은 `.cmd`가 아니라 별도 `readme\`·`data\` md에 둔다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 개발 단계 — 도메인 로직
|
||||
|
||||
### 3-1. 승인 시점에 qrToken 발급
|
||||
- **[이슈]** 출입증 QR·공개 링크·문자 발송이 모두 하나의 토큰에 의존한다.
|
||||
- **[규칙]** `qrToken`(UUID)은 **승인(`APPROVED`) 시점에만** 발급한다(`ApprovalService`). 신청/반려 단계에서는 발급하지 않는다.
|
||||
|
||||
### 3-2. 알림 발송 실패가 승인을 롤백하면 안 됨
|
||||
- **[이슈]** 문자 발송(외부 API)이 실패하면 승인 트랜잭션까지 롤백될 위험.
|
||||
- **[규칙]** 부수효과(알림)의 실패는 **catch & log**로 흡수하고 주 트랜잭션(승인)은 커밋한다(`ApprovalService.notifyVisitor`). 외부 I/O 실패로 핵심 업무가 무효화되지 않게 한다.
|
||||
|
||||
### 3-3. 체크인 검증 순서 고정
|
||||
- **[이슈]** 검증 순서가 흐트러지면 만료·차단·중복입장이 잘못된 우선순위로 처리될 수 있다.
|
||||
- **[규칙]** `AccessService.checkIn`의 검증 순서를 유지한다:
|
||||
1. 상태 `APPROVED` 확인
|
||||
2. 방문 종료일 경과 → `EXPIRED` 전이 + 거부
|
||||
3. 방문 시작일 이전 → 거부
|
||||
4. 블랙리스트 매칭 → 403 차단
|
||||
5. 이미 재실 중 → 409 중복입장
|
||||
6. 금일 퇴장 완료 → 재입장 불가
|
||||
- 상태 값·거부 사유 메시지를 응답에 명확히 담는다.
|
||||
|
||||
### 3-4. "일자" 기준 판정 (늦은 도착 허용)
|
||||
- **[이슈]** 시각까지 엄격히 보면 몇 분 늦은 방문자가 입장 거부된다.
|
||||
- **[규칙]** 입장 허용은 **날짜(LocalDate) 기준**으로 판정하고, 실제 입출입 시각은 신청 시각과 **분리하여** `access_events.event_at`에 기록한다.
|
||||
|
||||
### 3-5. 재실 판정은 마지막 이벤트로
|
||||
- **[이슈]** 입장/퇴장을 별도 플래그로 관리하면 정합성이 깨진다.
|
||||
- **[규칙]** "현재 재실 중"은 별도 상태 컬럼이 아니라 **해당 방문의 마지막 `AccessEvent` 방향이 `IN`인가**로 판정한다(`isInside`). 단일 진실원본(이벤트 로그)을 유지한다.
|
||||
|
||||
### 3-6. 웹캠 QR 스캔은 secure context 필수
|
||||
- **[이슈]** 출입콘솔/키오스크의 웹캠 QR 스캔이 `http://내부IP` 접속 시 카메라 차단으로 동작 안 함.
|
||||
- **[규칙]** 카메라 기능은 **HTTPS 또는 localhost**에서만 동작함을 전제로 배포한다. QR 스캔이 필요한 단말은 HTTPS 도메인 또는 localhost로 접속시킨다(대안: 이름 검색 체크인).
|
||||
|
||||
### 3-7. 문자 링크 주소(`ACS_PUBLIC_BASE_URL`)
|
||||
- **[이슈]** 문자에 담긴 출입증 링크가 `localhost`면 방문자 휴대폰에서 열리지 않는다.
|
||||
- **[규칙]** `ACS_PUBLIC_BASE_URL`은 **방문자 휴대폰에서 실제 접속 가능한 외부 URL**로 설정한다. localhost 금지. 실발송(`hanbank`) 전 메시지 API 도달성(`nc -vz`)을 확인한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 테스트 단계
|
||||
|
||||
### 4-1. 런타임 end-to-end 검증
|
||||
- **[이슈]** 컴파일 성공 ≠ 동작. 특히 QR 이미지·엑셀·권한 가드는 실제 응답을 봐야 안다.
|
||||
- **[규칙]** 핵심 플로우는 **curl 실서버 검증**으로 확인한다(로그인→신청→승인→체크인→재실→체크아웃, 역할 가드 403 포함). ACS는 1~7단계 전부 런타임 검증됨: QR PNG 240x240 유효/없으면 404, 블랙리스트 add→체크인 403→해제→200, stats 정확, 리포트 xlsx 셀 내용·역할 가드·잘못된 기간 400 확인.
|
||||
- API 스모크 테스트는 `backend/test-api.http`에 유지한다.
|
||||
|
||||
### 4-2. Flyway 마이그레이션 검증 자동화
|
||||
- **[이슈]** prod 스키마(Flyway `V1__init.sql`)와 JPA 엔티티가 어긋나면 운영 기동 시 터진다.
|
||||
- **[규칙]** Flyway 스크립트를 **JUnit 회귀 테스트**로 검증한다. ACS는 `FlywayValidationTest`(`@ActiveProfiles("fwtest")`, H2 PostgreSQL-mode + `ddl-auto=validate`)로 V1을 검증한다. 스키마를 바꿀 때 이 테스트를 반드시 통과시킨다.
|
||||
|
||||
### 4-3. 경계·예외 케이스
|
||||
- **[이슈]** 정상 경로만 보면 중복입장/만료/차단/권한 없음이 방치된다.
|
||||
- **[규칙]** 각 API의 **거부·오류 경로**(403/404/409/400)를 정상 경로와 함께 테스트한다. 상태 코드와 사유 메시지를 검증한다.
|
||||
|
||||
### 4-4. Docker 실기동은 별도 확인 필요
|
||||
- **[이슈]** 검증 시 Docker 데몬이 꺼져 있어 `docker compose up` 실기동은 미검증으로 남았다.
|
||||
- **[규칙]** 컨테이너 빌드/기동은 **환경 가용 시 별도 검증** 항목으로 명시하고, 미검증이면 "미검증"으로 솔직히 남긴다. 특히 사내망 `docker build`는 SSL 인스펙션에 걸리면 사내 CA 주입/내부 미러가 필요할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 수정/유지보수 단계
|
||||
|
||||
### 5-1. 버전 관리 시작
|
||||
- **[이슈]** 현재 저장소에 **git 커밋 이력이 없다**(작업이 커밋되지 않음). 잔여 `.tmp` 파일도 함께 방치.
|
||||
- **[규칙]** 의미 있는 단위로 **즉시 커밋**한다. 커밋 전 `.tmp.*` 정리 + 빌드/핵심 테스트 통과 확인. `.gitignore`에 `target/`, `node_modules/`, `*.tmp.*`, `.env`, `sms-outbox/` 등을 등록해 산출물·비밀·임시파일이 커밋되지 않게 한다.
|
||||
|
||||
### 5-2. dev 시드/비밀번호가 prod로 새면 안 됨
|
||||
- **[이슈]** `DataSeeder`의 admin/security/host + `ChangeMe123!`는 개발 편의용이다.
|
||||
- **[규칙]** 시드 계정은 `@Profile("!prod")`로 **prod에서 비활성**한다(적용됨). prod 사용자는 별도 스크립트(`scripts/seed-load.py`)로 적재하고, 최초 로그인 시 비밀번호 변경(`mustChangePassword`)을 강제한다. 기본 비밀번호를 문서/코드에 그대로 두지 않는다.
|
||||
|
||||
### 5-3. 상태 전이 로직 수정 시 파급 확인
|
||||
- **[이슈]** `VisitStatus` 전이나 체크인 검증을 고치면 승인·재실·리포트·통계가 연쇄 영향을 받는다.
|
||||
- **[규칙]** 상태/검증 로직을 수정하면 §3-3 검증 순서와 §4의 end-to-end·Flyway 테스트를 **재실행**한다. 상태 값을 추가/변경하면 프론트 표시 문자열과 통계 집계도 함께 갱신한다.
|
||||
|
||||
### 5-4. 비밀·설정은 `.env`로 분리
|
||||
- **[이슈]** DB 비밀번호, SMS provider, 공개 URL 등이 코드에 박히면 환경 이전이 위험해진다.
|
||||
- **[규칙]** 환경 의존 값은 전부 `.env`(예: `POSTGRES_PASSWORD`, `WEB_PORT`, `ACS_SMS_PROVIDER`, `ACS_PUBLIC_BASE_URL`, `ACS_SMS_API_URL`)로 분리하고 `.env.example`만 커밋한다. 실제 `.env`는 커밋 금지.
|
||||
|
||||
### 5-5. HTTPS/장비 연동 등 미완 항목 추적
|
||||
- **[이슈]** HTTPS(카메라), 실 출입장비, 사내망 Docker 빌드는 도메인/인증서/장비 확정 후 진행할 항목으로 남아 있다.
|
||||
- **[규칙]** 미완/보류 항목은 README·이 문서에 **명시적으로 남기고**, 전제 조건(도메인·인증서·장비 스펙)이 갖춰지면 반영한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 규칙 요약 체크리스트
|
||||
|
||||
**환경/빌드**
|
||||
- [ ] Lombok 1.18.46 + `annotationProcessorPaths` 유지 (JDK 26)
|
||||
- [ ] `MAVEN_OPTS`에 `-Dmaven.legacyLocalRepo=true`, 콜드빌드는 retry, 검증은 `java -jar`
|
||||
- [ ] 커밋 전 `*.tmp.*` 정리, `.gitignore` 등록
|
||||
- [ ] TLS 오류 시 검증 끄지 말 것 → `corporate-cert-fix` 스킬 (추가형 신뢰)
|
||||
- [ ] `scripts\*.cmd`는 ASCII + CRLF, 한글 설명은 md로 분리
|
||||
|
||||
**도메인 로직**
|
||||
- [ ] `qrToken`은 승인 시점에만 발급
|
||||
- [ ] 알림 실패는 catch & log (승인 롤백 금지)
|
||||
- [ ] 체크인 검증 순서 6단계 고정, 날짜 기준 판정
|
||||
- [ ] 재실 판정은 마지막 이벤트 방향으로
|
||||
- [ ] 카메라 QR = secure context, 문자 URL은 외부 접속 가능 주소
|
||||
|
||||
**테스트**
|
||||
- [ ] 정상+거부(403/404/409/400) 경로 모두 curl 검증
|
||||
- [ ] Flyway `FlywayValidationTest` 통과
|
||||
- [ ] Docker 실기동은 가용 시 별도 검증, 미검증이면 명시
|
||||
|
||||
**보안/운영**
|
||||
- [ ] 시드 계정 `@Profile("!prod")`, 기본 비밀번호 강제 변경
|
||||
- [ ] 비밀/설정은 `.env` 분리, `.env.example`만 커밋
|
||||
- [ ] 의미 단위 즉시 커밋, 미완 항목은 문서에 추적
|
||||
163
docs/workflow-sequence.md
Normal file
163
docs/workflow-sequence.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# ACS 워크플로우 — 시퀀스/상태 다이어그램 (Mermaid)
|
||||
|
||||
> 대상: IT센터 출입자관리시스템 · 작성일 2026-07-03
|
||||
> GitHub·VS Code·mermaid.live 등에서 렌더링됨. 서술형 문서는 [workflow.md](workflow.md) 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 방문 신청 상태 머신
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: 신청 초안
|
||||
DRAFT --> PENDING: 제출
|
||||
PENDING --> APPROVED: 승인 (qrToken 발급)
|
||||
PENDING --> REJECTED: 반려
|
||||
PENDING --> CANCELLED: 취소
|
||||
APPROVED --> CANCELLED: 취소
|
||||
APPROVED --> EXPIRED: 방문 종료일 경과 후 체크인 시도
|
||||
APPROVED --> [*]: 출입 완료
|
||||
REJECTED --> [*]
|
||||
CANCELLED --> [*]
|
||||
EXPIRED --> [*]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 흐름 (신청 → 승인 → 발송 → 입·출입)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Host as HOST/담당자
|
||||
actor Admin as ADMIN
|
||||
participant API as ACS 백엔드
|
||||
participant SMS as PassNotifier(SMS)
|
||||
actor Visitor as 방문자
|
||||
actor Sec as SECURITY/게이트
|
||||
participant GW as AccessControlGateway
|
||||
|
||||
Host->>API: ① 방문 사전신청 (POST /api/visit-requests)
|
||||
note right of API: 상태 = PENDING (개별 or 엑셀 업로드)
|
||||
|
||||
Admin->>API: ② 승인함 조회 (GET /api/visit-requests/pending)
|
||||
Admin->>API: ③ 승인 (POST /api/approvals/{id}/approve)
|
||||
note right of API: 상태 = APPROVED, qrToken(UUID) 발급, Approval 기록
|
||||
API->>SMS: ④ 출입증(QR PNG) 발송 요청
|
||||
note right of SMS: 발송 실패는 catch&log — 승인은 롤백 안 함
|
||||
SMS-->>Visitor: 출입증 링크 문자 (ACS_PUBLIC_BASE_URL/pass/:token)
|
||||
|
||||
Visitor->>API: ⑤ 공개 출입증 열람 (GET /pass/:token, 비로그인)
|
||||
API-->>Visitor: QR / 방문정보 표시
|
||||
|
||||
Visitor->>Sec: ⑥ 방문일 현장 도착
|
||||
Sec->>API: ⑦ 체크인 (POST /api/access/check-in, QR/이름)
|
||||
note right of API: 상태·일자·블랙리스트·중복·재입장 검증 (아래 §3)
|
||||
API->>GW: openGate(gateId)
|
||||
GW-->>API: accepted
|
||||
API-->>Sec: 입장 처리 (AccessEvent IN 기록)
|
||||
|
||||
Sec->>API: ⑧ 재실현황 (GET /api/access/inside)
|
||||
API-->>Sec: 재실 방문자 목록
|
||||
|
||||
Visitor->>Sec: ⑨ 퇴장
|
||||
Sec->>API: ⑩ 체크아웃 (POST /api/access/check-out)
|
||||
API-->>Sec: 퇴장 처리 (AccessEvent OUT 기록)
|
||||
|
||||
Admin->>API: ⑪ 대시보드 통계 (GET /api/stats/summary)
|
||||
Sec->>API: ⑫ 방문 리포트 (GET /api/reports/visits.xlsx)
|
||||
API-->>Sec: 엑셀(POI) 다운로드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 체크인 검증 로직 (AccessService.checkIn)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Sec as SECURITY / 키오스크
|
||||
participant AS as AccessService
|
||||
participant BL as BlacklistService
|
||||
participant AE as AccessEventRepo
|
||||
participant GW as AccessControlGateway
|
||||
|
||||
Sec->>AS: check-in (qrToken 또는 visitRequestId)
|
||||
AS->>AS: 방문신청 resolve
|
||||
|
||||
alt 상태 ≠ APPROVED
|
||||
AS-->>Sec: 400 승인되지 않은 방문
|
||||
else 방문 종료일 경과
|
||||
AS->>AS: 상태 = EXPIRED
|
||||
AS-->>Sec: 400 신청일 경과 — 재신청 필요
|
||||
else 방문 시작일 이전
|
||||
AS-->>Sec: 400 아직 방문일 아님
|
||||
else
|
||||
AS->>BL: blockReason(name, contact)
|
||||
alt 블랙리스트 매칭
|
||||
BL-->>AS: 사유
|
||||
AS-->>Sec: 403 차단된 방문자
|
||||
else 통과
|
||||
AS->>AE: isInside? / hasExitedToday?
|
||||
alt 이미 재실 중
|
||||
AS-->>Sec: 409 중복 입장
|
||||
else 금일 퇴장 완료
|
||||
AS-->>Sec: 400 재입장 불가
|
||||
else 정상
|
||||
AS->>AE: AccessEvent(IN) 저장
|
||||
AS->>GW: openGate(gateId)
|
||||
GW-->>AS: accepted
|
||||
AS-->>Sec: 입장 처리 완료
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 시스템 컴포넌트 개요
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph FE[프론트엔드 React/Vite]
|
||||
L[로그인] --> DB1[대시보드]
|
||||
VR[방문신청] --> AQ[승인함 ADMIN]
|
||||
AC[출입콘솔] --> KIOSK[키오스크 공개]
|
||||
RPT[리포트] & BLK[블랙리스트 ADMIN]
|
||||
PP[공개 출입증 /pass/:token]
|
||||
end
|
||||
|
||||
subgraph BE[백엔드 Spring Boot]
|
||||
CTRL[Controllers] --> SVC[Services]
|
||||
SVC --> REPO[(JPA Repositories)]
|
||||
SVC --> QR[QrService ZXing]
|
||||
SVC --> NOTI[PassNotifier SMS]
|
||||
SVC --> GWY[AccessControlGateway Mock/실장비]
|
||||
SVC --> POI[Report/Excel POI]
|
||||
end
|
||||
|
||||
subgraph DATA[저장소]
|
||||
DBH[(H2 dev)]
|
||||
DBP[(PostgreSQL prod + Flyway)]
|
||||
end
|
||||
|
||||
FE -->|/api 세션 인증| CTRL
|
||||
REPO --> DBH
|
||||
REPO --> DBP
|
||||
NOTI -->|hanbank| MSG[사내 메시지 API]
|
||||
PP -.공개 링크.-> FE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 배포 파이프라인 (운영, Docker)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ENV[.env 설정] --> BUILD[docker compose build]
|
||||
BUILD --> UP[compose up -d: db + app + web]
|
||||
UP --> FW[app 기동 시 Flyway V1__init 적용]
|
||||
FW --> SEED[compose run seed: 사용자 적재]
|
||||
SEED --> READY[서비스 준비: nginx :80 → /api → app :8080]
|
||||
READY --> DBV[(db_data 볼륨 영속)]
|
||||
```
|
||||
232
docs/workflow.md
Normal file
232
docs/workflow.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# IT센터 출입자관리시스템(ACS) 워크플로우
|
||||
|
||||
> 문서 작성일: 2026-07-03
|
||||
> 대상: `C:\ai-dev\workspace\access-control-system` (Spring Boot 3.4.5 / Java 21 · React 19 · Vite 6)
|
||||
> 목적: 방문자 사전신청 → 승인 → 출입증 발송 → 입·출입 체크 → 재실현황/리포트까지의 전체 업무 흐름 정리
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 개요
|
||||
|
||||
IT센터를 방문하는 외부 방문자의 **사전신청·승인·출입·통계**를 관리하는 웹 시스템.
|
||||
출입통제 하드웨어(게이트)는 `AccessControlGateway` 인터페이스로 추상화되어 있어 현재는 Mock으로 동작하고, 추후 실제 장비 연동이 가능하다.
|
||||
|
||||
| 구분 | 내용 |
|
||||
|------|------|
|
||||
| 백엔드 | Spring Boot 3.4.5 / Java 21 · Spring Security(세션) · JPA · H2(dev)/PostgreSQL(prod) · POI · ZXing · Flyway |
|
||||
| 프론트엔드 | React 19 · Vite 6 · TypeScript · react-router 7 |
|
||||
| 포트 | API `8080`, 웹 `5173`(dev) / nginx `80`(prod) |
|
||||
| 인증 | 세션 기반, 최초 로그인 시 비밀번호 강제 변경 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 역할(Role)과 접근 범위
|
||||
|
||||
| 역할 | 주요 권한 | 접근 화면 |
|
||||
|------|-----------|-----------|
|
||||
| `ADMIN` | 전체 관리, 모든 승인/반려, 사용자·블랙리스트·리포트 | 대시보드, 방문신청, 승인함, 출입콘솔, 블랙리스트, 리포트 |
|
||||
| `SECURITY` | 입·출입 콘솔(체크인/아웃), 재실현황, 배지 발급, 리포트 | 대시보드, 방문신청, 출입콘솔, 리포트 |
|
||||
| `HOST` | 방문 사전신청 등록, 담당 방문 확인 | 대시보드, 방문신청, 출입콘솔 |
|
||||
| (비로그인) | 방문자 공개 출입증(`/pass/:token`), 키오스크(`/kiosk`) | 공개 페이지 |
|
||||
|
||||
> 라우팅 기준: `frontend/src/App.tsx`. `승인함(/approvals)`·`블랙리스트(/blacklist)`는 ADMIN 전용, `리포트(/reports)`는 SECURITY·ADMIN.
|
||||
|
||||
---
|
||||
|
||||
## 3. 방문 신청 생명주기 (상태 머신)
|
||||
|
||||
`VisitStatus` (backend/entity/VisitStatus.java) 기준 상태 전이:
|
||||
|
||||
```
|
||||
신청 등록 승인
|
||||
[DRAFT] ───────────────▶ [PENDING] ───────────────▶ [APPROVED] ──▶ 출입 가능
|
||||
│ │
|
||||
│ 반려 │ 방문일 경과(체크인 시도)
|
||||
▼ ▼
|
||||
[REJECTED] [EXPIRED]
|
||||
|
||||
[PENDING] 또는 [APPROVED] ──── 취소 ────▶ [CANCELLED]
|
||||
```
|
||||
|
||||
| 상태 | 의미 | 진입 조건 |
|
||||
|------|------|-----------|
|
||||
| `DRAFT` | 임시 저장 | 신청 초안 |
|
||||
| `PENDING` | 승인 대기 | 신청 제출 |
|
||||
| `APPROVED` | 승인됨(출입 가능, qrToken 발급) | 관리자 승인 |
|
||||
| `REJECTED` | 반려됨 | 관리자 반려 |
|
||||
| `CANCELLED` | 신청 취소됨 | 신청자/관리자 취소 |
|
||||
| `EXPIRED` | 방문일 경과로 만료 | 방문 종료일 이후 체크인 시도 시 자동 전이 |
|
||||
|
||||
> 핵심 규칙: **승인 시점(ApprovalService.approve)** 에 `qrToken = UUID`가 발급되고, 이 토큰으로 출입증(QR)·공개 페이지·문자 발송이 이루어진다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 전체 업무 워크플로우 (End-to-End)
|
||||
|
||||
```
|
||||
[HOST/담당자] [ADMIN] [방문자] [SECURITY/게이트]
|
||||
│ │ │ │
|
||||
①방문 사전신청 ───────────▶ ②승인함 검토 │ │
|
||||
(개별 or 엑셀 업로드) │ │ │
|
||||
│ ③승인 / 반려 │ │
|
||||
│ │ (승인 시 qrToken) │ │
|
||||
│ ├── ④출입증 문자발송 ─▶ 휴대폰 링크 수신 │
|
||||
│ │ ⑤공개 출입증(/pass/:token) │
|
||||
│ │ │ QR 확인 │
|
||||
│ │ │ │
|
||||
│ │ └── ⑥방문일 현장 도착 ────▶ ⑦체크인(QR/이름)
|
||||
│ │ │ ├ 블랙리스트 검증
|
||||
│ │ │ ├ 상태/일자 검증
|
||||
│ │ │ └ 게이트 오픈 + 입장기록
|
||||
│ │ ⑧재실현황 표시
|
||||
│ │ └── ⑨퇴장 ───────────────▶ ⑩체크아웃(OUT 기록)
|
||||
│ │ │
|
||||
└───────────────── ⑪대시보드 통계 / ⑫방문 리포트(엑셀) ────────────────────┘
|
||||
```
|
||||
|
||||
### 단계별 상세
|
||||
|
||||
**① 방문 사전신청 (HOST/ADMIN/SECURITY)** — `POST /api/visit-requests`
|
||||
- 방문자 정보(이름·연락처·회사), 방문 구역(Zone), 방문 기간(visitFrom~visitTo), 담당 호스트 지정.
|
||||
- 개별 등록 또는 **엑셀 일괄 업로드**(`POST /api/visit-requests/upload`, `ExcelImportService`).
|
||||
- 상태 → `PENDING`.
|
||||
|
||||
**② ③ 승인/반려 (ADMIN)** — `GET /api/visit-requests/pending` → `POST /api/approvals/{id}/approve|reject`
|
||||
- `PENDING` 상태만 처리 가능(이미 처리된 건은 409 conflict).
|
||||
- 승인: 상태 → `APPROVED`, **`qrToken` 발급**, `Approval` 기록 저장.
|
||||
- 반려: 상태 → `REJECTED`, 사유(comment) 기록.
|
||||
|
||||
**④ 출입증 발송 (자동)** — `PassNotifier`
|
||||
- 승인 직후 QR PNG(240px)를 생성하여 방문자에게 발송.
|
||||
- 발송 실패는 승인 트랜잭션을 롤백하지 않음 (catch & log — 승인은 정상 처리).
|
||||
- Provider: `dev`(로그만, `LoggingPassNotifier`) / `hanbank`(사내 메시지 API로 LMS 실발송, `HanbankMessagePassNotifier`).
|
||||
- 문자에는 `ACS_PUBLIC_BASE_URL` 기반 공개 출입증 링크 포함.
|
||||
|
||||
**⑤ 공개 출입증 (방문자, 비로그인)** — `GET /pass/:token` → `PublicPassController`
|
||||
- 방문자가 휴대폰에서 링크 접속 → QR/방문정보 확인.
|
||||
- secure context(HTTPS/localhost)에서만 카메라·안전 접속 보장.
|
||||
|
||||
**⑥ ⑦ 입장 체크인 (SECURITY/게이트 or 키오스크)** — `POST /api/access/check-in`
|
||||
- 식별: QR 토큰 스캔 또는 방문자 이름 검색(`GET /api/access/search?q=`).
|
||||
- **검증 순서** (`AccessService.checkIn`):
|
||||
1. 상태 == `APPROVED` 아니면 거부.
|
||||
2. 방문 종료일 경과 → 상태 `EXPIRED` 전이 + 거부("재신청 필요").
|
||||
3. 방문 시작일 이전 → 거부("아직 방문일 아님").
|
||||
4. 블랙리스트 매칭(이름+연락처) → 403 차단.
|
||||
5. 이미 입장 중 → 409 중복입장.
|
||||
6. 금일 이미 퇴장 완료 → 재입장 불가.
|
||||
- 통과 시: `AccessEvent(IN)` 기록 + `gateway.openGate()` 호출(게이트 오픈).
|
||||
- **늦은 도착 허용**: 같은 '일자'면 시간은 엄격히 보지 않음. 실제 입장 시각은 `access_events.event_at`에 별도 기록.
|
||||
|
||||
**⑧ 재실현황 (SECURITY/ADMIN)** — `GET /api/access/inside`
|
||||
- 마지막 이벤트가 `IN`인 방문자 = 현재 재실 중. 이름·회사·구역·호스트·입장시각 표시.
|
||||
- 금일 전체 출입기록: `AccessService.listTodayRecords` (입장/퇴장/재실여부 포함).
|
||||
|
||||
**⑨ ⑩ 퇴장 체크아웃** — `POST /api/access/check-out`
|
||||
- 입장 기록이 없으면 409(퇴장 불가).
|
||||
- `AccessEvent(OUT)` 기록. 재실현황에서 제외됨.
|
||||
|
||||
**⑪ 대시보드 통계** — `GET /api/stats/summary` → `StatsController`
|
||||
- 오늘의 방문/승인대기/재실 인원 등 요약 집계.
|
||||
|
||||
**⑫ 방문 리포트 (SECURITY/ADMIN)** — `GET /api/reports/visits.xlsx?from=&to=`
|
||||
- 기간별 방문 내역을 Apache POI로 엑셀 생성·다운로드(`ReportService`).
|
||||
|
||||
---
|
||||
|
||||
## 5. 키오스크 셀프 체크인 흐름 (비로그인)
|
||||
|
||||
`GET /kiosk` → `KioskPage` — 입구 무인 단말에서 방문자가 직접 QR을 스캔하여 셀프 체크인/아웃.
|
||||
- 백엔드는 동일한 `check-in`/`check-out` API 사용하되 operator = null(셀프서비스).
|
||||
- 웹캠 QR 스캔은 secure context 필요(`useQrScanner.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 6. 블랙리스트 흐름 (ADMIN)
|
||||
|
||||
`GET/POST /api/blacklist`, `DELETE /api/blacklist/{id}` → `BlacklistController`
|
||||
- 이름+연락처 기준 차단 명단 관리.
|
||||
- **체크인 시 자동 검증**: `BlacklistService.blockReason()`이 매칭되면 입장 403 차단(사유 표시).
|
||||
|
||||
---
|
||||
|
||||
## 7. 컴포넌트 데이터 흐름 (백엔드 계층)
|
||||
|
||||
```
|
||||
Controller → Service → Repository(JPA) → DB(H2/PostgreSQL)
|
||||
│ │
|
||||
│ ├─ ApprovalService ─▶ QrService(ZXing) ─▶ PassNotifier(SMS)
|
||||
│ ├─ AccessService ───▶ AccessControlGateway(Mock/실장비)
|
||||
│ │ └─ BlacklistService
|
||||
│ └─ ReportService(POI) / StatsService / ExcelImportService(POI)
|
||||
│
|
||||
└─ 공통: GlobalExceptionHandler(예외→ApiResponse), ApiResponse(응답 래퍼), SecurityConfig(세션 인가)
|
||||
```
|
||||
|
||||
주요 엔티티: `User`, `Visitor`, `VisitRequest`, `Approval`, `AccessEvent`, `Zone`, `Blacklist`
|
||||
(공통 `BaseEntity` — JPA auditing으로 생성/수정 시각 자동 기록)
|
||||
|
||||
---
|
||||
|
||||
## 8. 배포/운영 워크플로우
|
||||
|
||||
### 로컬 개발 (H2)
|
||||
```cmd
|
||||
run-backend.cmd :: http://localhost:8080 (env.cmd로 포터블 JDK/Maven 로드)
|
||||
run-frontend.cmd :: http://localhost:5173
|
||||
```
|
||||
초기 계정(DataSeeder, dev 전용): `admin` / `security` / `host` — 비밀번호 `ChangeMe123!` (최초 로그인 시 변경 요구).
|
||||
|
||||
### 운영 (Docker, PostgreSQL)
|
||||
```bash
|
||||
cd infra
|
||||
cp .env.example .env # POSTGRES_PASSWORD, WEB_PORT, ACS_SMS_PROVIDER, ACS_PUBLIC_BASE_URL 설정
|
||||
docker compose up -d --build # db + app(:8080) + web(nginx :80)
|
||||
docker compose run --rm seed # 사용자 시드 적재
|
||||
docker compose logs -f app # Flyway 마이그레이션/기동 로그
|
||||
```
|
||||
- `prod` 프로파일 + Flyway `V1__init.sql`로 스키마 관리, DB는 named volume(`db_data`)에 영속.
|
||||
- nginx가 정적 파일 서빙 + `/api` 리버스 프록시.
|
||||
|
||||
### 문자 실발송(hanbank) 전제
|
||||
1. 서버→메시지 API 도달 확인: `nc -vz 210.104.132.59 8000`
|
||||
2. `ACS_SMS_PROVIDER=hanbank`, `ACS_PUBLIC_BASE_URL`=외부 접속 가능한 실제 URL(localhost 금지) 설정 후 재기동.
|
||||
|
||||
---
|
||||
|
||||
## 9. 구현 현황 (7단계)
|
||||
|
||||
| 단계 | 내용 | 상태 |
|
||||
|------|------|------|
|
||||
| 1 | 스캐폴딩 (pom, 설정, 공통 클래스, 전역 예외/응답 래퍼, JPA auditing) | ✅ |
|
||||
| 2 | 인증·인가 (세션 로그인, 역할 기반 권한, 시드) | ✅ |
|
||||
| 3 | 방문 사전신청 + 승인 (신청/취소/엑셀 업로드, 승인/반려 시 qrToken 발급) | ✅ |
|
||||
| 4 | 입·출입 체크인/체크아웃 + 재실현황 (AccessEvent, Gateway+Mock, 중복입장·만료 검증) | ✅ |
|
||||
| 5 | QR/배지 발급 (ZXing PNG, 배지 인쇄 화면) | ✅ |
|
||||
| 6 | 블랙리스트(체크인 차단) + 대시보드 통계 + 방문 리포트 엑셀(POI) | ✅ |
|
||||
| 7 | Flyway V1__init.sql(prod) + Docker Compose(db·app·web nginx) + Python 사용자 시드 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 10. 주요 API 요약
|
||||
|
||||
| 분류 | 엔드포인트 |
|
||||
|------|-----------|
|
||||
| 인증 | `POST /api/auth/login` · `/logout` · `GET /api/auth/me` · `POST /api/auth/change-password` |
|
||||
| 구역 | `GET /api/zones` |
|
||||
| 방문신청 | `GET/POST /api/visit-requests` · `GET .../pending` · `POST .../{id}/cancel` · `POST .../upload`(엑셀) |
|
||||
| 승인 | `POST /api/approvals/{id}/approve` · `/reject` |
|
||||
| 출입 | `POST /api/access/check-in` · `/check-out` · `GET /api/access/inside` · `/search?q=` |
|
||||
| 출입증 | `GET /api/passes/{id}` · `/qr.png` · 공개 `/pass/:token` |
|
||||
| 통계/리포트 | `GET /api/stats/summary` · `GET /api/reports/visits.xlsx?from=&to=` |
|
||||
| 블랙리스트 | `GET/POST /api/blacklist` · `DELETE /api/blacklist/{id}` (ADMIN) |
|
||||
|
||||
> API 스모크 테스트: `backend/test-api.http`
|
||||
|
||||
---
|
||||
|
||||
## 11. 향후 연동 포인트
|
||||
|
||||
- **실 출입장비 연동**: `AccessControlGateway` 구현체를 Mock → 실장비 드라이버로 교체.
|
||||
- **HTTPS/카메라 스캔**: 사내 도메인·인증서 확보 후 nginx 443 TLS 블록 + `ACS_PUBLIC_BASE_URL` https 설정 (웹캠 QR 스캔은 secure context 필수).
|
||||
- **사내망 Docker 빌드**: SSL 인스펙션 대응(사내 CA 주입 또는 내부 미러) 필요 시 Dockerfile 반영.
|
||||
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# ===== build =====
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ===== serve (nginx serves SPA + proxies /api to backend) =====
|
||||
FROM nginx:stable-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>IT센터 출입자관리시스템</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA client-side routing: fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API calls to the backend service (session cookie preserved)
|
||||
location /api/ {
|
||||
proxy_pass http://app:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cookie_path / /;
|
||||
}
|
||||
}
|
||||
2028
frontend/package-lock.json
generated
Normal file
2028
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "acs-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.4.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"react": "^19.0.0",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
90
frontend/src/App.tsx
Normal file
90
frontend/src/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { useAuth } from './auth/AuthContext';
|
||||
import { Role } from './types';
|
||||
import { Layout } from './components/Layout';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { ChangePasswordPage } from './pages/ChangePasswordPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { VisitRequestListPage } from './pages/VisitRequestListPage';
|
||||
import { VisitRequestFormPage } from './pages/VisitRequestFormPage';
|
||||
import { ApprovalQueuePage } from './pages/ApprovalQueuePage';
|
||||
import { AccessConsolePage } from './pages/AccessConsolePage';
|
||||
import { BadgePage } from './pages/BadgePage';
|
||||
import { PublicPassPage } from './pages/PublicPassPage';
|
||||
import { KioskPage } from './pages/KioskPage';
|
||||
import { BlacklistPage } from './pages/BlacklistPage';
|
||||
import { ReportPage } from './pages/ReportPage';
|
||||
|
||||
/** Requires a logged-in user; optionally one of the given roles. */
|
||||
const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => {
|
||||
const { user, loading, hasRole } = useAuth();
|
||||
if (loading) {
|
||||
return <div className="center-screen">불러오는 중…</div>;
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (user.mustChangePassword) {
|
||||
return <Navigate to="/change-password" replace />;
|
||||
}
|
||||
if (roles && !hasRole(...roles)) {
|
||||
return <div className="center-screen">접근 권한이 없습니다.</div>;
|
||||
}
|
||||
return <Layout>{children}</Layout>;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
{/* Public visitor pass — opened from the SMS link, no login. */}
|
||||
<Route path="/pass/:token" element={<PublicPassPage />} />
|
||||
{/* Public entrance kiosk — visitor self check-in/out, no login. */}
|
||||
<Route path="/kiosk" element={<KioskPage />} />
|
||||
|
||||
<Route path="/dashboard" element={<Protected><DashboardPage /></Protected>} />
|
||||
<Route path="/visit-requests" element={<Protected><VisitRequestListPage /></Protected>} />
|
||||
<Route path="/visit-requests/new" element={<Protected><VisitRequestFormPage /></Protected>} />
|
||||
<Route
|
||||
path="/approvals"
|
||||
element={
|
||||
<Protected roles={['ADMIN']}>
|
||||
<ApprovalQueuePage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/access"
|
||||
element={
|
||||
<Protected roles={['HOST', 'SECURITY', 'ADMIN']}>
|
||||
<AccessConsolePage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route path="/badge/:id" element={<Protected><BadgePage /></Protected>} />
|
||||
<Route
|
||||
path="/blacklist"
|
||||
element={
|
||||
<Protected roles={['ADMIN']}>
|
||||
<BlacklistPage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/reports"
|
||||
element={
|
||||
<Protected roles={['SECURITY', 'ADMIN']}>
|
||||
<ReportPage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
143
frontend/src/api.ts
Normal file
143
frontend/src/api.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
AccessAction,
|
||||
AccessRecord,
|
||||
ApiResponse,
|
||||
BlacklistCreate,
|
||||
BlacklistItem,
|
||||
ChangePasswordRequest,
|
||||
CurrentUser,
|
||||
ExcelImportResult,
|
||||
InsideVisitor,
|
||||
LoginRequest,
|
||||
StatsSummary,
|
||||
PublicPass,
|
||||
VisitRequestCreate,
|
||||
VisitRequestView,
|
||||
Zone,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
/** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */
|
||||
const NO_REDIRECT_ON_401 = ['/auth/me', '/auth/login', '/auth/logout', '/public/'];
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
});
|
||||
// Session expired / not authenticated on a protected call → send to login.
|
||||
if (
|
||||
res.status === 401 &&
|
||||
!NO_REDIRECT_ON_401.some((p) => path.startsWith(p)) &&
|
||||
typeof window !== 'undefined' &&
|
||||
!['/login', '/kiosk'].includes(window.location.pathname) &&
|
||||
!window.location.pathname.startsWith('/pass/')
|
||||
) {
|
||||
window.location.assign('/login');
|
||||
}
|
||||
let body: ApiResponse<T> | null = null;
|
||||
try {
|
||||
body = (await res.json()) as ApiResponse<T>;
|
||||
} catch {
|
||||
// non-JSON (shouldn't happen with our envelope)
|
||||
}
|
||||
if (!res.ok || !body) {
|
||||
throw new Error(body?.message || `HTTP ${res.status}`);
|
||||
}
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
function jsonInit(method: string, payload: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Auth =====
|
||||
export const login = (req: LoginRequest) =>
|
||||
request<CurrentUser>('/auth/login', jsonInit('POST', req));
|
||||
|
||||
export const logout = () =>
|
||||
request<string>('/auth/logout', { method: 'POST' });
|
||||
|
||||
export const getCurrentUser = () => request<CurrentUser>('/auth/me');
|
||||
|
||||
export const changePassword = (req: ChangePasswordRequest) =>
|
||||
request<string>('/auth/change-password', jsonInit('POST', req));
|
||||
|
||||
// ===== Zones =====
|
||||
export const listZones = () => request<Zone[]>('/zones');
|
||||
|
||||
// ===== Visit requests =====
|
||||
export const listVisitRequests = () =>
|
||||
request<VisitRequestView[]>('/visit-requests');
|
||||
|
||||
export const listPendingRequests = () =>
|
||||
request<VisitRequestView[]>('/visit-requests/pending');
|
||||
|
||||
export const getVisitRequest = (id: number) =>
|
||||
request<VisitRequestView>(`/visit-requests/${id}`);
|
||||
|
||||
export const createVisitRequest = (req: VisitRequestCreate) =>
|
||||
request<VisitRequestView>('/visit-requests', jsonInit('POST', req));
|
||||
|
||||
export const cancelVisitRequest = (id: number) =>
|
||||
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
|
||||
|
||||
export const uploadVisitRequests = (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return request<ExcelImportResult>('/visit-requests/upload', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
// ===== Approvals =====
|
||||
export const approveRequest = (id: number, comment?: string) =>
|
||||
request<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment }));
|
||||
|
||||
export const rejectRequest = (id: number, comment?: string) =>
|
||||
request<VisitRequestView>(`/approvals/${id}/reject`, jsonInit('POST', { comment }));
|
||||
|
||||
// ===== Access console (check-in / out, currently inside) =====
|
||||
export const searchApprovedForCheckIn = (q: string) =>
|
||||
request<VisitRequestView[]>(`/access/search?q=${encodeURIComponent(q)}`);
|
||||
|
||||
export const checkIn = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) =>
|
||||
request<AccessAction>('/access/check-in', jsonInit('POST', payload));
|
||||
|
||||
export const checkOut = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) =>
|
||||
request<AccessAction>('/access/check-out', jsonInit('POST', payload));
|
||||
|
||||
export const listInside = () => request<InsideVisitor[]>('/access/inside');
|
||||
export const listTodayAccess = () => request<AccessRecord[]>('/access/today');
|
||||
|
||||
// ===== Passes / badge =====
|
||||
export const getPass = (id: number) => request<VisitRequestView>(`/passes/${id}`);
|
||||
export const passQrUrl = (id: number) => `/api/passes/${id}/qr.png`;
|
||||
|
||||
// ===== Public pass + self-service kiosk (no login, token-based) =====
|
||||
export const getPublicPass = (token: string) => request<PublicPass>(`/public/passes/${token}`);
|
||||
export const publicPassQrUrl = (token: string) => `/api/public/passes/${token}/qr.png`;
|
||||
export const publicCheckIn = (token: string) =>
|
||||
request<AccessAction>(`/public/passes/${token}/check-in`, { method: 'POST' });
|
||||
export const publicCheckOut = (token: string) =>
|
||||
request<AccessAction>(`/public/passes/${token}/check-out`, { method: 'POST' });
|
||||
|
||||
// ===== Stats =====
|
||||
export const getStatsSummary = () => request<StatsSummary>('/stats/summary');
|
||||
|
||||
// ===== Blacklist (ADMIN) =====
|
||||
export const listBlacklist = () => request<BlacklistItem[]>('/blacklist');
|
||||
export const addBlacklist = (req: BlacklistCreate) =>
|
||||
request<BlacklistItem>('/blacklist', jsonInit('POST', req));
|
||||
export const deleteBlacklist = (id: number) =>
|
||||
request<string>(`/blacklist/${id}`, { method: 'DELETE' });
|
||||
|
||||
// ===== Reports =====
|
||||
export const reportDownloadUrl = (from: string, to: string) =>
|
||||
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|
||||
BIN
frontend/src/assets/bok-badge.png
Normal file
BIN
frontend/src/assets/bok-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
frontend/src/assets/bok-removebg.png
Normal file
BIN
frontend/src/assets/bok-removebg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
54
frontend/src/auth/AuthContext.tsx
Normal file
54
frontend/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { getCurrentUser } from '../api';
|
||||
import { CurrentUser, Role } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
user: CurrentUser | null;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
clear: () => void;
|
||||
hasRole: (...roles: Role[]) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setUser(await getCurrentUser());
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => setUser(null), []);
|
||||
|
||||
const hasRole = useCallback(
|
||||
(...roles: Role[]) => !!user && roles.some((r) => user.roles.includes(r)),
|
||||
[user],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, refresh, clear, hasRole }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
33
frontend/src/chime.ts
Normal file
33
frontend/src/chime.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Simple two-tone "띵동" chime via Web Audio — no audio asset needed.
|
||||
// Must be triggered from a user gesture (e.g. button click) to satisfy autoplay policy.
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
function tone(audio: AudioContext, freq: number, startAt: number, dur: number) {
|
||||
const osc = audio.createOscillator();
|
||||
const gain = audio.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
osc.connect(gain);
|
||||
gain.connect(audio.destination);
|
||||
gain.gain.setValueAtTime(0.0001, startAt);
|
||||
gain.gain.exponentialRampToValueAtTime(0.35, startAt + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + dur);
|
||||
osc.start(startAt);
|
||||
osc.stop(startAt + dur + 0.02);
|
||||
}
|
||||
|
||||
/** Plays a descending two-note "ding-dong" chime. Safe no-op if audio is unavailable. */
|
||||
export function playChime(): void {
|
||||
try {
|
||||
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
ctx = ctx ?? new Ctor();
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
const now = ctx.currentTime;
|
||||
tone(ctx, 784, now, 0.35); // "띵" (G5)
|
||||
tone(ctx, 523.25, now + 0.18, 0.45); // "동" (C5)
|
||||
} catch {
|
||||
/* ignore audio errors */
|
||||
}
|
||||
}
|
||||
55
frontend/src/components/DateTimePicker.tsx
Normal file
55
frontend/src/components/DateTimePicker.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useRef } from 'react';
|
||||
import DatePicker, { registerLocale } from 'react-datepicker';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
|
||||
registerLocale('ko', ko);
|
||||
|
||||
interface Props {
|
||||
/** datetime-local string, e.g. "2026-07-02T08:24". */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** Date → "YYYY-MM-DDTHH:mm" (local), the format the form/back-end expect. */
|
||||
function toLocalString(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Korean-localized date+time picker that replaces the browser-native
|
||||
* <input type="datetime-local"> (whose popup labels/border/buttons cannot be
|
||||
* styled). Keeps the calendar open until the user presses [입력] so the choice
|
||||
* is explicit.
|
||||
*/
|
||||
export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }) => {
|
||||
const ref = useRef<DatePicker>(null);
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
ref={ref}
|
||||
selected={value ? new Date(value) : null}
|
||||
onChange={(d: Date | null) => d && onChange(toLocalString(d))}
|
||||
showTimeSelect
|
||||
timeIntervals={5}
|
||||
timeCaption="시간"
|
||||
timeFormat="a K:mm"
|
||||
dateFormat="yyyy.MM.dd (eee) a K:mm"
|
||||
dateFormatCalendar="yyyy.M월"
|
||||
locale="ko"
|
||||
shouldCloseOnSelect={false}
|
||||
placeholderText={placeholder ?? '날짜와 시간을 선택하세요'}
|
||||
className="dt-input"
|
||||
popperClassName="acs-datepicker"
|
||||
>
|
||||
<div className="dt-actions">
|
||||
<button type="button" className="btn-primary dt-confirm" onClick={() => ref.current?.setOpen(false)}>
|
||||
입력
|
||||
</button>
|
||||
</div>
|
||||
</DatePicker>
|
||||
);
|
||||
};
|
||||
52
frontend/src/components/Dialog.tsx
Normal file
52
frontend/src/components/Dialog.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
message?: string;
|
||||
/** Show an optional text field and pass its value to onConfirm. */
|
||||
withInput?: boolean;
|
||||
inputPlaceholder?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: (text?: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app modal replacing window.prompt/confirm (unsupported in embedded browsers).
|
||||
* With `withInput`, collects an optional text value (e.g. rejection reason).
|
||||
*/
|
||||
export const Dialog: React.FC<Props> = ({
|
||||
title, message, withInput, inputPlaceholder, confirmLabel = '확인', cancelLabel = '취소',
|
||||
danger, onConfirm, onCancel,
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="modal-title">{title}</h3>
|
||||
{message && <p className="modal-message">{message}</p>}
|
||||
{withInput && (
|
||||
<textarea
|
||||
className="modal-input"
|
||||
placeholder={inputPlaceholder}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
autoFocus
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="btn-ghost" onClick={onCancel}>{cancelLabel}</button>
|
||||
<button
|
||||
className={danger ? 'btn-danger' : 'btn-primary'}
|
||||
onClick={() => onConfirm(withInput ? text.trim() || undefined : undefined)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
frontend/src/components/Layout.tsx
Normal file
53
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { logout } from '../api';
|
||||
import bokBadge from '../assets/bok-badge.png';
|
||||
|
||||
/** Display labels for role codes shown in the top bar (HOST is shown as USER). */
|
||||
const ROLE_LABEL: Record<string, string> = { ADMIN: 'ADMIN', SECURITY: 'SECURITY', HOST: 'USER' };
|
||||
|
||||
/** Shared app chrome: top bar with role-aware nav + logout. */
|
||||
export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, clear, hasRole } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} finally {
|
||||
clear();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<Link to="/dashboard" className="brand">
|
||||
<img src={bokBadge} alt="" className="brand-badge" />
|
||||
IT센터 출입자관리
|
||||
</Link>
|
||||
<nav className="nav">
|
||||
<NavLink to="/visit-requests">출입신청</NavLink>
|
||||
{hasRole('ADMIN') && <NavLink to="/approvals">승인대기</NavLink>}
|
||||
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access">출입콘솔</NavLink>}
|
||||
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports">리포트</NavLink>}
|
||||
{hasRole('ADMIN') && <NavLink to="/blacklist">블랙리스트</NavLink>}
|
||||
</nav>
|
||||
<div className="user-box">
|
||||
<span className="user-name">
|
||||
{user?.fullName}
|
||||
<span className="role-tags">
|
||||
{user?.roles.map((r) => (
|
||||
<span key={r} className="role-tag">{ROLE_LABEL[r] ?? r}</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
<button className="btn-ghost" onClick={onLogout}>로그아웃</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user