diff --git a/.gitignore b/.gitignore index 419f2ef5..e9b50340 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,7 @@ logs/ # 별도 저장소로 관리되는 프론트엔드 CareCode_FE/ + +# 업로드 저장소(app.storage.local.root 기본값 ./uploads). +# 프로필 이미지·건강기록 첨부는 실제 사용자 데이터다. 저장소에 들어가면 안 된다. +uploads/ diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 37e2429c..55d40468 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -96,6 +96,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 정적 리소스 (공개 접근) .requestMatchers("/css/**", "/js/**", "/images/**").permitAll() + // 프로필 이미지는 로 불러가므로 인증 헤더를 붙일 수 없다. + // 파일명이 UUID 라 주소를 모르면 찾을 수 없고, 원래 화면에 노출되는 값이다. + // + // 업로드 루트(/files/**) 전체를 열지 않는 이유는 같은 저장소에 건강기록 첨부가 + // 들어 있기 때문이다. 그쪽은 민감정보라 주소만 알면 열리는 상태로 두면 안 되고, + // 인증을 거치는 별도 다운로드 경로가 필요하다. + .requestMatchers("/files/profile-images/**").permitAll() .requestMatchers("/static/**").permitAll() // 통합 인증 관련 엔드포인트 (공개 접근) diff --git a/src/main/java/com/carecode/core/storage/FileStorageService.java b/src/main/java/com/carecode/core/storage/FileStorageService.java index 2ffbf40e..c96fcb80 100644 --- a/src/main/java/com/carecode/core/storage/FileStorageService.java +++ b/src/main/java/com/carecode/core/storage/FileStorageService.java @@ -1,5 +1,6 @@ package com.carecode.core.storage; +import org.springframework.core.io.Resource; import org.springframework.web.multipart.MultipartFile; /** 파일 저장소 추상화. 구현체를 바꾸면 로컬 디스크 ↔ S3 전환이 가능하도록 도메인 코드는 이 인터페이스에만 의존한다. */ @@ -8,6 +9,17 @@ public interface FileStorageService { /** 파일을 저장한다. */ StoredFile store(MultipartFile file, String directory); + /** + * 저장된 파일을 읽는다. + * + * 민감한 파일(건강기록 첨부 등)은 정적 경로로 공개할 수 없어, 인증을 거친 뒤 + * 서버가 직접 내려줘야 한다. 없는 키는 예외를 던진다. + */ + Resource load(String key); + + /** 공개 URL(`/files/...`)에서 저장 키를 되돌린다. 이미 키면 그대로 돌려준다. */ + String toKey(String publicUrl); + /** 저장된 파일을 삭제한다. 없는 키를 지워도 예외를 던지지 않는다. */ void delete(String key); } diff --git a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java index d7f9353e..c71327ba 100644 --- a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java +++ b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java @@ -1,9 +1,12 @@ package com.carecode.core.storage; import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; @@ -88,6 +91,34 @@ public StoredFile store(MultipartFile file, String directory) { .build(); } + @Override + public Resource load(String key) { + // BusinessException 은 전역 핸들러가 ErrorCode 와 무관하게 400 으로 바꾼다. + // 없는 파일은 404 여야 하므로 ResourceNotFoundException 을 쓴다. + if (key == null || key.isBlank()) { + throw new ResourceNotFoundException("파일을 찾을 수 없습니다."); + } + + Path target = rootLocation.resolve(key).normalize(); + // 저장소 밖을 가리키는 키는 읽지 않는다. 삭제와 같은 방어다. + if (!target.startsWith(rootLocation) || !Files.isReadable(target)) { + log.warn("읽을 수 없는 파일 요청: {}", key); + throw new ResourceNotFoundException("파일을 찾을 수 없습니다."); + } + + return new FileSystemResource(target); + } + + @Override + public String toKey(String publicUrl) { + if (publicUrl == null || publicUrl.isBlank()) { + return publicUrl; + } + + String prefix = publicBaseUrl + "/"; + return publicUrl.startsWith(prefix) ? publicUrl.substring(prefix.length()) : publicUrl; + } + @Override public void delete(String key) { if (key == null || key.isBlank()) { diff --git a/src/main/java/com/carecode/domain/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index ba2727f5..331439eb 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -20,11 +20,13 @@ import com.carecode.domain.health.entity.HospitalReview; import com.carecode.domain.health.repository.HospitalRepository; import com.carecode.domain.health.repository.HospitalLikeRepository; +import com.carecode.domain.user.repository.UserRepository; import com.carecode.domain.health.repository.HospitalReviewRepository; import lombok.RequiredArgsConstructor; import com.carecode.domain.health.mapper.HospitalMapper; import com.carecode.domain.health.mapper.HospitalReviewMapper; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -42,6 +44,7 @@ public class HealthFacade { private final HospitalReviewRepository hospitalReviewRepository; private final HospitalMapper hospitalMapper; private final HospitalReviewMapper hospitalReviewMapper; + private final UserRepository userRepository; // ==================== 건강 기록 관리 ==================== // 트랜잭션은 Service 계층에서 관리하므로 Facade에서는 제거 @@ -161,6 +164,7 @@ public HospitalInfoResponse getHospitalById(Long id) { return hospitalMapper.toResponse(hospital); } + @Transactional public boolean likeHospital(Long id, Long userId) { Hospital hospital = hospitalRepository.findById(id) .orElseThrow(() -> new HospitalNotFoundException(id)); @@ -170,15 +174,20 @@ public boolean likeHospital(Long id, Long userId) { return false; } + // userId 필드는 insertable=false 인 읽기 전용 그림자다. 여기에 값을 넣어도 + // user_id 컬럼에는 아무것도 쓰이지 않아 그동안 모든 찜이 user_id=NULL 로 저장됐다. + // 그 결과 중복 확인·해제·찜 여부가 전부 어긋났다. 연관 자체를 채운다. HospitalLike like = HospitalLike.builder() .hospital(hospital) - .userId(userId) + .user(userRepository.getReferenceById(userId)) .createdAt(java.time.LocalDateTime.now()) .build(); hospitalLikeRepository.save(like); return true; } + /** 파생 delete 는 트랜잭션 없이는 실행되지 않는다. 이게 없어 찜 해제가 항상 500 이었다. */ + @Transactional public boolean unlikeHospital(Long id, Long userId) { hospitalRepository.findById(id).orElseThrow(() -> new HospitalNotFoundException(id)); @@ -191,6 +200,13 @@ public boolean unlikeHospital(Long id, Long userId) { return true; } + /** 내가 찜한 병원 목록. 찜을 걸 수는 있는데 모아 볼 방법이 없었다. */ + public List getLikedHospitals(Long userId) { + return hospitalLikeRepository.findLikedWithHospitalByUserId(userId).stream() + .map(like -> hospitalMapper.toResponse(like.getHospital())) + .toList(); + } + public long getLikeCount(Long id) { hospitalRepository.findById(id).orElseThrow(() -> new HospitalNotFoundException(id)); @@ -235,13 +251,15 @@ public List getHospitalReviews(Long hospitalId) { .toList(); } + @Transactional public HospitalReviewResponse createHospitalReview(Long hospitalId, Long userId, Integer rating, String content) { Hospital hospital = hospitalRepository.findById(hospitalId) .orElseThrow(() -> new HospitalNotFoundException(hospitalId)); + // 찜과 같은 이유로 user 연관을 채운다 (userId 는 읽기 전용 그림자다) HospitalReview review = HospitalReview.builder() .hospital(hospital) - .userId(userId) + .user(userRepository.getReferenceById(userId)) .rating(rating) .content(content) .build(); @@ -250,6 +268,7 @@ public HospitalReviewResponse createHospitalReview(Long hospitalId, Long userId, return hospitalReviewMapper.toResponse(savedReview); } + @Transactional public HospitalReviewResponse updateHospitalReview(Long reviewId, Long userId, Integer rating, String content) { HospitalReview review = hospitalReviewRepository.findById(reviewId) .orElseThrow(() -> new HospitalReviewNotFoundException(reviewId)); @@ -265,6 +284,7 @@ public HospitalReviewResponse updateHospitalReview(Long reviewId, Long userId, I return hospitalReviewMapper.toResponse(updatedReview); } + @Transactional public void deleteHospitalReview(Long reviewId, Long userId) { HospitalReview review = hospitalReviewRepository.findById(reviewId) .orElseThrow(() -> new HospitalReviewNotFoundException(reviewId)); diff --git a/src/main/java/com/carecode/domain/health/controller/HealthController.java b/src/main/java/com/carecode/domain/health/controller/HealthController.java index 0d9edc00..1d24f1b2 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -277,6 +277,14 @@ public ResponseEntity unlikeHospital(@Parameter(description = "병원 ID", re return ResponseEntity.ok().build(); } + // 내가 찜한 병원 목록 + @GetMapping("/hospitals/likes") + @LogExecutionTime + @Operation(summary = "찜한 병원 목록 조회", description = "로그인한 사용자가 찜해 둔 병원 목록") + public ResponseEntity> getLikedHospitals() { + return ResponseEntity.ok(healthFacade.getLikedHospitals(getAuthenticatedUserPk())); + } + // 병원 좋아요 수 조회 // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. @PreAuthorize("permitAll()") diff --git a/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java b/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java index 38fc35a7..05805799 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java @@ -1,18 +1,24 @@ package com.carecode.domain.health.controller; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.domain.health.dto.response.AttachmentDownload; import com.carecode.domain.health.dto.response.AttachmentResponse; import com.carecode.domain.health.service.HealthRecordAttachmentService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; + /** 건강기록 첨부파일 업로드 API. 기존 POST /health/records/{id/attachments} 는 이미 업로드된 파일의 URL·메타데이터를 JSON 으로 */ @RestController @RequestMapping("/health/records/{recordId}/attachments") @@ -33,4 +39,32 @@ public ResponseEntity upload( return ResponseEntity.status(HttpStatus.CREATED) .body(attachmentService.upload(recordId, file, description)); } + + /** + * 첨부파일 내려받기. + * + * 업로드 저장소(`/files/**`)를 정적으로 공개하지 않는다 — 주소만 아는 사람이 남의 + * 진료 기록을 볼 수 있기 때문이다. 본인 기록인지 확인한 뒤 서버가 직접 내려준다. + */ + @GetMapping("/{attachmentId}/download") + @LogExecutionTime + @Operation(summary = "첨부파일 내려받기", description = "본인 건강기록의 첨부파일만 받을 수 있습니다") + public ResponseEntity download( + @PathVariable Long recordId, + @Parameter(description = "첨부파일 ID", required = true) @PathVariable Long attachmentId) { + AttachmentDownload download = attachmentService.download(recordId, attachmentId); + + // 브라우저가 파일명을 그대로 쓸 수 있게 RFC 5987 로 인코딩한다(한글 파일명 대응). + ContentDisposition disposition = ContentDisposition.attachment() + .filename(download.getFileName() != null ? download.getFileName() : "attachment", + StandardCharsets.UTF_8) + .build(); + + return ResponseEntity.ok() + .contentType(download.getContentType() != null + ? MediaType.parseMediaType(download.getContentType()) + : MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .body(download.getResource()); + } } diff --git a/src/main/java/com/carecode/domain/health/dto/response/AttachmentDownload.java b/src/main/java/com/carecode/domain/health/dto/response/AttachmentDownload.java new file mode 100644 index 00000000..eebe3491 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/response/AttachmentDownload.java @@ -0,0 +1,19 @@ +package com.carecode.domain.health.dto.response; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.core.io.Resource; + +/** + * 첨부파일 다운로드 결과. + * + * 건강기록 첨부는 민감정보라 정적 경로로 공개할 수 없다. 소유권을 확인한 뒤 + * 서버가 직접 내려주기 위해 파일 본문과 표시용 메타데이터를 함께 담는다. + */ +@Getter +@Builder +public class AttachmentDownload { + private final Resource resource; + private final String fileName; + private final String contentType; +} diff --git a/src/main/java/com/carecode/domain/health/repository/HospitalLikeRepository.java b/src/main/java/com/carecode/domain/health/repository/HospitalLikeRepository.java index 9495bb09..c5bb9e06 100644 --- a/src/main/java/com/carecode/domain/health/repository/HospitalLikeRepository.java +++ b/src/main/java/com/carecode/domain/health/repository/HospitalLikeRepository.java @@ -4,9 +4,22 @@ import com.carecode.domain.health.entity.HospitalLike; import com.carecode.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface HospitalLikeRepository extends JpaRepository { long countByHospitalId(Long hospitalId); + + /** + * 내가 찜한 병원 목록. + * + * 찜은 걸 수 있는데 모아 볼 방법이 없어 화면을 만들 수 없었다. + * 병원을 함께 가져오지 않으면 목록 길이만큼 추가 조회가 나간다(N+1). + */ + @Query("SELECT hl FROM HospitalLike hl JOIN FETCH hl.hospital WHERE hl.userId = :userId ORDER BY hl.createdAt DESC") + List findLikedWithHospitalByUserId(@Param("userId") Long userId); boolean existsByHospitalIdAndUserId(Long hospitalId, Long userId); void deleteByHospitalIdAndUserId(Long hospitalId, Long userId); } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java b/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java index 387da343..e3d9b57d 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java @@ -1,8 +1,10 @@ package com.carecode.domain.health.service; import com.carecode.core.exception.HealthRecordNotFoundException; +import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.security.CurrentUserFacade; import com.carecode.core.storage.FileStorageService; +import com.carecode.domain.health.dto.response.AttachmentDownload; import com.carecode.core.storage.StoredFile; import com.carecode.domain.health.dto.response.AttachmentResponse; import com.carecode.domain.health.entity.HealthRecord; @@ -58,16 +60,45 @@ public List list(Long recordId) { .toList(); } + /** + * 첨부파일 본문. + * + * `/files/**` 로 바로 열 수 없다 — 같은 저장소를 정적으로 공개하면 주소만 아는 사람이 + * 남의 진료 기록을 볼 수 있다. 여기서 본인 기록인지 확인한 뒤에만 내려준다. + */ + public AttachmentDownload download(Long recordId, Long attachmentId) { + // 소유권 확인이 먼저다. 남의 기록이면 존재 여부를 숨기려 404 로 응답한다. + requireOwnedRecord(recordId); + + // 없는 첨부와 "남의 기록에 달린 첨부" 는 같은 404 여야 한다. + // IllegalArgumentException 을 쓰면 전역 핸들러가 400 으로 바꾸는데, 그러면 + // 소유권 실패(404)와 응답이 갈려 "그 id 는 존재한다" 는 사실이 새어 나간다. + HealthRecordAttachment attachment = attachmentRepository.findById(attachmentId) + .orElseThrow(() -> new ResourceNotFoundException("첨부파일을 찾을 수 없습니다: " + attachmentId)); + + // 다른 기록의 첨부 id 를 끼워 넣어 남의 파일을 받아가지 못하게 한다. + if (attachment.getHealthRecord() == null + || !attachment.getHealthRecord().getId().equals(recordId)) { + throw new ResourceNotFoundException("첨부파일을 찾을 수 없습니다: " + attachmentId); + } + + return AttachmentDownload.builder() + .resource(fileStorageService.load(fileStorageService.toKey(attachment.getFileUrl()))) + .fileName(attachment.getFileName()) + .contentType(attachment.getFileType()) + .build(); + } + @Transactional public void delete(Long recordId, Long attachmentId) { requireOwnedRecord(recordId); HealthRecordAttachment attachment = attachmentRepository.findById(attachmentId) - .orElseThrow(() -> new IllegalArgumentException("첨부파일을 찾을 수 없습니다: " + attachmentId)); + .orElseThrow(() -> new ResourceNotFoundException("첨부파일을 찾을 수 없습니다: " + attachmentId)); if (attachment.getHealthRecord() == null || !attachment.getHealthRecord().getId().equals(recordId)) { - throw new IllegalArgumentException("첨부파일을 찾을 수 없습니다: " + attachmentId); + throw new ResourceNotFoundException("첨부파일을 찾을 수 없습니다: " + attachmentId); } attachmentRepository.delete(attachment); diff --git a/src/main/java/com/carecode/domain/user/app/UserFacade.java b/src/main/java/com/carecode/domain/user/app/UserFacade.java index fa7deb59..8df2517f 100644 --- a/src/main/java/com/carecode/domain/user/app/UserFacade.java +++ b/src/main/java/com/carecode/domain/user/app/UserFacade.java @@ -1,17 +1,24 @@ package com.carecode.domain.user.app; +import com.carecode.core.storage.FileStorageService; +import com.carecode.core.storage.StoredFile; +import com.carecode.domain.user.dto.response.ProfileImageResponse; import com.carecode.domain.user.dto.response.UserDto; import com.carecode.domain.user.entity.User; import com.carecode.domain.user.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor public class UserFacade { + private static final String PROFILE_IMAGE_DIRECTORY = "profile-images"; + private final UserService userService; + private final FileStorageService fileStorageService; @Transactional(readOnly = true) public UserDto getUserByEmail(String email) { @@ -23,6 +30,22 @@ public void updateProfileImage(String userId, String profileImageUrl) { userService.updateProfileImage(userId, profileImageUrl); } + /** + * 프로필 이미지 파일을 저장하고 사용자에 연결한다. + * + * 기존 경로는 URL 문자열만 받아서, 클라이언트가 파일을 올릴 곳이 없었다. + * 저장은 건강기록 첨부와 같은 FileStorageService 를 쓰므로 S3 로 옮길 때 함께 옮겨간다. + */ + @Transactional + public ProfileImageResponse uploadProfileImage(String userId, MultipartFile file) { + StoredFile stored = fileStorageService.store(file, PROFILE_IMAGE_DIRECTORY); + userService.updateProfileImage(userId, stored.getUrl()); + + return ProfileImageResponse.builder() + .profileImageUrl(stored.getUrl()) + .build(); + } + @Transactional public UserDto updateUserLocation(String userId, Double latitude, Double longitude) { return userService.updateUserLocation(userId, latitude, longitude); diff --git a/src/main/java/com/carecode/domain/user/controller/UserController.java b/src/main/java/com/carecode/domain/user/controller/UserController.java index b77bf2c5..8afa64bd 100644 --- a/src/main/java/com/carecode/domain/user/controller/UserController.java +++ b/src/main/java/com/carecode/domain/user/controller/UserController.java @@ -6,6 +6,7 @@ import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.user.app.UserFacade; import com.carecode.domain.user.dto.request.UserUpdateRequestDto; +import com.carecode.domain.user.dto.response.ProfileImageResponse; import com.carecode.domain.user.dto.response.UserDto; import com.carecode.domain.user.dto.response.UserProfileCompletionResponse; import com.carecode.domain.user.dto.response.UserProfileMissingFields; @@ -19,9 +20,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.Map; @@ -120,6 +123,21 @@ public ResponseEntity updateMyProfileImage( return ResponseEntity.ok().build(); } + /** + * 프로필 이미지 업로드. + * + * 기존 경로는 이미지 **URL 문자열만** 받았다. 클라이언트가 파일을 올릴 곳이 없어서 + * 프로필 사진을 바꾸는 화면을 만들 수 없었다. 건강기록 첨부와 같은 저장소를 쓴다. + */ + @PostMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @LogExecutionTime + @Operation(summary = "프로필 이미지 업로드", description = "이미지 파일을 올리고 저장된 URL 을 돌려준다") + @SecurityRequirement(name = "Bearer Authentication") + public ResponseEntity uploadMyProfileImage( + @Parameter(description = "이미지 파일", required = true) @RequestPart("file") MultipartFile file) { + return ResponseEntity.ok(userFacade.uploadProfileImage(selfDbId(), file)); + } + @PutMapping("/{userId}/profile-image") @LogExecutionTime @Operation(summary = "프로필 이미지 변경 (구 경로)", diff --git a/src/main/java/com/carecode/domain/user/dto/response/ProfileImageResponse.java b/src/main/java/com/carecode/domain/user/dto/response/ProfileImageResponse.java new file mode 100644 index 00000000..cbc5c718 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/dto/response/ProfileImageResponse.java @@ -0,0 +1,17 @@ +package com.carecode.domain.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** 프로필 이미지 업로드 결과. 저장된 주소를 돌려줘야 클라이언트가 곧바로 화면에 반영할 수 있다. */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProfileImageResponse { + private String profileImageUrl; +} diff --git a/src/test/java/com/carecode/domain/health/service/HealthRecordAttachmentServiceTest.java b/src/test/java/com/carecode/domain/health/service/HealthRecordAttachmentServiceTest.java new file mode 100644 index 00000000..b80a73be --- /dev/null +++ b/src/test/java/com/carecode/domain/health/service/HealthRecordAttachmentServiceTest.java @@ -0,0 +1,182 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.exception.HealthRecordNotFoundException; +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.core.storage.FileStorageService; +import com.carecode.domain.health.dto.response.AttachmentDownload; +import com.carecode.domain.health.entity.HealthRecord; +import com.carecode.domain.health.entity.HealthRecordAttachment; +import com.carecode.domain.health.repository.HealthRecordAttachmentRepository; +import com.carecode.domain.health.repository.HealthRecordRepository; +import com.carecode.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 첨부파일 내려받기의 접근 제어 테스트. + * + *

