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
21 changes: 21 additions & 0 deletions .claude/rules/git.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Git 워크플로우 규칙

## 커밋 타이밍
- 커밋/푸시는 사용자가 명시적으로 요청할 때만 수행한다
- 파일 수정 후 임의로 커밋/푸시하지 않는다

## 커밋 분리 원칙
한 번에 전부 커밋하지 않는다. 아래 순서로 레이어별 분리:

1. `entity` / `enums` 변경 시
2. `dto` (request / response)
3. `converter`
4. `repository`
5. `service` (command / query 각각 별도)
6. `controller`
7. `config` (SecurityConfig 등)

## 커밋 메시지
- 본문은 한글로 작성한다
- 기술 용어(DTO, OCR, ES, JWT 등)는 영어 그대로 쓴다 — 괄호로 번역 추가하지 않는다
- 접두사: `feat:`, `fix:`, `chore:`, `refactor:`, `docs:`
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Don't assume. Don't hide confusion. Surface tradeoffs.
- 테스트 작성/수정 시 → `testing_guide.md`
- Security/Config 만질 때 → `security.md`
- 배포/Docker/GitHub Actions 관련 → `deploy.md`
- 커밋/푸시 작업 시 → `git.md` (항상 로드)

### 🟣 AI 작업 흔적 (`.dev/`)
- 새로 알게 된 패턴·주의점·오류 기록 → `learnings/`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import java.util.List;

@Tag(name = "Drug", description = "약품 정보 API (DB 캐시 기반)")
@Tag(name = "Drug", description = "약품 정보 API (MySQL DB 기반 — ES 장애 시 폴백용)")
@Validated
@RestController
@RequestMapping("/api/drugs")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.piuda.callcare.domain.medication.controller;

