Compare commits

...

10 Commits

Author SHA1 Message Date
unknown
4fe4c37fa4 docs: record Coolify permission issue 2026-07-10 15:40:13 +09:00
unknown
1c717b4e47 docs: record deployment URL status 2026-07-10 15:27:52 +09:00
unknown
3e2566cba9 docs: update ACS work log 2026-07-10 15:19:26 +09:00
unknown
01d48fe808 feat: prepare ACS deployment 2026-07-10 15:18:29 +09:00
unknown
da0d35ae7f fix(ui): 테스트 피드백 반영 (출입증/보고서/상단바/대시보드/엑셀)
1. 출입증 카드 하단 안내문을 한 줄로(.badge-foot font-size 11px + nowrap).
2. 보고서 화면: 명칭 '리포트→보고서'·'방문 리포트→출입관리 보고서', 안내문 보강,
   날짜 입력을 YYYY.MM.DD 표시 커스텀 피커(DatePickerField)로 교체,
   기본값 시작일=이번 달 1일·종료일=오늘.
3. 보고서 엑셀 열 너비를 내용 기준(한글 2폭)으로 계산해 설정 → 셀 잘림 해소
   (POI autoSizeColumn의 CJK 과소측정 문제 회피).
4. 상단바에서 사용자 이름 표시 제거(역할 태그만 유지) → '발송내역' 메뉴 잘림 해소,
   발송내역 화면 [새로고침]→[조회].
5. 대시보드 '최근 출입 신청'에서 현재 재실 중인 방문자는 상태를 '재실중'으로 표시
   (기존 listInside API 재활용, 백엔드 무변경).

검증: 프론트 tsc+vite 빌드 통과, 백엔드 build+test(9건) 통과, 생성 xlsx의 열 너비가
내용에 맞게 설정됨(연락처15·출입일시18·상태10 등) 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:52:11 +09:00
unknown
d8ba443b35 feat(infra): HTTPS(TLS) 스캐폴딩 + 프록시 헤더 처리
- Spring prod: server.forward-headers-strategy=framework — nginx의 X-Forwarded-Proto를
  신뢰해 프록시 뒤에서도 request.isSecure()=true → Secure 쿠키·https 리다이렉트 정상화.
- frontend/nginx-tls.conf: 443 TLS 종료 + 80→443 리다이렉트(+ /api 프록시).
- infra/docker-compose.tls.yml: base와 함께 쓰는 HTTPS 오버레이(443 매핑·인증서 볼륨·
  ACS_PUBLIC_BASE_URL/ACS_COOKIE_SECURE). infra/certs/.gitignore로 인증서·키 커밋 제외.
- README: HTTPS 적용 절차(인증서 준비→.env→compose 오버레이) 갱신.

검증: forward-headers=framework + cookie.secure=true로 기동 후 X-Forwarded-Proto=https 유무 대조 —
헤더 있으면 XSRF-TOKEN·JSESSIONID 모두 Secure, 없으면 XSRF Secure 미부여(프록시 프로토콜 연동 확인).
자체서명 인증서 생성 확인. 전체 컨테이너 TLS e2e는 Docker 데몬 기동 시 별도 검증 필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:12:14 +09:00
unknown
17d6036bca feat(web): 감사 로그·발송 내역 관리 화면 추가
- AuditLogPage(/audit): 최근 관리 행위(승인/반려·블랙리스트) 조회.
- DeliveryOutboxPage(/deliveries): 출입증 발송 내역 조회(성공/실패/전체 필터) + 실패건 수동 재발송.
- api.ts/types에 listAudit·listDeliveries·retryDelivery + AuditLog·PassDelivery 타입 추가.
- Layout 네비(ADMIN)와 App 라우팅(ADMIN 가드) 연결, common.css에 row-gap·cell-error 유틸 추가.

검증: tsc+vite 빌드 통과. 런타임 승인 후 GET /api/admin/audit·/deliveries 응답이 타입과 일치 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:03:51 +09:00
unknown
f7aaeaba2a perf(access): 재실/오늘기록 조회 N+1 제거
- listInside/listTodayRecords가 방문건마다 findById+이벤트 조회를 반복하던 것을
  findAllWithVisitorAndHostByIdIn(join fetch) + findByVisitRequestIdInOrderByEventAtAsc
  배치 조회 2~3쿼리로 축소. 재실 판정은 오늘 이벤트의 마지막 방향으로 계산.
- 테스트: AccessQueryTest(체크인→재실/오늘기록, 체크아웃→재실 해제·checkOut 시각). 전체 9건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:49:14 +09:00
unknown
8ca939c057 feat(notify): P2 — 출입증 발송 outbox + 실패 자동/수동 재발송
- PassDelivery(pass_deliveries) outbox 엔티티: 발송 결과(SENT/FAILED)·채널·수신처·시도횟수·오류 기록.
- PassNotifier 계약 변경: 실패 시 예외 throw(+ channel()/recipient()). Hanbank/Email이 실패를
  더 이상 삼키지 않고 throw → PassDeliveryService가 결과를 outbox에 기록(승인은 롤백 안 됨).
- PassDeliveryRetryScheduler: 매 10분(acs.delivery.retry-cron) FAILED & attempts<max(기본5) 재발송.
- 관리 API: GET /api/admin/deliveries?status=FAILED, POST /api/admin/deliveries/{id}/retry (ADMIN).
- ApprovalService는 passDeliveryService.deliver(vr)로 위임(QR생성·발송·기록 일원화).
- Flyway V3__pass_delivery.sql. FlywayValidationTest가 V1+V2+V3를 엔티티와 대조.
- 테스트: PassDeliveryTest(발송 실패→FAILED 기록→재시도→SENT). 전체 8건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:43:42 +09:00
unknown
6905dcaaff feat(audit): P2 — 관리 행위 감사 로그(AuditLog) 추가
- AuditLog 엔티티/AuditAction(APPROVE/REJECT/BLACKLIST_ADD/BLACKLIST_REMOVE) + AuditService.record
  (호출자 트랜잭션 합류, actor는 SecurityUtils에서 채우되 미인증 컨텍스트는 null 허용).
- 훅: ApprovalService(승인/반려), BlacklistService(추가/해제)에 감사 기록.
- GET /api/admin/audit (ADMIN) 최근 200건 조회 + AuditLogResponse.
- Flyway V2__audit_log.sql (prod). FlywayValidationTest가 V1+V2를 엔티티와 대조.
- 테스트: AuditLogTest(승인/블랙리스트 추가 감사행 검증). 전체 7건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:34:08 +09:00
65 changed files with 2440 additions and 179 deletions

2
.gitignore vendored
View File

@@ -14,6 +14,8 @@ frontend/.vite/
*.iml
.DS_Store
Thumbs.db
*.log
*.bak
# Local env
infra/.env

View File

@@ -80,13 +80,26 @@ docker compose logs -f app # 기동/마이그레이션 로그
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`로 접속하면 카메라가 차단된다.
### HTTPS 적용 (TLS 종료는 nginx가 담당)
브라우저 → **nginx(web, TLS 종료)** → 내부 http → Spring(app) 구조. 앱은 `server.forward-headers-strategy=framework`
(prod에 반영됨)로 nginx의 `X-Forwarded-Proto`를 신뢰해 Secure 쿠키·https 리다이렉트를 처리한다.
TLS 오버레이가 준비돼 있다 — 인증서만 넣으면 된다:
1. **인증서 준비**`infra/certs/{fullchain.pem,privkey.pem}` (사내 CA 발급 / 공인 CA / 테스트용 자체서명).
자체서명 예: `openssl req -x509 -newkey rsa:2048 -nodes -days 825 -keyout infra/certs/privkey.pem -out infra/certs/fullchain.pem -subj "/CN=<host>"`
(`infra/certs/*.pem``.gitignore`로 커밋 제외.)
2. **`.env`**: `ACS_PUBLIC_BASE_URL=https://<도메인>`, `ACS_COOKIE_SECURE=true`.
3. **기동** (base + TLS 오버레이):
```bash
cd infra
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --build
```
→ nginx가 `443` TLS 종료 + `80→443` 리다이렉트. `frontend/nginx-tls.conf` 사용.
`ACS_COOKIE_SECURE=true`는 위처럼 **실제 HTTPS로 서비스될 때만** 켠다(평문 http에서 켜면 쿠키 미전송 → 로그인 불가).
- 출입콘솔의 **웹캠 QR 스캔**은 secure context(HTTPS 또는 localhost)에서만 동작 → HTTPS면 `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 서버는 공개망 직접

View File

@@ -29,13 +29,18 @@ public class DataSeeder implements CommandLineRunner {
private final ZoneRepository zoneRepository;
private final PasswordEncoder passwordEncoder;
private static final String DEFAULT_PASSWORD = "ChangeMe123!";
// 적용(운영 반영) 전까지 신속한 테스트를 위한 단축 계정. dev(비-prod)에서만 시드된다.
// 운영 전환 시 이 시드는 무시되고 Flyway/CSV 시더가 실제 계정을 관리한다.
private static final String TEST_PASSWORD = "1";
@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));
seedUser("a", "관리자", "IT운영팀", Set.of(RoleType.ADMIN));
seedUser("s", "보안담당", "보안팀", Set.of(RoleType.SECURITY));
seedUser("h", "홍길동", "개발1팀", Set.of(RoleType.HOST));
seedZone("LOBBY", "로비", 1);
seedZone("OFFICE", "사무공간", 2);
@@ -48,14 +53,14 @@ public class DataSeeder implements CommandLineRunner {
}
User user = new User();
user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(DEFAULT_PASSWORD));
user.setPasswordHash(passwordEncoder.encode(TEST_PASSWORD));
user.setFullName(fullName);
user.setDepartment(department);
user.setEmail(username + "@itcenter.local");
user.setRoles(roles);
user.setMustChangePassword(true);
user.setMustChangePassword(false); // 테스트 편의: 최초 로그인 시 비번변경 강제하지 않음
userRepository.save(user);
log.info("[seed] user '{}' created (default password: {})", username, DEFAULT_PASSWORD);
log.info("[seed] test user '{}' created (password: {})", username, TEST_PASSWORD);
}
private void seedZone(String code, String name, int level) {

View File

@@ -0,0 +1,31 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.AuditLogResponse;
import com.itcenter.acs.service.AuditService;
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;
/**
* Read-only audit trail. Restricted to ADMIN by SecurityConfig (/api/admin/**).
*/
@RestController
@RequestMapping("/api/admin/audit")
@RequiredArgsConstructor
public class AuditController {
private final AuditService auditService;
@GetMapping
public ResponseEntity<ApiResponse<List<AuditLogResponse>>> recent() {
List<AuditLogResponse> items = auditService.recent().stream()
.map(AuditLogResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success(items));
}
}

View File

@@ -103,6 +103,8 @@ public class AuthController {
principal.getId(),
principal.getUsername(),
principal.getFullName(),
principal.getDepartment(),
principal.getEmail(),
roles,
principal.isMustChangePassword());
}

View File

@@ -0,0 +1,43 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.PassDeliveryResponse;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.service.PassDeliveryService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Pass-delivery outbox admin view + manual resend. Restricted to ADMIN by
* SecurityConfig (/api/admin/**).
*/
@RestController
@RequestMapping("/api/admin/deliveries")
@RequiredArgsConstructor
public class DeliveryController {
private final PassDeliveryService passDeliveryService;
@GetMapping
public ResponseEntity<ApiResponse<List<PassDeliveryResponse>>> list(
@RequestParam(value = "status", required = false) DeliveryStatus status) {
List<PassDeliveryResponse> items = passDeliveryService.listByStatus(status).stream()
.map(PassDeliveryResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success(items));
}
@PostMapping("/{id}/retry")
public ResponseEntity<ApiResponse<PassDeliveryResponse>> retry(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(
PassDeliveryResponse.from(passDeliveryService.retryOne(id))));
}
}

View File

@@ -6,6 +6,7 @@ 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.ApprovalService;
import com.itcenter.acs.service.ExcelImportService;
import com.itcenter.acs.service.VisitRequestService;
import jakarta.validation.Valid;
@@ -31,13 +32,27 @@ public class VisitRequestController {
private final VisitRequestService visitRequestService;
private final ExcelImportService excelImportService;
private final ApprovalService approvalService;
/** Create a single pre-registration; current user becomes the host. */
/**
* Create pre-registration(s); current user becomes the host. One request is issued per
* selected server room, so the response may contain more than one (e.g. 4층+5층전산실 → 2건).
* When the registrant already holds approval authority (ADMIN/SECURITY), each request is
* self-approved immediately (QR issued) — HOST registrations stay PENDING.
*/
@PostMapping
public ResponseEntity<ApiResponse<VisitRequestResponse>> create(
public ResponseEntity<ApiResponse<List<VisitRequestResponse>>> create(
@Valid @RequestBody VisitRequestCreateRequest request) {
VisitRequest created = visitRequestService.create(request, SecurityUtils.currentUserId());
return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(created)));
Long userId = SecurityUtils.currentUserId();
List<VisitRequest> created = visitRequestService.createRequests(request, userId);
if (SecurityUtils.hasRole("ADMIN") || SecurityUtils.hasRole("SECURITY")) {
List<VisitRequest> approved = new java.util.ArrayList<>(created.size());
for (VisitRequest vr : created) {
approved.add(approvalService.approve(vr.getId(), userId, "본인 등록 자동승인"));
}
created = approved;
}
return ResponseEntity.ok(ApiResponse.success(toResponses(created)));
}
/** ADMIN/SECURITY see all; HOST sees only their own requests. */

View File

@@ -0,0 +1,29 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.AuditLog;
import java.time.LocalDateTime;
/** Read model for an audit trail entry. */
public record AuditLogResponse(
Long id,
LocalDateTime at,
Long actorId,
String actorUsername,
String action,
String targetType,
Long targetId,
String detail) {
public static AuditLogResponse from(AuditLog a) {
return new AuditLogResponse(
a.getId(),
a.getCreatedAt(),
a.getActorId(),
a.getActorUsername(),
a.getAction() != null ? a.getAction().name() : null,
a.getTargetType(),
a.getTargetId(),
a.getDetail());
}
}

View File

@@ -11,6 +11,8 @@ public class CurrentUserResponse {
private Long id;
private String username;
private String fullName;
private String department;
private String email;
private Set<String> roles;
private boolean mustChangePassword;
}

View File

@@ -0,0 +1,31 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.PassDelivery;
import java.time.LocalDateTime;
/** Read model for a pass-delivery outbox record. */
public record PassDeliveryResponse(
Long id,
Long visitRequestId,
String channel,
String recipient,
String status,
int attempts,
String lastError,
LocalDateTime createdAt,
LocalDateTime updatedAt) {
public static PassDeliveryResponse from(PassDelivery d) {
return new PassDeliveryResponse(
d.getId(),
d.getVisitRequestId(),
d.getChannel(),
d.getRecipient(),
d.getStatus() != null ? d.getStatus().name() : null,
d.getAttempts(),
d.getLastError(),
d.getCreatedAt(),
d.getUpdatedAt());
}
}

View File

