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
4 changes: 4 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,3 +65,7 @@ logs/

# 별도 저장소로 관리되는 프론트엔드
CareCode_FE/

# 업로드 저장소(app.storage.local.root 기본값 ./uploads).
# 프로필 이미지·건강기록 첨부는 실제 사용자 데이터다. 저장소에 들어가면 안 된다.
uploads/
7 changes: 7 additions & 0 deletions src/main/java/com/carecode/core/security/SecurityConfig.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,6 +96,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

// 정적 리소스 (공개 접근)
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
// 프로필 이미지는 <img src> 로 불러가므로 인증 헤더를 붙일 수 없다.
// 파일명이 UUID 라 주소를 모르면 찾을 수 없고, 원래 화면에 노출되는 값이다.
//
// 업로드 루트(/files/**) 전체를 열지 않는 이유는 같은 저장소에 건강기록 첨부가
// 들어 있기 때문이다. 그쪽은 민감정보라 주소만 알면 열리는 상태로 두면 안 되고,
// 인증을 거치는 별도 다운로드 경로가 필요하다.
.requestMatchers("/files/profile-images/**").permitAll()
.requestMatchers("/static/**").permitAll()

// 통합 인증 관련 엔드포인트 (공개 접근)
Expand Down
24 changes: 22 additions & 2 deletions src/main/java/com/carecode/domain/health/app/HealthFacade.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;

Expand All@@ -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에서는 제거
Expand DownExpand Up@@ -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));
Expand All@@ -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));

Expand All@@ -191,6 +200,13 @@ public boolean unlikeHospital(Long id, Long userId) {
return true;
}

/** 내가 찜한 병원 목록. 찜을 걸 수는 있는데 모아 볼 방법이 없었다. */
public List<HospitalInfoResponse> 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));

Expand DownExpand Up@@ -235,13 +251,15 @@ public List<HospitalReviewResponse> 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();
Expand All@@ -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));
Expand All@@ -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));
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -277,6 +277,14 @@ public ResponseEntity<?> unlikeHospital(@Parameter(description = "병원 ID", re
return ResponseEntity.ok().build();
}

// 내가 찜한 병원 목록
@GetMapping("/hospitals/likes")
@LogExecutionTime
@Operation(summary = "찜한 병원 목록 조회", description = "로그인한 사용자가 찜해 둔 병원 목록")
public ResponseEntity<List<HospitalInfoResponse>> getLikedHospitals() {
return ResponseEntity.ok(healthFacade.getLikedHospitals(getAuthenticatedUserPk()));
}

// 병원 좋아요 수 조회
// 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다.
@PreAuthorize("permitAll()")
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<HospitalLike, Long> {
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<HospitalLike> findLikedWithHospitalByUserId(@Param("userId") Long userId);
boolean existsByHospitalIdAndUserId(Long hospitalId, Long userId);
void deleteByHospitalIdAndUserId(Long hospitalId, Long userId);
}
23 changes: 23 additions & 0 deletions src/main/java/com/carecode/domain/user/app/UserFacade.java
Original file line numberDiff line numberDiff line change
@@ -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) {
Expand All@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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;
Expand DownExpand Up@@ -120,6 +123,21 @@ public ResponseEntity<Void> 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<ProfileImageResponse> uploadMyProfileImage(
@Parameter(description = "이미지 파일", required = true) @RequestPart("file") MultipartFile file) {
return ResponseEntity.ok(userFacade.uploadProfileImage(selfDbId(), file));
}

@PutMapping("/{userId}/profile-image")
@LogExecutionTime
@Operation(summary = "프로필 이미지 변경 (구 경로)",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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;
}
Loading