Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
94c597e
feat: 해시태그 추가
TTaiJin Apr 9, 2025
216e3eb
feat: 다이어리 도메인에 해시태그 관련된 로직 추가
TTaiJin Apr 9, 2025
2418fc4
test: test 컴파일 에러 수정
TTaiJin Apr 9, 2025
8f4f692
충돌 해결
TTaiJin Apr 9, 2025
bcc737f
test: HashtagServiceTest 추가
TTaiJin Apr 10, 2025
7178317
test: Test 리팩토링
TTaiJin Apr 10, 2025
0891d8f
feat: Diary - Like 순환참조 해결, 파사드 패턴 도입
TTaiJin Apr 10, 2025
72df827
feat: MySqlConfig에 해시태그 관련 Repository 추가
TTaiJin Apr 10, 2025
87f0c9b
refactor: CustomDiaryRepositoryImpl 리팩토링
TTaiJin Apr 11, 2025
72484bf
refactor: MediaService 리팩토링
TTaiJin Apr 11, 2025
ff1529a
refactor: DiaryFacade 수정 + 관련 코드 리팩토링
TTaiJin Apr 11, 2025
d7de353
feat: Media S3삭제 즉시삭제 및 비동기 방식으로 변경 + 삭제 실패 스케쥴링 재시도 추가
TTaiJin Apr 11, 2025
929b587
test: DiaryFacadeTest 도입 + 테스트 리팩토링
TTaiJin Apr 11, 2025
4acb23e
refactor: diaries/me 추가 + MediaController에서 사용 안하는 엔드포인트 제거
TTaiJin Apr 11, 2025
1472d37
feat: Diary 상세 조회 시 User 정보도 반환
TTaiJin Apr 13, 2025
bc0e84b
refactor: 좋아요 수나 생성 일자가 같은 경우 대비 보조 정렬 기준 추가
TTaiJin Apr 13, 2025
e0b492c
refactor: Diary 응답 구조 변경 + Test 수정
TTaiJin Apr 13, 2025
93e462f
Merge branch 'develop' of https://github.com/prgrms-web-devcourse-fin…
TTaiJin Apr 13, 2025
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
23 changes: 23 additions & 0 deletions src/main/java/com/example/log4u/common/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.example.log4u.common.config;