@@ -1,11 +1,11 @@
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;
import java.util.List;
@Data
public class VisitRequestCreateRequest {
@@ -20,12 +20,40 @@ public class VisitRequestCreateRequest {
private String email;
private String vehicleNo;
/** Access zone label (fixed list value or "기타" free text). */
private String zoneName;
/**
* Server rooms (전산실) selected via checkboxes, e.g. ["4층전산실", "5층전산실"].
* Each selected room becomes its own visit request / QR pass.
*/
private List<String> serverRooms;
/**
* Optional detail room (콤보박스, 기타 선택 시 자유 입력값). Auxiliary info — carried
* alongside the server room on the zone label but never issues its own QR. When no
* server room is selected, this becomes the sole access zone.
*/
private String roomZone;
@NotBlank(message = "출입 목적을 입력하세요.")
private String purpose;
/** 작업명 — optional concrete task detail, stored separately from purpose. */
private String workName;
// 출입통제담당자(본인) — 웹은 서버가 로그인 사용자로 채움. 엑셀은 파일값 사용.
private String controlName;
private String controlTeam;
private String controlContact;
// 현장감시자1(고정) — 웹은 서버가 고정 상수로 채움. 엑셀은 파일값(비면 고정).
private String watcher1Name;
private String watcher1Team;
private String watcher1Contact;
// 현장감시자2 — 담당자가 입력.
private String watcher2Name;
private String watcher2Team;
private String watcher2Contact;
@NotNull(message = "방문 시작 일시를 입력하세요.")
private LocalDateTime visitFrom;

View File

@@ -17,6 +17,16 @@ public class VisitRequestResponse {
private String hostDepartment;
private String zoneName;
private String purpose;
private String workName;
private String controlName;
private String controlTeam;
private String controlContact;
private String watcher1Name;
private String watcher1Team;
private String watcher1Contact;
private String watcher2Name;
private String watcher2Team;
private String watcher2Contact;
private LocalDateTime visitFrom;
private LocalDateTime visitTo;
private String status;
@@ -35,6 +45,16 @@ public class VisitRequestResponse {
r.hostDepartment = vr.getHost().getDepartment();
r.zoneName = vr.getZoneName();
r.purpose = vr.getPurpose();
r.workName = vr.getWorkName();
r.controlName = vr.getControlName();
r.controlTeam = vr.getControlTeam();
r.controlContact = vr.getControlContact();
r.watcher1Name = vr.getWatcher1Name();
r.watcher1Team = vr.getWatcher1Team();
r.watcher1Contact = vr.getWatcher1Contact();
r.watcher2Name = vr.getWatcher2Name();
r.watcher2Team = vr.getWatcher2Team();
r.watcher2Contact = vr.getWatcher2Contact();
r.visitFrom = vr.getVisitFrom();
r.visitTo = vr.getVisitTo();
r.status = vr.getStatus().name();

View File

@@ -0,0 +1,9 @@
package com.itcenter.acs.entity;
/** Auditable administrative actions. */
public enum AuditAction {
APPROVE,
REJECT,
BLACKLIST_ADD,
BLACKLIST_REMOVE
}

View File

@@ -0,0 +1,46 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* An audit record of an administrative action (who did what, when, to which target).
* Immutable once written. {@code actorId} is null for system/scheduler-initiated actions.
*/
@Entity
@Table(name = "audit_logs", indexes = {
@Index(name = "idx_audit_created_at", columnList = "createdAt"),
@Index(name = "idx_audit_action", columnList = "action")
})
@Getter
@Setter
@NoArgsConstructor
public class AuditLog extends BaseEntity {
/** User who performed the action; null for system-initiated actions. */
@Column(name = "actor_id")
private Long actorId;
@Column(name = "actor_username", length = 50)
private String actorUsername;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private AuditAction action;
@Column(name = "target_type", length = 30)
private String targetType;
@Column(name = "target_id")
private Long targetId;
@Column(length = 500)
private String detail;
}

View File

@@ -0,0 +1,7 @@
package com.itcenter.acs.entity;
/** Delivery outcome of a visitor pass notification. */
public enum DeliveryStatus {
SENT,
FAILED
}

View File

@@ -0,0 +1,46 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Outbox record of a visitor-pass delivery attempt. A FAILED row is retried by
* {@code PassDeliveryRetryScheduler} until it succeeds or hits the attempt cap.
*/
@Entity
@Table(name = "pass_deliveries", indexes = {
@Index(name = "idx_pd_status", columnList = "status"),
@Index(name = "idx_pd_visit_request", columnList = "visit_request_id")
})
@Getter
@Setter
@NoArgsConstructor
public class PassDelivery extends BaseEntity {
@Column(name = "visit_request_id", nullable = false)
private Long visitRequestId;
/** Delivery channel: "dev" / "hanbank" / "email". */
@Column(length = 20)
private String channel;
@Column(length = 120)
private String recipient;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private DeliveryStatus status;
@Column(nullable = false)
private int attempts;
@Column(name = "last_error", length = 500)
private String lastError;
}

View File

@@ -46,6 +46,34 @@ public class VisitRequest extends BaseEntity {
@Column(nullable = false, length = 255)
private String purpose;
/** 작업명 — concrete task detail, kept separate from the simple purpose category. */
@Column(name = "work_name", length = 255)
private String workName;
// ===== 출입통제담당자 (본인 = 등록한 내부 직원) 스냅샷 =====
@Column(name = "control_name", length = 80)
private String controlName;
@Column(name = "control_team", length = 80)
private String controlTeam;
@Column(name = "control_contact", length = 60)
private String controlContact;
// ===== 현장감시자1 (고정) =====
@Column(name = "watcher1_name", length = 80)
private String watcher1Name;
@Column(name = "watcher1_team", length = 80)
private String watcher1Team;
@Column(name = "watcher1_contact", length = 60)
private String watcher1Contact;
// ===== 현장감시자2 (담당자가 입력) =====
@Column(name = "watcher2_name", length = 80)
private String watcher2Name;
@Column(name = "watcher2_team", length = 80)
private String watcher2Team;
@Column(name = "watcher2_contact", length = 60)
private String watcher2Contact;
@Column(nullable = false)
private LocalDateTime visitFrom;

View File

@@ -50,8 +50,7 @@ public class EmailPassNotifier implements PassNotifier {
Visitor visitor = visitRequest.getVisitor();
String to = visitor != null ? visitor.getEmail() : null;
if (to == null || to.isBlank()) {
log.warn("[email] 방문자 이메일이 없어 출입증 메일을 발송하지 못했습니다. visitRequestId={}", visitRequest.getId());
return;
throw new IllegalStateException("방문자 이메일이 없어 출입증 메일을 발송할 수 없습니다.");
}
try {
@@ -66,10 +65,22 @@ public class EmailPassNotifier implements PassNotifier {
mailSender.send(message);
log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length);
} catch (Exception e) {
log.warn("[email] 출입증 메일 발송 실패 → {} : {}", to, e.getMessage());
// wrap so the caller records a retryable failure
throw new IllegalStateException("메일 발송 실패 → " + to + " : " + e.getMessage(), e);
}
}
@Override
public String channel() {
return "email";
}
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getEmail() : null;
}
private String buildBody(VisitRequest vr) {
Visitor visitor = vr.getVisitor();
String name = visitor != null ? visitor.getName() : "방문자";

View File

@@ -45,8 +45,7 @@ public class HanbankMessagePassNotifier implements PassNotifier {
Visitor visitor = visitRequest.getVisitor();
String phone = visitor != null ? digitsOnly(visitor.getContact()) : "";
if (phone.isBlank()) {
log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId());
return;
throw new IllegalStateException("방문자 연락처가 없어 출입증 문자를 발송할 수 없습니다.");
}
Map<String, String> body = Map.of(
@@ -55,22 +54,29 @@ public class HanbankMessagePassNotifier implements PassNotifier {
"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())) {
if (res == null || !"202".equals(res.statusCode())) {
throw new IllegalStateException("LMS 발송 실패 (statusCode="
+ (res != null ? res.statusCode() : "null")
+ ", statusName=" + (res != null ? res.statusName() : "null") + ")");
}
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());
@Override
public String channel() {
return "hanbank";
}
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getContact() : null;
}
private String buildContent(VisitRequest vr) {

View File

@@ -50,6 +50,17 @@ public class LoggingPassNotifier implements PassNotifier {
saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message);
}
@Override
public String channel() {
return "dev";
}
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getContact() : null;
}
private String buildMessage(VisitRequest vr) {
Visitor visitor = vr.getVisitor();
String name = visitor != null ? visitor.getName() : "방문자";

View File

@@ -18,6 +18,15 @@ public interface PassNotifier {
*
* @param visitRequest the approved request (carries visitor, phone, window)
* @param qrPng PNG bytes of the pass QR to attach/send
* @throws RuntimeException if delivery fails — the caller ({@code PassDeliveryService})
* records the failure so it can be retried. Implementations
* must NOT swallow delivery errors.
*/
void sendPass(VisitRequest visitRequest, byte[] qrPng);
/** Short channel identifier for the delivery record (e.g. "dev", "hanbank", "email"). */
String channel();
/** The address this channel delivers to for the given request (phone or email); may be null. */
String recipient(VisitRequest visitRequest);
}

View File

@@ -15,6 +15,9 @@ public interface AccessEventRepository extends JpaRepository<AccessEvent, Long>
List<AccessEvent> findByVisitRequestIdOrderByEventAtAsc(Long visitRequestId);
/** All events for a set of visits (asc), for computing per-visit state without N+1. */
List<AccessEvent> findByVisitRequestIdInOrderByEventAtAsc(java.util.Collection<Long> visitRequestIds);
/** 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 " +

View File

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

View File

@@ -0,0 +1,17 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PassDeliveryRepository extends JpaRepository<PassDelivery, Long> {
/** Failed deliveries still under the retry cap, oldest first. */
List<PassDelivery> findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus status, int maxAttempts);
List<PassDelivery> findByStatusOrderByCreatedAtDesc(DeliveryStatus status);
List<PassDelivery> findTop200ByOrderByCreatedAtDesc();
}

View File

@@ -32,6 +32,10 @@ public interface VisitRequestRepository extends JpaRepository<VisitRequest, Long
@Query("select vr from VisitRequest vr where vr.qrToken = :qrToken")
Optional<VisitRequest> findByQrTokenForUpdate(@Param("qrToken") String qrToken);
/** Loads visits with visitor+host eagerly in one query (avoids N+1 in the access lists). */
@Query("select vr from VisitRequest vr join fetch vr.visitor join fetch vr.host where vr.id in :ids")
List<VisitRequest> findAllWithVisitorAndHostByIdIn(@Param("ids") java.util.Collection<Long> ids);
/** Bulk-expire approved visits whose window has passed (visit_to before the cutoff). */
@Modifying(clearAutomatically = true)
@Query("update VisitRequest vr set vr.status = com.itcenter.acs.entity.VisitStatus.EXPIRED " +

View File

@@ -21,6 +21,8 @@ public class UserPrincipal implements UserDetails {
private final String username;
private final String password;
private final String fullName;
private final String department;
private final String email;
private final boolean mustChangePassword;
private final boolean enabled;
private final boolean locked;
@@ -31,6 +33,8 @@ public class UserPrincipal implements UserDetails {
this.username = user.getUsername();
this.password = user.getPasswordHash();
this.fullName = user.getFullName();
this.department = user.getDepartment();
this.email = user.getEmail();
this.mustChangePassword = user.isMustChangePassword();
this.enabled = user.isEnabled();
this.locked = user.isLocked();

View File

@@ -21,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -93,23 +94,26 @@ public class AccessService {
@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(
List<Long> ids = accessEventRepository.findInsideVisitRequestIds();
if (ids.isEmpty()) {
return List.of();
}
Map<Long, VisitRequest> vrById = byId(ids);
// last (latest) event per visit = check-in time for currently-inside visitors
Map<Long, LocalDateTime> checkInAt = new java.util.HashMap<>();
for (AccessEvent e : accessEventRepository.findByVisitRequestIdInOrderByEventAtAsc(ids)) {
checkInAt.put(e.getVisitRequest().getId(), e.getEventAt()); // asc → last wins = latest
}
return ids.stream()
.map(vrById::get)
.filter(java.util.Objects::nonNull)
.map(vr -> new InsideVisitorResponse(
vr.getId(),
vr.getVisitor().getName(),
vr.getVisitor().getCompany(),
vr.getZoneName(),
vr.getHost().getFullName(),
checkInAt);
})
checkInAt.get(vr.getId())))
.toList();
}
@@ -118,19 +122,31 @@ public class AccessService {
public List<AccessRecordResponse> listTodayRecords() {
LocalDateTime start = LocalDate.now().atStartOfDay();
LocalDateTime end = start.plusDays(1);
List<Long> ids = accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end);
if (ids.isEmpty()) {
return List.of();
}
Map<Long, VisitRequest> vrById = byId(ids);
// group today's events per visit in one pass (events already ordered ascending)
Map<Long, List<AccessEvent>> eventsByVr = new java.util.HashMap<>();
for (AccessEvent e : accessEventRepository.findByVisitRequestIdInOrderByEventAtAsc(ids)) {
if (!e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end)) {
eventsByVr.computeIfAbsent(e.getVisitRequest().getId(), k -> new java.util.ArrayList<>()).add(e);
}
}
List<AccessRecordResponse> out = new java.util.ArrayList<>();
for (Long id : accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end)) {
VisitRequest vr = visitRequestRepository.findById(id).orElse(null);
for (Long id : ids) {
VisitRequest vr = vrById.get(id);
if (vr == null) {
continue;
}
List<AccessEvent> events = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(id).stream()
.filter(e -> !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end))
.toList();
List<AccessEvent> events = eventsByVr.getOrDefault(id, List.of());
LocalDateTime checkInAt = events.stream()
.filter(e -> e.getDirection() == Direction.IN)
.map(AccessEvent::getEventAt).findFirst().orElse(null);
boolean inside = isInside(id);
// inside = the latest event today is an entry
boolean inside = !events.isEmpty()
&& events.get(events.size() - 1).getDirection() == Direction.IN;
LocalDateTime checkOutAt = inside ? null : events.stream()
.filter(e -> e.getDirection() == Direction.OUT)
.map(AccessEvent::getEventAt).reduce((a, b) -> b).orElse(null);
@@ -147,6 +163,15 @@ public class AccessService {
return out;
}
/** One query to load the given visits with visitor+host, keyed by id. */
private Map<Long, VisitRequest> byId(List<Long> ids) {
Map<Long, VisitRequest> map = new java.util.HashMap<>();
for (VisitRequest vr : visitRequestRepository.findAllWithVisitorAndHostByIdIn(ids)) {
map.put(vr.getId(), vr);
}
return map;
}
@Transactional(readOnly = true)
public List<VisitRequest> searchApproved(String q) {
if (q == null || q.isBlank()) {

View File

@@ -6,7 +6,6 @@ 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;
@@ -24,14 +23,11 @@ import java.util.UUID;
@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;
private final PassDeliveryService passDeliveryService;
private final AuditService auditService;
public VisitRequest approve(Long visitRequestId, Long approverId, String comment) {
return decide(visitRequestId, approverId, comment, ApprovalDecision.APPROVED);
@@ -67,24 +63,20 @@ public class ApprovalService {
approval.setDecidedAt(LocalDateTime.now());
approvalRepository.save(approval);
String visitorName = vr.getVisitor() != null ? vr.getVisitor().getName() : "?";
auditService.record(
decision == ApprovalDecision.APPROVED
? com.itcenter.acs.entity.AuditAction.APPROVE
: com.itcenter.acs.entity.AuditAction.REJECT,
"VISIT_REQUEST", vr.getId(),
"방문자=" + visitorName + (comment != null && !comment.isBlank() ? ", 의견=" + comment : ""));
if (decision == ApprovalDecision.APPROVED) {
notifyVisitor(vr);
// Delivery records its own outcome (SENT/FAILED) and never throws, so a
// delivery problem cannot roll back the approval; failures are retried later.
passDeliveryService.deliver(vr);
}
return vr;
}
/**
* Sends the freshly issued pass to the visitor. A delivery failure must not
* roll back the approval, so it is caught and logged rather than propagated.
*/
private void notifyVisitor(VisitRequest vr) {
try {
byte[] qrPng = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE);
passNotifier.sendPass(vr, qrPng);
} catch (Exception e) {
log.warn("[approval] 출입증 발송 실패 (승인은 정상 처리됨). visitRequestId={} err={}",
vr.getId(), e.getMessage());
}
}
}

View File

@@ -0,0 +1,55 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.AuditLog;
import com.itcenter.acs.repository.AuditLogRepository;
import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Records administrative actions to the audit trail. {@link #record} joins the
* caller's transaction so the action and its audit entry commit atomically.
*/
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditLogRepository auditLogRepository;
@Transactional
public void record(AuditAction action, String targetType, Long targetId, String detail) {
AuditLog log = new AuditLog();
log.setAction(action);
log.setTargetType(targetType);
log.setTargetId(targetId);
log.setDetail(truncate(detail));
// Best-effort actor resolution — some callers (e.g. schedulers) have no principal.
try {
UserPrincipal principal = SecurityUtils.currentPrincipal();
log.setActorId(principal.getId());
log.setActorUsername(principal.getUsername());
} catch (RuntimeException ignored) {
// system-initiated: leave actor null
}
auditLogRepository.save(log);
}
@Transactional(readOnly = true)
public List<AuditLog> recent() {
return auditLogRepository.findTop200ByOrderByCreatedAtDesc();
}
private static String truncate(String s) {
if (s == null) {
return null;
}
return s.length() <= 500 ? s : s.substring(0, 500);
}
}

View File

@@ -1,6 +1,7 @@
package com.itcenter.acs.service;
import com.itcenter.acs.dto.BlacklistRequest;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.Blacklist;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.exception.ApiException;
@@ -19,6 +20,7 @@ public class BlacklistService {
private final BlacklistRepository blacklistRepository;
private final UserRepository userRepository;
private final AuditService auditService;
@Transactional(readOnly = true)
public List<Blacklist> listActive() {
@@ -35,7 +37,10 @@ public class BlacklistService {
b.setReason(req.getReason());
b.setActive(true);
b.setCreatedBy(creator);
return blacklistRepository.save(b);
Blacklist saved = blacklistRepository.save(b);
auditService.record(AuditAction.BLACKLIST_ADD, "BLACKLIST", saved.getId(),
"대상=" + saved.getName() + ", 사유=" + saved.getReason());
return saved;
}
/** Soft-deactivate (lift) a block. */
@@ -43,6 +48,8 @@ public class BlacklistService {
Blacklist b = blacklistRepository.findById(id)
.orElseThrow(() -> ApiException.notFound("차단 항목을 찾을 수 없습니다."));
b.setActive(false);
auditService.record(AuditAction.BLACKLIST_REMOVE, "BLACKLIST", b.getId(),
"대상=" + b.getName());
}
/** Returns the matching block reason, or null if not blacklisted. */

View File

@@ -15,16 +15,24 @@ 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;
import java.util.ArrayList;
import java.util.List;
/**
* 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.
* Bulk-imports visit requests from the corporate 방문자명단 .xlsx template
* (itcas_visitor_template.xlsx). The data sheet is "방문자명단"; the first two rows are
* group + column headers and are skipped. Columns (0-based):
* 0 순번 | 1 출입목적 | 2 작업명 | 3 장소 | 4 출입일자 | 5 출입시간 |
* 6 이름 | 7 소속 | 8 연락처(휴대전화) | 9 차량번호 |
* 10 직원명 | 11 담당팀명 | 12 연락처 (출입통제담당자) |
* 13 이름 | 14 소속 | 15 연락처 (현장감시자1) |
* 16 이름 | 17 소속 | 18 연락처 (현장감시자2)
* Each row is one 장소 → one request/QR ("4층전산실과 5층전산실은 개별 행으로 작성"). Only the
* start date+time are captured; visitTo defaults to 23:59:59 of that day (actual entry/exit
* are tracked by check-in/out). 출입일자 accepts Excel date cells or "yyyy.M.d(요일)" /
* "yyyy-MM-dd" text; 출입시간 accepts Excel time cells, "HH:mm" text, or a day fraction.
*/
@Service
@RequiredArgsConstructor
@@ -32,7 +40,9 @@ public class ExcelImportService {
private final VisitRequestService visitRequestService;
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd[ HH:mm]");
private static final String DATA_SHEET = "방문자명단";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-M-d");
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("H:mm[:ss]");
// Not @Transactional: each row imports in its own transaction (create() is
// @Transactional), so a duplicate/invalid row fails independently without
@@ -42,10 +52,13 @@ public class ExcelImportService {
int dataRows = 0;
try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) {
Sheet sheet = workbook.getSheetAt(0);
Sheet sheet = workbook.getSheet(DATA_SHEET);
if (sheet == null) {
sheet = workbook.getSheetAt(0);
}
for (Row row : sheet) {
if (row.getRowNum() == 0) {
continue; // header
if (row.getRowNum() <= 1) {
continue; // 그룹 헤더 + 컬럼 헤더
}
if (isEmptyRow(row)) {
continue;
@@ -53,7 +66,7 @@ public class ExcelImportService {
dataRows++;
try {
VisitRequestCreateRequest req = parseRow(row);
visitRequestService.create(req, hostUserId);
visitRequestService.createRequests(req, hostUserId);
result.setSuccessCount(result.getSuccessCount() + 1);
} catch (Exception e) {
result.getErrors().add("" + (row.getRowNum() + 1) + ": " + e.getMessage());
@@ -66,20 +79,57 @@ public class ExcelImportService {
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), "퇴실 일시"));
req.setPurpose(requireString(row.getCell(1), "출입목적"));
req.setWorkName(getString(row.getCell(2)));
req.setServerRooms(parseServerRooms(requireString(row.getCell(3), "장소")));
LocalDate date = requireDate(row.getCell(4), "출입일자");
LocalTime time = parseTime(row.getCell(5));
req.setVisitFrom(date.atTime(time != null ? time : LocalTime.MIDNIGHT));
// 신청 시엔 시작만 입력받고, 종료(퇴실)는 당일 마감으로 둔다 (실제 입·퇴장은 체크인/아웃에서 관리).
req.setVisitTo(date.atTime(LocalTime.of(23, 59, 59)));
req.setVisitorName(requireString(row.getCell(6), "이름"));
req.setCompany(getString(row.getCell(7)));
req.setContact(requireString(row.getCell(8), "연락처"));
req.setVehicleNo(getString(row.getCell(9)));
// 출입통제담당자 (비면 서비스가 업로드 사용자로 채움)
req.setControlName(getString(row.getCell(10)));
req.setControlTeam(getString(row.getCell(11)));
req.setControlContact(getString(row.getCell(12)));
// 현장감시자1 (비면 서비스가 고정값으로 채움)
req.setWatcher1Name(getString(row.getCell(13)));
req.setWatcher1Team(getString(row.getCell(14)));
req.setWatcher1Contact(getString(row.getCell(15)));
// 현장감시자2
req.setWatcher2Name(getString(row.getCell(16)));
req.setWatcher2Team(getString(row.getCell(17)));
req.setWatcher2Contact(getString(row.getCell(18)));
return req;
}
/** Splits the 장소 cell into individual zones on comma / semicolon / slash (usually one). */
private List<String> parseServerRooms(String raw) {
if (raw == null || raw.isBlank()) {
return List.of();
}
List<String> rooms = new ArrayList<>();
for (String part : raw.split("[,;/]")) {
String trimmed = part.trim();
if (!trimmed.isEmpty()) {
rooms.add(trimmed);
}
}
return rooms;
}
// A pre-numbered but otherwise blank template row (순번 filled, rest empty) is skipped;
// 순번(0) is ignored so those rows don't count as data.
private static final int[] KEY_COLS = {1, 3, 4, 6, 8};
private boolean isEmptyRow(Row row) {
for (int c = 0; c <= 8; c++) {
for (int c : KEY_COLS) {
String v = getString(row.getCell(c));
if (v != null && !v.isBlank()) {
return false;
@@ -118,21 +168,62 @@ public class ExcelImportService {
return v;
}
private LocalDateTime requireDateTime(Cell cell, String field) {
/** Parses 출입일자: Excel date cell, or text like "2026.7.8(수)" / "2026-07-08" / "2026.07.08". */
private LocalDate requireDate(Cell cell, String field) {
if (cell == null) {
throw new IllegalArgumentException(field + "은(는) 필수입니다.");
}
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue();
return cell.getLocalDateTimeCellValue().toLocalDate();
}
String text = requireString(cell, field);
String norm = text;
int paren = norm.indexOf('(');
if (paren >= 0) {
norm = norm.substring(0, paren); // "(요일)" 제거
}
norm = norm.trim().replace('.', '-').replace('/', '-').replace(" ", "");
norm = norm.replaceAll("-{2,}", "-").replaceAll("-+$", "");
try {
if (text.length() <= 10) {
return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay();
}
return LocalDateTime.parse(text.replace('T', ' '), DT);
return LocalDate.parse(norm, DATE_FMT);
} catch (Exception e) {
throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다 (yyyy-MM-dd HH:mm): " + text);
throw new IllegalArgumentException(
field + " 형식이 올바르지 않습니다 (예: 2026-07-08 또는 2026.7.8): " + text);
}
}
/** Parses 출입시간 (optional): Excel time cell, "HH:mm" text, or a day fraction (0.625 = 15:00). */
private LocalTime parseTime(Cell cell) {
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.NUMERIC) {
if (DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue().toLocalTime();
}
return fractionToTime(cell.getNumericCellValue());
}
String t = getString(cell);
if (t == null || t.isBlank()) {
return null;
}
t = t.trim();
try {
if (t.matches("\\d{1,2}:\\d{2}(:\\d{2})?")) {
return LocalTime.parse(t, TIME_FMT);
}
double d = Double.parseDouble(t);
return fractionToTime(d);
} catch (Exception e) {
return null; // 해석 불가한 시간 → 시작시간 미지정으로 처리
}
}
private LocalTime fractionToTime(double d) {
double frac = d - Math.floor(d);
if (frac < 0) {
return null;
}
return LocalTime.ofSecondOfDay(Math.round(frac * 86400) % 86400);
}
}

View File

@@ -0,0 +1,27 @@
package com.itcenter.acs.service;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
/**
* Periodically retries failed pass deliveries recorded in the outbox, up to
* {@code acs.delivery.max-attempts}. Thin wrapper — the send/record logic lives
* in {@link PassDeliveryService}.
*/
@Service
@RequiredArgsConstructor
public class PassDeliveryRetryScheduler {
private final PassDeliveryService passDeliveryService;
@Value("${acs.delivery.max-attempts:5}")
private int maxAttempts;
/** Every 10 minutes by default; override with acs.delivery.retry-cron. */
@Scheduled(cron = "${acs.delivery.retry-cron:0 */10 * * * *}")
public void retryFailed() {
passDeliveryService.retryFailed(maxAttempts);
}
}

View File

@@ -0,0 +1,114 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.notification.PassNotifier;
import com.itcenter.acs.repository.PassDeliveryRepository;
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.util.List;
/**
* Sends the visitor pass and records the outcome in the {@code pass_deliveries}
* outbox. A failed send is persisted (status=FAILED) so it can be retried later
* by {@code PassDeliveryRetryScheduler} or an admin, rather than being lost.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PassDeliveryService {
/** QR pixel size for the pass image. */
private static final int PASS_QR_SIZE = 240;
private final PassDeliveryRepository passDeliveryRepository;
private final VisitRequestRepository visitRequestRepository;
private final QrService qrService;
private final PassNotifier notifier;
/** First delivery attempt, invoked right after approval. Never throws. */
@Transactional
public PassDelivery deliver(VisitRequest vr) {
PassDelivery d = new PassDelivery();
d.setVisitRequestId(vr.getId());
d.setChannel(notifier.channel());
d.setRecipient(notifier.recipient(vr));
d.setAttempts(1);
attempt(d, vr);
return passDeliveryRepository.save(d);
}
/** Retries every failed delivery still under the attempt cap. Returns how many now succeeded. */
@Transactional
public int retryFailed(int maxAttempts) {
List<PassDelivery> failed =
passDeliveryRepository.findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus.FAILED, maxAttempts);
int recovered = 0;
for (PassDelivery d : failed) {
if (retry(d)) {
recovered++;
}
}
if (!failed.isEmpty()) {
log.info("[delivery] 재발송 시도 {}건 중 {}건 성공", failed.size(), recovered);
}
return recovered;
}
/** Manually retry a single delivery (admin action). */
@Transactional
public PassDelivery retryOne(Long deliveryId) {
PassDelivery d = passDeliveryRepository.findById(deliveryId)
.orElseThrow(() -> ApiException.notFound("발송 기록을 찾을 수 없습니다."));
retry(d);
return d;
}
@Transactional(readOnly = true)
public List<PassDelivery> listByStatus(DeliveryStatus status) {
return status != null
? passDeliveryRepository.findByStatusOrderByCreatedAtDesc(status)
: passDeliveryRepository.findTop200ByOrderByCreatedAtDesc();
}
/** Re-attempts a managed delivery record (dirty-checked). Returns true if it now succeeded. */
private boolean retry(PassDelivery d) {
VisitRequest vr = visitRequestRepository.findById(d.getVisitRequestId()).orElse(null);
d.setAttempts(d.getAttempts() + 1);
if (vr == null) {
d.setStatus(DeliveryStatus.FAILED);
d.setLastError("방문 신청을 찾을 수 없습니다. (id=" + d.getVisitRequestId() + ")");
return false;
}
attempt(d, vr);
return d.getStatus() == DeliveryStatus.SENT;
}
/** Generates the QR and sends via the notifier, setting status/lastError on the record. */
private void attempt(PassDelivery d, VisitRequest vr) {
try {
byte[] qr = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE);
notifier.sendPass(vr, qr);
d.setStatus(DeliveryStatus.SENT);
d.setLastError(null);
} catch (Exception e) {
d.setStatus(DeliveryStatus.FAILED);
d.setLastError(truncate(e.getMessage()));
log.warn("[delivery] 출입증 발송 실패 (visitRequestId={}, attempts={}): {}",
d.getVisitRequestId(), d.getAttempts(), e.getMessage());
}
}
private static String truncate(String s) {
if (s == null) {
return null;
}
return s.length() <= 500 ? s : s.substring(0, 500);
}
}

View File

@@ -52,35 +52,42 @@ public class ReportService {
List<VisitRequest> rows = visitRequestRepository
.findByVisitFromBetweenOrderByVisitFromAsc(from.atStartOfDay(), to.plusDays(1).atStartOfDay());
String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "출입일시", "퇴실일시", "상태"};
String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "작업명", "출입일시", "퇴실일시", "상태"};
try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Sheet sheet = wb.createSheet("출입기록");
CellStyle headerStyle = headerStyle(wb);
// track the widest displayed content per column (CJK counts double) to size columns
int[] widths = new int[headers.length];
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);
widths[i] = displayWidth(headers[i]);
}
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()));
put(row, 0, vr.getVisitor().getName(), widths);
put(row, 1, nv(vr.getVisitor().getCompany()), widths);
put(row, 2, nv(vr.getVisitor().getContact()), widths);
put(row, 3, nv(vr.getZoneName()), widths);
put(row, 4, vr.getHost().getFullName(), widths);
put(row, 5, nv(vr.getPurpose()), widths);
put(row, 6, nv(vr.getWorkName()), widths);
put(row, 7, fmt(vr.getVisitFrom()), widths);
put(row, 8, fmt(vr.getVisitTo()), widths);
put(row, 9, STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()), widths);
}
// autoSizeColumn under-measures CJK text, so set widths from the content
// (1 char ≈ 256 units; +2 chars padding; capped so long purposes don't explode).
for (int i = 0; i < headers.length; i++) {
sheet.autoSizeColumn(i);
int chars = Math.min(widths[i] + 2, 60);
sheet.setColumnWidth(i, chars * 256);
}
wb.write(out);
@@ -90,6 +97,33 @@ public class ReportService {
}
}
/** Writes a string cell and grows the column's tracked display width. */
private void put(Row row, int col, String value, int[] widths) {
row.createCell(col).setCellValue(value);
int w = displayWidth(value);
if (w > widths[col]) {
widths[col] = w;
}
}
/** Display width where CJK (Hangul/한자/전각) glyphs count as 2 columns, others as 1. */
private int displayWidth(String s) {
if (s == null) {
return 0;
}
int w = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
boolean wide = (c >= 0xAC00 && c <= 0xD7A3) // Hangul syllables
|| (c >= 0x1100 && c <= 0x11FF) // Hangul Jamo
|| (c >= 0x3130 && c <= 0x318F) // Hangul compatibility Jamo
|| (c >= 0x4E00 && c <= 0x9FFF) // CJK unified ideographs
|| (c >= 0xFF00 && c <= 0xFFEF); // fullwidth forms
w += wide ? 2 : 1;
}
return w;
}
private CellStyle headerStyle(Workbook wb) {
CellStyle style = wb.createCellStyle();
Font font = wb.createFont();

View File

@@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
@@ -24,15 +25,23 @@ public class VisitRequestService {
private final VisitorRepository visitorRepository;
private final UserRepository userRepository;
// 현장감시자1은 고정 인원. (실제 지정 인원이 다르면 이 상수만 수정)
private static final String FIXED_WATCHER1_NAME = "류관순";
private static final String FIXED_WATCHER1_TEAM = "IT전략국";
private static final String FIXED_WATCHER1_CONTACT = "313";
/**
* Create a pre-registration. Reuses an existing visitor (matched by name+contact)
* or creates a new one. The created request starts in PENDING.
* Create pre-registration(s). Reuses an existing visitor (matched by name+contact)
* or creates a new one, then issues one PENDING request per resolved access zone —
* so selecting two server rooms produces two requests (and, on approval, two QR passes).
*/
public VisitRequest create(VisitRequestCreateRequest req, Long hostUserId) {
public List<VisitRequest> createRequests(VisitRequestCreateRequest req, Long hostUserId) {
if (req.getVisitTo().isBefore(req.getVisitFrom())) {
throw ApiException.badRequest("방문 종료 일시가 시작 일시보다 빠를 수 없습니다.");
}
List<String> zoneNames = resolveZones(req);
User host = userRepository.findById(hostUserId)
.orElseThrow(() -> ApiException.notFound("호스트 사용자를 찾을 수 없습니다."));
@@ -46,20 +55,84 @@ public class VisitRequestService {
visitor.setVehicleNo(req.getVehicleNo());
visitor = visitorRepository.save(visitor);
List<VisitRequest> created = new ArrayList<>();
for (String zoneName : zoneNames) {
if (visitRequestRepository.existsActiveDuplicate(
visitor.getId(), req.getVisitFrom(), req.getVisitTo(), req.getZoneName())) {
throw ApiException.conflict("이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복)");
visitor.getId(), req.getVisitFrom(), req.getVisitTo(), zoneName)) {
throw ApiException.conflict(
"이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복: " + zoneName + ")");
}
VisitRequest vr = new VisitRequest();
vr.setVisitor(visitor);
vr.setHost(host);
vr.setZoneName(req.getZoneName());
vr.setZoneName(zoneName);
vr.setPurpose(req.getPurpose());
vr.setWorkName(req.getWorkName());
vr.setVisitFrom(req.getVisitFrom());
vr.setVisitTo(req.getVisitTo());
vr.setStatus(VisitStatus.PENDING);
return visitRequestRepository.save(vr);
// 출입통제담당자 = 요청값 있으면 사용(엑셀), 없으면 본인(등록한 로그인 사용자) 정보로 채움.
vr.setControlName(orElse(req.getControlName(), host.getFullName()));
vr.setControlTeam(orElse(req.getControlTeam(), host.getDepartment()));
vr.setControlContact(orElse(req.getControlContact(), host.getEmail()));
// 현장감시자1 = 고정값(요청값 비면 고정 상수).
vr.setWatcher1Name(orElse(req.getWatcher1Name(), FIXED_WATCHER1_NAME));
vr.setWatcher1Team(orElse(req.getWatcher1Team(), FIXED_WATCHER1_TEAM));
vr.setWatcher1Contact(orElse(req.getWatcher1Contact(), FIXED_WATCHER1_CONTACT));
// 현장감시자2 = 담당자 입력값 그대로.
vr.setWatcher2Name(trimToNull(req.getWatcher2Name()));
vr.setWatcher2Team(trimToNull(req.getWatcher2Team()));
vr.setWatcher2Contact(trimToNull(req.getWatcher2Contact()));
created.add(visitRequestRepository.save(vr));
}
return created;
}
/** Trimmed value, or null when blank. */
private static String trimToNull(String v) {
if (v == null) {
return null;
}
String t = v.trim();
return t.isEmpty() ? null : t;
}
/** The trimmed value if present, otherwise the fallback. */
private static String orElse(String value, String fallback) {
String v = trimToNull(value);
return v != null ? v : fallback;
}
/**
* Resolves the access-zone labels for a request. Each selected server room (전산실) is a
* zone of its own (its own QR); the detail room (콤보박스) is auxiliary and appended to
* the label. With no server room selected, the detail room becomes the sole zone.
*/
private List<String> resolveZones(VisitRequestCreateRequest req) {
String room = req.getRoomZone() == null ? "" : req.getRoomZone().trim();
List<String> serverRooms = new ArrayList<>();
if (req.getServerRooms() != null) {
for (String s : req.getServerRooms()) {
if (s != null && !s.trim().isEmpty()) {
serverRooms.add(s.trim());
}
}
}
List<String> zones = new ArrayList<>();
if (!serverRooms.isEmpty()) {
for (String sr : serverRooms) {
zones.add(room.isEmpty() ? sr : sr + " / " + room);
}
} else if (!room.isEmpty()) {
zones.add(room);
} else {
throw ApiException.badRequest("출입 구역을 최소 1개 이상 선택하세요.");
}
return zones;
}
@Transactional(readOnly = true)

View File

@@ -1,6 +1,11 @@
spring.application.name=acs
server.port=8080
# ===== Reverse proxy (nginx terminates TLS) =====
# Honor X-Forwarded-Proto/For from the nginx front so request.isSecure() is true behind
# TLS termination — makes the CSRF XSRF-TOKEN cookie Secure and redirects use https.
server.forward-headers-strategy=framework
# ===== Session cookie hardening =====
# SameSite=Lax complements the CSRF token defense.
# Secure=true means the cookie is only sent over HTTPS — enable it (ACS_COOKIE_SECURE=true)

View File

@@ -0,0 +1,16 @@
-- 감사 로그: 관리 행위(승인/반려, 블랙리스트 추가/해제) 추적
-- Hibernate ddl-auto=validate 가 검증하므로 AuditLog 엔티티와 컬럼명·타입이 일치해야 한다.
CREATE TABLE audit_logs (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
actor_id BIGINT,
actor_username VARCHAR(50),
action VARCHAR(30) NOT NULL,
target_type VARCHAR(30),
target_id BIGINT,
detail VARCHAR(500)
);
CREATE INDEX idx_audit_created_at ON audit_logs (created_at);
CREATE INDEX idx_audit_action ON audit_logs (action);

View File

@@ -0,0 +1,16 @@
-- 출입증 발송 outbox: 발송 결과 기록 + 실패건 재발송 추적
-- Hibernate ddl-auto=validate 가 검증하므로 PassDelivery 엔티티와 컬럼명·타입이 일치해야 한다.
CREATE TABLE pass_deliveries (
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,
channel VARCHAR(20),
recipient VARCHAR(120),
status VARCHAR(20) NOT NULL,
attempts INTEGER NOT NULL,
last_error VARCHAR(500)
);
CREATE INDEX idx_pd_status ON pass_deliveries (status);
CREATE INDEX idx_pd_visit_request ON pass_deliveries (visit_request_id);

View File

@@ -0,0 +1,11 @@
-- Visit request detail fields added after the initial schema.
ALTER TABLE visit_requests ADD COLUMN work_name VARCHAR(255);
ALTER TABLE visit_requests ADD COLUMN control_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN control_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN control_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN watcher1_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher1_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher1_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN watcher2_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher2_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher2_contact VARCHAR(60);

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs;
import com.itcenter.acs.dto.AccessRecordResponse;
import com.itcenter.acs.dto.CheckInRequest;
import com.itcenter.acs.dto.InsideVisitorResponse;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import com.itcenter.acs.service.AccessService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
/** Guards the batched (N+1-free) listInside/listTodayRecords against behaviour regressions. */
@SpringBootTest
class AccessQueryTest {
@Autowired AccessService accessService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Test
void insideAndTodayRecordsReflectCheckInThenCheckOut() {
User host = userRepository.findByUsername("host").orElseThrow();
String name = "조회테스트-" + System.nanoTime();
Visitor v = new Visitor();
v.setName(name);
v.setCompany("테스트회사");
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("query");
vr.setZoneName("전산실");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.APPROVED);
Long vrId = visitRequestRepository.save(vr).getId();
CheckInRequest req = new CheckInRequest();
req.setVisitRequestId(vrId);
req.setGateId("TEST");
// check-in → appears inside with a check-in time
accessService.checkIn(req, null);
InsideVisitorResponse inside = accessService.listInside().stream()
.filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow();
assertThat(inside.getVisitorName()).isEqualTo(name);
assertThat(inside.getCheckInAt()).isNotNull();
AccessRecordResponse rec = accessService.listTodayRecords().stream()
.filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow();
assertThat(rec.isInside()).isTrue();
assertThat(rec.getCheckInAt()).isNotNull();
// check-out → no longer inside; today record shows a check-out time
accessService.checkOut(req, null);
assertThat(accessService.listInside().stream().anyMatch(r -> r.getVisitRequestId().equals(vrId)))
.isFalse();
AccessRecordResponse after = accessService.listTodayRecords().stream()
.filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow();
assertThat(after.isInside()).isFalse();
assertThat(after.getCheckOutAt()).isNotNull();
}
}

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs;
import com.itcenter.acs.dto.BlacklistRequest;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.repository.AuditLogRepository;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import com.itcenter.acs.service.ApprovalService;
import com.itcenter.acs.service.BlacklistService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
/** Approving a request and adding a blacklist entry each leave an audit record. */
@SpringBootTest
class AuditLogTest {
@Autowired ApprovalService approvalService;
@Autowired BlacklistService blacklistService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Autowired AuditLogRepository auditLogRepository;
@Test
void approvalIsAudited() {
User host = userRepository.findByUsername("host").orElseThrow();
User admin = userRepository.findByUsername("admin").orElseThrow();
Visitor v = new Visitor();
v.setName("감사테스트-" + System.nanoTime());
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("audit");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.PENDING);
Long vrId = visitRequestRepository.save(vr).getId();
approvalService.approve(vrId, admin.getId(), "확인함");
boolean approveAudited = auditLogRepository.findTop200ByOrderByCreatedAtDesc().stream()
.anyMatch(a -> a.getAction() == AuditAction.APPROVE
&& "VISIT_REQUEST".equals(a.getTargetType())
&& vrId.equals(a.getTargetId()));
assertThat(approveAudited).as("APPROVE audit row for the request").isTrue();
}
@Test
void blacklistAddIsAudited() {
BlacklistRequest req = new BlacklistRequest();
req.setName("차단테스트-" + System.nanoTime());
req.setReason("테스트 차단");
User admin = userRepository.findByUsername("admin").orElseThrow();
var saved = blacklistService.add(req, admin.getId());
boolean added = auditLogRepository.findTop200ByOrderByCreatedAtDesc().stream()
.anyMatch(a -> a.getAction() == AuditAction.BLACKLIST_ADD
&& saved.getId().equals(a.getTargetId()));
assertThat(added).as("BLACKLIST_ADD audit row").isTrue();
}
}

View File

@@ -64,7 +64,7 @@ class EmailPassNotifierTest {
}
@Test
void skipsWhenVisitorHasNoEmail() {
void throwsWhenVisitorHasNoEmail() {
JavaMailSender sender = mock(JavaMailSender.class);
EmailPassNotifier notifier =
new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173");
@@ -77,9 +77,9 @@ class EmailPassNotifierTest {
vr.setVisitTo(LocalDateTime.now().plusHours(1));
vr.setQrToken("t");
notifier.sendPass(vr, new byte[]{1});
// no email → never touches the mail sender
// no email → delivery failure is signalled (recorded/retried by PassDeliveryService)
org.assertj.core.api.Assertions.assertThatThrownBy(() -> notifier.sendPass(vr, new byte[]{1}))
.isInstanceOf(RuntimeException.class);
verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class));
}
}

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.notification.PassNotifier;
import com.itcenter.acs.repository.PassDeliveryRepository;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import com.itcenter.acs.service.PassDeliveryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
/** A failed send is recorded as FAILED and later flipped to SENT by the retry batch. */
@SpringBootTest
class PassDeliveryTest {
@MockitoBean PassNotifier notifier;
@Autowired PassDeliveryService passDeliveryService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Autowired PassDeliveryRepository passDeliveryRepository;
@Test
void failedDeliveryIsRecordedThenRetriedToSent() {
when(notifier.channel()).thenReturn("test");
User host = userRepository.findByUsername("host").orElseThrow();
Visitor v = new Visitor();
v.setName("발송테스트-" + System.nanoTime());
v.setEmail("visitor@example.com");
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("delivery");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.APPROVED);
vr.setQrToken(UUID.randomUUID().toString());
visitRequestRepository.save(vr);
// 1) send fails → recorded FAILED
doThrow(new RuntimeException("relay down")).when(notifier).sendPass(any(), any());
PassDelivery d = passDeliveryService.deliver(vr);
assertThat(d.getStatus()).isEqualTo(DeliveryStatus.FAILED);
assertThat(d.getLastError()).contains("relay down");
Long deliveryId = d.getId();
// 2) relay recovers → retry batch flips it to SENT
doNothing().when(notifier).sendPass(any(), any());
int recovered = passDeliveryService.retryFailed(5);
assertThat(recovered).isGreaterThanOrEqualTo(1);
assertThat(passDeliveryRepository.findById(deliveryId).orElseThrow().getStatus())
.isEqualTo(DeliveryStatus.SENT);
}
}

