diff --git a/backend/src/main/java/com/srsystem/controller/EmployeeController.java b/backend/src/main/java/com/srsystem/controller/EmployeeController.java index ae4ca2a..27db93e 100644 --- a/backend/src/main/java/com/srsystem/controller/EmployeeController.java +++ b/backend/src/main/java/com/srsystem/controller/EmployeeController.java @@ -8,6 +8,7 @@ import com.srsystem.dto.EmployeeSearchResult; import com.srsystem.dto.EmployeeSummary; import com.srsystem.dto.PortfolioResponse; import com.srsystem.dto.UpdateAchievementsRequest; +import com.srsystem.entity.AuditLog; import com.srsystem.entity.DispatchHistory; import com.srsystem.entity.Employee; import com.srsystem.entity.EmployeeEmergencyContact; @@ -16,6 +17,7 @@ import com.srsystem.entity.enums.DispatchStatus; import com.srsystem.entity.enums.EmpStatus; import com.srsystem.entity.enums.RoleCode; import com.srsystem.util.ExcelParser; +import com.srsystem.repository.AuditLogRepository; import com.srsystem.repository.EquipmentAssignmentRepository; import com.srsystem.service.DispatchHistoryService; import com.srsystem.service.EmployeeService; @@ -53,6 +55,7 @@ public class EmployeeController { private final DispatchHistoryService dispatchHistoryService; private final PortfolioService portfolioService; private final EquipmentAssignmentRepository equipmentAssignmentRepository; + private final AuditLogRepository auditLogRepository; @Operation(summary = "직원 목록 조회", description = "페이징된 직원 목록을 조회합니다. 상태 필터 옵션 포함") @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") @@ -254,6 +257,29 @@ public class EmployeeController { return ResponseEntity.ok(ApiResponse.ok("프로필 이미지가 저장되었습니다", employeeService.update(empId, emp))); } + @Operation(summary = "전화번호 열람 요청", description = "사유를 입력하면 전화번호를 반환합니다 (감사 로그 기록)") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PostMapping("/{empId}/request-phone") + public ResponseEntity>> requestPhone( + @PathVariable Long empId, @RequestBody Map body, + @CurrentUser Employee currentUser, + jakarta.servlet.http.HttpServletRequest request) { + Employee target = employeeService.findById(empId); + if (target.getPhone() == null) { + return ResponseEntity.ok(ApiResponse.ok("등록된 전화번호가 없습니다", Map.of("phone", ""))); + } + auditLogRepository.save(AuditLog.builder() + .action("VIEW_PHONE") + .targetType("EMPLOYEE") + .targetId(empId) + .actorId(currentUser.getEmpId()) + .reason(body.getOrDefault("reason", "")) + .ipAddress(request.getRemoteAddr()) + .createdAt(java.time.LocalDateTime.now()) + .build()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("phone", target.getPhone()))); + } + @Operation(summary = "비밀번호 변경", description = "현재 사용자의 비밀번호를 변경합니다") @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") @PutMapping("/me/password") @@ -293,11 +319,7 @@ public class EmployeeController { emp.setEmpStatus(EmpStatus.valueOf(row.getOrDefault("재직상태", "ACTIVE").toUpperCase())); emp.setDispatchStatus(DispatchStatus.valueOf(row.getOrDefault("파견상태", "WAITING").toUpperCase())); emp.setPassword("batch1234!"); - if (row.containsKey("생년월일")) emp.setBirthDate(java.time.LocalDate.parse(row.get("생년월일"))); - if (row.containsKey("학력")) emp.setEducation(row.get("학력")); - if (row.containsKey("학교명")) emp.setSchoolName(row.get("학교명")); if (row.containsKey("직급")) emp.setPosition(row.get("직급")); - if (row.containsKey("학과")) emp.setMajor(row.get("학과")); if (row.containsKey("입사일")) emp.setJoinedDate(java.time.LocalDate.parse(row.get("입사일"))); return emp; }, @@ -336,7 +358,7 @@ public class EmployeeController { @PreAuthorize("hasRole('ADMIN')") @GetMapping("/batch/template") public ResponseEntity downloadTemplate() throws Exception { - String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학교명", "직급", "학과", "입사일"}; + String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "직급", "입사일"}; Map dropdowns = Map.of( 4, new String[]{"ADMIN", "MANAGER", "EMPLOYEE"}, 5, new String[]{"ACTIVE", "ON_LEAVE", "RESIGNED"}, diff --git a/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java b/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java index 3dec69a..3659b66 100644 --- a/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java +++ b/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java @@ -10,18 +10,10 @@ public record EmployeeSummary( Long empId, String name, String email, - String phone, RoleCode roleCode, EmpStatus empStatus, DispatchStatus dispatchStatus, LocalDate joinedDate, LocalDate leftDate, - LocalDate birthDate, - String education, - String major, - LocalDate graduationDate, - String residence, - String profileImage, - String schoolName, - String position + String profileImage ) {} diff --git a/backend/src/main/java/com/srsystem/entity/AuditLog.java b/backend/src/main/java/com/srsystem/entity/AuditLog.java new file mode 100644 index 0000000..26156b1 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/AuditLog.java @@ -0,0 +1,41 @@ +package com.srsystem.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "tb_audit_log") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_audit_logs") + @SequenceGenerator(name = "sq_audit_logs", sequenceName = "sq_audit_logs", allocationSize = 50) + private Long id; + + @Column(name = "action", nullable = false, length = 50) + private String action; + + @Column(name = "target_type", length = 50) + private String targetType; + + @Column(name = "target_id") + private Long targetId; + + @Column(name = "actor_id") + private Long actorId; + + @Column(name = "reason", length = 500) + private String reason; + + @Column(name = "ip_address", length = 50) + private String ipAddress; + + @Column(name = "created_at") + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/srsystem/entity/Employee.java b/backend/src/main/java/com/srsystem/entity/Employee.java index f2db286..9f6cce3 100644 --- a/backend/src/main/java/com/srsystem/entity/Employee.java +++ b/backend/src/main/java/com/srsystem/entity/Employee.java @@ -1,9 +1,9 @@ package com.srsystem.entity; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.srsystem.entity.enums.DispatchStatus; import com.srsystem.entity.enums.EmpStatus; import com.srsystem.entity.enums.RoleCode; +import com.srsystem.util.PhoneEncryptionConverter; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.SQLDelete; @@ -41,7 +41,8 @@ public class Employee extends BaseEntity { @Column(name = "email", length = 255) private String email; - @Column(name = "phone", length = 20) + @Convert(converter = PhoneEncryptionConverter.class) + @Column(name = "phone", length = 255) private String phone; @Builder.Default @@ -65,18 +66,6 @@ public class Employee extends BaseEntity { @Column(name = "left_date") private LocalDate leftDate; - @Column(name = "birth_date") - private LocalDate birthDate; - - @Column(name = "education", length = 20) - private String education; - - @Column(name = "major", length = 100) - private String major; - - @Column(name = "school_name", length = 200) - private String schoolName; - @Column(name = "manager_id") private Long managerId; @@ -86,12 +75,6 @@ public class Employee extends BaseEntity { @Column(name = "position", length = 50) private String position; - @Column(name = "graduation_date") - private LocalDate graduationDate; - - @Column(name = "residence", length = 255) - private String residence; - @Column(name = "profile_image", length = 500) private String profileImage; diff --git a/backend/src/main/java/com/srsystem/repository/AuditLogRepository.java b/backend/src/main/java/com/srsystem/repository/AuditLogRepository.java new file mode 100644 index 0000000..dcd0a8b --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/AuditLogRepository.java @@ -0,0 +1,7 @@ +package com.srsystem.repository; + +import com.srsystem.entity.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuditLogRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/srsystem/service/EmployeeService.java b/backend/src/main/java/com/srsystem/service/EmployeeService.java index 2036c74..a08eb2a 100644 --- a/backend/src/main/java/com/srsystem/service/EmployeeService.java +++ b/backend/src/main/java/com/srsystem/service/EmployeeService.java @@ -104,13 +104,7 @@ public class EmployeeService { employee.setEmail(updateRequest.getEmail()); employee.setPhone(updateRequest.getPhone()); employee.setJoinedDate(updateRequest.getJoinedDate()); - employee.setBirthDate(updateRequest.getBirthDate()); - employee.setEducation(updateRequest.getEducation()); - employee.setMajor(updateRequest.getMajor()); - employee.setGraduationDate(updateRequest.getGraduationDate()); - employee.setResidence(updateRequest.getResidence()); employee.setProfileImage(updateRequest.getProfileImage()); - employee.setSchoolName(updateRequest.getSchoolName()); employee.setPosition(updateRequest.getPosition()); employee.setManagerId(updateRequest.getManagerId()); employee.setBackupManagerId(updateRequest.getBackupManagerId()); @@ -168,20 +162,12 @@ public class EmployeeService { employee.getEmpId(), employee.getName(), employee.getEmail(), - employee.getPhone(), employee.getRoleCode(), employee.getEmpStatus(), employee.getDispatchStatus(), employee.getJoinedDate(), employee.getLeftDate(), - employee.getBirthDate(), - employee.getEducation(), - employee.getMajor(), - employee.getGraduationDate(), - employee.getResidence(), - employee.getProfileImage(), - employee.getSchoolName(), - employee.getPosition() + employee.getProfileImage() ); } diff --git a/backend/src/main/java/com/srsystem/util/PhoneEncryptionConverter.java b/backend/src/main/java/com/srsystem/util/PhoneEncryptionConverter.java new file mode 100644 index 0000000..bc68c8c --- /dev/null +++ b/backend/src/main/java/com/srsystem/util/PhoneEncryptionConverter.java @@ -0,0 +1,71 @@ +package com.srsystem.util; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +@Converter +public class PhoneEncryptionConverter implements AttributeConverter { + + private static final String AES = "AES"; + private static final String AES_GCM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + + private static SecretKey secretKey; + private static final SecureRandom secureRandom = new SecureRandom(); + + public PhoneEncryptionConverter(@Value("${app.encryption.phone-key}") String key) { + byte[] keyBytes = key.getBytes(); + if (keyBytes.length < 16) { + byte[] padded = new byte[16]; + System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 16)); + keyBytes = padded; + } + secretKey = new SecretKeySpec(keyBytes, AES); + } + + @Override + public String convertToDatabaseColumn(String plaintext) { + if (plaintext == null) return null; + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(AES_GCM); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes()); + byte[] combined = new byte[GCM_IV_LENGTH + ciphertext.length]; + System.arraycopy(iv, 0, combined, 0, GCM_IV_LENGTH); + System.arraycopy(ciphertext, 0, combined, GCM_IV_LENGTH, ciphertext.length); + return Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { + throw new RuntimeException("Phone encryption failed", e); + } + } + + @Override + public String convertToEntityAttribute(String encrypted) { + if (encrypted == null) return null; + try { + byte[] combined = Base64.getDecoder().decode(encrypted); + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + System.arraycopy(combined, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length); + Cipher cipher = Cipher.getInstance(AES_GCM); + cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + return new String(cipher.doFinal(ciphertext)); + } catch (Exception e) { + return encrypted; + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 7d28beb..4ee7412 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -41,6 +41,10 @@ parking: weekday-end: "08:00" weekends: true +app: + encryption: + phone-key: ${PHONE_ENCRYPTION_KEY:change-me-to-32-chars!!} + file: upload-dir: ./uploads diff --git a/backend/src/main/resources/db/migration/V22__cleanup_and_encrypt.sql b/backend/src/main/resources/db/migration/V22__cleanup_and_encrypt.sql new file mode 100644 index 0000000..280d81a --- /dev/null +++ b/backend/src/main/resources/db/migration/V22__cleanup_and_encrypt.sql @@ -0,0 +1,20 @@ +ALTER TABLE tb_employee + DROP COLUMN IF EXISTS birth_date, + DROP COLUMN IF EXISTS residence, + DROP COLUMN IF EXISTS education, + DROP COLUMN IF EXISTS school_name, + DROP COLUMN IF EXISTS major, + DROP COLUMN IF EXISTS graduation_date; + +CREATE SEQUENCE IF NOT EXISTS sq_audit_logs INCREMENT BY 50; + +CREATE TABLE tb_audit_log ( + id BIGSERIAL PRIMARY KEY, + action VARCHAR(50) NOT NULL, + target_type VARCHAR(50), + target_id BIGINT, + actor_id BIGINT, + reason VARCHAR(500), + ip_address VARCHAR(50), + created_at TIMESTAMP +); diff --git a/src/app/(main)/employees/page.tsx b/src/app/(main)/employees/page.tsx index 41b618a..e2b690f 100644 --- a/src/app/(main)/employees/page.tsx +++ b/src/app/(main)/employees/page.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { Search, X, Camera, Phone, Mail, Calendar, MapPin, GraduationCap, Briefcase, Ticket, FileText, ExternalLink } from 'lucide-react'; +import { Search, X, Camera, Phone, Mail, Briefcase, Ticket, FileText, ExternalLink } from 'lucide-react'; import employeeService from '@/services/employeeService'; import apiClient from '@/services/apiClient'; import { useCommonCodes } from '@/hooks/useCommonCodes'; @@ -40,6 +40,11 @@ export default function EmployeesPage() { const [detailEquipment, setDetailEquipment] = useState([]); const [detailLoading, setDetailLoading] = useState(false); + // Phone Reveal State + const [showPhoneDialog, setShowPhoneDialog] = useState(false); + const [phoneReason, setPhoneReason] = useState(''); + const [revealedPhone, setRevealedPhone] = useState(''); + const fetchEmployees = async () => { try { setLoading(true); @@ -71,6 +76,8 @@ export default function EmployeesPage() { setDetailLoading(true); setDetailDispatch([]); setDetailEquipment([]); + setRevealedPhone(''); + setPhoneReason(''); try { const [dispatchRes, equipRes] = await Promise.all([ @@ -92,6 +99,9 @@ export default function EmployeesPage() { setDetailDispatch([]); setDetailEquipment([]); setDetailLoading(false); + setShowPhoneDialog(false); + setPhoneReason(''); + setRevealedPhone(''); }; const [skillKeyword, setSkillKeyword] = useState(''); @@ -123,14 +133,6 @@ export default function EmployeesPage() { return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' }); }; - const educationLabel: Record = { - HIGH_SCHOOL: '고졸', - ASSOCIATE: '전문대', - BACHELOR: '학사', - MASTER: '석사', - DOCTORATE: '박사', - }; - // ── Detail Modal ── if (selectedEmployee) { const emp = selectedEmployee; @@ -217,36 +219,23 @@ export default function EmployeesPage() { {emp.email} )} - {emp.phone && ( -
- - {emp.phone} -
- )} - {emp.birthDate && ( -
- - {formatDate(emp.birthDate)} -
- )} - {emp.residence && ( -
- - {emp.residence} -
- )} - {emp.education && ( -
- - {educationLabel[emp.education] || emp.education}{emp.schoolName ? ` · ${emp.schoolName}` : ''}{emp.major ? ` · ${emp.major}` : ''} -
- )} - {emp.graduationDate && ( -
- - 졸업 {formatDate(emp.graduationDate)} -
- )} +
+ + {emp.phone ? ( + revealedPhone ? ( + {revealedPhone} + ) : ( + + ) + ) : ( + - + )} +
@@ -353,6 +342,58 @@ export default function EmployeesPage() { )} + + {/* Phone Reveal Dialog */} + {showPhoneDialog && ( +
{ setShowPhoneDialog(false); setPhoneReason(''); }}> +
e.stopPropagation()}> +

전화번호 조회

+

조회 사유를 입력해주세요.

+