Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
@@ -1,43 +1,62 @@
package com.piuda.callcare.domain.drugconflict.converter;

import java.time.LocalDate;

import org.springframework.stereotype.Component;

import com.piuda.callcare.domain.drugconflict.dto.response.ConflictDrug;
import com.piuda.callcare.domain.drugconflict.dto.response.DrugConflictDetailResponse;
import com.piuda.callcare.domain.drugconflict.dto.response.DrugConflictResponse;
import com.piuda.callcare.domain.drugconflict.entity.DrugConflict;
import com.piuda.callcare.domain.medication.entity.Medication;

@Component
public class DrugConflictConverter {

// DrugConflict → DrugConflictResponse (목록 카드용)
// 카드 표시값(제품종류·제품명·처방기관·처방날짜)은 저장하지 않고 Medication에서 조합한다.
public DrugConflictResponse toResponse(DrugConflict conflict) {
return new DrugConflictResponse(
conflict.getId(),
conflict.getMedication1().getId(),
conflict.getMedication1().getDrugName(),
conflict.getMedication2().getId(),
conflict.getMedication2().getDrugName(),
toConflictDrug(conflict.getMedication1()),
toConflictDrug(conflict.getMedication2()),
conflict.getSeverity(),
conflict.getSeverity().getLabel(),
conflict.getIsResolved()
);
}

// DrugConflict → DrugConflictDetailResponse (상세 조회용)
// 약 정보는 목록과 동일한 ConflictDrug로 채워 두 응답의 형태를 맞춘다.
public DrugConflictDetailResponse toDetail(DrugConflict conflict) {
return new DrugConflictDetailResponse(
conflict.getId(),
conflict.getMedication1().getId(),
conflict.getMedication1().getDrugName(),
conflict.getMedication1().getDrugNickname(),
conflict.getMedication2().getId(),
conflict.getMedication2().getDrugName(),
conflict.getMedication2().getDrugNickname(),
toConflictDrug(conflict.getMedication1()),
toConflictDrug(conflict.getMedication2()),
conflict.getSeverity(),
conflict.getSeverity().getLabel(),
conflict.getConflictDescription(),
conflict.getIsResolved(),
conflict.getCreatedAt()
);
}
}

// Medication → ConflictDrug (목록·상세에 표시할 약 정보 합성)
private ConflictDrug toConflictDrug(Medication medication) {
return new ConflictDrug(
medication.getId(),
medication.getDrugType(),
medication.getDrugName(),
medication.getDrugNickname(),
medication.getHospitalName(),
resolvePrescriptionDate(medication)
);
}