건강기록 첨부는 진단서·검진표다. 업로드 저장소를 정적 경로로 공개하면 주소만 아는 + * 사람이 남의 진료 기록을 볼 수 있어, 소유권을 확인하고 서버가 직접 내려주는 경로를 쓴다. + * 그 확인이 빠지면 그대로 개인정보 유출이므로 여기서 계약을 고정한다. + * + *

응답 코드도 함께 고정한다. "없는 첨부" 와 "남의 기록에 달린 첨부" 가 다른 코드를 주면 + * 그 차이만으로 id 의 존재 여부를 알아낼 수 있다. 둘 다 404 여야 한다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("HealthRecordAttachmentService - 첨부 내려받기") +class HealthRecordAttachmentServiceTest { + + private static final Long MY_RECORD = 1L; + private static final Long OTHERS_RECORD = 2L; + + @Mock private HealthRecordRepository healthRecordRepository; + @Mock private HealthRecordAttachmentRepository attachmentRepository; + @Mock private FileStorageService fileStorageService; + @Mock private CurrentUserFacade currentUserFacade; + + @InjectMocks private HealthRecordAttachmentService service; + + private User me; + + @BeforeEach + void setUp() { + me = User.builder().id(10L).userId("user_me").email("me@example.com").build(); + User other = User.builder().id(99L).userId("user_other").email("other@example.com").build(); + + when(currentUserFacade.requireCurrentUser()).thenReturn(me); + + when(healthRecordRepository.findById(MY_RECORD)) + .thenReturn(Optional.of(HealthRecord.builder().id(MY_RECORD).user(me).build())); + when(healthRecordRepository.findById(OTHERS_RECORD)) + .thenReturn(Optional.of(HealthRecord.builder().id(OTHERS_RECORD).user(other).build())); + + when(fileStorageService.toKey(anyString())).thenAnswer(inv -> inv.getArgument(0)); + when(fileStorageService.load(anyString())) + .thenReturn(new ByteArrayResource("bytes".getBytes())); + } + + private HealthRecordAttachment attachment(Long id, Long recordId) { + HealthRecordAttachment attachment = HealthRecordAttachment.builder() + .healthRecord(HealthRecord.builder().id(recordId).user(me).build()) + .fileUrl("health-records/2026/08/29/stored.png") + .fileName("진단서.png") + .fileType("image/png") + .build(); + // id 는 @Builder 가 감싼 생성자에 없다(JPA 가 채우는 값). 테스트에서만 직접 넣는다. + ReflectionTestUtils.setField(attachment, "id", id); + return attachment; + } + + @Nested + @DisplayName("정상 경로") + class Success { + + @Test + @DisplayName("본인 기록의 첨부는 내려받을 수 있다") + void ownerCanDownload() { + when(attachmentRepository.findById(5L)).thenReturn(Optional.of(attachment(5L, MY_RECORD))); + + AttachmentDownload download = service.download(MY_RECORD, 5L); + + assertThat(download.getResource()).isNotNull(); + assertThat(download.getFileName()).isEqualTo("진단서.png"); + assertThat(download.getContentType()).isEqualTo("image/png"); + } + + @Test + @DisplayName("저장 키는 공개 URL 을 되돌려 얻는다") + void resolvesStorageKeyFromPublicUrl() { + when(attachmentRepository.findById(5L)).thenReturn(Optional.of(attachment(5L, MY_RECORD))); + + service.download(MY_RECORD, 5L); + + verify(fileStorageService).toKey("health-records/2026/08/29/stored.png"); + } + } + + @Nested + @DisplayName("접근 제어") + class AccessControl { + + @Test + @DisplayName("남의 건강기록이면 404 이고 파일을 읽지 않는다") + void othersRecordIsRejected() { + assertThatThrownBy(() -> service.download(OTHERS_RECORD, 5L)) + .isInstanceOf(HealthRecordNotFoundException.class); + + // 소유권 확인이 저장소 접근보다 먼저여야 한다. + verify(fileStorageService, never()).load(anyString()); + } + + @Test + @DisplayName("다른 기록에 달린 첨부 id 를 끼워 넣어도 받을 수 없다") + void crossRecordAttachmentIsRejected() { + // 내 기록 id 는 맞지만, 그 첨부는 다른 기록의 것이다. + when(attachmentRepository.findById(7L)).thenReturn(Optional.of(attachment(7L, 12345L))); + + assertThatThrownBy(() -> service.download(MY_RECORD, 7L)) + .isInstanceOf(ResourceNotFoundException.class); + + verify(fileStorageService, never()).load(anyString()); + } + + @Test + @DisplayName("없는 첨부와 남의 첨부는 같은 예외를 낸다") + void missingAndForeignAreIndistinguishable() { + when(attachmentRepository.findById(8L)).thenReturn(Optional.empty()); + when(attachmentRepository.findById(9L)).thenReturn(Optional.of(attachment(9L, 12345L))); + + // 응답이 갈리면 그 차이만으로 "그 id 는 존재한다" 를 알아낼 수 있다. + assertThatThrownBy(() -> service.download(MY_RECORD, 8L)) + .isInstanceOf(ResourceNotFoundException.class); + assertThatThrownBy(() -> service.download(MY_RECORD, 9L)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + @DisplayName("건강기록이 없으면 404") + void missingRecord() { + when(healthRecordRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.download(404L, 5L)) + .isInstanceOf(HealthRecordNotFoundException.class); + } + } + + @Nested + @DisplayName("삭제") + class Delete { + + @Test + @DisplayName("다른 기록의 첨부는 지울 수 없고 404 다") + void crossRecordDeleteIsRejected() { + when(attachmentRepository.findById(7L)).thenReturn(Optional.of(attachment(7L, 12345L))); + + assertThatThrownBy(() -> service.delete(MY_RECORD, 7L)) + .isInstanceOf(ResourceNotFoundException.class); + + verify(attachmentRepository, never()).delete(org.mockito.ArgumentMatchers.any()); + } + } +}