feat: 가계부·게시판 백엔드 API 구현
- com.sb.web 패키지로 재구성: account / auth / board / user / admin / common - 가계부(account): 내역(필터), 계좌·순자산, 예산, 분류, 정기 거래, 투자 포트폴리오 (종목·매매 이력, 이동평균 평단·실현/평가손익) - MyBatis + MariaDB + Redis 세션, BCrypt - 스키마 자동 초기화(db/*.sql, 멱등), 사용자별 데이터 격리(member_id) - 문서: docs/BACKEND.md, .env.example Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9243a41385
commit
2a2f81dc32
@@ -0,0 +1,127 @@
|
||||
package com.sb.web.board.controller;
|
||||
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
import com.sb.web.board.dto.CommentRequest;
|
||||
import com.sb.web.board.dto.CommentResponse;
|
||||
import com.sb.web.board.dto.PageResponse;
|
||||
import com.sb.web.board.dto.PostDetail;
|
||||
import com.sb.web.board.dto.PostRequest;
|
||||
import com.sb.web.board.dto.PostSummary;
|
||||
import com.sb.web.board.dto.TagCategoryResponse;
|
||||
import com.sb.web.board.service.BoardService;
|
||||
import com.sb.web.board.service.TagService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 자유게시판 API. (전체 로그인 필요 — WebConfig 인터셉터로 /api/board/** 보호)
|
||||
* GET /api/board/posts 목록(페이징·태그·검색)
|
||||
* GET /api/board/posts/{id} 상세(조회수 증가)
|
||||
* POST /api/board/posts 작성
|
||||
* PUT /api/board/posts/{id} 수정(작성자/관리자)
|
||||
* DELETE /api/board/posts/{id} 삭제(작성자/관리자)
|
||||
* GET /api/board/tags 전체 태그
|
||||
* POST /api/board/posts/{id}/comments 댓글 작성
|
||||
* DELETE /api/board/comments/{commentId} 댓글 삭제(작성자/관리자)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/board")
|
||||
@RequiredArgsConstructor
|
||||
public class BoardController {
|
||||
|
||||
private final BoardService boardService;
|
||||
private final TagService tagService;
|
||||
|
||||
@GetMapping("/posts")
|
||||
public PageResponse<PostSummary> list(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String tag,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String searchType) {
|
||||
return boardService.list(page, size, tag, keyword, searchType);
|
||||
}
|
||||
|
||||
@GetMapping("/posts/{id}")
|
||||
public PostDetail get(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.get(id, current);
|
||||
}
|
||||
|
||||
@PostMapping("/posts")
|
||||
public ResponseEntity<PostDetail> create(
|
||||
@Valid @RequestBody PostRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(boardService.create(req, current));
|
||||
}
|
||||
|
||||
@PutMapping("/posts/{id}")
|
||||
public PostDetail update(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody PostRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.update(id, req, current);
|
||||
}
|
||||
|
||||
@DeleteMapping("/posts/{id}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.delete(id, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 열람 제한 (관리자) */
|
||||
@PostMapping("/posts/{id}/block")
|
||||
public ResponseEntity<Void> block(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) Map<String, String> body,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.setBlocked(id, true, body != null ? body.get("reason") : null, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 열람 제한 해제 (관리자) */
|
||||
@PostMapping("/posts/{id}/unblock")
|
||||
public ResponseEntity<Void> unblock(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.setBlocked(id, false, null, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/tags")
|
||||
public List<String> tags() {
|
||||
return boardService.allTags();
|
||||
}
|
||||
|
||||
/** 글 작성 시 선택 가능한 태그 (게시판 설정 카테고리로 제한) */
|
||||
@GetMapping("/tag-groups")
|
||||
public List<TagCategoryResponse> tagGroups() {
|
||||
return tagService.listWritableGroups();
|
||||
}
|
||||
|
||||
@PostMapping("/posts/{id}/comments")
|
||||
public ResponseEntity<CommentResponse> addComment(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody CommentRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(boardService.addComment(id, req, current));
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{commentId}")
|
||||
public ResponseEntity<Void> deleteComment(
|
||||
@PathVariable Long commentId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.deleteComment(commentId, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.sb.web.board.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 댓글 도메인. comment 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Comment implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private Long postId;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.board.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 게시글 도메인. post 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Post implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private Integer viewCount;
|
||||
private Boolean blocked;
|
||||
private String blockReason;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sb.web.board.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 태그 도메인. tag 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Tag implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long categoryId;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.board.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 태그 카테고리(그룹). tag_category 테이블과 매핑.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TagCategory implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 게시판 설정 변경 요청 — 사용할 태그 카테고리(null 이면 제한 없음).
|
||||
*/
|
||||
@Data
|
||||
public class BoardSettingRequest {
|
||||
private Long tagCategoryId;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 게시판 설정 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class BoardSettingResponse {
|
||||
private Long tagCategoryId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 댓글 작성 요청.
|
||||
*/
|
||||
@Data
|
||||
public class CommentRequest {
|
||||
|
||||
@NotBlank(message = "댓글 내용을 입력하세요.")
|
||||
@Size(max = 1000, message = "댓글은 1000자 이내여야 합니다.")
|
||||
private String content;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import com.sb.web.board.domain.Comment;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 댓글 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CommentResponse {
|
||||
|
||||
private Long id;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public static CommentResponse from(Comment c) {
|
||||
return CommentResponse.builder()
|
||||
.id(c.getId())
|
||||
.authorId(c.getAuthorId())
|
||||
.authorName(c.getAuthorName())
|
||||
.content(c.getContent())
|
||||
.createdAt(c.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 페이지네이션 응답 래퍼.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class PageResponse<T> {
|
||||
|
||||
private List<T> content;
|
||||
private int page; // 1-base
|
||||
private int size;
|
||||
private long totalElements;
|
||||
private int totalPages;
|
||||
|
||||
public static <T> PageResponse<T> of(List<T> content, int page, int size, long total) {
|
||||
int totalPages = size > 0 ? (int) Math.ceil((double) total / size) : 0;
|
||||
return PageResponse.<T>builder()
|
||||
.content(content)
|
||||
.page(page)
|
||||
.size(size)
|
||||
.totalElements(total)
|
||||
.totalPages(totalPages)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import com.sb.web.board.domain.Post;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 게시글 상세 (태그 + 댓글 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class PostDetail {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private Long authorId;
|
||||
private String authorName;
|
||||
private Integer viewCount;
|
||||
private Boolean blocked;
|
||||
private String blockReason;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private List<String> tags;
|
||||
private List<CommentResponse> comments;
|
||||
|
||||
public static PostDetail of(Post p, List<String> tags, List<CommentResponse> comments) {
|
||||
return PostDetail.builder()
|
||||
.id(p.getId())
|
||||
.title(p.getTitle())
|
||||
.content(p.getContent())
|
||||
.authorId(p.getAuthorId())
|
||||
.authorName(p.getAuthorName())
|
||||
.viewCount(p.getViewCount())
|
||||
.blocked(Boolean.TRUE.equals(p.getBlocked()))
|
||||
.blockReason(p.getBlockReason())
|
||||
.createdAt(p.getCreatedAt())
|
||||
.updatedAt(p.getUpdatedAt())
|
||||
.tags(tags)
|
||||
.comments(comments)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 비관리자에게 보여줄 열람 제한 상세 (본문·댓글·태그 숨김) */
|
||||
public static PostDetail restricted(Post p) {
|
||||
return PostDetail.builder()
|
||||
.id(p.getId())
|
||||
.title(p.getTitle())
|
||||
.authorName(p.getAuthorName())
|
||||
.viewCount(p.getViewCount())
|
||||
.blocked(true)
|
||||
.createdAt(p.getCreatedAt())
|
||||
.tags(List.of())
|
||||
.comments(List.of())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 게시글 작성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class PostRequest {
|
||||
|
||||
@NotBlank(message = "제목을 입력하세요.")
|
||||
@Size(max = 200, message = "제목은 200자 이내여야 합니다.")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "내용을 입력하세요.")
|
||||
private String content;
|
||||
|
||||
/** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 게시글 목록 항목 (요약).
|
||||
*/
|
||||
@Data
|
||||
public class PostSummary {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String authorName;
|
||||
private Integer viewCount;
|
||||
private Integer commentCount;
|
||||
private Boolean blocked;
|
||||
private LocalDateTime createdAt;
|
||||
private List<String> tags;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 태그 카테고리 생성/수정 요청.
|
||||
*/
|
||||
@Data
|
||||
public class TagCategoryRequest {
|
||||
|
||||
@NotBlank(message = "카테고리 이름을 입력하세요.")
|
||||
@Size(max = 50, message = "카테고리 이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import com.sb.web.board.domain.TagCategory;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 태그 카테고리 응답 (소속 태그 포함).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class TagCategoryResponse {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer sortOrder;
|
||||
private List<TagResponse> tags;
|
||||
|
||||
public static TagCategoryResponse of(TagCategory c, List<TagResponse> tags) {
|
||||
return TagCategoryResponse.builder()
|
||||
.id(c.getId())
|
||||
.name(c.getName())
|
||||
.sortOrder(c.getSortOrder())
|
||||
.tags(tags)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 태그 생성/수정 요청 (관리자).
|
||||
*/
|
||||
@Data
|
||||
public class TagRequest {
|
||||
|
||||
@NotBlank(message = "태그 이름을 입력하세요.")
|
||||
@Size(max = 50, message = "태그 이름은 50자 이내여야 합니다.")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "카테고리를 선택하세요.")
|
||||
private Long categoryId;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sb.web.board.dto;
|
||||
|
||||
import com.sb.web.board.domain.Tag;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 태그 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class TagResponse {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long categoryId;
|
||||
|
||||
public static TagResponse from(Tag t) {
|
||||
return TagResponse.builder()
|
||||
.id(t.getId())
|
||||
.name(t.getName())
|
||||
.categoryId(t.getCategoryId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface BoardSettingMapper {
|
||||
|
||||
/** 게시판에서 사용할 태그 카테고리 ID (없으면 null) */
|
||||
Long findTagCategoryId();
|
||||
|
||||
int updateTagCategoryId(@Param("categoryId") Long categoryId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.domain.Comment;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CommentMapper {
|
||||
|
||||
List<Comment> findByPostId(@Param("postId") Long postId);
|
||||
|
||||
Comment findById(@Param("id") Long id);
|
||||
|
||||
int insert(Comment comment);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int deleteByPostId(@Param("postId") Long postId);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.domain.Post;
|
||||
import com.sb.web.board.dto.PostSummary;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface PostMapper {
|
||||
|
||||
List<PostSummary> findPage(@Param("offset") int offset,
|
||||
@Param("size") int size,
|
||||
@Param("tag") String tag,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("searchType") String searchType);
|
||||
|
||||
long countPage(@Param("tag") String tag,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("searchType") String searchType);
|
||||
|
||||
Post findById(@Param("id") Long id);
|
||||
|
||||
int insert(Post post);
|
||||
|
||||
int update(Post post);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int increaseViewCount(@Param("id") Long id);
|
||||
|
||||
int updateBlocked(@Param("id") Long id,
|
||||
@Param("blocked") boolean blocked,
|
||||
@Param("blockReason") String blockReason);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.domain.TagCategory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface TagCategoryMapper {
|
||||
|
||||
List<TagCategory> findAll();
|
||||
|
||||
TagCategory findById(@Param("id") Long id);
|
||||
|
||||
int countByName(@Param("name") String name, @Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(TagCategory category);
|
||||
|
||||
int update(TagCategory category);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import com.sb.web.board.domain.Tag;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface TagMapper {
|
||||
|
||||
/* ----- 게시글 작성/표시 ----- */
|
||||
List<String> findNamesByPostId(@Param("postId") Long postId);
|
||||
|
||||
List<String> findAllNames();
|
||||
|
||||
int insertPostTag(@Param("postId") Long postId, @Param("tagId") Long tagId);
|
||||
|
||||
int deletePostTagsByPostId(@Param("postId") Long postId);
|
||||
|
||||
/** 주어진 ID 중 실제 존재하는 태그 ID만 반환 (선택 검증용) */
|
||||
List<Long> findExistingIds(@Param("ids") List<Long> ids);
|
||||
|
||||
/* ----- 관리자 태그 CRUD ----- */
|
||||
List<Tag> findAll();
|
||||
|
||||
List<Tag> findByCategoryId(@Param("categoryId") Long categoryId);
|
||||
|
||||
Tag findById(@Param("id") Long id);
|
||||
|
||||
int countByName(@Param("name") String name, @Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(Tag tag);
|
||||
|
||||
int update(Tag tag);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int deletePostTagsByTagId(@Param("tagId") Long tagId);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.sb.web.board.service;
|
||||
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.board.domain.Comment;
|
||||
import com.sb.web.board.domain.Post;
|
||||
import com.sb.web.board.dto.CommentRequest;
|
||||
import com.sb.web.board.dto.CommentResponse;
|
||||
import com.sb.web.board.dto.PageResponse;
|
||||
import com.sb.web.board.dto.PostDetail;
|
||||
import com.sb.web.board.dto.PostRequest;
|
||||
import com.sb.web.board.dto.PostSummary;
|
||||
import com.sb.web.board.mapper.CommentMapper;
|
||||
import com.sb.web.board.mapper.PostMapper;
|
||||
import com.sb.web.board.mapper.TagMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 자유게시판 서비스. 글 CRUD, 태그(find-or-create), 조회수, 댓글, 권한 처리.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BoardService {
|
||||
|
||||
private final PostMapper postMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final CommentMapper commentMapper;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
|
||||
private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24);
|
||||
|
||||
/* ===================== 게시글 ===================== */
|
||||
|
||||
public PageResponse<PostSummary> list(int page, int size, String tag, String keyword, String searchType) {
|
||||
int p = Math.max(page, 1);
|
||||
int s = Math.min(Math.max(size, 1), 100);
|
||||
int offset = (p - 1) * s;
|
||||
String st = normalizeSearchType(searchType);
|
||||
|
||||
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
|
||||
List<PostSummary> content = postMapper.findPage(offset, s, blankToNull(tag), blankToNull(keyword), st);
|
||||
long total = postMapper.countPage(blankToNull(tag), blankToNull(keyword), st);
|
||||
return PageResponse.of(content, p, s, total);
|
||||
}
|
||||
|
||||
private String normalizeSearchType(String t) {
|
||||
return ("content".equals(t) || "comment".equals(t)) ? t : "title";
|
||||
}
|
||||
|
||||
/** 상세 조회 (열람 제한 글은 관리자만 전체 열람, 그 외엔 제한 응답) */
|
||||
@Transactional
|
||||
public PostDetail get(Long id, SessionUser viewer) {
|
||||
Post post = mustFindPost(id);
|
||||
if (Boolean.TRUE.equals(post.getBlocked()) && !isAdmin(viewer)) {
|
||||
return PostDetail.restricted(post); // 본문·댓글 숨김, 조회수 증가 없음
|
||||
}
|
||||
if (shouldCountView(id, viewer)) {
|
||||
postMapper.increaseViewCount(id);
|
||||
post.setViewCount(post.getViewCount() + 1);
|
||||
}
|
||||
return assemble(post);
|
||||
}
|
||||
|
||||
/** 게시글 열람 제한/해제 (관리자 전용) */
|
||||
@Transactional
|
||||
public void setBlocked(Long id, boolean blocked, String reason, SessionUser user) {
|
||||
assertAdmin(user);
|
||||
mustFindPost(id);
|
||||
postMapper.updateBlocked(id, blocked, blocked ? reason : null);
|
||||
}
|
||||
|
||||
/** Redis 로 (사용자, 게시글) 단위 첫 열람 여부 판단 → 중복 클릭 시 조회수 증가 방지 */
|
||||
private boolean shouldCountView(Long postId, SessionUser viewer) {
|
||||
if (viewer == null) {
|
||||
return false;
|
||||
}
|
||||
String key = "board:view:" + viewer.getId() + ":" + postId;
|
||||
Boolean firstView = redisTemplate.opsForValue().setIfAbsent(key, "1", VIEW_DEDUP_TTL);
|
||||
return Boolean.TRUE.equals(firstView);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PostDetail create(PostRequest req, SessionUser author) {
|
||||
Post post = Post.builder()
|
||||
.title(req.getTitle())
|
||||
.content(req.getContent())
|
||||
.authorId(author.getId())
|
||||
.authorName(author.getName())
|
||||
.build();
|
||||
postMapper.insert(post);
|
||||
applyTags(post.getId(), req.getTagIds());
|
||||
return assemble(mustFindPost(post.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PostDetail update(Long id, PostRequest req, SessionUser user) {
|
||||
Post post = mustFindPost(id);
|
||||
if (Boolean.TRUE.equals(post.getBlocked()) && !isAdmin(user)) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글은 수정할 수 없습니다.");
|
||||
}
|
||||
assertCanModify(post.getAuthorId(), user);
|
||||
|
||||
post.setTitle(req.getTitle());
|
||||
post.setContent(req.getContent());
|
||||
postMapper.update(post);
|
||||
|
||||
tagMapper.deletePostTagsByPostId(id);
|
||||
applyTags(id, req.getTagIds());
|
||||
return assemble(mustFindPost(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, SessionUser user) {
|
||||
Post post = mustFindPost(id);
|
||||
assertCanModify(post.getAuthorId(), user);
|
||||
tagMapper.deletePostTagsByPostId(id);
|
||||
commentMapper.deleteByPostId(id);
|
||||
postMapper.delete(id);
|
||||
}
|
||||
|
||||
/* ===================== 댓글 ===================== */
|
||||
|
||||
@Transactional
|
||||
public CommentResponse addComment(Long postId, CommentRequest req, SessionUser author) {
|
||||
Post post = mustFindPost(postId);
|
||||
if (Boolean.TRUE.equals(post.getBlocked())) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글에는 댓글을 작성할 수 없습니다.");
|
||||
}
|
||||
Comment comment = Comment.builder()
|
||||
.postId(postId)
|
||||
.authorId(author.getId())
|
||||
.authorName(author.getName())
|
||||
.content(req.getContent())
|
||||
.build();
|
||||
commentMapper.insert(comment);
|
||||
return CommentResponse.from(commentMapper.findById(comment.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteComment(Long commentId, SessionUser user) {
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||
}
|
||||
assertCanModify(comment.getAuthorId(), user);
|
||||
commentMapper.delete(commentId);
|
||||
}
|
||||
|
||||
/* ===================== 태그 ===================== */
|
||||
|
||||
public List<String> allTags() {
|
||||
return tagMapper.findAllNames();
|
||||
}
|
||||
|
||||
/* ===================== 내부 ===================== */
|
||||
|
||||
private PostDetail assemble(Post post) {
|
||||
List<String> tags = tagMapper.findNamesByPostId(post.getId());
|
||||
List<CommentResponse> comments = commentMapper.findByPostId(post.getId())
|
||||
.stream().map(CommentResponse::from).toList();
|
||||
return PostDetail.of(post, tags, comments);
|
||||
}
|
||||
|
||||
/** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */
|
||||
private void applyTags(Long postId, List<Long> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> distinct = tagIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (distinct.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Long tagId : tagMapper.findExistingIds(distinct)) {
|
||||
tagMapper.insertPostTag(postId, tagId);
|
||||
}
|
||||
}
|
||||
|
||||
private Post mustFindPost(Long id) {
|
||||
Post post = postMapper.findById(id);
|
||||
if (post == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.");
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
/** 작성자 본인 또는 관리자만 수정/삭제 가능 */
|
||||
private void assertCanModify(Long authorId, SessionUser user) {
|
||||
if (!authorId.equals(user.getId()) && !isAdmin(user)) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAdmin(SessionUser user) {
|
||||
return user != null && "ADMIN".equals(user.getRole());
|
||||
}
|
||||
|
||||
private void assertAdmin(SessionUser user) {
|
||||
if (!isAdmin(user)) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.sb.web.board.service;
|
||||
|
||||
import com.sb.web.board.domain.Tag;
|
||||
import com.sb.web.board.domain.TagCategory;
|
||||
import com.sb.web.board.dto.TagCategoryRequest;
|
||||
import com.sb.web.board.dto.TagCategoryResponse;
|
||||
import com.sb.web.board.dto.BoardSettingResponse;
|
||||
import com.sb.web.board.dto.TagRequest;
|
||||
import com.sb.web.board.dto.TagResponse;
|
||||
import com.sb.web.board.mapper.BoardSettingMapper;
|
||||
import com.sb.web.board.mapper.TagCategoryMapper;
|
||||
import com.sb.web.board.mapper.TagMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 태그 카테고리 + 태그 관리 서비스 (관리자 기능 + 그룹 조회).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TagService {
|
||||
|
||||
private final TagCategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final BoardSettingMapper boardSettingMapper;
|
||||
|
||||
/** 카테고리별로 묶은 전체 태그 (관리 화면용) */
|
||||
public List<TagCategoryResponse> listGrouped() {
|
||||
List<TagCategoryResponse> result = new ArrayList<>();
|
||||
for (TagCategory c : categoryMapper.findAll()) {
|
||||
result.add(toGroup(c));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 글 작성 시 선택 가능한 태그 그룹 — 게시판 설정 카테고리로 제한 (없으면 전체) */
|
||||
public List<TagCategoryResponse> listWritableGroups() {
|
||||
Long catId = boardSettingMapper.findTagCategoryId();
|
||||
if (catId == null) {
|
||||
return listGrouped();
|
||||
}
|
||||
TagCategory c = categoryMapper.findById(catId);
|
||||
return c == null ? listGrouped() : List.of(toGroup(c));
|
||||
}
|
||||
|
||||
private TagCategoryResponse toGroup(TagCategory c) {
|
||||
List<TagResponse> tags = tagMapper.findByCategoryId(c.getId())
|
||||
.stream().map(TagResponse::from).toList();
|
||||
return TagCategoryResponse.of(c, tags);
|
||||
}
|
||||
|
||||
/* ===================== 게시판 설정 ===================== */
|
||||
|
||||
public BoardSettingResponse getBoardSetting() {
|
||||
return BoardSettingResponse.builder()
|
||||
.tagCategoryId(boardSettingMapper.findTagCategoryId())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BoardSettingResponse setBoardSetting(Long tagCategoryId) {
|
||||
if (tagCategoryId != null && categoryMapper.findById(tagCategoryId) == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||
}
|
||||
boardSettingMapper.updateTagCategoryId(tagCategoryId);
|
||||
return BoardSettingResponse.builder().tagCategoryId(tagCategoryId).build();
|
||||
}
|
||||
|
||||
/* ===================== 카테고리 ===================== */
|
||||
|
||||
@Transactional
|
||||
public TagCategoryResponse createCategory(TagCategoryRequest req) {
|
||||
String name = req.getName().trim();
|
||||
if (categoryMapper.countByName(name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 카테고리입니다.");
|
||||
}
|
||||
TagCategory c = TagCategory.builder()
|
||||
.name(name)
|
||||
.sortOrder(req.getSortOrder() != null ? req.getSortOrder() : 0)
|
||||
.build();
|
||||
categoryMapper.insert(c);
|
||||
return TagCategoryResponse.of(c, List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TagCategoryResponse updateCategory(Long id, TagCategoryRequest req) {
|
||||
TagCategory c = mustFindCategory(id);
|
||||
String name = req.getName().trim();
|
||||
if (categoryMapper.countByName(name, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 카테고리입니다.");
|
||||
}
|
||||
c.setName(name);
|
||||
if (req.getSortOrder() != null) {
|
||||
c.setSortOrder(req.getSortOrder());
|
||||
}
|
||||
categoryMapper.update(c);
|
||||
List<TagResponse> tags = tagMapper.findByCategoryId(id).stream().map(TagResponse::from).toList();
|
||||
return TagCategoryResponse.of(c, tags);
|
||||
}
|
||||
|
||||
/** 카테고리 삭제 — 소속 태그와 그 연결(post_tag)까지 함께 삭제 */
|
||||
@Transactional
|
||||
public void deleteCategory(Long id) {
|
||||
mustFindCategory(id);
|
||||
for (Tag t : tagMapper.findByCategoryId(id)) {
|
||||
tagMapper.deletePostTagsByTagId(t.getId());
|
||||
tagMapper.delete(t.getId());
|
||||
}
|
||||
categoryMapper.delete(id);
|
||||
}
|
||||
|
||||
/* ===================== 태그 ===================== */
|
||||
|
||||
@Transactional
|
||||
public TagResponse createTag(TagRequest req) {
|
||||
mustFindCategory(req.getCategoryId());
|
||||
String name = req.getName().trim();
|
||||
if (tagMapper.countByName(name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
|
||||
}
|
||||
Tag t = Tag.builder().name(name).categoryId(req.getCategoryId()).build();
|
||||
tagMapper.insert(t);
|
||||
return TagResponse.from(t);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TagResponse updateTag(Long id, TagRequest req) {
|
||||
Tag t = mustFindTag(id);
|
||||
mustFindCategory(req.getCategoryId());
|
||||
String name = req.getName().trim();
|
||||
if (tagMapper.countByName(name, id) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
|
||||
}
|
||||
t.setName(name);
|
||||
t.setCategoryId(req.getCategoryId());
|
||||
tagMapper.update(t);
|
||||
return TagResponse.from(t);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTag(Long id) {
|
||||
mustFindTag(id);
|
||||
tagMapper.deletePostTagsByTagId(id);
|
||||
tagMapper.delete(id);
|
||||
}
|
||||
|
||||
/* ===================== 내부 ===================== */
|
||||
|
||||
private TagCategory mustFindCategory(Long id) {
|
||||
TagCategory c = categoryMapper.findById(id);
|
||||
if (c == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private Tag mustFindTag(Long id) {
|
||||
Tag t = tagMapper.findById(id);
|
||||
if (t == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "태그를 찾을 수 없습니다.");
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user