// 처방 날짜 폴백: prescription_date(OCR) → 없으면 start_date(사용자 입력)
private LocalDate resolvePrescriptionDate(Medication medication) {
return medication.getPrescriptionDate() != null
? medication.getPrescriptionDate()
: medication.getStartDate();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.piuda.callcare.domain.drugconflict.dto.response;

import java.time.LocalDate;

import io.swagger.v3.oas.annotations.media.Schema;

// 충돌에 관련된 약 한 알의 표시 정보. 목록 카드와 상세가 같은 구조를 공유한다.
// 저장하지 않고 조회 시 Medication에서 조합한다(파생값 비저장 원칙).
@Schema(description = "충돌에 관련된 약 정보")
public record ConflictDrug(

@Schema(description = "약(medication) ID") Long medicationId,
@Schema(description = "제품 종류 (drug_type)") String drugType,
@Schema(description = "제품명 (drug_name)") String drugName,
@Schema(description = "약 별명 (drug_nickname)") String drugNickname,
@Schema(description = "처방 기관 (hospital_name)") String hospitalName,
@Schema(description = "처방 날짜 (prescription_date, 없으면 start_date로 폴백)") LocalDate prescriptionDate
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,17 @@
import io.swagger.v3.oas.annotations.media.Schema;

// 약물 충돌 상세(카드 클릭 시). 충돌 설명 원문 포함.
// 약별 표시 정보는 목록 카드와 동일한 ConflictDrug 구조를 재사용한다.
@Schema(description = "약물 충돌 상세")
public record DrugConflictDetailResponse(

@Schema(description = "충돌 ID") Long conflictId,
@Schema(description = "약1 ID") Long medicationId1,
@Schema(description = "약1 이름") String drugName1,
@Schema(description = "약1 별명") String drugNickname1,
@Schema(description = "약2 ID") Long medicationId2,
@Schema(description = "약2 이름") String drugName2,
@Schema(description = "약2 별명") String drugNickname2,
@Schema(description = "약1 정보") ConflictDrug drug1,
@Schema(description = "약2 정보") ConflictDrug drug2,
@Schema(description = "심각도 (CONTRAINDICATED=금기, CAUTION=주의)") ConflictSeverity severity,
@Schema(description = "심각도 한글 라벨") String severityLabel,
@Schema(description = "충돌 설명 (상호작용 텍스트에서 추출한 문장)") String conflictDescription,
@Schema(description = "확인(해결) 여부") boolean isResolved,
@Schema(description = "분석 저장 시각") LocalDateTime createdAt
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@
import io.swagger.v3.oas.annotations.media.Schema;

// 약물 충돌 카드 1칸(목록용). 상세 설명은 상세 조회에서 제공.
// 약별 표시 정보는 ConflictDrug로 묶어 상세 응답과 같은 형태를 유지한다.
@Schema(description = "약물 충돌 카드")
public record DrugConflictResponse(

@Schema(description = "충돌 ID") Long conflictId,
@Schema(description = "약1 ID") Long medicationId1,
@Schema(description = "약1 이름") String drugName1,
@Schema(description = "약2 ID") Long medicationId2,
@Schema(description = "약2 이름") String drugName2,
@Schema(description = "약1 정보") ConflictDrug drug1,
@Schema(description = "약2 정보") ConflictDrug drug2,
@Schema(description = "심각도 (CONTRAINDICATED=금기, CAUTION=주의)") ConflictSeverity severity,
@Schema(description = "심각도 한글 라벨") String severityLabel,
@Schema(description = "확인(해결) 여부") boolean isResolved
) {
Comment on lines 10 to 18

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Find code and tests that still reference removed flat response fields.
rg -n -C 3 \
  'DrugConflictResponse|DrugConflictDetailResponse|ConflictDrug|drug1Id|drug2Id|drug1Name|drug2Name' \
  src/main/java src/test/java

# Locate controller and API-documentation tests for the affected endpoints.
rg -n -C 5 \
  'getConflicts|getConflictDetail|/api/conflicts|MockMvc|WebTestClient' \
  src/main/java src/test/java

Repository: PIUDAProject/Backend

Length of output: 41576


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect exact response DTOs and converter logic.
printf '--- DTO records ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java
printf '\n--- ConflictDrug ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java
printf '\n--- DrugConflictDetailResponse ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java
printf '\n--- Converter relevant section ---\n'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java | sed -n '1,80p'

# Read-only verifier: compare current public DTO public constructor/schema fields against removed flat fields.
python3 - <<'PY'
from pathlib import Path
import re

files = [
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java",
]
records = []
for path in files:
    text = Path(path).read_text()
    m = re.search(r'public record \w+\s*(.*?)\n\{', text, re.S)
    if not m:
        raise SystemExit(f"You need check {path}")
    body = re.sub(r'`@Schema`\s*\(.*?\)\s*', '', m.group(1), flags=re.S)
    fields = [re.sub(r'[,;\s]+.*','', f.strip()) for f in body.splitlines() if f.strip() and not f.strip().startswith('//')]
    records.append((path.split('/')[-1], fields))

for name, fields in records:
    print(name, fields)

flat_fields = ['drug1Id','drug2Id','drug1Name','drug2Name','drug1DrugType','drug2DrugType','drug1DrugNickname','drug2DrugNickname','drug1HospitalName','drug2HospitalName','drug1PrescriptionDate','drug2PrescriptionDate']
for name, fields in records:
    removed = [f for f in fields if f.startswith('drug') or 'drugId' in f or f in flat_fields]
    nested = [f for f in fields if f in ('drug1','drug2')]
    if removed or nested:
        print('root flat/nested fields:', name, removed or nested)
PY

printf '\n--- API tests using /api/conflicts or controller responses ---\n'
rg -n -C 4 '/api/conflicts|DrugConflictController|MockMvc|JsonPath|Extract.*Response|ApiResponse' src/test/java || true

Repository: PIUDAProject/Backend

Length of output: 249


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect exact response DTOs and converter logic.
echo '--- DTO records ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java
echo
echo '--- ConflictDrug ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java
echo
echo '--- DrugConflictDetailResponse ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java
echo
echo '--- Converter relevant section ---'
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java | sed -n '1,80p'

# Read-only verifier: compare current public DTO public constructor/schema fields against removed flat fields.
python3 - <<'PY'
from pathlib import Path
import re

files = [
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java",
    "src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java",
]
records = []
for path in files:
    text = Path(path).read_text()
    m = re.search(r'public record \w+\s*(.*?)\n\{', text, re.S)
    if not m:
        raise SystemExit(f"Check {path}")
    body = re.sub(r'`@Schema`\s*\([^)]*\)\s*', '', m.group(1), flags=re.S)
    fields = [re.sub(r'[,;\s]+.*','', f.strip()) for f in body.splitlines() if f.strip() and not f.strip().startswith('//')]
    records.append((path.split('/')[-1], fields))

flat_fields = ['drug1Id','drug2Id','drug1Name','drug2Name','drug1DrugType','drug2DrugType','drug1DrugNickname','drug2DrugNickname','drug1HospitalName','drug2HospitalName','drug1PrescriptionDate','drug2PrescriptionDate']
for name, fields in records:
    print(name, fields)
    removed = [f for f in fields if f.startswith('drug') or 'drugId' in f or f in flat_fields]
    nested = [f for f in fields if f in ('drug1','drug2')]
    if removed or nested:
        print('root flat/nested fields:', name, removed or nested)
PY

echo
echo '--- API tests using /api/conflicts or controller responses ---'
rg -n -C 4 '/api/conflicts|DrugConflictController|MockMvc|JsonPath|Extract.*Response|ApiResponse' src/test/java || true

Repository: PIUDAProject/Backend

Length of output: 6127


외부 API 응답 계약 변경을 배포 전에 확인하십시오.

약물 필드가 drug1/drug2 중첩 객체로 바뀌었습니다. 기존 클라이언트는 drug1Name, drug2Name 같은 평탄 필드를 더 읽지 못하므로 렌더링 오류가 발생할 수 있습니다.

  • GET /api/conflicts/api/conflicts/{conflictId} 응답을 새 중첩 JSON 구조로 매핑하는 프론트 변경을 배포와 맞추십시오.
  • API 문서와 예시 응답은 기존 필드 삭제와 drugType, drugName, prescriptionDate 같은 중첩 필드 추가를 반영하십시오.
  • 이 변경은 GET /api/conflicts와 상세 조회 모두에 적용됩니다.
📍 Affects 4 files
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java#L10-L18 (this comment)
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java#L10-L18
  • src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java#L12-L22
  • src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java#L18-L60
🤖 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/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java`
around lines 10 - 18,
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictResponse.java:10-18에서
DrugConflictResponse의 drug1/drug2 중첩 응답 계약을 기준으로 목록 API의 새 JSON 구조를 반영하십시오.
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/ConflictDrug.java:10-18의
drugType, drugName, prescriptionDate 등 중첩 필드를 사용하고 기존 평탄 필드는 제거한 뒤,
src/main/java/com/piuda/callcare/domain/drugconflict/dto/response/DrugConflictDetailResponse.java:12-22와
src/main/java/com/piuda/callcare/domain/drugconflict/converter/DrugConflictConverter.java:18-60에서도
목록 및 상세 조회 모두 동일하게 매핑되도록 수정하십시오. API 문서와 예시 응답도 새 중첩 구조와 필드 삭제를 반영하고 프론트 소비자 변경을
배포에 맞추십시오.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.Objects;

@Entity
@Table(name = "drug_conflict", uniqueConstraints = @UniqueConstraint(
Expand Down Expand Up @@ -66,4 +67,15 @@ public DrugConflict(Senior senior, Medication medication1, Medication medication
public void resolve() {
this.isResolved = true;
}

// 재분석 upsert용: severity/description이 실제로 바뀐 경우에만 갱신한다.
// 값이 동일하면 dirty checking으로도 UPDATE가 안 나가지만, 의도를 코드로 못박아 불필요한 갱신을 막는다.
public void updateAnalysis(ConflictSeverity severity, String conflictDescription) {
boolean changed = this.severity != severity
|| !Objects.equals(this.conflictDescription, conflictDescription);
if (changed) {
this.severity = severity;
this.conflictDescription = conflictDescription;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,46 @@

public interface DrugConflictRepository extends JpaRepository<DrugConflict, Long> {

// 중복 저장 방지용: (medication_id_1 < medication_id_2)로 정규화해 저장하므로 순서 무관 조합을 한 번에 검사
boolean existsBySenior_IdAndMedication1_IdAndMedication2_Id(Long seniorId, Long medication1Id, Long medication2Id);
// 재분석 upsert용: 정규화된 쌍(medication_id_1 < medication_id_2)으로 기존 행을 가져와
// 최신 분석 결과로 갱신(없으면 신규 저장). 순서 무관 중복은 정규화 + UNIQUE 제약이 함께 보장.
Optional<DrugConflict> findBySenior_IdAndMedication1_IdAndMedication2_Id(Long seniorId, Long medication1Id, Long medication2Id);

// 목록 조회: 두 약을 함께 로딩(LazyInitialization 방지). 정렬은 서비스에서 등급 우선순위로 처리.
// 재분석 정리용: 어르신의 모든 충돌 행(노출 필터 없음)을 약과 함께 로딩.
// 이번 분석에서 다시 매칭되지 않은 stale 행을 걸러내야 하므로 목록 쿼리의 활성·삭제 조건을 걸지 않는다.
@Query("""
SELECT dc FROM DrugConflict dc
JOIN FETCH dc.medication1
JOIN FETCH dc.medication2
WHERE dc.senior.id = :seniorId
""")
List<DrugConflict> findAllWithMedicationsForReanalysis(@Param("seniorId") Long seniorId);

// 목록 조회: 두 약을 함께 로딩(LazyInitialization 방지). 정렬은 서비스에서 등급 우선순위로 처리.
// 두 약이 모두 활성(is_active=true)이고 삭제되지 않은 충돌만 노출 — 복용 종료·삭제된 약의 stale 충돌 방지.
@Query("""
SELECT dc FROM DrugConflict dc
JOIN FETCH dc.medication1 m1
JOIN FETCH dc.medication2 m2
WHERE dc.senior.id = :seniorId
AND m1.isActive = true
AND m2.isActive = true
AND m1.deletedAt IS NULL
AND m2.deletedAt IS NULL
ORDER BY dc.id ASC
""")
List<DrugConflict> findAllWithMedicationsBySeniorId(@Param("seniorId") Long seniorId);

// 상세 조회: 두 약을 함께 로딩
// 상세 조회: 두 약을 함께 로딩. 노출 기준은 목록과 동일하게 맞춘다 —
// 목록에서 사라진 충돌(복용 종료·삭제된 약)은 상세도 열리지 않아야 유효하지 않은 경고를 현재 위험으로 오인하지 않는다.
@Query("""
SELECT dc FROM DrugConflict dc
JOIN FETCH dc.medication1
JOIN FETCH dc.medication2
JOIN FETCH dc.medication1 m1
JOIN FETCH dc.medication2 m2
WHERE dc.id = :conflictId
AND m1.isActive = true
AND m2.isActive = true
AND m1.deletedAt IS NULL
AND m2.deletedAt IS NULL
""")
Optional<DrugConflict> findWithMedicationsById(@Param("conflictId") Long conflictId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.piuda.callcare.domain.drugconflict.service.command;

import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -38,25 +42,53 @@ public void analyze(Long seniorId) {
// TODO: 인증 도입 후 seniorId 소유권 검증 추가

List<Medication> medications = medicationRepository.findActiveWithDrugInfoBySeniorId(seniorId);
Set<MedicationPair> matchedPairs = new HashSet<>();

for (int i = 0; i < medications.size(); i++) {
for (int j = i + 1; j < medications.size(); j++) {
Medication a = medications.get(i);
Medication b = medications.get(j);
drugConflictMatcher.match(a.getDrugInfo(), b.getDrugInfo())
.ifPresent(match -> saveIfAbsent(senior, a, b, match));
.ifPresent(match -> {
saveOrUpdate(senior, a, b, match);
matchedPairs.add(MedicationPair.of(a, b));
});
}
}

deleteStaleConflicts(seniorId, medications, matchedPairs);
}

// 이번 분석에서 다시 매칭되지 않은 기존 충돌을 제거 — 약 정보가 바뀌어 더 이상 충돌이 아닌 쌍이
// 옛 등급으로 목록에 남는 것을 막는다(upsert만으로는 사라진 충돌을 정리할 수 없다).
// 삭제 범위는 이번 분석 대상(활성·미삭제) 약들로만 이뤄진 쌍에 한정한다 —
// 비활성·삭제된 약이 낀 행은 애초에 매칭 대상이 아니었을 뿐이므로 지우면 분석 이력이 사라진다.
private void deleteStaleConflicts(Long seniorId, List<Medication> analyzed, Set<MedicationPair> matchedPairs) {
Set<Long> analyzedIds = analyzed.stream().map(Medication::getId).collect(Collectors.toSet());

List<DrugConflict> stale = drugConflictRepository.findAllWithMedicationsForReanalysis(seniorId).stream()
.filter(conflict -> analyzedIds.contains(conflict.getMedication1().getId())
&& analyzedIds.contains(conflict.getMedication2().getId()))
.filter(conflict -> !matchedPairs.contains(MedicationPair.of(
conflict.getMedication1(), conflict.getMedication2())))
.toList();

if (!stale.isEmpty()) {
drugConflictRepository.deleteAll(stale);
}
}

// (medication_id_1 < medication_id_2)로 정규화해 순서 무관 중복 저장을 방지(find→분기, 2단계 upsert 패턴).
private void saveIfAbsent(Senior senior, Medication x, Medication y, ConflictMatch match) {
// (medication_id_1 < medication_id_2)로 정규화해 순서 무관 중복을 방지하고,
// 기존 쌍이 있으면 최신 분석 결과로 upsert(값이 실제로 바뀐 경우만 UPDATE). 없으면 신규 저장.
private void saveOrUpdate(Senior senior, Medication x, Medication y, ConflictMatch match) {
Medication first = x.getId() < y.getId() ? x : y;
Medication second = x.getId() < y.getId() ? y : x;

boolean exists = drugConflictRepository.existsBySenior_IdAndMedication1_IdAndMedication2_Id(
Optional<DrugConflict> existing = drugConflictRepository.findBySenior_IdAndMedication1_IdAndMedication2_Id(
senior.getId(), first.getId(), second.getId());
if (exists) {
if (existing.isPresent()) {
// 이미 커밋된 쌍 → 최신 결과로 갱신(dirty checking). 값이 같으면 updateAnalysis가 UPDATE를 생략한다.
existing.get().updateAnalysis(match.severity(), match.description());
return;
}

Expand All @@ -69,7 +101,18 @@ private void saveIfAbsent(Senior senior, Medication x, Medication y, ConflictMat
.conflictDescription(match.description())
.build());
} catch (DataIntegrityViolationException e) {
// 동시 요청으로 같은 조합이 먼저 저장된 경우(uk_drug_conflict_senior_med1_med2). 정합성은 제약이 보장하므로 무시.
// 동시 요청으로 같은 조합이 먼저 INSERT된 레이스(uk_drug_conflict_senior_med1_med2).
// 정합성은 UNIQUE 제약이 보장하므로 이번 요청은 저장을 건너뛴다(삼중 방어 유지).
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect the declared JPA/Hibernate versions and the current transaction path.
fd -HI '^(pom\.xml|build\.gradle|build\.gradle\.kts)$' . -x sh -c \
  'echo "--- $1"; rg -n -C 2 "spring-boot|spring-data-jpa|hibernate" "$1"' sh {} || true

ast-grep outline \
  src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java \
  --view expanded

rg -n -C 4 \
  'DataIntegrityViolationException|saveAndFlush|flush\(|`@Transactional`|findBySenior_IdAndMedication1_IdAndMedication2_Id' \
  src/main/java src/test/java

Repository: PIUDAProject/Backend

Length of output: 37173


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "--- drug-conflict service"
cat -n src/main/java/com/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java

echo "--- drug-conflict entity"
fd -HI 'DrugConflict\.java$' src/main/java -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}

echo "--- build files"
cat -n build.gradle | sed -n '1,120p'
fd -HI '^(gradle\.properties|gradle-wrapper\.properties|libs\.versions\.toml)$' . -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}

Repository: PIUDAProject/Backend

Length of output: 12771


🌐 Web query:

Hibernate JPA data integrity violation unique constraint flush exception isRollbackOnly transaction rollback only

💡 Result:

In Hibernate and JPA, a data integrity violation—such as a unique constraint failure—typically occurs during the flush process, when the persistence context synchronizes its state with the database [1][2]. Because this violation puts the persistence context into an inconsistent or potentially corrupted state, Hibernate marks the current transaction as "rollback-only" [3]. When a transaction is marked as rollback-only, any attempt to commit it will fail, usually resulting in a javax.persistence.RollbackException or Spring's UnexpectedRollbackException [4][5][3]. Even if you catch the underlying DataIntegrityViolationException within your code, you cannot "save" the transaction; it is already doomed to be rolled back by the transaction manager [5][3]. Key Technical Realities: 1. Persistence Context Inconsistency: When a flush fails due to a constraint violation, Hibernate cannot guarantee the integrity of the session [3]. Consequently, the session is invalidated for the remainder of the transaction, and any subsequent attempts to use it will likely fail [3]. 2. Deferred Execution: Hibernate often delays SQL execution until a flush is triggered (e.g., at commit, during a query, or via a manual flush) [2]. This is why the exception is often thrown at the end of a method rather than the specific line of code that triggered the data change [4][6]. 3. Handling the Exception: - You cannot simply catch the exception to continue the same transaction [5][3]. - If you need to handle duplicate entries or constraint violations gracefully, the standard approach is to perform a check (e.g., repository.existsBy...) before attempting the operation [7]. - If you must attempt an operation that might fail, you can isolate it by using a separate transaction with propagation set to Propagation.REQUIRES_NEW [3]. This ensures the failure only affects the isolated transaction and does not mark the main transaction as rollback-only [3]. If you encounter the isRollbackOnly issue, it confirms that your application attempted to commit a transaction that encountered an unrecoverable error during the Hibernate flush phase [8][3]. To resolve this, redesign your logic to avoid relying on exceptions for flow control, or isolate risky operations into independent transactions [3][7].

Citations:


🌐 Web query:

Hibernate UNIQUE constraint violation unique key insert select insert exception

💡 Result:

A ConstraintViolationException occurs in Hibernate when a database operation violates a defined integrity constraint, such as a UNIQUE or PRIMARY KEY constraint [1][2]. Because Hibernate sessions are generally invalidated upon encountering such exceptions, the current transaction must be rolled back [3][4][5]. To handle scenarios involving potential unique constraint violations, consider the following approaches: 1. Use Upsert Logic (Recommended) The most robust solution to avoid duplicate key exceptions is to perform an atomic upsert operation rather than a select-then-insert pattern [6][4]. Since Hibernate 6.5, you can use the ON CONFLICT DO clause in JPQL/Criteria queries to handle conflicts in a database-agnostic or database-specific way (e.g., ON DUPLICATE KEY UPDATE in MySQL or MERGE in Oracle) [7]. 2. Defensive Checks You may perform a select query to check for the existence of the entity before attempting an insert [3][8]. Note that this is not atomic and can still fail due to race conditions in high-concurrency environments; using a transaction with an appropriate isolation level is necessary to mitigate this [3][4]. 3. Exception Handling If you must catch the exception, be aware of the following: - JPA often wraps the Hibernate ConstraintViolationException inside a PersistenceException [5]. - Because the session is invalidated, you cannot simply swallow the exception and continue using the same session [3][4]. You must rollback the transaction and, if necessary, start a new one to proceed [4]. - You can inspect the exception to identify the specific constraint violated using the getConstraintName and getKind methods, provided the database driver reports this information reliably [1][9][2]. Avoid the anti-pattern of relying solely on catching exceptions to control business logic, as it can be less performant and harder to maintain [8]. Always ensure that your database sequences or auto-increment configurations are correctly synchronized with existing data to prevent unintended primary key collisions [10][11].

Citations:


🌐 Web query:

MySQL InnoDB UNIQUE constraint violation deadlock duplicate insert wait lock unique index

💡 Result:

In MySQL InnoDB, deadlocks involving unique constraint violations are often caused by the specific way InnoDB handles locking when an INSERT statement encounters a duplicate key [1][2]. When a duplicate-key error occurs, InnoDB typically acquires a shared (S) lock on the existing duplicate index record [1][3]. This behavior is designed to ensure serializability and to guarantee that the information returned to the user—that a duplicate exists—is consistent with the database state at that moment [2]. The deadlock mechanism generally occurs as follows: 1. Multiple sessions attempt to insert the same unique key value [1][2]. 2. If one session already holds an exclusive (X) lock on the record (e.g., due to a prior operation like a DELETE or UPDATE), subsequent sessions attempting the insert will request a shared (S) lock on that same record due to the detected duplicate key [1][2]. 3. When the session holding the exclusive lock rolls back or completes, the queued requests for shared locks are granted [1][2]. 4. If multiple sessions now hold a shared lock and subsequently attempt to promote those locks to exclusive locks (or perform other operations requiring exclusive access) on the same record, they can deadlock because each session is waiting for the others to release their shared locks [1][2]. Additional factors that can contribute to these deadlocks include: - Next-Key Locking: InnoDB may apply next-key locks (a combination of a record lock and a gap lock) rather than simple record locks, which can block concurrent inserts in surrounding gaps, increasing the surface area for contention [1][4][5]. - Gap Locking with Multiple Unique Keys: When tables have multiple unique indexes, a duplicate key error on one index may trigger gap locks on the primary key or other secondary indexes to maintain consistency, further limiting concurrency [5][6]. - INSERT... ON DUPLICATE KEY UPDATE: While this statement behaves differently by acquiring exclusive locks rather than shared locks upon a collision, complex interaction with concurrent standard INSERT operations can still lead to deadlocks [1][7]. These behaviors are inherent to InnoDB's pessimistic locking approach to ensure data integrity and ACID compliance, particularly under high concurrency [8]. They are generally not considered bugs but expected behavior within the engine's locking architecture [5][8]. To mitigate these issues, it is recommended to ensure application code is prepared to handle deadlock errors (e.g., via retries) and to optimize access patterns to avoid frequent collisions on the same unique keys [7][6].

Citations:


🌐 Web query:

MySQL 2024-2025 duplicate key insert lock lock wait timeout gap lock concurrent insert unique index

💡 Result:

In MySQL's InnoDB storage engine, a lock wait timeout during concurrent INSERT operations involving unique indexes is often caused by the locking behavior triggered by duplicate-key errors [1][2][3][4]. When a session attempts an INSERT and encounters a duplicate-key error (due to a violation of a UNIQUE or PRIMARY KEY constraint), InnoDB sets a lock on the existing duplicate index record [5][3]. For simple INSERT statements, this is typically a shared (S) lock, whereas INSERT... ON DUPLICATE KEY UPDATE sets an exclusive (X) lock [5][1]. The key technical factors causing lock wait timeouts in concurrent environments include: 1. Next-Key Locking and Gap Locks: While a standard INSERT primarily uses insert intention locks (which allow concurrency), a duplicate-key error can cause InnoDB to escalate to a next-key lock (index record lock plus a gap lock) on the duplicate record [5][1]. This gap lock prevents other sessions from inserting into the gap before the record, potentially blocking unrelated concurrent inserts [6][3][4]. 2. Tables with Multiple Unique Indexes: When a table has multiple unique indexes, an insert might successfully pass one index check but trigger a duplicate-key error on another [2]. This behavior can result in unexpected gap locks on indexes that were not intended to be locked, leading to wide-ranging blocking of other INSERT operations and subsequent lock wait timeouts for unrelated transactions [2][3][4]. 3. Deadlocks and Lock Wait Timeouts: If multiple sessions attempt to insert the same duplicate value simultaneously, they may all request locks on the same record [5]. If one session holds an exclusive lock and others request shared locks (or vice-versa), it can lead to deadlocks or prolonged waiting periods that exceed the innodb_lock_wait_timeout setting [5][1][4]. To mitigate these issues, it is generally recommended to avoid using ON DUPLICATE KEY UPDATE on tables with multiple unique indexes [2]. Furthermore, because gap locks are necessary for maintaining data integrity (specifically to prevent phantom rows and enforce uniqueness constraints in transactional isolation), they cannot be entirely disabled [3][7][8]. If you encounter frequent lock wait timeouts, analyze your transaction patterns to reduce the time that locks are held and consider whether your application logic can be structured to minimize contention on highly active unique indexes [4][9].

Citations:


동시 INSERT 충돌을 현재 트랜잭션에서 무시하지 마십시오.

@Transactional 메서드에서 발생한 DataIntegrityViolationException은 일반적으로 트랜잭션을 rollback-only로 만들고, 이후 commitUnexpectedRollbackException으로 실패할 수 있습니다. duplicate row가 commit되면 severity/conflictDescription도 현재 요청의 최신 결과로 갱신되지 않습니다. DB 원자적 upsert 또는 REQUIRES_NEW 단위로 재조회 후 갱신하는 별도 트랜잭션 흐름으로 변경하고, 두 트랜잭션에서 같은 약물 쌍을 동시 분석하는 통합 케이스를 추가하십시오.

🤖 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/piuda/callcare/domain/drugconflict/service/command/DrugConflictCommandService.java`
around lines 103 - 106, Update the DataIntegrityViolationException handling in
DrugConflictCommandService so concurrent drug-pair inserts are not swallowed
within the surrounding `@Transactional` transaction; use an atomic database upsert
or a separate REQUIRES_NEW transaction that re-reads the duplicate row and
updates severity and conflictDescription. Add an integration test covering two
transactions analyzing the same drug pair concurrently and verifying the
committed row contains the latest analysis result without
UnexpectedRollbackException.

}

// 순서 무관 비교용 약 쌍 키. 저장 정규화 규칙과 동일하게 (작은 id, 큰 id)로 맞춘다.
private record MedicationPair(Long first, Long second) {

static MedicationPair of(Medication x, Medication y) {
return x.getId() < y.getId()
? new MedicationPair(x.getId(), y.getId())
: new MedicationPair(y.getId(), x.getId());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ public MedicationLogToggleResponse toggle(Long medicationId, MealTime mealTime,
throw new CallCareException(ErrorCode.MEDICATION_LOG_TOGGLE_NOT_TODAY);
}

// 2) 약 존재 검증
Medication medication = medicationRepository.findById(medicationId)
// 2) 약 존재 검증 (삭제된 약은 없는 것으로 취급)
Medication medication = medicationRepository.findByIdAndDeletedAtIsNull(medicationId)
.orElseThrow(() -> new CallCareException(ErrorCode.MEDICATION_NOT_FOUND));

// 3) 그 약에 해당 시간대의 오늘 활성 스케줄이 실제 존재하는지 검증 (완료 재계산과 동일 기준)
Expand Down Expand Up @@ -76,7 +76,7 @@ public MedicationLogToggleResponse toggle(Long medicationId, MealTime mealTime,
// 홈카드와 동일한 완료 규칙으로 해당 시간대 완료 여부를 산출 → CompletedStatus로 변환
private CompletedStatus recalculateMealTimeStatus(Long seniorId, LocalDate date, MealTime mealTime) {
List<MedicationSchedule> slotSchedules = medicationScheduleRepository
.findActiveSchedulesForHomeCards(seniorId, date).stream()
.findActiveSchedulesForHomeCards(seniorId, date, date.plusDays(1).atStartOfDay()).stream()
.filter(schedule -> schedule.getMealTime() == mealTime)
.toList();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ public HomeCardResponse getHomeCards(Long seniorId, LocalDate date) {
}

HomeCardMode mode = HomeCardMode.from(targetDate);
List<MedicationSchedule> schedules =
medicationScheduleRepository.findActiveSchedulesForHomeCards(seniorId, targetDate);
// 조회 날짜의 다음날 0시 — 이 시각 이후에 삭제된 약은 그 날엔 아직 복용 중이었으므로 카드에 남긴다
List<MedicationSchedule> schedules = medicationScheduleRepository.findActiveSchedulesForHomeCards(
seniorId, targetDate, targetDate.plusDays(1).atStartOfDay());

// 미래 모드는 완료 개념이 없어 로그 조회 자체를 생략
Set<TakenKey> takenKeys = mode.tracksCompletion()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public ResponseEntity<ApiResponse<List<MedicationReportGroupResponse>>> getRepor
return ResponseUtils.ok(medicationQueryService.getReport(userId, seniorId));
}

@Operation(summary = "약 삭제", description = "약과 복용 스케줄을 완전 삭제합니다. 복구 불가능합니다.")
@Operation(summary = "약 삭제", description = "약을 삭제 처리합니다. 삭제일 당일부터 약물노트·상세 조회·오늘/미래 홈카드·충돌 목록에서 제외되며, 삭제 이전 날짜의 홈카드와 복약 기록 리포트에는 그대로 남습니다.")
@DeleteMapping("/{medicationId}")
public ResponseEntity<ApiResponse<Void>> delete(
@AuthenticationPrincipal Long userId,
Expand Down
Loading