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
12 changes: 12 additions & 0 deletions src/main/java/com/carecode/core/storage/FileStorageService.java
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.carecode.core.storage;

import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

/** 파일 저장소 추상화. 구현체를 바꾸면 로컬 디스크 ↔ S3 전환이 가능하도록 도메인 코드는 이 인터페이스에만 의존한다. */
Expand All@@ -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);
}
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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()) {
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
@@ -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")
Expand All@@ -33,4 +39,32 @@ public ResponseEntity<AttachmentResponse> upload(
return ResponseEntity.status(HttpStatus.CREATED)
.body(attachmentService.upload(recordId, file, description));
}

/**
* 첨부파일 내려받기.
*
* 업로드 저장소(`/files/**`)를 정적으로 공개하지 않는다 — 주소만 아는 사람이 남의
* 진료 기록을 볼 수 있기 때문이다. 본인 기록인지 확인한 뒤 서버가 직접 내려준다.
*/
@GetMapping("/{attachmentId}/download")
@LogExecutionTime
@Operation(summary = "첨부파일 내려받기", description = "본인 건강기록의 첨부파일만 받을 수 있습니다")
public ResponseEntity<Resource> 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());
}
}
Original file line numberDiff line numberDiff line change
@@ -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;
}
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);
}
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -58,16 +60,45 @@ public List<AttachmentResponse> 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);
Expand Down
Loading
Loading