From 9e6f48214b587670c8e62aaebac39d4823907d44 Mon Sep 17 00:00:00 2001 From: thoh Date: Wed, 26 Aug 2026 18:48:27 +0900 Subject: [PATCH 1/2] =?UTF-8?q?FEAT=20:=20=EC=B0=9C=ED=95=9C=20=EB=B3=91?= =?UTF-8?q?=EC=9B=90=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프런트에서 만들 수 없던 화면 두 개가 API 가 없어서 막혀 있었다. - GET /health/hospitals/likes — 찜한 병원 목록. 찜은 걸 수 있는데 모아 볼 방법이 없었다. 병원을 JOIN FETCH 로 함께 가져와 N+1 을 막는다 - POST /users/me/profile-image — multipart 업로드. 기존 PUT 은 이미지 URL 문자열만 받아서 클라이언트가 파일을 올릴 곳이 없었다. 건강기록 첨부와 같은 FileStorageService 를 쓰므로 S3 로 옮길 때 함께 옮겨간다 만들면서 드러난 기존 버그 두 가지를 함께 고쳤다. HospitalLike·HospitalReview 의 userId 는 insertable=false 인 읽기 전용 그림자 필드다. 생성 시 .userId(...) 로만 채우고 있어서 user_id 컬럼에는 아무것도 쓰이지 않았고, 모든 찜과 병원 리뷰가 user_id=NULL 로 저장됐다. 그 결과 중복 확인·찜 여부·작성자 판별이 전부 어긋나 있었다. 연관(user) 자체를 채우도록 고쳤다. HealthFacade 에 트랜잭션이 없어 파생 delete(deleteByHospitalIdAndUserId)가 "No EntityManager with actual transaction" 으로 죽었다. 찜 해제가 항상 500 이었다. 쓰기 메서드에 @Transactional 을 붙였다. /files/profile-images/** 만 인증 없이 연다. 에는 인증 헤더를 붙일 수 없고 파일명이 UUID 라 주소를 모르면 찾을 수 없다. 업로드 루트 전체를 열지 않은 것은 같은 저장소에 건강기록 첨부(민감정보)가 들어 있기 때문이다 — 그쪽은 인증을 거치는 별도 다운로드 경로가 필요하다. --- .../core/security/SecurityConfig.java | 7 +++++ .../domain/health/app/HealthFacade.java | 24 ++++++++++++++++-- .../health/controller/HealthController.java | 8 ++++++ .../repository/HospitalLikeRepository.java | 13 ++++++++++ .../carecode/domain/user/app/UserFacade.java | 23 +++++++++++++++++ .../user/controller/UserController.java | 18 +++++++++++++ .../dto/response/ProfileImageResponse.java | 17 +++++++++++++ .../26/4a1cb34350af4426a8b85f5d3bb3e316.png | Bin 0 -> 70 bytes 8 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/carecode/domain/user/dto/response/ProfileImageResponse.java create mode 100644 uploads/profile-images/2026/08/26/4a1cb34350af4426a8b85f5d3bb3e316.png 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/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/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/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/uploads/profile-images/2026/08/26/4a1cb34350af4426a8b85f5d3bb3e316.png b/uploads/profile-images/2026/08/26/4a1cb34350af4426a8b85f5d3bb3e316.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 From 52d515df81aa9b635d84ff18bfd5b96dc19fc2ac Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 30 Aug 2026 19:07:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?CHORE=20:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20git=20=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.storage.local.root 기본값이 ./uploads 라, 로컬에서 업로드를 시험할 때마다 저장소 안으로 파일이 쌓인다. .gitignore 에 규칙이 없어서 실제로 시험용 파일 하나가 커밋에 섞여 들어왔다(uploads/profile-images/.../*.png, 70바이트). 이번 것은 더미 파일이라 실제 피해는 없다. 문제는 경로다. 프로필 이미지와 건강기록 첨부(진단서·검진표)가 같은 디렉터리 아래 쌓이고, 이 저장소는 공개다. 한 번이라도 진짜 파일이 섞이면 커밋 이력에 남아 되돌리기 어렵다. 파일을 추적에서 빼고 uploads/ 를 무시 목록에 넣는다. --- .gitignore | 4 ++++ .../08/26/4a1cb34350af4426a8b85f5d3bb3e316.png | Bin 70 -> 0 bytes 2 files changed, 4 insertions(+) delete mode 100644 uploads/profile-images/2026/08/26/4a1cb34350af4426a8b85f5d3bb3e316.png 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/uploads/profile-images/2026/08/26/4a1cb34350af4426a8b85f5d3bb3e316.png b/uploads/profile-images/2026/08/26/4a1cb34350af4426a8b85f5d3bb3e316.png deleted file mode 100644 index f37764b1f7606623616dcdc169cc858273ea2d94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J