/************************************************************ * * @packageName : io.company.localhost.service * @fileName : localbordService.java * @author : 서지희 * @date : 25.01.07 * @description : * * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 25.01.07 서지희 최초 생성 * *************************************************************/ package io.company.localhost.service; import java.io.IOException; import java.math.BigInteger; import java.util.ArrayList; import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import io.company.localhost.common.dto.ApiResponse; import io.company.localhost.common.dto.MapDto; import io.company.localhost.mapper.localbordMapper; import io.company.localhost.utils.AuthUtil; import io.company.localhost.utils.BlobUtil; import io.company.localhost.utils.PageUtil; import io.company.localhost.vo.MemberVo; import io.company.localhost.vo.UploadFile; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor @Slf4j public class localbordService { private final localbordMapper boardMapper; private final FileService fileService; private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB private final PasswordEncoder passwordEncoder; public List selectNotices(MapDto map) { List posts = boardMapper.selectNotices(map); enrichPostsWithAdditionalData(posts); return posts; } public PageInfo selectGeneralPosts(MapDto map) { System.out.println(map); int page = map.getString("page") != null ? Integer.parseInt(map.getString("page")) : 1; int size = map.getString("size") != null ? Integer.parseInt(map.getString("size")) : 10; String orderBy = map.getString("orderBy"); if (orderBy == null || (!orderBy.equals("date") && !orderBy.equals("views"))) { map.put("orderBy", "date"); } PageHelper.startPage(page, size); List result = boardMapper.selectGeneralPosts(map); enrichPostsWithAdditionalData(result); return PageUtil.redefineNavigation(new PageInfo<>(result, size)); } public void updateIncrementViewCount(Long boardId) { boardMapper.updateIncrementViewCount(boardId); } public BigInteger insertBoard(MapDto map) { // 익명게시판이면 회원 정보를 null로 설정 if ("300102".equals(String.valueOf(map.get("LOCBRDTYP")))) { map.put("MEMBERSEQ", null); }else { Long userId = AuthUtil.getUser().getId(); map.put("MEMBERSEQ", userId); } boardMapper.insertBoard(map); return (BigInteger) map.get("LOCBRDSEQ"); } public void insertAttachment(MapDto map, MultipartFile file) { String boardSeqStr = (String) map.get("CMNBRDSEQ"); Long boardSeq = Long.parseLong(boardSeqStr); map.put("CMNBRDSEQ", boardSeq); String newFilename = UUID.randomUUID().toString(); map.put("CMNFLENAM", newFilename); String Path = fileService.boardUploadFile(file); map.put("CMNFLEPAT", Path); boardMapper.insertAttachment(map); } public void validateAttachmentsSize(List attachments) { long totalSize = attachments.stream() .mapToLong(attachment -> (Long) attachment.get("size")) .sum(); if (totalSize > MAX_FILE_SIZE) { throw new IllegalArgumentException("첨부파일의 총 용량이 5MB를 초과합니다."); } } public MapDto selectBoardDetail(Long boardId) { updateIncrementViewCount(boardId); MapDto boardDetail = boardMapper.selectBoardDetail(boardId); enrichBoardDetail(boardDetail); return boardDetail; } public List selectAttachments(Long boardId) { return boardMapper.selectAttachments(boardId); } public void deleteBoard(MapDto map) { boardMapper.deleteCommentsByBoardId(map); boardMapper.deleteBoard(map); } public int updateBoard(MapDto map) { return boardMapper.updateBoard(map); } public void procReactToBoard(MapDto map) { MapDto existingReaction = boardMapper.selectReaction(map); if (existingReaction != null) { boardMapper.updateReaction(map); } else { boardMapper.insertReaction(map); } } public PageInfo selectComments(MapDto map) { int page = map.getString("page") != null ? Integer.parseInt(map.getString("page")) : 1; int size = map.getString("size") != null ? Integer.parseInt(map.getString("size")) : 10; PageHelper.startPage(page, size); List result = boardMapper.selectComments(map); enrichCommentsWithAdditionalData(result); // 댓글 데이터 보강 return PageUtil.redefineNavigation(new PageInfo<>(result, size)); } public List selectReply(MapDto map) { return boardMapper.selectReply(map); } public void insertCommentOrReply(MapDto map) { // 익명게시판이면 회원 정보를 null로 설정 if ("300102".equals(String.valueOf(map.get("LOCBRDTYP")))) { map.put("MEMBERSEQ", null); }else { Long userId = AuthUtil.getUser().getId(); map.put("MEMBERSEQ", userId); } if (map.get("LOCCMTPNT") == null) { map.put("LOCCMTPNT", null); } boardMapper.insertCommentOrReply(map); } public void updateComment(MapDto map) { boardMapper.updateComment(map); } public void deleteComment(MapDto map) { Long commentId = (Long) map.get("LOCCMTSEQ"); // 댓글이 대댓글이 있는지 확인 boolean hasReplies = boardMapper.selectReplyCount(commentId) > 0; if (hasReplies) { // 대댓글이 있는 경우, '삭제된 댓글입니다.'로 변경 (소프트 삭제) boardMapper.updateSoftDeleteComment(commentId); } else { // 대댓글이 없는 경우, 완전 삭제 boardMapper.deleteComment(commentId); } checkAndDeleteParentComment(map); } private void checkAndDeleteParentComment(MapDto map) { Long parentId = (Long) map.get("LOCCMTPNT"); if (parentId == null) return; // 부모가 없으면 종료 // 부모 댓글의 남아있는 대댓글 개수 확인 int remainingReplies = boardMapper.selectReplyCount(parentId); if (remainingReplies == 0) { // 남은 대댓글이 없으면 부모 댓글도 삭제 boardMapper.deleteComment(parentId); } } public String selectCommentPassword(int commentId) { return boardMapper.selectCommentPassword(commentId); } public String selectBoardPassword(int boardId) { return boardMapper.selectBoardPassword(boardId); } public MapDto selectCommentById(int commentId) { return boardMapper.selectCommentById(commentId); } public int selectCountComments(Long boardId) { return boardMapper.selectCountComments(boardId); } public boolean selectIsAttachments(Long boardId) { int count = boardMapper.selectIsAttachments(boardId); return count > 0; } public MapDto selectCountBoardReactions(Long boardId) { return boardMapper.selectCountBoardReactions(boardId); } public MapDto selectCountCommentReactions(Long boardId) { return boardMapper.selectCountCommentReactions(boardId); } public static String procFirstImageUrl(String jsonContent) { try { ObjectMapper objectMapper = new ObjectMapper(); JsonNode rootNode = objectMapper.readTree(jsonContent); // "ops" 배열 가져오기 JsonNode opsNode = rootNode.get("ops"); if (opsNode != null && opsNode.isArray()) { for (JsonNode node : opsNode) { JsonNode insertNode = node.get("insert"); if (insertNode != null && insertNode.has("image")) { return insertNode.get("image").asText(); // 첫 번째 이미지 URL 반환 } } } } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("Failed to extract first image URL: " + e.getMessage(), e); } return null; // 이미지가 없는 경우 } private boolean procIsValidJson(String json) { try { final ObjectMapper objectMapper = new ObjectMapper(); objectMapper.readTree(json); // JSON 파싱 시도 return true; // JSON이 유효하면 true 반환 } catch (Exception e) { return false; // 유효하지 않은 경우 false 반환 } } private String procPlainTextFromJson(String jsonContent) { StringBuilder plainTextBuilder = new StringBuilder(); try { ObjectMapper objectMapper = new ObjectMapper(); JsonNode rootNode = objectMapper.readTree(jsonContent); // JSON 배열인지 확인 if (!rootNode.isArray()) { System.err.println("JSON content is not an array"); return ""; } // JSON 노드 순회 for (JsonNode node : rootNode) { JsonNode insertNode = node.get("insert"); // insert 노드가 텍스트인지 확인 if (insertNode != null && insertNode.isTextual()) { String text = insertNode.asText(); // '\n' 제거하고 순수 텍스트만 추가 if (!text.trim().isEmpty() && !text.trim().equals("\n")) { plainTextBuilder.append(text.trim()); } } } } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("Failed to extract plain text: " + e.getMessage(), e); } return plainTextBuilder.toString(); } private void enrichBoardDetail(MapDto boardDetail) { if(boardDetail == null) return; long boardId = ((Number) boardDetail.get("id")).longValue(); boardDetail.put("hasAttachment", selectIsAttachments(boardId)); boardDetail.put("commentCount", selectCountComments(boardId)); MapDto reactions = selectCountBoardReactions(boardId); boardDetail.put("likeCount", reactions.getOrDefault("likeCount", 0)); boardDetail.put("dislikeCount", reactions.getOrDefault("dislikeCount", 0)); // Blob 데이터를 문자열로 변환 Object content = boardDetail.get("content"); if (content != null) { String contentString = BlobUtil.procBlobToString(content); // Blob을 문자열로 변환 boardDetail.put("content", contentString); // JSON 변환 가능 } } private void enrichPostsWithAdditionalData(List posts) { for (MapDto post : posts) { Object idObject = post.get("id"); if (idObject instanceof Number) { long postId = ((Number) idObject).longValue(); post.put("commentCount", selectCountComments(postId)); post.put("hasAttachment", selectAttachments(postId)); MapDto reactions = selectCountBoardReactions(postId); post.put("likeCount", reactions.getOrDefault("likeCount", 0)); post.put("dislikeCount", reactions.getOrDefault("dislikeCount", 0)); Object content = post.get("content"); String contentString = BlobUtil.procBlobToString(content); post.put("content", contentString); String firstImageUrl = procFirstImageUrl(contentString); post.put("firstImageUrl", firstImageUrl); String plainContent = procPlainTextFromJson(contentString); post.put("plainContent", plainContent); } } } private void enrichCommentsWithAdditionalData(List comments) { for (MapDto comment : comments) { Object idObject = comment.get("LOCCMTSEQ"); String userId = ""; // 프로필 이미지 추가 if(comment.containsKey("authorId")) { userId = String.valueOf(comment.get("authorId")); String profileImg = boardMapper.selectUserProfileImg(userId); comment.put("profileImg", profileImg); } if (idObject instanceof Number) { long commentId = ((Number) idObject).longValue(); MapDto reactions = boardMapper.selectCountCommentReactions(commentId); comment.put("likeCount", reactions != null ? reactions.getOrDefault("likeCount", 0) : 0); comment.put("dislikeCount", reactions != null ? reactions.getOrDefault("dislikeCount", 0) : 0); Object content = comment.get("content"); comment.put("content", content); } } } /** * 게시판 수정 * * @param map * @param files * @throws IOException */ @Transactional public ApiResponse updateBoardWithFiles(MapDto map, List files) throws IOException { int result = this.updateBoard(map); // 게시글 수정 Long userId = AuthUtil.getUser().getId(); if(result == 1) { if(files != null && !files.isEmpty()) { List list = fileService.boardUploadFiles(files); // 파일 업로드 map.put("CMNFLEREG", userId); map.put("list", list); boardMapper.insertAttachments(map); } if(map.get("delFileIdx") != null) { String[] array = String.valueOf(map.get("delFileIdx")).split(","); List delListInfo = this.selectDelFileInfo(array); // 삭제 정보 조회 for(String item : delListInfo) { fileService.removeFile(item); // 파일 삭제 } this.deleteFileInfo(array); // db 데이터 삭제 } } else { return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR, "게시물이 수정에 실패하였습니다."); } return ApiResponse.ok("게시물이 수정되었습니다."); } /** * 첨부파일 데이터 삭제 * * @param array */ private void deleteFileInfo(String[] array) { boardMapper.deleteFileInfo(array); } /** * 삭제 첨부파일 정보 조회 * * @param array * @return */ private List selectDelFileInfo(String[] array) { return boardMapper.selectDelFileInfo(array); } /** * 게시글 수정 조회 * * @param map * @return */ public ApiResponse selectBoardDetail2(MapDto map) { String password = map.getString("password"); // 입력한 비밀번호 Long boardId = map.getLong("boardId"); // 조회한 게시글 번호 MemberVo member = AuthUtil.getUser(); // 로그인 정보 조회 MapDto resultMap = boardMapper.selectBoardDetail2(boardId); // 게시글 정보 조회 if(resultMap == null) return ApiResponse.error(HttpStatus.NOT_FOUND, "해당 게시글이 없습니다"); String boardType = resultMap.getString("type"); // 게시글 타입 // 익명 게시글 if("300102".equals(boardType)) { String hashedPassword = resultMap.getString("password"); if(StringUtils.hasText(password)) { boolean isMatch = passwordEncoder.matches(password, hashedPassword); if(!isMatch) return ApiResponse.error(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다"); } else { return ApiResponse.error(HttpStatus.UNAUTHORIZED, "비밀번호를 입력하세요"); } // 자유 게시글 } else if("300101".equals(boardType)) { Long writerId = Long.valueOf(resultMap.getInt("authorId")); if(member.getId() != writerId) return ApiResponse.error(HttpStatus.UNAUTHORIZED, "권한이 없습니다"); // 공지글 } else if("300103".equals(boardType)) { if(!"ROLE_ADMIN".equals(member.getRole())) return ApiResponse.error(HttpStatus.UNAUTHORIZED, "권한이 없습니다"); } else { log.error("게시글 카테고리 정보 없음"); return ApiResponse.error(HttpStatus.NOT_FOUND, "해당 게시글이 없습니다"); } this.enrichBoardDetail(resultMap); // 추가정보 // 📌 첨부파일 목록 추가 List attachments = this.selectAttachments(boardId); resultMap.put("attachments", attachments != null ? attachments : new ArrayList<>()); return ApiResponse.ok(resultMap); } }