/************************************************************ * * @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.scheduling.annotation.Scheduled; 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.FileVo; 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); } @SuppressWarnings("unchecked") @Transactional 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); //에디터 첨부 이미지 게시글 번호 업데이트 if(map.get("editorUploadedImgList") != null) { ArrayList editorUploadedImgList = (ArrayList) map.get("editorUploadedImgList"); map.put("editorImgList", editorUploadedImgList); this.updateBoardIndexToFile(map); } // 에디터 수정 시 업로드 된 에디터 이미지 삭제 if(map.get("editorDeleteImgList") != null) { ArrayList editorDeleteImgList = (ArrayList) map.getList("editorDeleteImgList", String.class); String[] array = editorDeleteImgList.stream().toArray(String[]::new); this.deleteFileAndData(array); } 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); } @Transactional public ApiResponse deleteBoard(MapDto map) { boardMapper.deleteCommentsByBoardId(map); // 댓글 대댓글 boardMapper.deleteGoodOrBadByBoardId(map); // 좋아요 싫어요 삭제 this.deleteBoardFiles(map); // 파일 삭제 boardMapper.deleteBoard(map); // 게시글 삭제 return ApiResponse.ok("게시물이 삭제되었습니다."); } /** * 게시글 첨부파일 및 에디터 이미지 파일 삭제 * * @param map */ private void deleteBoardFiles(MapDto map) { List list = this.selectFilesInfo(map); // 삭제 파일 정보 조회 for(FileVo vo : list) { String fileName = vo.getCMNFLEPAT(); //if(!fileName.contains(vo.getCMNFLEEXT())) fileName = fileName + vo.getCMNFLEEXT(); fileService.removeFile(fileName); // 파일 삭제 } this.deleteFiles(map); // 파일 데이터 삭제 } /** * 게시글 파일정보 삭제 * @param map */ public void deleteFiles(MapDto map) { boardMapper.deleteFiles(map); } /** * 게시글 파일 정보 조회 * * @param map * @return */ public List selectFilesInfo(MapDto map) { return boardMapper.selectFilesInfo(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 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); } } } 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(); String[] editorUploadedImgList = null; // 수정 성공 시 첨부파일 수정 변경 if(result == 1) { //에디터 첨부 이미지 게시글 번호 업데이트 if(map.get("editorUploadedImgList") != null) { editorUploadedImgList = String.valueOf(map.get("editorUploadedImgList")).split(","); map.put("editorImgList", editorUploadedImgList); this.updateBoardIndexToFile(map); } // 추가 첨부파일 업로드 if(files != null && !files.isEmpty()) { List list = fileService.boardUploadFiles(files); // 파일 업로드 map.put("CMNFLEREG", userId); map.put("list", list); boardMapper.insertAttachments(map); // 파일 정보 DB 적재 } // 제거 첨부파일 삭제 if(map.get("delFileIdx") != null) { String[] array = String.valueOf(map.get("delFileIdx")).split(","); this.deleteFileAndData(array); } // 에디터 수정 시 업로드 된 에디터 이미지 삭제 if(map.get("editorDeleteImgList") != null) { String[] array = String.valueOf(map.get("editorDeleteImgList")).split(","); this.deleteFileAndData(array); } } else { return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR, "게시물이 수정에 실패하였습니다."); } return ApiResponse.ok("게시물이 수정되었습니다."); } /** * 첨부파일 데이터 삭제 * * @param array */ private void deleteFileInfo(String[] array) { boardMapper.deleteFileInfo(array); } private int updateBoardIndexToFile(MapDto map) { return boardMapper.updateBoardIndexToFile(map); } /** * 파일 삭제 및 db 데이터 제거 * * @param array */ private void deleteFileAndData(String[] array) { List delListInfo = this.selectDelFileInfo(array); // 삭제할 파일 정보 조회 for(String item : delListInfo) { fileService.removeFile(item); // 파일 삭제 } this.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) || "300103".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); } /** * 에디터 업로드 이미지 게시글 번호 업데이트 * * @param map * @return */ public int insertUploadEditorImageInfo(MapDto map) { return boardMapper.insertUploadEditorImageInfo(map); } /** * 게시글 정보가 없는 파일 조회 * * @return */ public List selectFilesBoardIndexIsNull() { return boardMapper.selectFilesBoardIndexIsNull(); } /** * 파일 data 제거 * * @param vo * @return */ public int deleteTrashFileData(FileVo vo) { return boardMapper.deleteTrashFileData(vo); } /** * Garbage File Collector */ @Scheduled(cron = "0 0 2 1 * *", zone = "Asia/Seoul") public void deleteTrashFiles() { List list = this.selectFilesBoardIndexIsNull(); for(FileVo file : list) { String fileName = file.getCMNFLEPAT(); //if(!fileName.contains(file.getCMNFLEEXT())) fileName = fileName + file.getCMNFLEEXT(); boolean deleteResult = fileService.removeFile(fileName); if(deleteResult) this.deleteTrashFileData(file); } } }