Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import deepple.deepple.common.enums.StatusType;
import deepple.deepple.common.response.BaseResponse;
import deepple.deepple.datingexam.application.dto.DatingExamInfoWithSubjectSubmissionResponse;
import deepple.deepple.datingexam.application.dto.DominantPersonalityTypeResponse;
import deepple.deepple.datingexam.application.provided.DatingExamFinder;
import deepple.deepple.datingexam.application.provided.DatingExamSubmitter;
import deepple.deepple.datingexam.domain.dto.DatingExamSubmitRequest;
Expand Down Expand Up @@ -52,4 +53,14 @@ public ResponseEntity<BaseResponse<DatingExamInfoWithSubjectSubmissionResponse>>
authContext.getId());
return ResponseEntity.ok(BaseResponse.of(StatusType.OK, optionalExamInfo));
}

@Operation(summary = "대표 성격 유형 조회 API")
@GetMapping("/personality-type")
public ResponseEntity<BaseResponse<DominantPersonalityTypeResponse>> getDominantPersonalityType(
@AuthPrincipal AuthContext authContext
) {
final DominantPersonalityTypeResponse response = datingExamFinder.findDominantPersonalityType(
authContext.getId());
return ResponseEntity.ok(BaseResponse.of(StatusType.OK, response));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,19 @@
import deepple.deepple.datingexam.application.dto.AllRequiredSubjectSubmittedEvent;
import deepple.deepple.datingexam.application.dto.DatingExamInfoResponse;
import deepple.deepple.datingexam.application.provided.DatingExamSubmitter;
import deepple.deepple.datingexam.application.required.DatingExamQueryRepository;
import deepple.deepple.datingexam.application.required.DatingExamSubjectRepository;
import deepple.deepple.datingexam.application.required.DatingExamSubmitRepository;
import deepple.deepple.datingexam.domain.DatingExamAnswerEncoder;
import deepple.deepple.datingexam.domain.DatingExamSubject;
import deepple.deepple.datingexam.domain.DatingExamSubmit;
import deepple.deepple.datingexam.domain.SubjectType;
import deepple.deepple.datingexam.application.required.*;
import deepple.deepple.datingexam.domain.*;
import deepple.deepple.datingexam.domain.dto.AnswerSubmitRequest;
import deepple.deepple.datingexam.domain.dto.DatingExamSubmitRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@Service
@Transactional
Expand All @@ -28,6 +27,8 @@ public class DatingExamModifyService implements DatingExamSubmitter {
private final DatingExamSubmitRepository datingExamSubmitRepository;
private final DatingExamQueryRepository datingExamQueryRepository;
private final DatingExamAnswerEncoder answerEncoder;
private final DatingExamAnswerRepository datingExamAnswerRepository;
private final DatingExamSubmitResultRepository datingExamSubmitResultRepository;

@Override
@Transactional
Expand All @@ -36,6 +37,7 @@ public void submitSubject(DatingExamSubmitRequest submitRequest, long memberId)
validateSubmit(submitRequest, subject, memberId);
DatingExamSubmit datingExamSubmit = DatingExamSubmit.from(submitRequest, answerEncoder, memberId);
datingExamSubmitRepository.save(datingExamSubmit);
updateSubmitResult(submitRequest, memberId);
checkAndPublishAllRequiredSubjectsSubmitted(subject, memberId);
}

Expand Down Expand Up @@ -63,6 +65,25 @@ private void checkAndPublishAllRequiredSubjectsSubmitted(DatingExamSubject subje
}
}

