From 64e210440de232bd98ad89c7f17373695b141018 Mon Sep 17 00:00:00 2001 From: wooh Date: Sat, 23 May 2026 05:43:22 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20=EC=A3=BC=EC=9A=94=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=9E=91=EC=97=85=20=EA=B0=90=EC=82=AC=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B8=B0=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuditLog 엔티티와 Repository, Service 추가 - 요청 IP와 User-Agent 자동 수집 처리 - 공고 생성/수정, 모의 서류 지원 생성 감사로그 기록 - 문항 후보 추가, 문항 선택 저장, 답변 저장 감사로그 기록 - 자소서 분석 실행 결과 감사로그 기록 - 변경 전/후 값을 JSON 문자열로 저장하도록 구현 --- .../analysis/service/AnalysisService.java | 31 +++++- .../analysis/service/QuestionService.java | 56 +++++++++- .../domain/audit/entity/AuditLog.java | 102 ++++++++++++++++++ .../audit/repository/AuditLogRepository.java | 7 ++ .../domain/audit/service/AuditLogService.java | 95 ++++++++++++++++ .../jobposting/service/JobPostingService.java | 46 +++++++- .../mockapply/service/MockApplyService.java | 41 ++++++- 7 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/audit/entity/AuditLog.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/audit/repository/AuditLogRepository.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java index 85b2ace..d16f302 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java @@ -11,6 +11,7 @@ import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.audit.service.AuditLogService; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; @@ -42,6 +43,7 @@ public class AnalysisService { private final QuestionAnalysisRepository questionAnalysisRepository; private final AnalysisAiClient analysisAiClient; private final CreditService creditService; + private final AuditLogService auditLogService; @Transactional public AnalysisResponse analyze(User user, Long mockApplyId) { @@ -78,7 +80,24 @@ public AnalysisResponse analyze(User user, Long mockApplyId) { questionAnalysisRepository.saveAll(questionAnalyses); mockApply.updateStatus(MockApplyStatus.COMPLETED); - return getAnalysis(user, mockApplyId); + AnalysisResponse response = toResponse(mockApply, analysis, questions, questionAnalyses); + auditLogService.record( + user, + "ANALYSIS_RUN", + "MOCK_APPLY", + mockApply.getId(), + null, + new AnalysisAuditValue( + analysis.getId(), + analysis.getScore(), + analysis.getJobFit(), + analysis.getImpact(), + analysis.getCompleteness(), + questionAnalyses.size() + ) + ); + + return response; } catch (RuntimeException e) { creditService.refund(user, 1, "자소서 분석 실패 환불", referenceId); throw e; @@ -223,4 +242,14 @@ private QuestionAnalysisStatus normalizeStatus(String status) { return QuestionAnalysisStatus.MENTIONED; } } + + private record AnalysisAuditValue( + Long analysisId, + int score, + int jobFit, + int impact, + int completeness, + int highlightCount + ) { + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java index 7ad7fb8..d8cb95a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java @@ -11,6 +11,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.audit.service.AuditLogService; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; @@ -50,6 +51,7 @@ public class QuestionService { private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; private final CustomQuestionCandidateRepository customQuestionCandidateRepository; + private final AuditLogService auditLogService; public List getQuestionCandidates(User user, Long mockApplyId) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); @@ -94,13 +96,23 @@ public QuestionCandidateResponse addCustomQuestionCandidate( CustomQuestionCandidate candidate = findOrCreateCustomCandidate(mockApply, content, request.charLimit()); boolean selected = questionRepository.existsByMockApplyIdAndContent(mockApply.getId(), candidate.getContent()); - return new QuestionCandidateResponse( + QuestionCandidateResponse response = new QuestionCandidateResponse( candidate.getId(), candidate.getContent(), candidate.getLimit(), selected, true ); + auditLogService.record( + user, + "CUSTOM_QUESTION_CANDIDATE_ADD", + "MOCK_APPLY", + mockApply.getId(), + null, + response + ); + + return response; } private CustomQuestionCandidate findOrCreateCustomCandidate( @@ -150,6 +162,9 @@ public QuestionSelectionResponse saveSelectedQuestions( validateSelectionCount(request.questions().size()); List existingQuestions = questionRepository.findAllByMockApplyId(mockApply.getId()); + List beforeQuestions = existingQuestions.stream() + .map(QuestionAuditValue::from) + .toList(); questionRepository.deleteAll(existingQuestions); List questions = request.questions().stream() @@ -163,11 +178,21 @@ public QuestionSelectionResponse saveSelectedQuestions( List savedQuestions = questionRepository.saveAll(questions); mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); - return new QuestionSelectionResponse( + QuestionSelectionResponse response = new QuestionSelectionResponse( mockApply.getId(), mockApply.getStatus(), savedQuestions.stream().map(QuestionResponse::from).toList() ); + auditLogService.record( + user, + "QUESTION_SELECTION_SAVE", + "MOCK_APPLY", + mockApply.getId(), + beforeQuestions, + savedQuestions.stream().map(QuestionAuditValue::from).toList() + ); + + return response; } @Transactional @@ -180,6 +205,9 @@ public QuestionAnswerResponse saveAnswers( List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); Map questionMap = questions.stream() .collect(Collectors.toMap(Question::getId, Function.identity())); + List beforeAnswers = questions.stream() + .map(QuestionAnswerAuditValue::from) + .toList(); for (QuestionAnswerSaveRequest.AnswerItem item : request.answers()) { Question question = questionMap.get(item.questionId()); @@ -192,11 +220,21 @@ public QuestionAnswerResponse saveAnswers( question.updateAnswer(normalizeAnswer(item.answer())); } - return new QuestionAnswerResponse( + QuestionAnswerResponse response = new QuestionAnswerResponse( mockApply.getId(), mockApply.getStatus(), questions.stream().map(QuestionResponse::from).toList() ); + auditLogService.record( + user, + "QUESTION_ANSWER_SAVE", + "MOCK_APPLY", + mockApply.getId(), + beforeAnswers, + questions.stream().map(QuestionAnswerAuditValue::from).toList() + ); + + return response; } private MockApply getOwnedMockApply(User user, Long mockApplyId) { @@ -246,4 +284,16 @@ private String normalizeAnswer(String answer) { private record QuestionCandidate(Long id, String content, int charLimit) { } + + private record QuestionAuditValue(Long questionId, String content, int charLimit) { + private static QuestionAuditValue from(Question question) { + return new QuestionAuditValue(question.getId(), question.getContent(), question.getLimit()); + } + } + + private record QuestionAnswerAuditValue(Long questionId, String answer) { + private static QuestionAnswerAuditValue from(Question question) { + return new QuestionAnswerAuditValue(question.getId(), question.getAnswer()); + } + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/audit/entity/AuditLog.java b/src/main/java/com/jobdri/jobdri_api/domain/audit/entity/AuditLog.java new file mode 100644 index 0000000..ca11e55 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/audit/entity/AuditLog.java @@ -0,0 +1,102 @@ +package com.jobdri.jobdri_api.domain.audit.entity; + +import com.jobdri.jobdri_api.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "audit_logs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false, length = 80) + private String action; + + @Column(nullable = false, length = 80) + private String targetType; + + private Long targetId; + + @Column(columnDefinition = "TEXT") + private String beforeValue; + + @Column(columnDefinition = "TEXT") + private String afterValue; + + @Column(length = 100) + private String ipAddress; + + @Column(length = 500) + private String userAgent; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Builder(access = AccessLevel.PRIVATE) + private AuditLog( + User user, + String action, + String targetType, + Long targetId, + String beforeValue, + String afterValue, + String ipAddress, + String userAgent, + LocalDateTime createdAt + ) { + this.user = user; + this.action = action; + this.targetType = targetType; + this.targetId = targetId; + this.beforeValue = beforeValue; + this.afterValue = afterValue; + this.ipAddress = ipAddress; + this.userAgent = userAgent; + this.createdAt = createdAt; + } + + public static AuditLog create( + User user, + String action, + String targetType, + Long targetId, + String beforeValue, + String afterValue, + String ipAddress, + String userAgent + ) { + return AuditLog.builder() + .user(user) + .action(action) + .targetType(targetType) + .targetId(targetId) + .beforeValue(beforeValue) + .afterValue(afterValue) + .ipAddress(ipAddress) + .userAgent(userAgent) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/audit/repository/AuditLogRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/audit/repository/AuditLogRepository.java new file mode 100644 index 0000000..47dd4ef --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/audit/repository/AuditLogRepository.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.audit.repository; + +import com.jobdri.jobdri_api.domain.audit.entity.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuditLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java b/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java new file mode 100644 index 0000000..ddc734e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java @@ -0,0 +1,95 @@ +package com.jobdri.jobdri_api.domain.audit.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.audit.entity.AuditLog; +import com.jobdri.jobdri_api.domain.audit.repository.AuditLogRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Service +@RequiredArgsConstructor +public class AuditLogService { + + private static final int MAX_USER_AGENT_LENGTH = 500; + + private final AuditLogRepository auditLogRepository; + private final ObjectMapper objectMapper; + + @Transactional(propagation = Propagation.MANDATORY) + public void record( + User user, + String action, + String targetType, + Long targetId, + Object beforeValue, + Object afterValue + ) { + HttpServletRequest request = currentRequest(); + auditLogRepository.save(AuditLog.create( + user, + action, + targetType, + targetId, + toJson(beforeValue), + toJson(afterValue), + resolveIpAddress(request), + truncate(resolveUserAgent(request), MAX_USER_AGENT_LENGTH) + )); + } + + private HttpServletRequest currentRequest() { + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) { + return attributes.getRequest(); + } + return null; + } + + private String resolveIpAddress(HttpServletRequest request) { + if (request == null) { + return null; + } + + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",")[0].trim(); + } + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isBlank()) { + return realIp.trim(); + } + return request.getRemoteAddr(); + } + + private String resolveUserAgent(HttpServletRequest request) { + if (request == null) { + return null; + } + return request.getHeader("User-Agent"); + } + + private String toJson(Object value) { + if (value == null) { + return null; + } + + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + return String.valueOf(value); + } + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java index b04a9c4..9c9da75 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.audit.service.AuditLogService; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; @@ -28,6 +29,7 @@ public class JobPostingService { private final CompanyRepository companyRepository; private final DetailClassificationRepository detailClassificationRepository; private final UserService userService; + private final AuditLogService auditLogService; @Transactional public JobPostingResponse createJobPosting(User user, JobPostingCreateRequest request) { @@ -44,13 +46,25 @@ public JobPostingResponse createJobPosting(User user, JobPostingCreateRequest re request.preferred() ); - return JobPostingResponse.from(jobPostingRepository.save(jobPosting)); + JobPosting savedJobPosting = jobPostingRepository.save(jobPosting); + JobPostingResponse response = JobPostingResponse.from(savedJobPosting); + auditLogService.record( + validatedUser, + "JOB_POSTING_CREATE", + "JOB_POSTING", + savedJobPosting.getId(), + null, + JobPostingAuditValue.from(savedJobPosting) + ); + + return response; } @Transactional public JobPostingResponse updateJobPosting(User user, Long jobPostingId, JobPostingUpdateRequest request) { User validatedUser = userService.validateUser(user); JobPosting jobPosting = getOwnedJobPosting(validatedUser, jobPostingId); + JobPostingAuditValue beforeValue = JobPostingAuditValue.from(jobPosting); Company company = findOrCreateCompany(request.companyName(), request.companySize()); DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); @@ -63,6 +77,14 @@ public JobPostingResponse updateJobPosting(User user, Long jobPostingId, JobPost request.requirement(), request.preferred() ); + auditLogService.record( + validatedUser, + "JOB_POSTING_UPDATE", + "JOB_POSTING", + jobPosting.getId(), + beforeValue, + JobPostingAuditValue.from(jobPosting) + ); return JobPostingResponse.from(jobPosting); } @@ -112,4 +134,26 @@ private DetailClassification findDetailClassification(Long detailClassificationI "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + detailClassificationId )); } + + private record JobPostingAuditValue( + Long jobPostingId, + Long companyId, + String companyName, + Long detailClassificationId, + String task, + String requirement, + String preferred + ) { + private static JobPostingAuditValue from(JobPosting jobPosting) { + return new JobPostingAuditValue( + jobPosting.getId(), + jobPosting.getCompany().getId(), + jobPosting.getCompany().getName(), + jobPosting.getDetailClassification().getId(), + jobPosting.getTask(), + jobPosting.getRequirement(), + jobPosting.getPreferred() + ); + } + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index cd2b1b4..153eeb6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.audit.service.AuditLogService; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; @@ -40,6 +41,7 @@ public class MockApplyService { private final MockJobPostingGenerationService mockJobPostingGenerationService; private final JobPostingService jobPostingService; private final UserService userService; + private final AuditLogService auditLogService; @Transactional public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { @@ -47,7 +49,10 @@ public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); MockApply mockApply = MockApply.create(validatedUser, jobPosting, ApplyType.ACTUAL); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + MockApply savedMockApply = mockApplyRepository.save(mockApply); + MockApplyCreateResponse response = MockApplyCreateResponse.from(savedMockApply); + recordMockApplyCreated(validatedUser, savedMockApply); + return response; } @Transactional @@ -56,7 +61,10 @@ public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long job JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); MockApply mockApply = MockApply.create(validatedUser, jobPosting, ApplyType.MOCK); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + MockApply savedMockApply = mockApplyRepository.save(mockApply); + MockApplyCreateResponse response = MockApplyCreateResponse.from(savedMockApply); + recordMockApplyCreated(validatedUser, savedMockApply); + return response; } @Transactional @@ -87,7 +95,10 @@ public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockReq )); MockApply mockApply = MockApply.create(validatedUser, savedJobPosting, ApplyType.MOCK); - return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); + MockApply savedMockApply = mockApplyRepository.save(mockApply); + MockApplyCreateResponse response = MockApplyCreateResponse.from(savedMockApply); + recordMockApplyCreated(validatedUser, savedMockApply); + return response; } public JobPostingResponse getMockApplyJobPosting(User user, Long mockApplyId) { @@ -162,4 +173,28 @@ private MockApply getOwnedMockApply(User user, Long mockApplyId) { return mockApply; } + + private void recordMockApplyCreated(User user, MockApply mockApply) { + auditLogService.record( + user, + "MOCK_APPLY_CREATE", + "MOCK_APPLY", + mockApply.getId(), + null, + new MockApplyAuditValue( + mockApply.getId(), + mockApply.getJobPosting().getId(), + mockApply.getApplyType().name(), + mockApply.getStatus().name() + ) + ); + } + + private record MockApplyAuditValue( + Long mockApplyId, + Long jobPostingId, + String applyType, + String status + ) { + } }