import com.piuda.callcare.domain.medication.dto.request.MedicationCreateRequest;
import com.piuda.callcare.domain.medication.dto.response.MedicationDetailResponse;
import com.piuda.callcare.domain.medication.dto.response.MedicationResponse;
import com.piuda.callcare.domain.medication.service.command.MedicationCommandService;
import com.piuda.callcare.domain.medication.service.query.MedicationQueryService;
import com.piuda.callcare.global.common.response.ApiResponse;
import com.piuda.callcare.global.common.response.ResponseUtils;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -22,6 +24,7 @@
public class MedicationController {

private final MedicationCommandService medicationCommandService;
private final MedicationQueryService medicationQueryService;

@Operation(summary = "약 등록", description = "약을 1개 이상 등록합니다. 단건이면 리스트에 1개, 여러 개면 여러 개 담아서 보내세요. 하나라도 실패하면 전체 롤백됩니다.")
@PostMapping("/batch")
Expand All @@ -31,4 +34,13 @@ public ResponseEntity<ApiResponse<List<MedicationResponse>>> registerBatch(
) {
return ResponseUtils.created(medicationCommandService.registerBatch(userId, requests));
}

@Operation(summary = "약 상세 정보 조회", description = "약 카드 클릭 시 주의사항/부작용/용법·용량/효능·효과를 반환합니다. DrugInfo 미연결 약은 의약품 정보 필드 null로 반환됩니다.")
@GetMapping("/{medicationId}/detail")
public ResponseEntity<ApiResponse<MedicationDetailResponse>> getDetail(
@AuthenticationPrincipal Long userId,
@PathVariable Long medicationId
) {
return ResponseUtils.ok(medicationQueryService.getDetail(userId, medicationId));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.piuda.callcare.domain.medication.converter;

import com.piuda.callcare.domain.druginfo.entity.DrugInfo;
import com.piuda.callcare.domain.medication.dto.response.MedicationDetailResponse;
import com.piuda.callcare.domain.medication.dto.response.MedicationResponse;
import com.piuda.callcare.domain.medication.entity.Medication;
import com.piuda.callcare.domain.medication.entity.MedicationSchedule;
Expand All @@ -11,6 +13,21 @@
@Component
public class MedicationConverter {

// Medication → MedicationDetailResponse (약 상세 조회용, DrugInfo 없으면 전 필드 null)
public MedicationDetailResponse toDetailResponse(Medication medication) {
DrugInfo drugInfo = medication.getDrugInfo();
if (drugInfo == null) {
return new MedicationDetailResponse(medication.getDrugName(), null, null, null, null);
}
return new MedicationDetailResponse(
medication.getDrugName(),
drugInfo.getEfcyQesitm(),
drugInfo.getUseMethodQesitm(),
drugInfo.getAtpnQesitm(),
drugInfo.getSeQesitm()
);
}
Comment on lines +16 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

PR 목표와 구현이 불일치합니다 — DrugInfo 미연결 시 응답 처리 방식 확인 필요

PR 목표는 "DrugInfo가 연결되지 않은 경우 전체 상세 응답을 null로 반환"이라고 명시하지만, 현재 구현은 drugName을 포함한 non-null 응답을 반환합니다. 또한 MedicationDetailResponse의 @Schema 설명인 "DrugInfo 미연결 시 전 필드 null"과도 일치하지 않습니다 (drugName이 설정됨).

두 가지 해석이 가능합니다:

  1. PR 목표대로 drugInfo == null일 때 null 반환
  2. 현재 구현대로 drugName은 유지하되 Schema/PR 목표 업데이트
💡 옵션 1: PR 목표에 맞추는 경우
     if (drugInfo == null) {
-        return new MedicationDetailResponse(medication.getDrugName(), null, null, null, null);
+        return null;
     }
💡 옵션 2: 현재 구현을 유지하는 경우 — Schema 설명 수정
-@Schema(description = "약 상세 정보 응답 — DrugInfo 미연결 시 전 필드 null")
+@Schema(description = "약 상세 정보 응답 — DrugInfo 미연결 시 DrugInfo 관련 필드는 null, drugName은 Medication에서 반환")

Based on learnings, if multiple interpretations of a task exist, present the alternatives rather than choosing silently.

🤖 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/medication/converter/MedicationConverter.java`
around lines 16 - 29, There is a mismatch between the PR goal and
`MedicationConverter.toDetailResponse`: when `drugInfo` is null, the method
currently returns a partially populated `MedicationDetailResponse` with
`drugName`, which conflicts with the stated behavior and the
`MedicationDetailResponse` schema. Please resolve this by choosing one approach
explicitly: either make `toDetailResponse` return null when
`medication.getDrugInfo()` is null, or keep `drugName` populated and update the
`MedicationDetailResponse` schema/PR intent to match that behavior.

Source: Learnings


// Medication + 스케줄 목록 → MedicationResponse
public MedicationResponse toResponse(Medication medication, List<MedicationSchedule> schedules) {
List<MealTime> mealTimes = schedules.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ public record MedicationCreateRequest(
LocalDate prescriptionDate,

@Schema(description = "병원 이름 — 직접 입력, 없으면 null")
String hospitalName
String hospitalName,

@Schema(description = "메모 (복용방법 + 보관방법 자동 생성 또는 사용자 직접 입력, 없으면 null)")
String memo
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.piuda.callcare.domain.medication.dto.response;

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

@Schema(description = "약 상세 정보 응답 — drugName은 항상 반환, DrugInfo 미연결 시 의약품 정보 필드 null")
public record MedicationDetailResponse(

@Schema(description = "약품명")
String drugName,

@Schema(description = "효능·효과 (없으면 null)")
String efcyQesitm,

@Schema(description = "용법·용량 (없으면 null)")
String useMethodQesitm,

@Schema(description = "주의사항 (없으면 null)")
String atpnQesitm,

@Schema(description = "부작용 (없으면 null)")
String seQesitm
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ private MedicationResponse doRegister(Long userId, MedicationCreateRequest reque
.prescriptionDate(request.prescriptionDate())
.isActive(true)
.ocrResultId(request.ocrResultId())
.memo(request.memo())
.build();

Medication saved = medicationRepository.save(medication);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.piuda.callcare.domain.medication.service.query;

import com.piuda.callcare.domain.medication.converter.MedicationConverter;
import com.piuda.callcare.domain.medication.dto.response.MedicationDetailResponse;
import com.piuda.callcare.domain.medication.entity.Medication;
import com.piuda.callcare.domain.medication.repository.MedicationRepository;
import com.piuda.callcare.global.exception.CallCareException;
import com.piuda.callcare.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MedicationQueryService {

private final MedicationRepository medicationRepository;
private final MedicationConverter medicationConverter;

public MedicationDetailResponse getDetail(Long userId, Long medicationId) {
Medication medication = medicationRepository.findById(medicationId)
.orElseThrow(() -> new CallCareException(ErrorCode.MEDICATION_NOT_FOUND));
if (userId != null && !medication.getSenior().getUser().getId().equals(userId)) {
throw new CallCareException(ErrorCode.FORBIDDEN);
}
return medicationConverter.toDetailResponse(medication);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(HttpMethod.GET, "/api/conflicts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/conflicts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/ocr/**").permitAll()
// TODO: 인증 필터 도입 후 제거하고, userId 기반 소유권 검증으로 전환
.requestMatchers(HttpMethod.POST, "/api/medications/batch").permitAll()
.requestMatchers(HttpMethod.GET, "/api/medications/**").permitAll()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

GET /api/medications/** permitAll — IDOR 위험 및 TODO 주석 누락

GET /api/medications/**가 permitAll로 설정되어 인증 없이 모든 약 상세 정보에 접근 가능합니다. medicationId가 순차적 DB ID이므로 열거 공격으로 타 사용자의 약 정보(drugName 등)가 노출됩니다.

/api/home/**, /api/conflicts/**에는 인증 필터 도입 시 제거하겠다는 TODO 주석이 있지만, medications에는 누락되어 있습니다. 또한 MedicationQueryService.getDetail에 userId 기반 소유권 검증이 없어 인증 도입 시에도 IDOR이 해결되지 않습니다.

🔒 권장 사항

1. TODO 주석 추가 (즉시, 기존 패턴 일관성):

+			// TODO: 인증 필터 도입 시 제거하고, 로그인 사용자의 seniorId 소유권 검증으로 전환
 			.requestMatchers(HttpMethod.GET, "/api/medications/**").permitAll()

2. 인증 도입 시 소유권 검증 추가 (MedicationQueryService + MedicationController):

 // MedicationController
 public ResponseEntity<ApiResponse<MedicationDetailResponse>> getDetail(
+        `@AuthenticationPrincipal` Long userId,
         `@PathVariable` Long medicationId
 ) {
-    return ResponseUtils.ok(medicationQueryService.getDetail(medicationId));
+    return ResponseUtils.ok(medicationQueryService.getDetail(medicationId, userId));
 }
 // MedicationQueryService
-public MedicationDetailResponse getDetail(Long medicationId) {
+public MedicationDetailResponse getDetail(Long medicationId, Long userId) {
     Medication medication = medicationRepository.findById(medicationId)
             .orElseThrow(() -> new CallCareException(ErrorCode.MEDICATION_NOT_FOUND));
+    if (!medication.getSenior().getUser().getId().equals(userId)) {
+        throw new CallCareException(ErrorCode.FORBIDDEN);
+    }
     return medicationConverter.toDetailResponse(medication);
 }
📝 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
.requestMatchers(HttpMethod.GET, "/api/medications/**").permitAll()
// TODO: 인증 필터 도입 시 제거하고, 로그인 사용자의 seniorId 소유권 검증으로 전환
.requestMatchers(HttpMethod.GET, "/api/medications/**").permitAll()
🤖 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/global/config/SecurityConfig.java` at line
47, `GET /api/medications/**` is currently exposed via `permitAll`, which leaves
medication detail lookup vulnerable to IDOR; update `SecurityConfig` to stop
allowing anonymous access here and add the same temporary TODO-style note used
for `/api/home/**` and `/api/conflicts/**` if you need to preserve the existing
pattern. Also make sure `MedicationQueryService.getDetail` and the corresponding
`MedicationController` path enforce `userId`-based ownership checks so the
detail lookup is scoped to the authenticated user rather than a sequential
`medicationId`.

.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
Expand Down