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/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;
+}