private void updateSubmitResult(DatingExamSubmitRequest submitRequest, long memberId) {
List<Long> answerIds = submitRequest.answers().stream()
.map(AnswerSubmitRequest::answerId)
.toList();

List<DatingExamAnswer> answers = datingExamAnswerRepository.findAllByIdIn(answerIds);

Map<AnswerPersonalityType, Integer> counts = answers.stream()
.collect(Collectors.groupingBy(
DatingExamAnswer::getPersonalityType,
Collectors.summingInt(e -> 1)
));
Comment on lines +73 to +79
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 4, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

조회 누락 answerId를 검증하지 않아 personality count가 왜곡될 수 있습니다.

Line 73-79은 findAllByIdIn 결과 개수 검증이 없어, 요청 ID 일부가 누락되면 under-count가 그대로 저장됩니다. dominant personality type 계산이 틀어질 수 있으니 요청 ID 집합과 조회 결과를 비교해 mismatch 시 예외 처리하세요.

Diff 제안
         List<DatingExamAnswer> answers = datingExamAnswerRepository.findAllByIdIn(answerIds);
+        long requestedUniqueCount = answerIds.stream().distinct().count();
+        if (answers.size() != requestedUniqueCount) {
+            throw new IllegalArgumentException("유효하지 않은 답변 ID가 포함되어 있습니다.");
+        }

         Map<AnswerPersonalityType, Integer> counts = answers.stream()
             .collect(Collectors.groupingBy(
                 DatingExamAnswer::getPersonalityType,
                 Collectors.summingInt(e -> 1)
             ));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/deepple/deepple/datingexam/application/DatingExamModifyService.java`
around lines 73 - 79, The code in DatingExamModifyService using
datingExamAnswerRepository.findAllByIdIn(answerIds) should validate that all
requested answerIds were returned; compare the returned answers' IDs (from
DatingExamAnswer::getId) with the input answerIds set, compute the missing IDs
(set difference), and if any are missing throw a clear exception (e.g.,
IllegalArgumentException or a domain NotFoundException) rather than proceeding
to compute counts, so dominant personality calculation isn't corrupted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DatingExamSubmitRequestValidator 로 이미 검증하고 있습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and display DatingExamSubmitRequestValidator
fd 'DatingExamSubmitRequestValidator.java' --exec cat {}

Length of output: 3849


@hainho, 확인했습니다. DatingExamSubmitRequestValidator.validateAnswer()에서 각 answerId가 해당 questionId의 유효한 답변 집합(validAnswers.contains(answerId))에 속하는지 이미 검증하고 있어, findAllByIdIn 호출 전에 유효하지 않은 ID는 모두 차단됩니다. 제 지적이 잘못되었습니다.


DatingExamSubmitResult result = datingExamSubmitResultRepository.findByMemberIdForUpdate(memberId)
.orElseGet(() -> DatingExamSubmitResult.create(memberId));
result.addCounts(counts);
datingExamSubmitResultRepository.save(result);
}

private boolean isAllRequiredSubjectsSubmitted(Long memberId) {
Set<DatingExamSubject> requiredSubjects = datingExamSubjectRepository.findAllByType(
SubjectType.REQUIRED);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import deepple.deepple.datingexam.application.dto.DatingExamInfoResponse;
import deepple.deepple.datingexam.application.dto.DatingExamInfoWithSubjectSubmissionResponse;
import deepple.deepple.datingexam.application.dto.DominantPersonalityTypeResponse;
import deepple.deepple.datingexam.application.provided.DatingExamFinder;
import deepple.deepple.datingexam.application.required.DatingExamQueryRepository;
import deepple.deepple.datingexam.application.required.DatingExamSubmitRepository;
import deepple.deepple.datingexam.application.required.DatingExamSubmitResultRepository;
import deepple.deepple.datingexam.domain.DatingExamSubmit;
import deepple.deepple.datingexam.domain.DatingExamSubmitResult;
import deepple.deepple.datingexam.domain.SubjectType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand All @@ -21,6 +24,7 @@
public class DatingExamQueryService implements DatingExamFinder {
private final DatingExamSubmitRepository datingExamSubmitRepository;
private final DatingExamQueryRepository datingExamQueryRepository;
private final DatingExamSubmitResultRepository datingExamSubmitResultRepository;

@Override
public DatingExamInfoWithSubjectSubmissionResponse findRequiredExamInfo(Long memberId) {
Expand All @@ -35,4 +39,11 @@ public DatingExamInfoWithSubjectSubmissionResponse findOptionalExamInfo(Long mem
Set<DatingExamSubmit> submittedExams = datingExamSubmitRepository.findAllByMemberId(memberId);
return new DatingExamInfoWithSubjectSubmissionResponse(datingExamInfo, submittedExams);
}

@Override
public DominantPersonalityTypeResponse findDominantPersonalityType(Long memberId) {
DatingExamSubmitResult result = datingExamSubmitResultRepository.findByMemberId(memberId)
.orElseThrow(() -> new IllegalStateException("연애고사 제출 결과가 없습니다. memberId: " + memberId));
return DominantPersonalityTypeResponse.from(result);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package deepple.deepple.datingexam.application.dto;

import deepple.deepple.datingexam.domain.AnswerPersonalityType;
import deepple.deepple.datingexam.domain.DatingExamSubmitResult;
import io.swagger.v3.oas.annotations.media.Schema;

public record DominantPersonalityTypeResponse(
@Schema(implementation = AnswerPersonalityType.class)
String personalityType,
int decisiveIndependentCount,
int growingRunningMateCount,
int devotedRomanticCount,
int realisticShelterCount
) {
public static DominantPersonalityTypeResponse from(DatingExamSubmitResult result) {
return new DominantPersonalityTypeResponse(
result.getDominantPersonalityType().name(),
result.getDecisiveIndependentCount(),
result.getGrowingRunningMateCount(),
result.getDevotedRomanticCount(),
result.getRealisticShelterCount()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package deepple.deepple.datingexam.application.provided;

import deepple.deepple.datingexam.application.dto.DatingExamInfoWithSubjectSubmissionResponse;
import deepple.deepple.datingexam.application.dto.DominantPersonalityTypeResponse;

public interface DatingExamFinder {
DatingExamInfoWithSubjectSubmissionResponse findRequiredExamInfo(Long memberId);

DatingExamInfoWithSubjectSubmissionResponse findOptionalExamInfo(Long memberId);

DominantPersonalityTypeResponse findDominantPersonalityType(Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package deepple.deepple.datingexam.application.required;

import deepple.deepple.datingexam.domain.DatingExamAnswer;
import org.springframework.data.repository.Repository;

import java.util.Collection;
import java.util.List;

public interface DatingExamAnswerRepository extends Repository<DatingExamAnswer, Long> {
List<DatingExamAnswer> findAllByIdIn(Collection<Long> ids);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package deepple.deepple.datingexam.application.required;

import deepple.deepple.datingexam.domain.DatingExamSubmitResult;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface DatingExamSubmitResultRepository extends Repository<DatingExamSubmitResult, Long> {
Optional<DatingExamSubmitResult> findByMemberId(Long memberId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

어떤 경우 동시성 문제가 발생하나요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

서로 다른 과목 답변 제출이 동시에 처리되면 동시성 문제 발생 가능해요

앱이라서 실제 과목 답변 제출 사이에 텀이 있겠지만, 네트워크 지연이나 등의 이유로 발생이 가능하긴해서
방지하는게 안전할듯 하네요

@Query("SELECT r FROM DatingExamSubmitResult r WHERE r.memberId = :memberId")
Optional<DatingExamSubmitResult> findByMemberIdForUpdate(@Param("memberId") Long memberId);

DatingExamSubmitResult save(DatingExamSubmitResult result);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package deepple.deepple.datingexam.domain;

public enum AnswerPersonalityType {
DECISIVE_INDEPENDENT, // (A) 단호한 독립주의자
GROWING_RUNNING_MATE, // (B) 성장하는 러닝메이트
DEVOTED_ROMANTIC, // (C) 헌신적인 로맨티스트
REALISTIC_SHELTER // (D) 현실적인 안식처
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

import deepple.deepple.common.entity.BaseEntity;
import deepple.deepple.datingexam.domain.exception.InvalidDatingExamAnswerContentException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
Expand All @@ -27,13 +24,18 @@ public class DatingExamAnswer extends BaseEntity {
@Column(nullable = false)
private String content;

private DatingExamAnswer(Long questionId, String content) {
@Enumerated(EnumType.STRING)
@Column(columnDefinition = "varchar(50)", nullable = false)
private AnswerPersonalityType personalityType;

private DatingExamAnswer(Long questionId, String content, AnswerPersonalityType personalityType) {
setQuestionId(questionId);
setContent(content);
setPersonalityType(personalityType);
}

public static DatingExamAnswer create(Long questionId, String content) {
return new DatingExamAnswer(questionId, content);
public static DatingExamAnswer create(Long questionId, String content, AnswerPersonalityType personalityType) {
return new DatingExamAnswer(questionId, content, personalityType);
}

private void setQuestionId(@NonNull Long questionId) {
Expand All @@ -46,4 +48,8 @@ private void setContent(@NonNull String content) {
}
this.content = content;
}

private void setPersonalityType(@NonNull AnswerPersonalityType personalityType) {
this.personalityType = personalityType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package deepple.deepple.datingexam.domain;

import deepple.deepple.common.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;

import java.util.Map;

import static jakarta.persistence.GenerationType.IDENTITY;
import static lombok.AccessLevel.PROTECTED;

@Entity
@NoArgsConstructor(access = PROTECTED)
@Getter
@Table(
uniqueConstraints = @UniqueConstraint(
name = "uk_dating_exam_submit_result_member",
columnNames = {"memberId"}
)
)
public class DatingExamSubmitResult extends BaseEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

@Column(nullable = false)
private Long memberId;

@Column(nullable = false)
private int decisiveIndependentCount;

@Column(nullable = false)
private int growingRunningMateCount;

@Column(nullable = false)
private int devotedRomanticCount;

@Column(nullable = false)
private int realisticShelterCount;

@Enumerated(EnumType.STRING)
@Column(columnDefinition = "varchar(50)", nullable = false)
private AnswerPersonalityType dominantPersonalityType;

private DatingExamSubmitResult(@NonNull Long memberId) {
this.memberId = memberId;
this.decisiveIndependentCount = 0;
this.growingRunningMateCount = 0;
this.devotedRomanticCount = 0;
this.realisticShelterCount = 0;
this.dominantPersonalityType = AnswerPersonalityType.DECISIVE_INDEPENDENT;
}

public static DatingExamSubmitResult create(Long memberId) {
return new DatingExamSubmitResult(memberId);
}

public void addCounts(Map<AnswerPersonalityType, Integer> counts) {
this.decisiveIndependentCount += counts.getOrDefault(AnswerPersonalityType.DECISIVE_INDEPENDENT, 0);
this.growingRunningMateCount += counts.getOrDefault(AnswerPersonalityType.GROWING_RUNNING_MATE, 0);
this.devotedRomanticCount += counts.getOrDefault(AnswerPersonalityType.DEVOTED_ROMANTIC, 0);
this.realisticShelterCount += counts.getOrDefault(AnswerPersonalityType.REALISTIC_SHELTER, 0);
recalculateDominant();
}

private void recalculateDominant() {
AnswerPersonalityType dominant = AnswerPersonalityType.DECISIVE_INDEPENDENT;
int maxCount = this.decisiveIndependentCount;

if (this.growingRunningMateCount > maxCount) {
dominant = AnswerPersonalityType.GROWING_RUNNING_MATE;
maxCount = this.growingRunningMateCount;
}
if (this.devotedRomanticCount > maxCount) {
dominant = AnswerPersonalityType.DEVOTED_ROMANTIC;
maxCount = this.devotedRomanticCount;
}
if (this.realisticShelterCount > maxCount) {
dominant = AnswerPersonalityType.REALISTIC_SHELTER;
}

this.dominantPersonalityType = dominant;
}
}
13 changes: 13 additions & 0 deletions src/main/resources/db/migration/V11__reset_dating_exams.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- 기존 데이터 삭제 (submit → answer → question → subject 순서)
DELETE
FROM dating_exam_submit;
DELETE
FROM dating_exam_answer;
DELETE
FROM dating_exam_question;
DELETE
FROM dating_exam_subject;

-- members 테이블의 is_dating_exam_submitted를 전체 false로 초기화
UPDATE members
SET is_dating_exam_submitted = FALSE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE dating_exam_answer
ADD COLUMN personality_type VARCHAR(50) NOT NULL;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading