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:
ByungCheol
2026-05-31 15:43:09 +09:00
co-authored by Claude Opus 4.8
parent 9243a41385
commit 2a2f81dc32
121 changed files with 5899 additions and 31 deletions
@@ -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;
}
}