276
docs/ACS작업일지.md Normal file
View File

@@ -0,0 +1,276 @@
# ACS 작업일지
## 2026-07-10
### 작업 범위 합의
- Codex는 `C:\ai-dev\workspace\acs`의 ACS 개선 및 배포 준비를 담당하기로 함.
- RTGS 프로젝트는 별도 담당자가 작업하므로, 사용자가 명시적으로 요청하지 않는 한 `C:\ai-dev\workspace\rtgs`는 읽기/수정/빌드/배포하지 않기로 함.
- ACS 관련 설치, 구성, 변경, 배포, 검증 내용은 이 파일에 계속 갱신하기로 함.
- 서버/저장소 계정 정보와 비밀번호는 일지에 기록하지 않기로 함.
### 로컬 프로젝트 확인
- ACS 구조 확인:
- 백엔드: Spring Boot 3.4.5, Java 21, Maven, JPA, Flyway, PostgreSQL 운영 구성
- 프론트엔드: React 19, Vite 6, TypeScript
- 배포 구성: `infra/docker-compose.yml`, `infra/docker-compose.tls.yml`, nginx reverse proxy
- Git 원격 저장소는 아직 등록되어 있지 않음을 확인함.
- `.git/index.lock` 파일이 남아 있어, 추후 `git add/commit/push` 전 정리가 필요함.
### 배포 전 빌드/테스트 검증
- 프론트엔드 `npm run build` 성공.
- 백엔드 최초 테스트에서 운영 Flyway 스키마와 엔티티 불일치 발견:
- `visit_requests` 테이블에 `work_name`, `control_*`, `watcher*_ *` 계열 컬럼 누락.
- 신규 Flyway 마이그레이션 추가:
- `backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql`
- 기존 DB에도 적용 가능하도록 `ALTER TABLE ... ADD COLUMN` 방식 사용.
- 테스트용 H2 호환성을 위해 컬럼 추가 구문을 개별 `ALTER TABLE` 문으로 분리함.
- `DataSeeder`의 로컬 계정과 테스트 코드 기대 계정 불일치 수정:
- 기존 단축 계정 `a/s/h` 유지
- 테스트 및 CSV seed와 맞는 `admin/security/host`도 함께 생성하도록 보정
- 백엔드 `mvn -B -ntp test` 성공:
- 9 tests, 0 failures, 0 errors
- 백엔드 `mvn -B -ntp -DskipTests package` 성공:
- `backend/target/acs-0.0.1-SNAPSHOT.jar` 생성 확인
### Docker/Compose 확인
- 로컬 Docker 설치 확인:
- `docker --version` 성공
- `docker compose` 플러그인은 없음
- `docker-compose --version`은 사용 가능
- Compose 설정 문법 확인:
- `docker-compose --env-file infra/.env.example -f infra/docker-compose.yml config` 성공
- `docker-compose --env-file infra/.env.example -f infra/docker-compose.yml -f infra/docker-compose.tls.yml config` 성공
- 운영 배포 URL을 환경변수로 넣은 HTTPS 구성 확인:
- `ACS_PUBLIC_BASE_URL=https://acs.apps.bokdev.in`
- `ACS_COOKIE_SECURE=true`
- Compose config 정상
- Docker 이미지 빌드는 로컬 Docker 빌더 문제로 실패:
- buildx 플러그인 없음
- Docker legacy builder가 Docker API v1.54 build 요청에서 `500 Internal Server Error` 반환
- 코드/Compose 문법 문제보다는 로컬 Docker Desktop/빌더 환경 문제로 판단
### 서버/URL 접근성 확인
- `https://portal.bokdev.in/`:
- HTTPS 접근 가능
- HTTP 200 OK 확인
- `https://acs.apps.bokdev.in`:
- DNS 해석 가능
- 현재 HTTP 503 Service Unavailable 확인
- 아직 앱이 정상 배포/기동되지 않은 상태로 판단
- `https://acs.bokdev.in/0420301/repo`:
- 현재 PC 네트워크에서 `acs.bokdev.in` DNS 해석 실패
- 저장소 원격 등록/푸시 전 네트워크 또는 도메인 확인 필요
### 저장소 URL/DNS 추가 확인
- 저장소 후보 URL `https://acs.bokdev.in/0420301/repo` 확인.
- 로컬 ACS Git remote 확인 결과, 등록된 원격 저장소 없음.
- `git ls-remote https://acs.bokdev.in/0420301/repo` 실행 결과:
- `Could not resolve host: acs.bokdev.in`
- 현재 PC DNS 서버:
- `210.104.132.1`, `210.104.132.2`
- `10.168.198.34`, `10.168.198.35`
- 기본 DNS 및 공개 DNS 확인 결과:
- `portal.bokdev.in``27.96.158.129`로 해석됨.
- `acs.apps.bokdev.in``27.96.158.129`로 해석됨.
- `acs.bokdev.in`은 해석 실패.
- `bokdev.in` 권한 DNS 서버 확인:
- `salvador.ns.porkbun.com`
- `fortaleza.ns.porkbun.com`
- `curitiba.ns.porkbun.com`
- `maceio.ns.porkbun.com`
- 권한 DNS 서버 `curitiba.ns.porkbun.com`에 직접 질의한 결과:
- `portal.bokdev.in` A 레코드 존재: `27.96.158.129`
- `acs.apps.bokdev.in` A 레코드 존재: `27.96.158.129`
- `acs.bokdev.in` A/CNAME 레코드 없음
- `nslookup acs.bokdev.in curitiba.ns.porkbun.com` 결과: `Non-existent domain`
- 결론:
- 현재 저장소 URL의 호스트명 `acs.bokdev.in`은 DNS에 등록되어 있지 않은 것으로 판단.
- 저장소 URL이 잘못 전달되었거나, DNS 레코드 생성이 아직 완료되지 않았을 가능성이 큼.
- 원격 저장소 등록/푸시 전에 정확한 저장소 URL 재확인이 필요.
### AI DEV 매뉴얼 확인
- 참조 파일: `docs/AIdev.md`
- 매뉴얼 기준 주요 서비스:
- 포털: `https://portal.bokdev.in`
- Coder: `https://coder.bokdev.in`
- Gitea: `https://gitea.bokdev.in`
- Kubero: `https://kubero.bokdev.in`
- Coolify: `https://coolify.bokdev.in`
- Gitea 저장소 생성/Push 기준:
- Gitea의 `playground` 조직에 저장소를 생성.
- 저장소 URL 형식은 `https://gitea.bokdev.in/playground/<repo>.git`.
- 예: ACS 저장소명이 `acs`라면 `https://gitea.bokdev.in/playground/acs.git`.
- 배포 기준:
- Gitea 저장소 1개가 배포 앱 1개에 대응.
- Kubero 또는 Coolify 중 하나를 사용.
- Coolify는 Public Repository 방식으로 Gitea 저장소 URL 전체를 입력하고 Dockerfile 빌드를 사용.
- Coolify에서 도메인 생성 시 `https://<repo>.apps.bokdev.in` 형식으로 지정되는 것으로 매뉴얼에 기재되어 있음.
- ACS에 대한 적용 판단:
- 기존 후보 `https://acs.bokdev.in/0420301/repo`는 매뉴얼 기준 저장소 URL 형식이 아님.
- ACS 저장소 URL은 우선 `https://gitea.bokdev.in/playground/acs.git`로 보는 것이 타당.
- 저장소가 아직 없다면 Gitea `playground` 조직에 `acs` repository를 생성해야 함.
- 배포 URL `https://acs.apps.bokdev.in`은 Coolify 도메인 형식과 일치.
### 프로젝트 설정 URL 오류 확인
- 사용자가 포털 프로젝트 수정 화면에서 저장소 URL을 `https://acs.bokdev.in/0420301/repo`로 임의 입력한 사실 확인.
- 해당 값은 Gitea 저장소 URL이 아니므로 수정 필요.
- 후보 저장소 페이지 확인:
- `https://gitea.bokdev.in/playground/acs`: Not found
- `https://gitea.bokdev.in/playground/ACS`: Not found
- `https://gitea.bokdev.in/0420301/acs`: Not found
- `https://gitea.bokdev.in/0420301/ACS`: Not found
- 결론:
- 현재 ACS Gitea 저장소는 아직 생성되지 않았거나, 비공개/다른 이름으로 생성된 상태일 수 있음.
- 매뉴얼 기준으로는 `playground` 조직에 `acs` 저장소를 새로 만들고, 프로젝트 저장소 URL을 `https://gitea.bokdev.in/playground/acs.git`로 설정하는 것이 우선 추천.
- 배포 URL `https://acs.apps.bokdev.in`은 유지 가능.
### Gitea Credential Helper 및 저장소 재확인
- Git 접근 중 Windows `CredentialHelperSelector` 팝업 발생.
- 권장값인 `manager` 선택 완료.
- 이후 `git ls-remote https://gitea.bokdev.in/playground/acs.git` 재시도 시 인증 대기 상태로 타임아웃됨.
- 비대화식 확인:
- `GIT_TERMINAL_PROMPT=0`
- `git -c credential.helper= ls-remote https://gitea.bokdev.in/playground/acs.git`
- 결과: `could not read Username for 'https://gitea.bokdev.in': terminal prompts disabled`
- Gitea API 비교 확인:
- `https://gitea.bokdev.in/api/v1/repos/playground/MANUAL`: 200 OK
- `https://gitea.bokdev.in/api/v1/repos/playground/acs`: `The target couldn't be found`
- 판단:
- Gitea 서버와 API는 정상 접근 가능.
- `playground/acs` 저장소는 비로그인/API 기준으로 존재하지 않거나 private/권한 미승인 상태.
- 포털 프로젝트의 저장소 URL 수정만으로 Gitea 저장소가 자동 생성되는 것은 아니므로, Gitea에서 `playground/acs` repository 생성 여부를 별도로 확인해야 함.
### Gitea ACS 저장소 생성 및 원격 등록
- 사용자가 Gitea에서 ACS 저장소 생성 완료.
- 생성된 저장소 확인:
- 웹 URL: `https://gitea.bokdev.in/0420301/acs`
- Git URL: `https://gitea.bokdev.in/0420301/acs.git`
- `playground/acs`가 아니라 개인 네임스페이스 `0420301/acs`로 생성됨.
- 빈 저장소 상태 확인:
- `git ls-remote https://gitea.bokdev.in/0420301/acs.git` 결과가 비어 있으나 exit code 0으로 정상.
- 이전 타임아웃된 Gitea 확인용 Git 프로세스와 stale lock 파일 정리:
- `.git/config.lock`
- `.git/index.lock`
- 로컬 ACS Git remote 등록 완료:
- `origin https://gitea.bokdev.in/0420301/acs.git`
- 남은 사항:
- push 전 커밋 대상 정리 필요.
- 로그 파일(`backend/backend-run.log`, `frontend/frontend-dev.log`)과 백업 파일(`docs/ACS작업일지.md.bak`)은 커밋 제외 권장.
- 작업트리 변경분 검토 후 최초 commit/push 진행 필요.
### 최초 커밋 및 Gitea Push
- 커밋 대상 정리:
- `.gitignore``*.log`, `*.bak` 제외 규칙 추가.
- `backend/backend-run.log`, `frontend/frontend-dev.log`, `docs/ACS작업일지.md.bak`는 커밋 제외.
- 최초 Gitea push용 커밋 생성:
- 커밋: `01d48fe`
- 메시지: `feat: prepare ACS deployment`
- 포함: ACS 코드 변경, Flyway V4, 문서, 작업일지, AI DEV 매뉴얼 참조 파일.
- 원격 push 완료:
- remote: `origin`
- URL: `https://gitea.bokdev.in/0420301/acs.git`
- branch: `main`
- `origin/main` 추적 설정 완료.
- 원격 검증:
- `git ls-remote origin main` 결과 `01d48fe... refs/heads/main` 확인.
- `https://gitea.bokdev.in/0420301/acs` 웹 페이지 접근 200 OK 확인.
### 테스트용 화면 URL 확인
- 프론트 라우트 정의 파일: `frontend/src/App.tsx`
- 관리자/담당자 로그인 화면:
- 운영 배포 기준: `https://acs.apps.bokdev.in/login`
- 로컬 개발 기준: `http://localhost:5173/login`
- 로그인 후 주요 내부 화면:
- 대시보드: `/dashboard`
- 방문 신청 목록: `/visit-requests`
- 방문 신청 등록: `/visit-requests/new`
- 승인 대기: `/approvals`
- 출입 콘솔: `/access`
- 관리자 감사 로그: `/audit`
- 발송 내역: `/deliveries`
- 방문자/출입 QR 관련 공개 화면:
- 방문자 휴대폰 출입증 화면: `/pass/{qrToken}`
- 출입구 키오스크 QR 태깅/스캔 화면: `/kiosk`
- 운영 배포 기준 키오스크 URL: `https://acs.apps.bokdev.in/kiosk`
- QR/출입증 API:
- 공개 출입증 조회: `/api/public/passes/{qrToken}`
- 공개 QR 이미지: `/api/public/passes/{qrToken}/qr.png`
- 키오스크 체크인: `/api/public/passes/{qrToken}/check-in`
- 키오스크 체크아웃: `/api/public/passes/{qrToken}/check-out`
- QR 토큰은 방문 신청 승인 후 `qrToken`으로 발급됨.
- 문자/이메일 발송 링크는 `ACS_PUBLIC_BASE_URL + "/pass/" + qrToken` 형식으로 생성됨.
### 배포 URL 접속 상태 확인
- 운영 배포 후보 URL 확인:
- `https://acs.apps.bokdev.in`
- `https://acs.apps.bokdev.in/login`
- 확인 결과:
- 두 URL 모두 `no available server` 응답.
- 판단:
- Gitea push는 완료되었으나, Coolify 등 배포 플랫폼에서 해당 도메인에 연결된 앱 서버가 아직 생성/기동되지 않았거나 배포가 실패한 상태로 판단.
- 포털의 프로젝트 등록/저장소 URL 설정과 실제 앱 배포는 별도 단계임.
- 다음 확인 필요:
- Coolify에 ACS resource/app 생성 여부.
- Repository URL이 `https://gitea.bokdev.in/0420301/acs.git`로 설정되었는지.
- Branch가 `main`인지.
- 빌드 방식이 Docker Compose 또는 Dockerfile 중 무엇인지.
- 배포 도메인이 `https://acs.apps.bokdev.in`로 연결되었는지.
- Deployments 로그에서 빌드/기동 실패 원인 확인.
### Coolify Docker Compose 리소스 설정 진행
- 사용자가 Coolify `aidev` 프로젝트에서 `+ Add Resource`를 통해 Docker Compose Empty 리소스 생성 화면 진입.
- Docker Compose file 입력 화면에 최초로 `https://acs.apps.bokdev.in`만 입력했으나, 해당 칸은 도메인 입력칸이 아니라 compose YAML 전체를 입력하는 영역임을 안내.
- Coolify용 compose는 repository root 기준으로 `./backend`, `./frontend` build context를 사용하는 형태가 필요하다고 안내.
- 도메인 `https://acs.apps.bokdev.in`은 compose file 영역이 아니라 resource의 Domains 설정에서 별도 입력해야 함.
- 환경변수는 리소스 저장 후 resource 상세 화면의 `Environment Variables`, `Variables`, 또는 `Developer view`에서 입력해야 함.
- Coolify 배포에 필요한 최소 환경변수:
- `POSTGRES_PASSWORD`
- `POSTGRES_USER=acs`
- `POSTGRES_DB=acs`
- `ACS_PUBLIC_BASE_URL=https://acs.apps.bokdev.in`
- `ACS_COOKIE_SECURE=true`
- `ACS_SMS_PROVIDER=dev`
- Docker Compose 입력 화면에서 저장 시 권한 없음 메시지 발생.
- `Teams > aidev > General` 화면 확인:
- 팀 설정 입력칸이 비활성화된 상태로 보임.
- 현재 계정은 `aidev` 팀/프로젝트 조회는 가능하지만 resource 생성/수정 권한이 부족할 가능성이 큼.
- 다음 확인 필요:
- `Teams > aidev > Members`에서 사용자 `0420301`의 역할 확인.
- `aidev` 팀 또는 프로젝트에서 resource create/update/deploy 권한 부여 요청.
- 권한이 없는 경우 권한 있는 관리자가 ACS resource를 생성하거나, 사용자에게 프로젝트 관리자 권한을 부여해야 함.
### 인증서 확인
- `infra/certs/fullchain.pem`, `infra/certs/privkey.pem` 존재 확인.
- 현재 인증서는 `CN=localhost`, SAN도 `localhost`, `127.0.0.1`용임.
- 운영 도메인 `acs.apps.bokdev.in`용 인증서가 아니므로 실제 HTTPS 운영 배포에는 부적합.
- 운영 배포 전 포털/플랫폼 인증서 자동 제공 여부 또는 도메인 인증서 교체 필요.
### 배포 정책 추천
- 권장 흐름:
- 로컬 개발
- Git commit/push
- 서버 개발환경 배포
- 서버 개발환경 검증
- 운영 배포 승인
- 서버 운영환경 배포
- 서버에서 직접 코드를 수정하며 개발하는 방식은 비추천.
- 서버 개발환경도 Git으로 받은 검증 대상 환경으로 운영하고, 운영환경에는 검증된 커밋/태그만 배포하는 정책을 권장.
- 권장 브랜치/환경:
- `dev` 또는 `develop`: 서버 개발환경 배포
- `main`: 운영 배포 가능 브랜치
- 운영 배포 시 태그 사용 예: `acs-v0.1.0`
- DB 변경은 Flyway migration으로만 반영하는 정책 유지.
### 남은 작업
- `.git/index.lock` 정리 후 Git 작업 가능 상태 확인.
- ACS 원격 저장소 URL/DNS 문제 확인.
- 원격 저장소 등록 및 최초 push 여부 결정.
- 서버 개발환경과 운영환경을 포털에서 분리 구성할 수 있는지 확인.
- 운영 도메인 인증서 처리 방식 확인.
- 로컬 Docker buildx 또는 Docker Desktop 빌더 문제 해결.
- 실제 배포 전 운영 `.env` 값 확정:
- `POSTGRES_PASSWORD`
- `ACS_PUBLIC_BASE_URL`
- `ACS_COOKIE_SECURE`
- `ACS_SMS_PROVIDER`
- SMTP 또는 사내 메시지 API 설정