import java.util.concurrent.Executor;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "mediaTaskExecutor")
public Executor mediaTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("media-async-");
executor.initialize();
return executor;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"com.example.log4u.domain.reports",
"com.example.log4u.domain.supports",
"com.example.log4u.domain.user",
"com.example.log4u.domain.subscription"
"com.example.log4u.domain.subscription",
"com.example.log4u.domain.hashtag"
},
entityManagerFactoryRef = "mysqlEntityManagerFactory",
transactionManagerRef = "mysqlTransactionManager"
Expand Down Expand Up @@ -57,7 +58,8 @@ public LocalContainerEntityManagerFactoryBean mysqlEntityManagerFactory() {
"com.example.log4u.domain.reports",
"com.example.log4u.domain.supports",
"com.example.log4u.domain.user",
"com.example.log4u.domain.subscription"
"com.example.log4u.domain.subscription",
"com.example.log4u.domain.hashtag"
);
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
vendorAdapter.setShowSql(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import com.example.log4u.domain.diary.SortType;
import com.example.log4u.domain.diary.dto.DiaryRequestDto;
import com.example.log4u.domain.diary.dto.DiaryResponseDto;
import com.example.log4u.domain.diary.service.DiaryService;
import com.example.log4u.domain.diary.facade.DiaryFacade;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -30,7 +30,7 @@
@Slf4j
public class DiaryController {

private final DiaryService diaryService;
private final DiaryFacade diaryFacade;

@GetMapping("/users/{userId}")
public ResponseEntity<PageResponse<DiaryResponseDto>> getDiariesByUserId(
Expand All @@ -39,18 +39,30 @@ public ResponseEntity<PageResponse<DiaryResponseDto>> getDiariesByUserId(
@RequestParam(required = false) Long cursorId,
@RequestParam(defaultValue = "12") int size
) {
PageResponse<DiaryResponseDto> response = diaryService.getDiariesByCursor(customOAuth2User.getUserId(),
PageResponse<DiaryResponseDto> response = diaryFacade.getDiariesByCursor(customOAuth2User.getUserId(),
targetUserId, cursorId, size);

return ResponseEntity.ok(response);
}

@GetMapping("/users/me")
public ResponseEntity<PageResponse<DiaryResponseDto>> getMyDiaries(
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
@RequestParam(required = false) Long cursorId,
@RequestParam(defaultValue = "12") int size
) {
PageResponse<DiaryResponseDto> response = diaryFacade.getDiariesByCursor(customOAuth2User.getUserId(),
customOAuth2User.getUserId(), cursorId, size);

return ResponseEntity.ok(response);
}

@PostMapping
public ResponseEntity<Void> createDiary(
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
@Valid @RequestBody DiaryRequestDto request
) {
diaryService.saveDiary(customOAuth2User.getUserId(), request);
diaryFacade.createDiary(customOAuth2User.getUserId(), request);
return ResponseEntity.status(HttpStatus.CREATED).build();
}

Expand All @@ -60,10 +72,10 @@ public ResponseEntity<PageResponse<DiaryResponseDto>> searchDiaries(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "LATEST") SortType sort,
@RequestParam(required = false) Long cursorId,
@RequestParam(defaultValue = "6") int size
@RequestParam(defaultValue = "12") int size
) {
return ResponseEntity.ok(
diaryService.searchDiariesByCursor(keyword, sort, cursorId, size)
diaryFacade.searchDiariesByCursor(keyword, sort, cursorId, size)
);
}

Expand All @@ -72,7 +84,7 @@ public ResponseEntity<DiaryResponseDto> getDiary(
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
@PathVariable Long diaryId
) {
DiaryResponseDto diary = diaryService.getDiary(customOAuth2User.getUserId(), diaryId);
DiaryResponseDto diary = diaryFacade.getDiary(customOAuth2User.getUserId(), diaryId);
return ResponseEntity.ok(diary);
}

Expand All @@ -82,16 +94,16 @@ public ResponseEntity<Void> modifyDiary(
@PathVariable Long diaryId,
@Valid @RequestBody DiaryRequestDto request
) {
diaryService.updateDiary(customOAuth2User.getUserId(), diaryId, request);
diaryFacade.updateDiary(customOAuth2User.getUserId(), diaryId, request);
return ResponseEntity.ok().build();
}

@DeleteMapping("/{diaryId}")
public ResponseEntity<?> deleteDiary(
public ResponseEntity<Void> deleteDiary(
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
@PathVariable Long diaryId
) {
diaryService.deleteDiary(customOAuth2User.getUserId(), diaryId);
diaryFacade.deleteDiary(customOAuth2User.getUserId(), diaryId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ public record DiaryRequestDto(
@NotNull(message = "미디어 첨부는 필수입니다.")
@Size(min = 1, max = 10, message = "미디어는 최소 1개, 최대 10개까지만 업로드 가능합니다.")
@Valid
List<MediaRequestDto> mediaList
List<MediaRequestDto> mediaList,

List<String> hashtagList
) {
public static Diary toEntity(Long userId, DiaryRequestDto diaryRequestDto, String thumbnailUrl) {
return Diary.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
import com.example.log4u.domain.map.dto.LocationDto;
import com.example.log4u.domain.media.dto.MediaResponseDto;
import com.example.log4u.domain.media.entity.Media;
import com.example.log4u.domain.user.entity.User;

import lombok.Builder;

@Builder
public record DiaryResponseDto(
Long diaryId,
Long userId,
Long authorId,
String authorNickname,
String authorProfileImage,
LocationDto location,
String title,
String content,
Expand All @@ -24,13 +27,23 @@ public record DiaryResponseDto(
String thumbnailUrl,
Long likeCount,
List<MediaResponseDto> mediaList,
List<String> hashtagList,
boolean isLiked
) {
public static DiaryResponseDto of(Diary diary, List<Media> media, boolean isLiked) {
// 단건 조회용 (isLiked + User)
public static DiaryResponseDto of(
Diary diary,
List<Media> media,
List<String> hashtagList,
boolean isLiked,
User author
) {
return DiaryResponseDto.builder()
.diaryId(diary.getDiaryId())
.userId(diary.getUserId())
.location(LocationDto.of(diary.getLocation()))
.authorId(diary.getUserId())
.authorNickname(author.getNickname())
.authorProfileImage(author.getProfileImage())
.location(com.example.log4u.domain.map.dto.LocationDto.of(diary.getLocation()))
.title(diary.getTitle())
.content(diary.getContent())
.weatherInfo(diary.getWeatherInfo().name())
Expand All @@ -41,12 +54,36 @@ public static DiaryResponseDto of(Diary diary, List<Media> media, boolean isLike
.likeCount(diary.getLikeCount())
.mediaList(media.stream()
.map(MediaResponseDto::of).toList())
.hashtagList(hashtagList)
.isLiked(isLiked)
.build();
}

// 다이어리 목록 반환 시 사용 (isLiked false 기본값)
public static DiaryResponseDto of(Diary diary, List<Media> media) {
return DiaryResponseDto.of(diary, media, false);
// 다이어리 목록 반환 시 사용 (isLiked false, User null 기본값)
public static DiaryResponseDto of(
Diary diary,
List<Media> media,
List<String> hashtagList
) {
return DiaryResponseDto.builder()
.diaryId(diary.getDiaryId())
.authorId(diary.getUserId())
.authorNickname(null)
.authorProfileImage(null)
.location(com.example.log4u.domain.map.dto.LocationDto.of(diary.getLocation()))
.title(diary.getTitle())
.content(diary.getContent())
.weatherInfo(diary.getWeatherInfo().name())
.visibility(diary.getVisibility().name())
.createdAt(diary.getCreatedAt())
.updatedAt(diary.getUpdatedAt())
.thumbnailUrl(diary.getThumbnailUrl())
.likeCount(diary.getLikeCount())
.mediaList(media.stream()
.map(MediaResponseDto::of).toList())
.hashtagList(hashtagList)
.isLiked(false)
.build();
}

}
142 changes: 142 additions & 0 deletions src/main/java/com/example/log4u/domain/diary/facade/DiaryFacade.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.example.log4u.domain.diary.facade;

import java.util.List;

import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import com.example.log4u.common.dto.PageResponse;
import com.example.log4u.domain.diary.SortType;
import com.example.log4u.domain.diary.dto.DiaryRequestDto;
import com.example.log4u.domain.diary.dto.DiaryResponseDto;
import com.example.log4u.domain.diary.entity.Diary;
import com.example.log4u.domain.diary.service.DiaryService;
import com.example.log4u.domain.hashtag.service.HashtagService;
import com.example.log4u.domain.like.service.LikeService;
import com.example.log4u.domain.map.service.MapService;
import com.example.log4u.domain.media.entity.Media;
import com.example.log4u.domain.media.service.MediaService;
import com.example.log4u.domain.user.entity.User;
import com.example.log4u.domain.user.service.UserService;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class DiaryFacade {

private final DiaryService diaryService;
private final MediaService mediaService;
private final MapService mapService;
private final LikeService likeService;
private final HashtagService hashtagService;
private final UserService userService;

/**
* 다이어리 생성 use case
* <ul><li>호출 과정</li></ul>
* 1. mediaService: 섬네일 이미지 url 생성<br>
* 2. diaryService: 다이어리 생성<br>
* 2. mediaService: 해당 다이어리의 이미지 저장<br>
* 3. mapService: 해당 구역 카운트 증가
* */
@Transactional
public void createDiary(Long userId, DiaryRequestDto request) {
String thumbnailUrl = mediaService.extractThumbnailUrl(request.mediaList());
Diary diary = diaryService.saveDiary(userId, request, thumbnailUrl);
mediaService.saveMedia(diary.getDiaryId(), request.mediaList());
hashtagService.saveOrUpdateHashtag(diary.getDiaryId(), request.hashtagList());
mapService.increaseRegionDiaryCount(request.location().latitude(), request.location().longitude());
}

/**
* 다이어리 삭제 use case
* <ul><li>호출 과정</li></ul>
* 1. diaryService: 다이어리 검증
* 2. mediaService: 해당 다이어리 이미지 삭제<br>
* 3. diaryService: 다이어리 삭제<br>
* */
@Transactional
public void deleteDiary(Long userId, Long diaryId) {
Diary diary = diaryService.getDiaryAfterValidateOwnership(diaryId, userId);
mediaService.deleteMediaByDiaryId(diaryId);
hashtagService.deleteHashtagsByDiaryId(diaryId);
diaryService.deleteDiary(diary);
}

/**
* 다이어리 수정 use case
* <ul><li>호출 과정</li></ul>
* 1. diaryService: 다이어리 검증<br>
* 2. mediaService: 해당 다이어리 이미지 삭제<br>
* 3. diaryService: 다이어리 수정
* */
@Transactional
public void updateDiary(Long userId, Long diaryId, DiaryRequestDto request) {
Diary diary = diaryService.getDiaryAfterValidateOwnership(diaryId, userId);
mediaService.updateMediaByDiaryId(diary.getDiaryId(), request.mediaList());
hashtagService.saveOrUpdateHashtag(diary.getDiaryId(), request.hashtagList());

String newThumbnailUrl = mediaService.extractThumbnailUrl(request.mediaList());
diaryService.updateDiary(diary, request, newThumbnailUrl);
}

/**
* 다이어리 단건 조회 use case
* <ul><li>호출 과정</li></ul>
* 1. diaryService: 공개 범위 검증 후 다이어리 조회<br>
* 2. likeService: 좋아요 기록 조회<br>
* 3. mediaService: 해당 다이어리의 이미지 조회<br>
* 4. 모든 정보 조합 후 dto 변환 해 반환
* */
@Transactional(readOnly = true)
public DiaryResponseDto getDiary(Long userId, Long diaryId) {
Diary diary = diaryService.getDiaryAfterValidateAccess(diaryId, userId);
User user = userService.getUserById(diary.getUserId());
boolean isLiked = likeService.isLiked(userId, diaryId);
List<Media> media = mediaService.getMediaByDiaryId(diary.getDiaryId());
List<String> hashtags = hashtagService.getHashtagsByDiaryId(diary.getDiaryId());
return DiaryResponseDto.of(diary, media, hashtags, isLiked, user);
}

/**
* 다이어리 목록 조회 By UserId use case
* <ul><li>호출 과정</li></ul>
* 1. diaryService : DiaryResponseDto Slice 객체 조회<br>
* 2. nextCursor 정보 생성<br>
* 3. PageResponse 조합 후 반환
* */
@Transactional(readOnly = true)
public PageResponse<DiaryResponseDto> getDiariesByCursor(
Long userId,
Long targetUserId,
Long cursorId,
int size
) {
Slice<DiaryResponseDto> dtoSlice = diaryService.getDiaryResponseDtoSlice(userId, targetUserId, cursorId, size);
// 다음 커서 ID 계산
Long nextCursor = !dtoSlice.isEmpty() ? dtoSlice.getContent().getLast().diaryId() : null;
return PageResponse.of(dtoSlice, nextCursor);
}

/**
* 다이어리 검색 목록 조회 By Cursor use case
* <ul><li>호출 과정</li></ul>
* 1. diaryService : DiaryResponseDto Slice 객체 조회<br>
* 2. nextCursor 정보 생성<br>
* 3. PageResponse 조합 후 반환<br>
* */
@Transactional(readOnly = true)
public PageResponse<DiaryResponseDto> searchDiariesByCursor(
String keyword,
SortType sort,
Long cursorId,
int size
) {
Slice<DiaryResponseDto> dtoSlice = diaryService.searchDiariesByCursor(keyword, sort, cursorId, size);
// 다음 커서 ID 계산
Long nextCursor = !dtoSlice.isEmpty() ? dtoSlice.getContent().getLast().diaryId() : null;
return PageResponse.of(dtoSlice, nextCursor);
}
}
Loading