Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Comment on lines +84 to 103
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Audit failure currently triggers an incorrect credit refund path.

At Line 84, auditLogService.record(...) is inside the same try as the analysis execution, and Line 101 catches all RuntimeExceptions. If audit logging fails after analysis is already saved, Line 102 refunds credit even though the analysis actually succeeded.

Suggested fix
-            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;
+            AnalysisResponse response = toResponse(mockApply, analysis, questions, questionAnalyses);
+            try {
+                auditLogService.record(
+                        user,
+                        "ANALYSIS_RUN",
+                        "MOCK_APPLY",
+                        mockApply.getId(),
+                        null,
+                        new AnalysisAuditValue(
+                                analysis.getId(),
+                                analysis.getScore(),
+                                analysis.getJobFit(),
+                                analysis.getImpact(),
+                                analysis.getCompleteness(),
+                                questionAnalyses.size()
+                        )
+                );
+            } catch (RuntimeException ignored) {
+                // 감사로그 실패가 분석 성공/크레딧 정산을 깨지 않도록 분리
+            }
+            return response;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java`
around lines 84 - 103, The auditLogService.record(...) call is inside the same
try that catches RuntimeException and triggers creditService.refund(...),
causing successful analyses to be refunded if the audit log fails; update
AnalysisService so audit logging failures do not trigger refunds by moving the
auditLogService.record(...) outside the main try/catch that wraps analysis
execution or by wrapping only the audit call in its own try/catch that logs
audit errors (but does not call creditService.refund) — keep
creditService.refund(...) only in the branch that genuinely represents analysis
execution failure.

Expand Down Expand Up @@ -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
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<QuestionCandidateResponse> getQuestionCandidates(User user, Long mockApplyId) {
MockApply mockApply = getOwnedMockApply(user, mockApplyId);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -150,6 +162,9 @@ public QuestionSelectionResponse saveSelectedQuestions(
validateSelectionCount(request.questions().size());

List<Question> existingQuestions = questionRepository.findAllByMockApplyId(mockApply.getId());
List<QuestionAuditValue> beforeQuestions = existingQuestions.stream()
.map(QuestionAuditValue::from)
.toList();
questionRepository.deleteAll(existingQuestions);

List<Question> questions = request.questions().stream()
Expand All @@ -163,11 +178,21 @@ public QuestionSelectionResponse saveSelectedQuestions(
List<Question> 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
Expand All @@ -180,6 +205,9 @@ public QuestionAnswerResponse saveAnswers(
List<Question> questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId());
Map<Long, Question> questionMap = questions.stream()
.collect(Collectors.toMap(Question::getId, Function.identity()));
List<QuestionAnswerAuditValue> beforeAnswers = questions.stream()
.map(QuestionAnswerAuditValue::from)
.toList();

for (QuestionAnswerSaveRequest.AnswerItem item : request.answers()) {
Question question = questionMap.get(item.questionId());
Expand All @@ -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) {
Expand Down Expand Up @@ -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());
}
}
}
102 changes: 102 additions & 0 deletions src/main/java/com/jobdri/jobdri_api/domain/audit/entity/AuditLog.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<AuditLog, Long> {
}
Original file line number Diff line number Diff line change
@@ -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();
Comment on lines +59 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden IP capture against header spoofing.

Line 59–66 trust client-supplied forwarding headers directly, so audit IP can be forged. For audit integrity, only use trusted proxy-resolved remote address at this layer.

Suggested fix
 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();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
return request.getRemoteAddr();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java`
around lines 59 - 67, The current IP capture in AuditLogService reads
client-supplied headers ("X-Forwarded-For", "X-Real-IP") which can be spoofed;
change the implementation of the IP extraction (the method that calls
request.getHeader(...) in AuditLogService) to stop trusting those headers and
always use the proxy-resolved address returned by request.getRemoteAddr();
remove or ignore usage of forwardedFor.split(...) and realIp.trim() in this
method so audit entries rely solely on request.getRemoteAddr() at this layer.

}

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);
}
Comment on lines +82 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep beforeValue/afterValue strictly JSON on serialization fallback.

Line 85 currently returns raw String.valueOf(value), which can store non-JSON text and break downstream parsing expectations.

Suggested fix
     try {
         return objectMapper.writeValueAsString(value);
     } catch (JsonProcessingException e) {
-        return String.valueOf(value);
+        try {
+            return objectMapper.writeValueAsString(String.valueOf(value));
+        } catch (JsonProcessingException ignored) {
+            return "\"<unserializable>\"";
+        }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
return String.valueOf(value);
}
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
try {
return objectMapper.writeValueAsString(String.valueOf(value));
} catch (JsonProcessingException ignored) {
return "\"<unserializable>\"";
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java`
around lines 82 - 86, The current catch in AuditLogService that returns
String.valueOf(value) can produce non-JSON output; change the fallback to
produce a valid JSON string instead — call
objectMapper.writeValueAsString(String.valueOf(value)) in the catch so
beforeValue/afterValue remain JSON, and if that secondary call can fail, fall
back to returning a safely quoted/escaped JSON string (i.e., ensure the final
return is a JSON string value). Update the try/catch around
objectMapper.writeValueAsString(value) accordingly so the fallback preserves
JSON format.

}

private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
}
}
Loading
Loading