-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathBoardService.java
More file actions
430 lines (362 loc) · 17.6 KB
/
BoardService.java
File metadata and controls
430 lines (362 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
package cc.backend.board.service;
import cc.backend.apiPayLoad.code.status.ErrorStatus;
import cc.backend.apiPayLoad.exception.GeneralException;
import cc.backend.board.dto.request.BoardRequest;
import cc.backend.board.dto.request.BoardSearchRequest;
import cc.backend.board.dto.response.BoardDetailResponse;
import cc.backend.board.dto.response.BoardListResponse;
import cc.backend.board.dto.response.BoardResponse;
import cc.backend.board.entity.Board;
import cc.backend.board.entity.BoardLike;
import cc.backend.board.entity.HotBoard;
import cc.backend.board.entity.enums.BoardType;
import cc.backend.board.repository.BoardLikeRepository;
import cc.backend.board.repository.HotBoardRepository;
import cc.backend.kafka.event.commentEvent.CommentProducer;
import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent;
import cc.backend.image.DTO.ImageRequestDTO;
import cc.backend.image.DTO.ImageResponseDTO;
import cc.backend.image.FilePath;
import cc.backend.image.entity.Image;
import cc.backend.image.repository.ImageRepository;
import cc.backend.image.service.ImageService;
import cc.backend.kafka.event.hotBoardEvent.HotBoardProducer;
import cc.backend.kafka.event.replyEvent.ReplyProducer;
import cc.backend.member.entity.Member;
import cc.backend.board.repository.BoardRepository;
import cc.backend.member.enumerate.Role;
import cc.backend.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class BoardService {
private final BoardRepository boardRepository;
private final BoardLikeRepository boardLikeRepository;
private final HotBoardRepository hotBoardRepository;
private final MemberRepository memberRepository;
private final ImageService imageService;
private final ImageRepository imageRepository;
private final HotBoardProducer hotBoardProducer;
// 게시글 작성
@Transactional
public BoardResponse createBoard(Long memberId, BoardRequest dto) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND));
// 홍보게시판은 Performer만 작성 가능
if (dto.getBoardType() == BoardType.PROMOTION && member.getRole() != Role.PERFORMER) {
throw new GeneralException(ErrorStatus.ONLY_PERFORMER_CAN_WRITE_PROMOTION);
}
//contentId 얻기 위해 먼저 게시글 저장
Board board = Board.builder()
.title(dto.getTitle())
.content(dto.getContent())
.boardType(dto.getBoardType())
.likeCount(0)
.commentCount(0)
.member(member)
.build();
Board savedBoard = boardRepository.save(board);
//이미지 저장
if (dto.getImageRequestDTOs() != null && !dto.getImageRequestDTOs().isEmpty()) {
List<ImageRequestDTO.FullImageRequestDTO> fullImageRequestDTOs = dto.getImageRequestDTOs()
.stream()
.map(imageDto -> ImageRequestDTO.FullImageRequestDTO.builder()
.keyName(imageDto.getKeyName())
.filePath(FilePath.board) // FilePath enum 사용
.contentId(savedBoard.getId()) // 저장된 게시글 ID 사용
.memberId(memberId)
.build())
.collect(Collectors.toList());
imageService.saveImages(memberId, fullImageRequestDTOs);
}
List<Image> images = imageRepository.findAllByFilePathAndContentId(FilePath.board, board.getId());
List<String> imgUrls = imageService.getImages(images, memberId).stream()
.map(ImageResponseDTO.ImageResultWithPresignedUrlDTO::getPresignedUrl)
.toList();
return BoardResponse.builder()
.boardId(savedBoard.getId())
.boardType(savedBoard.getBoardType())
.title(savedBoard.getTitle())
.content(savedBoard.getContent())
.imgUrls(imgUrls) // 응답에 포함
.createdAt(savedBoard.getCreatedAt())
.updatedAt(savedBoard.getUpdatedAt())
.build();
}
//게시글 수정
@Transactional
public BoardResponse updateBoard(Long memberId, Long boardId, BoardRequest dto) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new GeneralException(ErrorStatus.BOARD_NOT_FOUND));
// 작성자 본인만 수정 가능
if (!board.getMember().getId().equals(memberId)) {
throw new GeneralException(ErrorStatus.BOARD_ACCESS_DENIED);
}
board.update(dto.getTitle(), dto.getContent(), dto.getBoardType());
// 이미지 수정 처리
if (dto.getImageRequestDTOs() != null) {
updateBoardImages(board, dto.getImageRequestDTOs(), memberId);
}
// 수정된 이미지 URL 목록 조회
List<Image> images = imageRepository.findAllByFilePathAndContentId(FilePath.board, boardId);
List<String> updatedImgUrls = imageService.getImages(images, memberId).stream()
.map(ImageResponseDTO.ImageResultWithPresignedUrlDTO::getPresignedUrl)
.toList();
return BoardResponse.builder()
.boardId(board.getId())
.boardType(board.getBoardType())
.title(board.getTitle())
.content(board.getContent())
.imgUrls(updatedImgUrls)
.createdAt(board.getCreatedAt())
.updatedAt(board.getUpdatedAt())
.build();
}
// 게시글 삭제
@Transactional
public void deleteBoard(Long memberId, Long boardId) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new GeneralException(ErrorStatus.BOARD_NOT_FOUND));
// 작성자 본인만 삭제 가능
if (!board.getMember().getId().equals(memberId)) {
throw new GeneralException(ErrorStatus.BOARD_ACCESS_DENIED);
}
// 게시글과 연관된 이미지들 삭제
List<Image> images = imageRepository.findAllByFilePathAndContentId(FilePath.board, boardId);
images.forEach(image -> imageService.deleteImage(image.getId(), memberId));
boardRepository.delete(board); //soft delete
}
@Transactional(readOnly = true)
public Slice<BoardListResponse> getAllBoards(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());
Slice<Board> boardSlice = boardRepository.findAllBy(pageable);
// 첫 번째 이미지 배치 조회
Map<Long, String> firstImageMap = getFirstImageMapForBoards(
boardSlice.getContent().stream()
.map(Board::getId)
.collect(Collectors.toList())
);
return boardSlice.map(board -> BoardListResponse.of(
board,
firstImageMap.get(board.getId())
));
}
//타입별 게시글 조회
@Transactional(readOnly = true)
public Slice<BoardListResponse> getBoards(BoardType boardType, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());
Slice<Board> boardSlice = boardRepository.findAllByBoardTypeOrderByIdDesc(boardType, pageable);
// 첫 번째 이미지 배치 조회
Map<Long, String> firstImageMap = getFirstImageMapForBoards(
boardSlice.getContent().stream()
.map(Board::getId)
.collect(Collectors.toList())
);
return boardSlice.map(board -> BoardListResponse.of(
board,
firstImageMap.get(board.getId())
));
}
//게시글 상세 조회
@Transactional(readOnly = true)
public BoardDetailResponse getBoard(Long boardId, Long memberId) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new GeneralException(ErrorStatus.BOARD_NOT_FOUND));
// 현재 사용자가 좋아요 눌렀는지 확인
boolean liked = false;
if (memberId != null) {
liked = boardLikeRepository.existsByMemberIdAndBoardId(memberId, boardId);
}
List<Image> boardImages = imageRepository.findAllByFilePathAndContentId(FilePath.board, boardId);
List<ImageResponseDTO.ImageResultWithPresignedUrlDTO> imageResults =
imageService.getImages(boardImages, memberId);
List<String> imgUrls = imageResults.stream()
.map(ImageResponseDTO.ImageResultWithPresignedUrlDTO::getPresignedUrl)
.toList();
return BoardDetailResponse.from(board,liked, imgUrls);
}
//게시글 좋아요
@Transactional
public int toggleLike(Long boardId, Long memberId) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new GeneralException(ErrorStatus.BOARD_NOT_FOUND));
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND));
Optional<BoardLike> existingLike = boardLikeRepository.findByMemberAndBoard(member, board);
if (existingLike.isPresent()) {
// 좋아요 취소
boardLikeRepository.delete(existingLike.get());
board.decreaseLikeCount();
return -1;
} else {
// 좋아요 추가
BoardLike newLike = BoardLike.of(member, board);
boardLikeRepository.save(newLike);
board.increaseLikeCount();
promoteToHotBoard(board);
return 1;
}
}
//핫게시판 조회
@Transactional(readOnly = true)
public Slice<BoardListResponse> getHotBoards() {
List<HotBoard> hotBoards = hotBoardRepository.findTop10ByOrderByBoard_CreatedAtDesc();
// 등록일 순으로 정렬
List<Board> boards = hotBoards.stream()
.sorted(Comparator.comparing(hb -> hb.getBoard().getCreatedAt()))
.map(HotBoard::getBoard)
.collect(Collectors.toList());
// 첫 번째 이미지 배치 조회
Map<Long, String> firstImageMap = getFirstImageMapForBoards(
boards.stream()
.map(Board::getId)
.collect(Collectors.toList())
);
List<BoardListResponse> content = boards.stream()
.map(board -> BoardListResponse.of(
board,
firstImageMap.get(board.getId())
))
.toList();
Pageable pageable = PageRequest.of(0, content.size());
return new SliceImpl<>(content, pageable, false);
}
//게시판 검색
public Slice<BoardListResponse> searchBoards(BoardSearchRequest request) {
if (request.getKeyword() == null || request.getKeyword().trim().isEmpty()) {
// 검색어가 없으면 일반 조회
return getBoards(request.getBoardType(), request.getPage(), request.getSize());
}
Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), Sort.by("id").descending());
Slice<Board> boardSlice;
try {
if (request.getBoardType() == BoardType.NORMAL) {
// 일반게시판: 제목 + 내용 검색
boardSlice = boardRepository.searchNormalBoardsWithFullText(
request.getBoardType().name(), request.getKeyword(), pageable);
} else if (request.getBoardType() == BoardType.PROMOTION) {
// 홍보게시판: 제목 + 내용 + 작성자 검색
boardSlice = boardRepository.searchPromotionBoardsWithFullText(
request.getBoardType().name(), request.getKeyword(), pageable);
} else {
throw new GeneralException(ErrorStatus.INVALID_BOARD_TYPE);
}
} catch (Exception e) {
// Full-Text Search 실패 시 기존 LIKE 검색으로 fallback
log.warn("Full-Text Search failed, falling back to LIKE search: {}", e.getMessage());
if (request.getBoardType() == BoardType.NORMAL) {
boardSlice = boardRepository.searchNormalBoards(
request.getBoardType(), request.getKeyword(), pageable);
} else {
boardSlice = boardRepository.searchPromotionBoards(
request.getBoardType(), request.getKeyword(), pageable);
}
}
Map<Long, String> firstImageMap = getFirstImageMapForBoards(
boardSlice.getContent().stream()
.map(Board::getId)
.collect(Collectors.toList())
);
return boardSlice.map(board -> BoardListResponse.of(
board,
firstImageMap.get(board.getId())
));
}
//내가 쓴 게시글 리스트 조회
@Transactional(readOnly = true)
public Slice<BoardListResponse> getMyBoards(Long memberId, int page, int size) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND));
Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());
Slice<Board> boardSlice = boardRepository.findAllByMemberIdOrderByIdDesc(member.getId(), pageable);
Map<Long, String> firstImageMap = getFirstImageMapForBoards(
boardSlice.getContent().stream()
.map(Board::getId)
.collect(Collectors.toList())
);
return boardSlice.map(board -> BoardListResponse.of(
board,
firstImageMap.get(board.getId())
));
}
// --------------- 내부 메서드 ------------
//핫게시판 선정 로직
private void promoteToHotBoard(Board board) {
// 이미 핫게시글이면 아무것도 하지 않음
if (hotBoardRepository.findByBoard(board).isPresent()) {
return;
}
if (board.getLikeCount() >= 10) {
// 핫게시글이 10개 이상이면 가장 오래된 것 제거
if (hotBoardRepository.count() >= 10) {
List<HotBoard> hotBoards = hotBoardRepository.findTop10ByOrderByHotRegisteredAtAsc();
HotBoard oldest = hotBoards.get(0);
hotBoardRepository.delete(oldest);
}
// 핫게시글로 등록
HotBoard hotBoard = HotBoard.builder()
.board(board)
.hotRegisteredAt(LocalDateTime.now())
.build();
hotBoardRepository.save(hotBoard);
//핫게 알림용 카프카 이벤트 생성
hotBoardProducer.publish(new HotBoardEvent(board.getId(), board.getMember().getId()));
}
}
// 게시글 이미지 수정 처리 메서드
private void updateBoardImages(Board board, List<ImageRequestDTO.PartialImageRequestDTO> newImageDTOs, Long memberId) {
// 기존 이미지들 가져오기
List<Image> existingImages = imageRepository.findAllByFilePathAndContentId(FilePath.board, board.getId());
// 기존 keyName 목록
Set<String> existingKeyNames = existingImages.stream()
.map(Image::getKeyName)
.collect(Collectors.toSet());
// 프론트에서 요청받은 keyName 목록
Set<String> newKeyNames = newImageDTOs.stream()
.map(ImageRequestDTO.PartialImageRequestDTO::getKeyName)
.collect(Collectors.toSet());
// 삭제 대상 찾기 (기존 이미지 중 새로운 목록에 없는 것들)
existingImages.stream()
.filter(img -> !newKeyNames.contains(img.getKeyName()))
.forEach(img -> imageService.deleteImage(img.getId(), memberId));
// 추가 대상 찾기 (새로운 이미지 중 기존에 없는 것들)
List<ImageRequestDTO.FullImageRequestDTO> toAdd = newImageDTOs.stream()
.filter(dto -> !existingKeyNames.contains(dto.getKeyName()))
.map(dto -> ImageRequestDTO.FullImageRequestDTO.builder()
.keyName(dto.getKeyName())
.filePath(FilePath.board)
.contentId(board.getId())
.memberId(memberId)
.build())
.toList();
if (!toAdd.isEmpty()) {
imageService.saveImages(memberId, toAdd);
}
}
//첫번 째 이미지만 조회 (N+1 문제 고려)
private Map<Long, String> getFirstImageMapForBoards(List<Long> boardIds) {
if (boardIds == null || boardIds.isEmpty()) {
return Collections.emptyMap();
}
// 배치로 첫 번째 이미지 조회
List<Image> firstImages = imageRepository.findFirstByContentIds(boardIds, FilePath.board);
// 첫번째 이미지url 발급
Map<Long, String> map = new HashMap<>();
for (Image img : firstImages) {
String presignedUrl = imageService.getImages(List.of(img), img.getMemberId())
.get(0)
.getPresignedUrl();
map.put(img.getContentId(), presignedUrl);
}
return map;
}
}