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..4b91102 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.annotation.AuditLogEvent; 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; @@ -44,6 +45,7 @@ public class AnalysisService { private final CreditService creditService; @Transactional + @AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1") public AnalysisResponse analyze(User user, Long mockApplyId) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); @@ -78,7 +80,7 @@ public AnalysisResponse analyze(User user, Long mockApplyId) { questionAnalysisRepository.saveAll(questionAnalyses); mockApply.updateStatus(MockApplyStatus.COMPLETED); - return getAnalysis(user, mockApplyId); + return toResponse(mockApply, analysis, questions, questionAnalyses); } catch (RuntimeException e) { creditService.refund(user, 1, "자소서 분석 실패 환불", referenceId); throw e; @@ -223,4 +225,5 @@ private QuestionAnalysisStatus normalizeStatus(String status) { return QuestionAnalysisStatus.MENTIONED; } } + } 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..1acaad1 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.annotation.AuditLogEvent; 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; @@ -82,6 +83,7 @@ public List getQuestionCandidates(User user, Long moc } @Transactional + @AuditLogEvent(action = "CUSTOM_QUESTION_CANDIDATE_ADD", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionCandidateResponse addCustomQuestionCandidate( User user, Long mockApplyId, @@ -141,6 +143,7 @@ public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyI } @Transactional + @AuditLogEvent(action = "QUESTION_SELECTION_SAVE", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionSelectionResponse saveSelectedQuestions( User user, Long mockApplyId, @@ -171,6 +174,7 @@ public QuestionSelectionResponse saveSelectedQuestions( } @Transactional + @AuditLogEvent(action = "QUESTION_ANSWER_SAVE", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionAnswerResponse saveAnswers( User user, Long mockApplyId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/audit/annotation/AuditLogEvent.java b/src/main/java/com/jobdri/jobdri_api/domain/audit/annotation/AuditLogEvent.java new file mode 100644 index 0000000..dfe211d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/audit/annotation/AuditLogEvent.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.audit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuditLogEvent { + String action(); + + String targetType(); + + String targetId() default ""; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/audit/aop/AuditLogAspect.java b/src/main/java/com/jobdri/jobdri_api/domain/audit/aop/AuditLogAspect.java new file mode 100644 index 0000000..a3de64c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/audit/aop/AuditLogAspect.java @@ -0,0 +1,117 @@ +package com.jobdri.jobdri_api.domain.audit.aop; + +import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.audit.service.AuditLogService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.context.expression.MethodBasedEvaluationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.Map; + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class AuditLogAspect { + + private static final String RESULT_VARIABLE = "result"; + + private final AuditLogService auditLogService; + private final ExpressionParser expressionParser = new SpelExpressionParser(); + private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + @Around("@annotation(auditLogEvent)") + public Object recordAuditLog(ProceedingJoinPoint joinPoint, AuditLogEvent auditLogEvent) throws Throwable { + Map beforeValue = extractParameters(joinPoint); + + Object result = joinPoint.proceed(); + + try { + auditLogService.record( + extractUser(joinPoint), + auditLogEvent.action(), + auditLogEvent.targetType(), + evaluateTargetId(joinPoint, auditLogEvent.targetId(), result), + beforeValue, + result + ); + } catch (RuntimeException e) { + log.warn("Audit log recording failed. action={}, method={}", + auditLogEvent.action(), + joinPoint.getSignature().toShortString(), + e + ); + throw e; + } + + return result; + } + + private User extractUser(ProceedingJoinPoint joinPoint) { + for (Object arg : joinPoint.getArgs()) { + if (arg instanceof User user) { + return user; + } + } + return null; + } + + private Long evaluateTargetId(ProceedingJoinPoint joinPoint, String targetIdExpression, Object result) { + if (targetIdExpression == null || targetIdExpression.isBlank()) { + return null; + } + + Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); + MethodBasedEvaluationContext context = new MethodBasedEvaluationContext( + null, + method, + joinPoint.getArgs(), + parameterNameDiscoverer + ); + Object[] args = joinPoint.getArgs(); + for (int i = 0; i < args.length; i++) { + context.setVariable("arg" + i, args[i]); + context.setVariable("p" + i, args[i]); + } + context.setVariable(RESULT_VARIABLE, result); + + Object value = expressionParser.parseExpression(targetIdExpression).getValue(context); + if (value instanceof Number number) { + return number.longValue(); + } + if (value instanceof String string && !string.isBlank()) { + return Long.parseLong(string); + } + return null; + } + + private Map extractParameters(ProceedingJoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + Map parameters = new LinkedHashMap<>(); + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof User) { + continue; + } + String parameterName = parameterNames != null && i < parameterNames.length + ? parameterNames[i] + : "arg" + i; + parameters.put(parameterName, args[i]); + } + return parameters; + } +} 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..e6913aa --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java @@ -0,0 +1,94 @@ +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.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 + 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..9305a3d 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.annotation.AuditLogEvent; 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; @@ -30,6 +31,7 @@ public class JobPostingService { private final UserService userService; @Transactional + @AuditLogEvent(action = "JOB_POSTING_CREATE", targetType = "JOB_POSTING", targetId = "#result.getJobPostingId()") public JobPostingResponse createJobPosting(User user, JobPostingCreateRequest request) { User validatedUser = userService.validateUser(user); Company company = findOrCreateCompany(request.companyName(), request.companySize()); @@ -48,6 +50,7 @@ public JobPostingResponse createJobPosting(User user, JobPostingCreateRequest re } @Transactional + @AuditLogEvent(action = "JOB_POSTING_UPDATE", targetType = "JOB_POSTING", targetId = "#arg1") public JobPostingResponse updateJobPosting(User user, Long jobPostingId, JobPostingUpdateRequest request) { User validatedUser = userService.validateUser(user); JobPosting jobPosting = getOwnedJobPosting(validatedUser, jobPostingId); 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..ef58858 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.annotation.AuditLogEvent; 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; @@ -42,6 +43,7 @@ public class MockApplyService { private final UserService userService; @Transactional + @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { User validatedUser = userService.validateUser(user); JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); @@ -51,6 +53,7 @@ public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { } @Transactional + @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId) { User validatedUser = userService.validateUser(user); JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); @@ -60,6 +63,7 @@ public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long job } @Transactional + @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockRequest request) { User validatedUser = userService.validateUser(user); Company company = companyRepository.findById(request.companyId()) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java index 66438e6..781d7ff 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java @@ -1,5 +1,8 @@ package com.jobdri.jobdri_api.domain.payment.dto.toss; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) public record TossPaymentConfirmResponse( String paymentKey, String orderId,