349
docs/AIdev.md Normal file
View File

@@ -0,0 +1,349 @@
# AI DEV 개발·배포 매뉴얼 (개발자용)
행번 계정 하나로 **Coder에서 개발**하고, **Gitea에 push**, **Kubero 또는 Coolify로 배포**합니다.
(배포 도구는 Kubero·Coolify 중 검토 중입니다. [9번](#9-배포-gitea--kubero--coolify)에 두 방법을 모두 정리해 두었습니다.)
DB(PostgreSQL)·파일저장소(MinIO)·AI(LiteLLM - key 제외)는 워크스페이스에 미리 연결되어 있습니다.
<!-- 이미지: `![설명](images/파일명.png)` 자리에 맞춰 캡처 후 images/ 에 추가 -->
## 목차
**최초 1회**
[1. 로그인](#1-로그인) → [2. 워크스페이스 만들기](#2-워크스페이스-만들기-최초-1회) → [3. VS Code 열기](#3-vs-code-열기) → [5. AI 키 등록](#5-ai-키-등록-최초-1회)
**앱마다**
[6. 새 프로젝트 시작](#6-새-프로젝트-시작) → [7. 개발](#7-개발) → [8. 로컬 실행·확인](#8-로컬-실행확인) → [9. 배포](#9-배포-gitea--kubero--coolify)
```
[최초 1회] 로그인(1) → 워크스페이스 생성(2) → VS Code(3) → AI 키 등록(5)
[앱마다] new-project + git init(6) → 개발·커밋(7) → 로컬 확인(8) → 레포 생성·push·배포(Kubero/Coolify)(9)
```
## 0. 서비스 주소
| 용도 | 주소 |
|---|---|
| AI DEV 포털 (시작점) | https://portal.bokdev.in |
| Coder (개발 워크스페이스) | https://coder.bokdev.in |
| Gitea (코드 저장소) | https://gitea.bokdev.in |
| Kubero (배포 · 후보) | https://kubero.bokdev.in |
| Coolify (배포 · 후보) | https://coolify.bokdev.in |
| 개발 중 미리보기 | `https://<자동생성>.coder.bokdev.in` |
| 배포된 앱 | `https://<레포명>.playground.bokdev.in` |
모든 서비스는 **행번 계정(SSO)** 으로 로그인합니다.
코드에서 쓰는 접속정보(DB·S3)는 `.project-env` 파일로 자동 제공됩니다. 직접 입력할 값이 없습니다.
## 1. 로그인
1. https://portal.bokdev.in 접속
<!-- TODO: 포털 주소 portal.bokdev.in / backstage.bokdev.in 중 확정 -->
2. 행번 계정으로 로그인
- 아이디: 본인 행번 (예: `2620227`)
- 비밀번호: 본인 비밀번호 (초기 비밀번호: `bok1234!!` + `행번 7자리`)
![포털 로그인 화면](images/01-login.png)
3. 이후 Coder·Gitea·Kubero는 추가 로그인 없이 같은 계정으로 열립니다.
- Kubero의 경우 `OAuth로 로그인하기`를 눌러 SSO 로그인이 가능합니다.
![포털 로그인 화면](images/portal.bokdev.in_.png)
## 2. Coder 워크스페이스 만들기 (최초 1회)
Coder 워크스페이스 = 본인 전용 개발 컨테이너(VS Code + 개발 도구 일체).
> ⚠️**주의**: 같은 브라우저에 다른 계정으로 Gitea 로그인이 남아 있으면 그 계정으로 연동됩니다.
> 승인 전에 Gitea에서 로그아웃했는지 확인하거나, **시크릿 모드**에서 진행합니다.
> Coder와 Gitea의 로그인 계정이 일치하지 않는 경우 Workspace 생성 후 계정 불일치로 Push가 되지 않을 수 있습니다.
1. https://coder.bokdev.in → **Workspaces****Create Workspace** (템플릿: `aidev`)
2. 설정값 입력
- **Name**: 워크스페이스 이름 (예: `ws-aidev-<행번>`)
- **External Authentication**: Gitea — **애플리케이션 승인** 클릭
- **CPU / Memory / Disk**: 기본값(2 Core / 4 GiB / 10 GiB) 사용. 추후 변경 가능.
![Gitea 계정 연동](images/02-coder-gitea.png)
3. **Create Workspace** 클릭
4. 최초 빌드는 2~5분 소요. 상태가 **Running**이 되면 완료.
> **주의**: 워크스페이스는 한 번 만들면 계속 사용합니다.
## 3. VS Code 열기
1. 워크스페이스 화면에서 **VS Code Web** 아이콘 클릭
2. `/home/coder/projects` 폴더가 자동으로 열립니다. ("Yes, I trust the authors" 클릭)
3. 안에 **`sample`** 폴더가 있습니다. DB·S3 연결이 확인된 참조용 예제이며 **직접 수정하지 않습니다**. [6번](#6-새-프로젝트-시작)에서 복사해 사용합니다.
<!-- TODO: 예제 폴더명 sample → connection-validation 변경 반영 여부 확인 (new-project 스크립트 포함) -->
> 작업 파일은 반드시 `/home/coder/projects` 아래에 둡니다. 이 폴더만 워크스페이스 재시작 후에도 보존됩니다.
## 4. 기본 제공 환경
새 워크스페이스에 아래가 설치·연결되어 있습니다.
| 항목 | 내용 |
|---|---|
| 개발 도구 | Java(JDK)/Maven, Node 22, Python 3.12, git, psql |
| 컨테이너 | podman (`docker` 명령도 동일 동작) |
| DB | 본인 전용 PostgreSQL 스키마 (`$DATABASE_URL`) |
| VS Code 확장 | Claude Code, Codex |
| AI CLI | `claude`, `codex` — [5번](#5-ai-키-등록-최초-1회)에서 키 등록 필요 |
## 5. AI 키 등록 (최초 1회)
Claude Code / Codex 는 사내 AI 게이트웨이(LiteLLM)를 사용합니다. 발급받은 본인 virtual key를 한 번만 등록하면 CLI·확장이 모두 공유합니다.
1. 터미널 열기: VS Code 메뉴(좌상단 ☰) → **Terminal → New Terminal**
2. 아래 명령 실행 후 본인 키(`sk-...`) 입력:
```bash
update-litellm-key
```
```
LiteLLM virtual key 입력 (sk-...): sk-본인-키
키 갱신 완료 (len=25). 현재 터미널에 즉시 적용됨.
```
3. 확인:
```bash
echo $ANTHROPIC_BASE_URL # https://litellm.bok.or.kr 이면 정상
claude
```
```bash
echo $OPENAI_BASE_URL # https://litellm.bok.or.kr/v1 이면 정상
codex
```
> **키 등록·변경 후 VS Code(웹)가 응답하지 않을 수 있습니다.**
> 워크스페이스 화면에서 VS Code 서버를 **Stop → Start** 하여 재시작합니다.
키는 워크스페이스의 `~/.env`에만 저장됩니다. 키를 바꿀 때도 같은 명령을 다시 실행합니다.
기본 모델은 게이트웨이에 맞춰 설정되어 있습니다.
| 도구 | 기본 모델 | 설정 파일 |
|---|---|---|
| Claude Code | `claude-opus-4-8` | `~/.claude/settings.json` |
| Codex | `gpt-5.5` | `~/.codex/config.toml` |
## 6. 새 프로젝트 시작
`sample` 예제를 복사해 시작합니다.
**(1) 터미널에서 프로젝트 생성** — 반드시 `~/projects` 에서 실행:
```bash
cd ~/projects
cd sample && git pull && cd .. # 예제 최신화
new-project myapp # 예제를 ~/projects/myapp 으로 복사 + .project-env 자동 생성
```
`myapp`은 예시입니다. 이 이름은 Gitea 레포명으로 설정할 이름과 동일하게 맞추시면 되고, 소문자·숫자·하이픈만 사용합니다.
**(2) git 초기화** — 개발 시작 시점에 합니다. 커밋 이력을 처음부터 관리하기 위함이며, 원격(Gitea) 연결은 배포 단계([9번](#9-배포-gitea--kubero--coolify))에서 합니다:
```bash
cd ~/projects/myapp
git init -b main
git add .
git commit -m "init project"
```
**(3) VS Code로 폴더 열기**: **File → Open Folder…** → `/home/coder/projects/myapp` → OK
왼쪽에 `myapp` 파일 목록이 보이면 완료. 새 터미널은 이 폴더에서 시작됩니다.
**(4) 라이브러리 설치**:
```bash
npm install
```
> **`.project-env`** 는 이 프로젝트의 설정 파일(DB·S3 접속정보)입니다. 폴더에 들어가면(cd) 자동으로 환경변수에 로드됩니다.
> <!-- TODO: 예제 .gitignore 에서 .project-env 제외할지 확인 필요 -->
> LiteLLM 키만 예외로 워크스페이스 공용 `~/.env`([5번](#5-ai-키-등록-최초-1회))에서 관리합니다.
## 7. 개발
- **편집**: 왼쪽 파일 목록에서 파일 선택 → 수정 → Ctrl+S 저장
- **AI 도구**: 프로젝트 폴더 안 터미널에서 `claude` 또는 `codex` 실행. 폴더 밖에서 실행하면 프로젝트 파일을 읽지 못합니다.
- **커밋**: 기능 단위로 수시로 커밋합니다. push는 배포 단계에서.
```bash
git add . && git commit -m "메시지"
```
- **DB 접속**:
```bash
psql "$DATABASE_URL" # 프로젝트 폴더에서 실행 (.project-env 로드 필요)
```
- 코드에서는 `process.env.DATABASE_URL`, `process.env.S3_*` 를 사용합니다.
- **`CLAUDE.md`**: 프로젝트 규칙·주의사항을 적어두면 Claude Code가 자동으로 읽고 따릅니다. 예제에 기본 파일이 포함되어 있습니다.
- AI에게는 구체적으로 지시합니다. 예: "로그인 API 만들어줘" 대신 "`src/`에 POST /login 추가, 검증 실패 시 401 반환". 생성된 코드는 [8번](#8-로컬-실행확인)으로 직접 확인 후 커밋합니다.
### 7-1. bkit 플러그인 (선택)
Claude Code에 계획→설계→구현→검증 절차를 더하는 플러그인. 터미널의 `claude` CLI에서만 동작합니다(VS Code 확장 미지원).
설치(최초 1회, `claude` 실행 후 프롬프트에 입력):
```
/plugin marketplace add popup-studio-ai/bkit-claude-code
/plugin install bkit
```
사용: `/pdca pm <기능이름>` — 기능 하나를 계획부터 검증까지 진행. 세분화 명령은 `/pdca plan` `/pdca design` `/pdca do` `/pdca analyze`.
## 8. 로컬 실행·확인
**(1) 실행**
```bash
cd ~/projects/myapp
npm run dev # 저장 시 자동 재시작
```
`listening on :3000` 이 에러 없이 출력되면 기동 성공.
실패 시 순서대로 확인: ① `npm install` 했는지 ② 코드 문법 오류 ③ 프로젝트 폴더 밖에서 실행(`.project-env` 미로딩).
**(2) 연결 점검** — 앱을 띄우지 않고 DB·S3 연결만 확인:
```bash
npm run db:check # "DB OK: ..." 이면 정상
npm run minio:check # "S3 OK: ..." 이면 정상
```
FAIL이면 `.project-env` 값을 확인합니다. 여기서 통과하면 배포 환경에서도 동일하게 동작합니다.
**(3) 브라우저 미리보기** — 워크스페이스는 클러스터 내부라 `localhost:3000`이 PC 브라우저에서 열리지 않습니다. 포트 포워딩을 사용합니다:
1. VS Code 하단 **PORTS** 탭 → **Forward a Port** → `3000` 입력
2. 포워딩된 포트의 **Open in Browser** 클릭 → `https://<자동생성>.coder.bokdev.in`
![브라우저 미리보기](images/coder-ports-preview.png)
**(4) 엔드포인트 확인**
```bash
curl 127.0.0.1:3000/healthz # {"ok":true} 앱 기동
curl 127.0.0.1:3000/db # {"ok":true,"now":...} DB 연결
curl 127.0.0.1:3000/s3 # {"ok":true,"bucket":...} S3 연결
```
`"ok": false` 이면 함께 출력되는 `error` 메시지가 원인입니다.
미리보기 URL은 본인 전용이며 워크스페이스를 끄면 사라집니다. 정식 배포는 [9번](#9-배포-gitea--kubero--coolify).
## 9. 배포 (Gitea → Kubero / Coolify)
배포 단위: Gitea `playground` 조직의 레포 1개 = 배포 앱 1개.
배포 주소: `https://<레포명>.playground.bokdev.in`
배포 도구는 **Kubero**와 **Coolify** 중 하나를 사용합니다.
**Gitea 레포 생성([9-1](#9-1-gitea-원격-레포-생성-앱당-1회))과 push([9-2](#9-2-push))는 두 도구 공통**이며, 이후 사용하는 도구에 따라 [9-3A(Kubero)](#9-3a-kubero에-앱-추가-앱당-1회) 또는 [9-3B(Coolify)](#9-3b-coolify에-앱-추가-앱당-1회)를 따릅니다.
| 항목 | Kubero | Coolify |
|---|---|---|
| 배포 위치 | `playground` 파이프라인에 앱 추가 | `ai-dev` 팀 → `ai-dev` 프로젝트에 앱 추가 |
| 코드 수정 반영 | push 후 **수동 재빌드** (자동 빌드 미연동) | push 시 **자동 재빌드·배포** (webhook 설정 시, [9-3B](#9-3b-coolify에-앱-추가-앱당-1회)) |
| 환경변수 입력 | `.project-env` 업로드 → 자동 파싱 | `.project-env` 값을 붙여넣기 (Developer view) |
| 빌드 방식 | Dockerfile | Dockerfile |
### 9-1. Gitea 원격 레포 생성 (앱당 1회)
1. https://gitea.bokdev.in/playground → 우측 상단 **`+` → New Repository**
![새 저장소 만들기](images/make-new-repository.png)
2. **Owner: `playground`** 로 변경, Repository Name 입력 (예: `myapp`), public 설정
![새 저장소 옵션 설정](images/make-new-repository-2.png)
3. README / .gitignore / License 는 체크하지 않음(빈 저장소여야 함) → **Create Repository**
### 9-2. push
```bash
cd ~/projects/myapp
git remote add origin https://gitea.bokdev.in/playground/myapp.git
git push -u origin main
```
- 최초 push 시 Gitea 승인 화면이 뜨면 **Authorize** 클릭([2번](#2-워크스페이스-만들기-최초-1회)에서 승인했다면 생략됨).
- 이후 수정 반영: `git add . && git commit -m "..." && git push`
### 9-3A. Kubero에 앱 추가 (앱당 1회)
`playground` pipeline을 사용하시면 되며, 사용자는 그 안에 본인 앱만 추가합니다.
1. https://kubero.bokdev.in 접속
2. **`playground`** 파이프라인 선택
3. **Production** 아래의 `+` 버튼을 클릭해 앱을 추가
4. App Name과 환경 ENVIRONMENT VARIABLES 추가
![Kubero 배포 앱 추가](images/kubero-pipeline.png)
- `.project-env` 파일을 업로드 하면 자동으로 파싱되어 등록됩니다.
> 자동 빌드는 현재 미연동입니다. **코드 수정 후에는 push 하고 Kubero에서 해당 앱의 빌드를 다시 실행합니다.**
### 9-3B. Coolify에 앱 추가 (앱당 1회)
Coolify는 "**push → Dockerfile로 자동 빌드·배포**" 방식입니다. 사용자는 `ai-dev` 프로젝트에 본인 앱만 추가합니다.
1. https://coolify.bokdev.in 접속 → 우측 상단에서 **`aidev`** 팀 선택
2. 좌측 **`Projects` → `aidev`** (서버·DB가 연결된 프로젝트) → **`+ Add Resource`**
![Coolify 팀 선택](images/coolify-team.png)
3. 리소스 종류에서 **`Public Repository`** 선택
![Coolify 리소스 종류 선택](images/coolify-new-resource.png)
4. **Repository URL**에 **전체 주소**를 입력 후 **`Check Repository`**:
`https://gitea.bokdev.in/playground/myapp.git`
(`playground/myapp` 처럼 줄여 쓰면 실패합니다.)
> **비공개(private) 레포일 때** — `Public Repository` 로도 받을 수 있습니다. URL에 Gitea 토큰을 끼워 넣습니다:
> `https://<토큰>@gitea.bokdev.in/playground/myapp.git`
> - 토큰 발급: Gitea → 우측 상단 프로필 → **Settings → Applications → Generate New Token**. 이름 지정 후 **`repository` 읽기 권한(Read)** 만 체크 → 생성. 표시되는 토큰은 **이때 한 번만** 보이므로 복사해 둡니다.
> - 발급한 토큰을 위 URL의 `<토큰>` 자리에 넣고 **`Check Repository`**. (토큰이 URL·Coolify 설정에 저장되므로 읽기 전용 권한만 부여합니다.)
5. **Build Pack: `Dockerfile`**, Branch `main`, Port `3000`.
![Coolify 저장소 연결](images/coolify-repo.png)
6. **Configuration → Domains** 에서 **`Generate Domain`** 클릭 → `https://<레포명>.apps.bokdev.in` 형태로 지정.
7. **Environment Variables** 에 `.project-env` 값 등록:
- **Developer view** 에서 `.project-env` 내용을 그대로 붙여넣으면 일괄 등록됩니다. (`cat ~/projects/myapp/.project-env`)
- `DATABASE_URL` 은 `%20`·`%3D` 인코딩까지 **그대로** 넣습니다(빼면 DB 연결이 깨집니다).
8. **Deploy** 클릭 → **Deployments** 탭에서 빌드 로그 확인. `New container started` / `Deployment finished` 가 보이면 성공.
#### 자동 배포(auto deploy) 설정 (앱당 1회)
Public Repository 방식은 webhook을 걸어야 push가 자동 배포로 이어집니다(Coolify가 push를 스스로 감지하지 못함). Coolify에 별도의 "Auto Deploy" 켜기 단계는 없고, **Webhooks 탭의 URL·Secret을 Gitea에 등록하는 것이 곧 자동 배포 설정**입니다. 앱마다 한 번만 하면 됩니다.
1. **Coolify 앱** → **Configuration → Webhooks** 탭에서, **Gitea** 항목의 **Webhook URL** 과 **Secret** 을 복사합니다. (Secret 칸이 비어 있으면 값을 입력/생성 후 저장)
![Coolify Webhook URL·Secret](images/coolify-webhook.png)
2. **Gitea 레포** → `https://gitea.bokdev.in/playground/myapp` → **Settings → Webhooks → Add Webhook → Gitea** 에 등록:
- **Target URL**: 1번의 Webhook URL
- **Secret**: 1번의 Secret
- **Content Type**: `application/json`
- **Trigger**: Push events, **Active** 체크 → **Add Webhook**
![Gitea Webhook 등록](images/gitea-webhook.png)
3. Gitea webhook 화면의 **Test Delivery** 를 누르거나 실제로 `git push` → Coolify **Deployments** 에 새 빌드가 자동으로 뜨면 완료.
> push가 배포를 트리거할지는 앱 **Advanced** 탭의 **`Auto Deploy`** 옵션이 결정하며, **기본값이 켜짐**이라 따로 켤 필요는 없습니다(자동 배포를 끄고 싶을 때만 여기서 해제).
> 설정 후에는 코드를 고쳐 **`git push` 하면 자동으로 다시 빌드·배포**됩니다(Deployments에서 새 빌드 로그 확인). webhook을 걸지 않았다면 앱 화면에서 **Deploy** 를 눌러 수동 배포합니다.
### 9-4. 확인
배포·재시작 직후 약 1~2분은 초기화(코드 다운로드·설치) 시간입니다. 일시적으로 404가 나오는 경우, 잠시 기다린 후 Ctrl + Shift + R로 강력 새로고침 후 확인해주세요.
```bash
curl https://<레포명>.playground.bokdev.in/healthz # {"ok":true}
curl https://<레포명>.playground.bokdev.in/db
curl https://<레포명>.playground.bokdev.in/s3
```
![배포된 앱](images/08-deployed-app.png)
문제가 있으면 Kubero에서 해당 앱의 빌드/배포 로그를 확인합니다. 로그에 `listening on :3000` 이 보이면 기동 성공입니다.
## FAQ
- Coder Workspace 켜고 끄기
- Coder workspace 재기동이 필요한 경우: Coder 워크스페이스 화면에서 **Stop**
- 다시 사용: **Start** (VS Code Web 아이콘이 뜰 때까지 대기)
- `~/projects` 만 보존됩니다. 그 외 경로의 파일은 사라질 수 있습니다.
- **로그인을 서비스마다 해야 하나요** → 아니요. 행번 계정 SSO 하나로 전부 로그인됩니다.
- **Coder에서 파일이 사라졌어요** → `~/projects` 밖에 저장한 경우 파일이 유실될 수 있습니다([3번](#3-vs-code-열기)).
- **AI 도구 401 오류** → `update-litellm-key` 재실행([5번](#5-ai-키-등록-최초-1회)). 키가 `sk-`로 시작하는지 확인.
- **AI 도구 400 (Invalid model)** → [5번](#5-ai-키-등록-최초-1회) 표의 기본 모델명 사용.
- **키 등록 후 VS Code가 먹통** → VS Code 서버 Stop → Start([5번](#5-ai-키-등록-최초-1회)).
- **`$DATABASE_URL` 이 비어 있음** → 프로젝트 폴더 안에서 실행해야 `.project-env` 가 로드됩니다([6번](#6-새-프로젝트-시작)).
- **`npm run dev` 가 `Cannot find package ...`** → `npm install` 미실행([6번](#6-새-프로젝트-시작)).
- **push 인증을 물어봄** → Gitea 승인을 아직 안 한 경우. 승인 화면에서 Authorize([9-2](#9-2-push)).
- **다른 계정으로 push/연동됨** → 브라우저에 남아 있던 Gitea 로그인 세션 때문입니다. Gitea 로그아웃 후 재승인하거나 시크릿 창 사용([2번](#2-워크스페이스-만들기-최초-1회)).
- **배포 주소가 404** → ① 배포 직후 1~2분 대기 ② `/healthz` 확인 ③ Kubero/Coolify 배포 로그 확인([9-4](#9-4-확인)).
- **`/healthz` 는 되는데 `/db`·`/s3` 가 500** → 먼저 로컬에서 `npm run db:check` / `minio:check` 통과 확인([8번](#8-로컬-실행확인)). 로컬에서 되면 배포 로그의 에러 메시지 확인. Coolify는 `.project-env` 값(특히 `DATABASE_URL` 의 `%20`/`%3D`)이 그대로인지도 확인.
- **배포가 옛날 코드** → push 됐는지 먼저 확인. Kubero는 빌드 재실행([9-3A](#9-3a-kubero에-앱-추가-앱당-1회)), Coolify는 push 시 자동 재배포([9-3B](#9-3b-coolify에-앱-추가-앱당-1회)).
- **DB가 비어 있음** → 정상입니다. 빈 전용 스키마가 제공되며 테이블은 직접 생성합니다.
- **K8s에 직접 접근하고 싶어요** → 직원은 K8s에 직접 접근하지 않습니다. Coder·Gitea·Kubero로 개발·배포가 완결됩니다.
## 문의
- IT 전략국 클라우드팀 김창록 팀장
- IT 전략국 정보시스템개발팀 박성록 과장
- IT 전략국 클라우드팀 이혜민 조사역

View File

@@ -1,7 +1,7 @@
# ACS 개발 이슈 정리 및 유의사항(규칙)
> 문서 작성일: 2026-07-03
> 대상: IT센터 출입자관리시스템 (`C:\ai-dev\workspace\access-control-system`)
> 대상: IT센터 출입자관리시스템 (`C:\ai-dev\workspace\acs`)
> 목적: 기획·개발·테스트·수정 단계에서 실제로 겪은 이슈를 정리하고, 재발 방지를 위한 **규칙**으로 제안한다.
> 표기: 각 항목은 **[이슈] → [규칙]** 형태. 규칙 요약은 문서 끝 §6 체크리스트 참조.

View File

@@ -1,7 +1,7 @@
# IT센터 출입자관리시스템(ACS) 워크플로우
> 문서 작성일: 2026-07-03
> 대상: `C:\ai-dev\workspace\access-control-system` (Spring Boot 3.4.5 / Java 21 · React 19 · Vite 6)
> 대상: `C:\ai-dev\workspace\acs` (Spring Boot 3.4.5 / Java 21 · React 19 · Vite 6)
> 목적: 방문자 사전신청 → 승인 → 출입증 발송 → 입·출입 체크 → 재실현황/리포트까지의 전체 업무 흐름 정리
---

View File

@@ -0,0 +1,6 @@
회사의 이메일 API 사용법
https://helpdesk.dooray.com/share/pages/9wWo-xwiR66BO5LGshgVTg/2937064454837487755
https://helpdesk.dooray.com/share/pages/9wWo-xwiR66BO5LGshgVTg/2939991731086319521

43
frontend/nginx-tls.conf Normal file
View File

@@ -0,0 +1,43 @@
# HTTPS variant of nginx.conf — used by the docker-compose.tls.yml overlay.
# nginx terminates TLS and reverse-proxies /api to the backend over the internal network.
# Mount a certificate at /etc/nginx/certs/{fullchain.pem,privkey.pem}.
# Redirect all plain HTTP to HTTPS.
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name _;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
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).
# X-Forwarded-Proto=https lets the app (server.forward-headers-strategy=framework)
# know the original request was secure → Secure cookies + https redirects.
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 / /;
}
}

View File

@@ -15,6 +15,8 @@ import { PublicPassPage } from './pages/PublicPassPage';
import { KioskPage } from './pages/KioskPage';
import { BlacklistPage } from './pages/BlacklistPage';
import { ReportPage } from './pages/ReportPage';
import { AuditLogPage } from './pages/AuditLogPage';
import { DeliveryOutboxPage } from './pages/DeliveryOutboxPage';
/** Requires a logged-in user; optionally one of the given roles. */
const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => {
@@ -81,6 +83,22 @@ export default function App() {
</Protected>
}
/>
<Route
path="/audit"
element={
<Protected roles={['ADMIN']}>
<AuditLogPage />
</Protected>
}
/>
<Route
path="/deliveries"
element={
<Protected roles={['ADMIN']}>
<DeliveryOutboxPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />

View File

@@ -2,13 +2,16 @@ import {
AccessAction,
AccessRecord,
ApiResponse,
AuditLog,
BlacklistCreate,
BlacklistItem,
ChangePasswordRequest,
CurrentUser,
DeliveryStatus,
ExcelImportResult,
InsideVisitor,
LoginRequest,
PassDelivery,
StatsSummary,
PublicPass,
VisitRequestCreate,
@@ -94,7 +97,7 @@ export const getVisitRequest = (id: number) =>
request<VisitRequestView>(`/visit-requests/${id}`);
export const createVisitRequest = (req: VisitRequestCreate) =>
request<VisitRequestView>('/visit-requests', jsonInit('POST', req));
request<VisitRequestView[]>('/visit-requests', jsonInit('POST', req));
export const cancelVisitRequest = (id: number) =>
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
@@ -153,3 +156,12 @@ export const deleteBlacklist = (id: number) =>
// ===== Reports =====
export const reportDownloadUrl = (from: string, to: string) =>
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
// ===== Audit log (ADMIN) =====
export const listAudit = () => request<AuditLog[]>('/admin/audit');
// ===== Pass delivery outbox (ADMIN) =====
export const listDeliveries = (status?: DeliveryStatus) =>
request<PassDelivery[]>(`/admin/deliveries${status ? `?status=${status}` : ''}`);
export const retryDelivery = (id: number) =>
request<PassDelivery>(`/admin/deliveries/${id}/retry`, { method: 'POST' });

View File

@@ -0,0 +1,43 @@
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 {
/** date string in "YYYY-MM-DD" (the format the back-end expects). */
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
const pad = (n: number) => String(n).padStart(2, '0');
/** Date → "YYYY-MM-DD" (local). */
function toISODate(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/**
* Korean-localized date-only picker. Displays the value as YYYY.MM.DD (which the
* native <input type="date"> cannot force — it follows the OS locale) while the
* bound value stays "YYYY-MM-DD" for the API.
*/
export const DatePickerField: React.FC<Props> = ({ value, onChange, placeholder }) => {
const ref = useRef<DatePicker>(null);
return (
<DatePicker
ref={ref}
selected={value ? new Date(`${value}T00:00:00`) : null}
onChange={(d: Date | null) => d && onChange(toISODate(d))}
dateFormat="yyyy.MM.dd"
dateFormatCalendar="yyyy.M월"
locale="ko"
placeholderText={placeholder ?? '날짜를 선택하세요'}
className="dt-input"
popperClassName="acs-datepicker"
/>
);
};

View File

@@ -32,12 +32,13 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<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('SECURITY', 'ADMIN') && <NavLink to="/reports"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/blacklist"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/deliveries"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/audit"></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>

View File

@@ -91,7 +91,7 @@ export const AccessConsolePage: React.FC = () => {
{results.length > 0 && (
<table className="table" style={{ marginTop: 12 }}>
<thead>
<tr><th></th><th></th><th></th><th></th></tr>
<tr><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{results.map((r) => (
@@ -101,11 +101,17 @@ export const AccessConsolePage: React.FC = () => {
<td>{r.zoneName || '-'}</td>
<td>
{insideIds.has(r.id) ? (
<span className="status-cell">
<span className="badge badge-green"></span>
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.id)}></button>
</span>
) : exitedIds.has(r.id) ? (
<span className="muted"> </span>
<span className="badge badge-gray"></span>
) : (
<span className="status-cell">
<span className="badge badge-amber"></span>
<button className="btn-success" disabled={busy} onClick={() => forceCheckIn(r.id)}></button>
</span>
)}
</td>
</tr>

View File

@@ -0,0 +1,68 @@
import React, { useEffect, useState } from 'react';
import { listAudit } from '../api';
import { AuditLog } from '../types';
import { formatDateTime } from '../status';
const ACTION_LABEL: Record<AuditLog['action'], string> = {
APPROVE: '승인',
REJECT: '반려',
BLACKLIST_ADD: '블랙리스트 등록',
BLACKLIST_REMOVE: '블랙리스트 해제',
};
const ACTION_CLASS: Record<AuditLog['action'], string> = {
APPROVE: 'green',
REJECT: 'red',
BLACKLIST_ADD: 'red',
BLACKLIST_REMOVE: 'gray',
};
export const AuditLogPage: React.FC = () => {
const [items, setItems] = useState<AuditLog[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
listAudit()
.then(setItems)
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
.finally(() => setLoading(false));
};
useEffect(load, []);
return (
<div>
<div className="page-head">
<h2> </h2>
<button className="btn-ghost" onClick={load} disabled={loading}></button>
</div>
{error && <div className="alert alert-error">{error}</div>}
<div className="card">
<h3> ({items.length})</h3>
{items.length === 0 ? (
<p className="muted">{loading ? '불러오는 중…' : '기록이 없습니다.'}</p>
) : (
<table className="table">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{items.map((a) => (
<tr key={a.id}>
<td>{formatDateTime(a.at)}</td>
<td>{a.actorUsername || '시스템'}</td>
<td><span className={`badge badge-${ACTION_CLASS[a.action]}`}>{ACTION_LABEL[a.action] ?? a.action}</span></td>
<td>{a.targetType ? `${a.targetType}${a.targetId != null ? ` #${a.targetId}` : ''}` : '-'}</td>
<td>{a.detail || '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};

View File

@@ -1,17 +1,22 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getStatsSummary, listVisitRequests } from '../api';
import { getStatsSummary, listInside, listVisitRequests } from '../api';
import { StatsSummary, VisitRequestView } from '../types';
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
export const DashboardPage: React.FC = () => {
const [items, setItems] = useState<VisitRequestView[]>([]);
const [stats, setStats] = useState<StatsSummary | null>(null);
const [insideIds, setInsideIds] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getStatsSummary().then(setStats).catch(() => setStats(null));
// currently-inside visits → show "재실중" instead of the plain approved status
listInside()
.then((rows) => setInsideIds(new Set(rows.map((r) => r.visitRequestId))))
.catch(() => setInsideIds(new Set()));
listVisitRequests()
.then(setItems)
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
@@ -56,7 +61,13 @@ export const DashboardPage: React.FC = () => {
<td>{r.company || '-'}</td>
<td>{r.zoneName || '-'}</td>
<td>{formatDateTime(r.visitFrom)}</td>
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
<td>
{insideIds.has(r.id) ? (
<span className="badge badge-green"></span>
) : (
<span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span>
)}
</td>
</tr>
))}
</tbody>

View File

@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { listDeliveries, retryDelivery } from '../api';
import { DeliveryStatus, PassDelivery } from '../types';
import { formatDateTime } from '../status';
type Filter = 'ALL' | DeliveryStatus;
export const DeliveryOutboxPage: React.FC = () => {
const [items, setItems] = useState<PassDelivery[]>([]);
const [filter, setFilter] = useState<Filter>('FAILED');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [retryingId, setRetryingId] = useState<number | null>(null);
const load = () => {
setLoading(true);
listDeliveries(filter === 'ALL' ? undefined : filter)
.then(setItems)
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
.finally(() => setLoading(false));
};
useEffect(load, [filter]);
const onRetry = async (id: number) => {
setError(null);
setRetryingId(id);
try {
await retryDelivery(id);
load();
} catch (err) {
setError(err instanceof Error ? err.message : '재발송 실패');
} finally {
setRetryingId(null);
}
};
return (
<div>
<div className="page-head">
<h2> </h2>
<div className="row-gap">
<select value={filter} onChange={(e) => setFilter(e.target.value as Filter)}>
<option value="FAILED"></option>
<option value="SENT"></option>
<option value="ALL"></option>
</select>
<button className="btn-ghost" onClick={load} disabled={loading}></button>
</div>
</div>
{error && <div className="alert alert-error">{error}</div>}
<div className="card">
<h3> ({items.length})</h3>
{items.length === 0 ? (
<p className="muted">{loading ? '불러오는 중…' : '해당 조건의 발송 기록이 없습니다.'}</p>
) : (
<table className="table">
<thead>
<tr>
<th></th><th></th><th></th><th></th>
<th></th><th></th><th></th><th></th>
</tr>
</thead>
<tbody>
{items.map((d) => (
<tr key={d.id}>
<td>{formatDateTime(d.updatedAt || d.createdAt)}</td>
<td>#{d.visitRequestId}</td>
<td>{d.channel || '-'}</td>
<td>{d.recipient || '-'}</td>
<td><span className={`badge badge-${d.status === 'SENT' ? 'green' : 'red'}`}>
{d.status === 'SENT' ? '성공' : '실패'}</span></td>
<td>{d.attempts}</td>
<td className="cell-error" title={d.lastError || ''}>{d.lastError || '-'}</td>
<td>
{d.status === 'FAILED' && (
<button className="btn-link" onClick={() => onRetry(d.id)} disabled={retryingId === d.id}>
{retryingId === d.id ? '재발송 중…' : '재발송'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};

View File

@@ -58,7 +58,7 @@ export const LoginPage: React.FC = () => {
{busy ? '로그인 중…' : '로그인'}
</button>
<p className="hint"> 계정: admin / security / host ( ChangeMe123!)</p>
<p className="hint"> 계정: a() / s() / h() · 1</p>
</form>
</div>
);

View File

@@ -1,17 +1,22 @@
import React, { useState } from 'react';
import { reportDownloadUrl } from '../api';
import { DatePickerField } from '../components/DatePickerField';
const pad = (n: number) => String(n).padStart(2, '0');
/** Today in YYYY-MM-DD (local). */
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
function monthAgoISO(): string {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().slice(0, 10);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** First day of the current month in YYYY-MM-DD (local). */
function firstOfMonthISO(): string {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`;
}
export const ReportPage: React.FC = () => {
const [from, setFrom] = useState(monthAgoISO());
const [from, setFrom] = useState(firstOfMonthISO());
const [to, setTo] = useState(todayISO());
const onDownload = () => {
@@ -29,18 +34,18 @@ export const ReportPage: React.FC = () => {
return (
<div>
<div className="page-head"><h2> </h2></div>
<div className="page-head"><h2> </h2></div>
<div className="card">
<p className="muted"> (.xlsx) . ( )</p>
<p className="muted"> [ ] , (.xlsx) . ( )</p>
<div className="report-row">
<label className="field">
<span></span>
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
<DatePickerField value={from} onChange={setFrom} />
</label>
<label className="field">
<span></span>
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
<DatePickerField value={to} onChange={setTo} />
</label>
<button className="btn-primary" onClick={onDownload}> </button>
</div>

View File

@@ -2,15 +2,35 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createVisitRequest } from '../api';
import { DateTimePicker } from '../components/DateTimePicker';
import { useAuth } from '../auth/AuthContext';
const ZONE_OPTIONS = [
'4층전산실', '5층전산실', '3층사무실', '4층사무실', '5층사무실',
'종합상황실', 'BMT실', '의사결정실', '기타',
// 코드 시트 목록을 콤보/체크박스에 반영.
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
// 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력.
const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타'];
const PURPOSE_OPTIONS = ['점검', '작업', '견학', '회의', '청소', '기타'];
// 소속(코드 시트) — 내부 팀. 담당자·감시자 팀 콤보에 사용.
const AFFILIATION_OPTIONS = [
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
'금융IT인프라팀', '정보인프라팀', 'AI플랫폼팀', '보안운영팀', '보안관제반',
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
];
const PURPOSE_OPTIONS = ['유지점검', '장비반입', '업무협의', '공사', '기타'];
// 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지).
const FIXED_WATCHER1 = { name: '류관순', team: 'IT전략국', contact: '313' };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** True if the datetime's calendar date is before today (time-of-day ignored). */
const isPastDate = (iso: string): boolean => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return false;
const day = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const today = new Date();
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
return day < todayStart;
};
export const VisitRequestFormPage: React.FC = () => {
const [form, setForm] = useState({
visitorName: '',
@@ -18,10 +38,15 @@ export const VisitRequestFormPage: React.FC = () => {
contact: '',
email: '',
vehicleNo: '',
zone: '',
zoneEtc: '',
serverRooms: [] as string[],
room: '',
roomEtc: '',
purpose: '',
purposeEtc: '',
workName: '',
watcher2Name: '',
watcher2Team: '',
watcher2Contact: '',
visitFrom: '',
visitTo: '',
});
@@ -29,22 +54,34 @@ export const VisitRequestFormPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const { user } = useAuth();
const update = (k: keyof typeof form) => (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => setForm({ ...form, [k]: e.target.value });
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({
...f,
serverRooms: e.target.checked
? [...f.serverRooms, room]
: f.serverRooms.filter((r) => r !== room),
}));
/** Returns the first Korean validation error, or null if valid. */
const validate = (): string | null => {
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
if (!form.contact.trim()) return '방문자 연락처를 입력하세요.';
if (form.email.trim() && !EMAIL_RE.test(form.email.trim()))
return '이메일 형식이 올바르지 않습니다. (예: name@example.com)';
if (!form.zone) return '출입 구역을 선택하세요.';
if (form.zone === '기타' && !form.zoneEtc.trim()) return '기타 출입 구역을 입력하세요.';
if (form.serverRooms.length === 0 && !form.room)
return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.';
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.';
if (!form.purpose) return '출입 목적을 선택하세요.';
if (form.purpose === '기타' && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.';
if (!form.visitFrom) return '출입 일시를 입력하세요.';
// 오늘 이전(전일자)은 불가. 같은 날 안에서 현재보다 이른 시각은 허용(날짜만 비교).
if (isPastDate(form.visitFrom)) return '과거일자는 입력이 안됩니다.';
if (!form.visitTo) return '퇴실 일시를 입력하세요.';
if (new Date(form.visitTo) < new Date(form.visitFrom))
return '퇴실 일시는 출입 일시보다 빠를 수 없습니다.';
@@ -62,14 +99,20 @@ export const VisitRequestFormPage: React.FC = () => {
setError(null);
setBusy(true);
try {
const roomZone = form.room === '기타' ? form.roomEtc.trim() : form.room;
await createVisitRequest({
visitorName: form.visitorName.trim(),
company: form.company.trim() || undefined,
contact: form.contact.trim(),
email: form.email.trim() || undefined,
vehicleNo: form.vehicleNo.trim() || undefined,
zoneName: form.zone === '기타' ? form.zoneEtc.trim() : form.zone,
serverRooms: form.serverRooms,
roomZone: roomZone || undefined,
purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose,
workName: form.workName.trim() || undefined,
watcher2Name: form.watcher2Name.trim() || undefined,
watcher2Team: form.watcher2Team.trim() || undefined,
watcher2Contact: form.watcher2Contact.trim() || undefined,
visitFrom: form.visitFrom,
visitTo: form.visitTo,
});
@@ -84,7 +127,6 @@ export const VisitRequestFormPage: React.FC = () => {
return (
<div>
<div className="page-head"><h2> </h2></div>
{error && <div className="alert alert-error">{error}</div>}
{/* noValidate: use our Korean messages instead of the browser's native popups */}
<form className="card form-grid" onSubmit={onSubmit} noValidate>
@@ -110,17 +152,33 @@ export const VisitRequestFormPage: React.FC = () => {
<input className="ime-ko" value={form.vehicleNo} onChange={update('vehicleNo')} />
</label>
<div className="field span-2">
<span> <em className="hint-inline">: (QR) .</em></span>
<div className="checkbox-row">
{SERVER_ROOM_OPTIONS.map((z) => (
<label key={z} className="checkbox-inline">
<input
type="checkbox"
checked={form.serverRooms.includes(z)}
onChange={toggleServerRoom(z)}
/>
<span>{z}</span>
</label>
))}
</div>
</div>
<label className="field">
<span> *</span>
<select value={form.zone} onChange={update('zone')}>
<span> </span>
<select value={form.room} onChange={update('room')}>
<option value=""></option>
{ZONE_OPTIONS.map((z) => <option key={z} value={z}>{z}</option>)}
{ROOM_OPTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
{form.zone === '기타' ? (
{form.room === '기타' ? (
<label className="field">
<span> *</span>
<input value={form.zoneEtc} onChange={update('zoneEtc')} placeholder="출입 구역을 입력하세요" />
<input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" />
</label>
) : <div />}
@@ -138,6 +196,11 @@ export const VisitRequestFormPage: React.FC = () => {
</label>
) : <div />}
<label className="field span-2">
<span> <em className="hint-inline">: (: Active-Active )</em></span>
<input className="ime-ko" value={form.workName} onChange={update('workName')} placeholder="작업 내용을 입력하세요 (선택)" />
</label>
<label className="field">
<span> *</span>
<DateTimePicker
@@ -155,6 +218,56 @@ export const VisitRequestFormPage: React.FC = () => {
/>
</label>
<div className="form-section span-2">
<em className="hint-inline">: .</em>
</div>
<label className="field">
<span></span>
<input value={user?.fullName ?? ''} readOnly />
</label>
<label className="field">
<span></span>
<input value={user?.department ?? ''} readOnly />
</label>
<label className="field">
<span></span>
<input value={user?.email ?? ''} readOnly />
</label>
<div />
<div className="form-section span-2">1 <em className="hint-inline">: ( )</em></div>
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.name} readOnly />
</label>
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.team} readOnly />
</label>
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.contact} readOnly />
</label>
<div />
<div className="form-section span-2">2 <em className="hint-inline">: </em></div>
<label className="field">
<span></span>
<input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} />
</label>
<label className="field">
<span></span>
<select value={form.watcher2Team} onChange={update('watcher2Team')}>
<option value=""></option>
{AFFILIATION_OPTIONS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</label>
<label className="field">
<span></span>
<input value={form.watcher2Contact} onChange={update('watcher2Contact')} placeholder="내선/휴대번호" />
</label>
<div />
<div className="span-2 consent-box">
<label className="consent-label">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
@@ -169,6 +282,7 @@ export const VisitRequestFormPage: React.FC = () => {
</div>
<div className="form-actions span-2">
{error && <span className="form-error" role="alert">{error}</span>}
<button type="button" className="btn-ghost" onClick={() => navigate(-1)}></button>
<button type="submit" className="btn-primary" disabled={busy}>
{busy ? '신청 중…' : '출입 신청'}

View File

@@ -77,7 +77,7 @@ export const VisitRequestListPage: React.FC = () => {
<table className="table">
<thead>
<tr>
<th></th><th></th><th></th><th></th>
<th></th><th></th><th></th><th></th><th></th>
<th></th><th></th><th></th>
</tr>
</thead>
@@ -88,6 +88,7 @@ export const VisitRequestListPage: React.FC = () => {
<td>{r.company || '-'}</td>
<td>{r.zoneName || '-'}</td>
<td>{r.purpose || '-'}</td>
<td>{r.workName || '-'}</td>
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
<td className="row-actions">

View File

@@ -174,6 +174,14 @@ a { color: inherit; text-decoration: none; }
}
.form-grid .span-2 { grid-column: 1 / -1; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
/* Inline validation message shown at the left of the action row, beside the 취소 button. */
.form-actions .form-error { margin-right: auto; align-self: center; color: #b91c1c; font-size: 14px; font-weight: 600; }
/* Status badge + action button shown together in a table cell (access console). */
.status-cell { display: inline-flex; align-items: center; gap: 8px; }
/* Sub-section heading inside a form grid (담당자/감시자 등). */
.form-section { grid-column: 1 / -1; margin: 6px 0 -2px; padding-top: 10px; border-top: 1px solid #e2e8f0; font-weight: 700; color: #1e293b; font-size: 14px; }
/* Read-only auto-filled fields (본인/고정값). */
.field input[readonly] { background: #f1f5f9; color: #475569; cursor: default; }
/* faint inline helper next to a label */
.hint-inline { font-weight: 400; font-style: normal; color: var(--muted); font-size: 12px; }
@@ -189,6 +197,11 @@ a { color: inherit; text-decoration: none; }
.consent-label { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 13px; color: #334155; line-height: 1.5; }
.consent-label input { margin-top: 2px; width: 16px; height: 16px; flex-shrink: 0; }
/* Inline checkbox group (e.g. 전산실 다중 선택) */
.checkbox-row { display: flex; flex-wrap: wrap; gap: 18px; padding: 8px 2px; }
.checkbox-inline { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: #334155; }
.checkbox-inline input { width: 16px; height: 16px; flex-shrink: 0; }
/* ===== Buttons ===== */
button { font-family: inherit; cursor: pointer; }
.btn-primary, .btn-ghost, .btn-success, .btn-danger {
@@ -288,7 +301,7 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.badge-qr { width: 200px; height: 200px; image-rendering: pixelated; }
.badge-meta { text-align: center; margin: 16px 0; font-size: 13px; }
.badge-meta > div { padding: 4px 0; border-bottom: 1px dashed var(--border); }
.badge-foot { font-size: 12px; color: var(--muted); margin-top: 8px; }
.badge-foot { font-size: 11px; color: var(--muted); margin-top: 8px; white-space: nowrap; }
@media print {
.topbar, .no-print { display: none !important; }
@@ -302,3 +315,9 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.form-grid { grid-template-columns: 1fr; }
.nav { display: none; }
}
/* inline row of controls (e.g. filter + refresh in page-head) */
.row-gap { display: flex; gap: 8px; align-items: center; }
/* long delivery error text: keep the row compact, reveal full text on hover (title attr) */
.cell-error { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--red, #c0392b); }

View File

@@ -10,6 +10,8 @@ export interface CurrentUser {
id: number;
username: string;
fullName: string;
department?: string;
email?: string;
roles: Role[];
mustChangePassword: boolean;
}
@@ -45,8 +47,17 @@ export interface VisitRequestCreate {
contact: string;
email?: string;
vehicleNo?: string;
zoneName?: string;
/** 전산실 checkboxes; each selected room yields its own request/QR. */
serverRooms?: string[];
/** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */
roomZone?: string;
purpose: string;
/** 작업명 — optional concrete task detail, stored separately from purpose. */
workName?: string;
/** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */
watcher2Name?: string;
watcher2Team?: string;
watcher2Contact?: string;
visitFrom: string; // ISO local datetime
visitTo: string;
}
@@ -62,6 +73,16 @@ export interface VisitRequestView {
hostDepartment?: string;
zoneName?: string;
purpose: string;
workName?: string;
controlName?: string;
controlTeam?: string;
controlContact?: string;
watcher1Name?: string;
watcher1Team?: string;
watcher1Contact?: string;
watcher2Name?: string;
watcher2Team?: string;
watcher2Contact?: string;
visitFrom: string;
visitTo: string;
status: VisitStatus;
@@ -143,3 +164,28 @@ export interface BlacklistCreate {
contact?: string;
reason: string;
}
export interface AuditLog {
id: number;
at: string;
actorId?: number;
actorUsername?: string;
action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE';
targetType?: string;
targetId?: number;
detail?: string;
}
export type DeliveryStatus = 'SENT' | 'FAILED';
export interface PassDelivery {
id: number;
visitRequestId: number;
channel?: string;
recipient?: string;
status: DeliveryStatus;
attempts: number;
lastError?: string;
createdAt: string;
updatedAt: string;
}

5
infra/certs/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# TLS certificates/keys are environment-specific secrets — never commit them.
*.pem
*.key
*.crt
!.gitignore

View File

@@ -0,0 +1,21 @@
# HTTPS overlay. Use together with the base compose file:
# docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --build
#
# Requires a TLS certificate under ./certs/{fullchain.pem,privkey.pem}.
# For a quick internal/test cert (self-signed):
# openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
# -keyout certs/privkey.pem -out certs/fullchain.pem -subj "/CN=<your-host>"
# For production, drop in the cert issued for your internal domain (corporate CA / public CA).
services:
web:
ports:
- "${WEB_TLS_PORT:-443}:443" # base file already maps WEB_PORT:80 (used for the redirect)
volumes:
- ./certs:/etc/nginx/certs:ro
- ../frontend/nginx-tls.conf:/etc/nginx/conf.d/default.conf:ro
app:
environment:
# The public link in the pass email/SMS + the secure-cookie switch.
ACS_PUBLIC_BASE_URL: ${ACS_PUBLIC_BASE_URL:-https://localhost}
ACS_COOKIE_SECURE: "true"