diff --git a/src/main/java/com/sb/web/SbBtApplication.java b/src/main/java/com/sb/web/SbBtApplication.java index b93652d..fece9d5 100644 --- a/src/main/java/com/sb/web/SbBtApplication.java +++ b/src/main/java/com/sb/web/SbBtApplication.java @@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling -@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper"}) +@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper"}) public class SbBtApplication { public static void main(String[] args) { diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java index c0898fc..3a30d40 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -29,6 +29,7 @@ public class AuthController { private final AuthService authService; private final com.sb.web.admin.service.AppSettingService appSettingService; + private final com.sb.web.point.PointService pointService; /** 회원가입 허용 여부 (비보호) — 프론트가 가입 진입 전에 차단 표시용 */ @GetMapping("/signup-enabled") @@ -79,6 +80,13 @@ public class AuthController { return current; } + /** 현재 사용자 활동 포인트 (최신값 — 게시판 작성으로 수시 변동) */ + @GetMapping("/points") + public java.util.Map points( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return java.util.Map.of("points", pointService.getPoints(current.getId())); + } + @PutMapping("/password") public ResponseEntity changePassword( @Valid @RequestBody PasswordChangeRequest req, @@ -104,4 +112,12 @@ public class AuthController { HttpServletRequest request) { return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req); } + + /** 프로필 사진(사용자 지정) 변경/해제 — image 가 비면 해제(구글 사진으로 폴백) */ + @PutMapping("/profile-image") + public MemberResponse updateProfileImage( + @RequestBody com.sb.web.auth.dto.ProfileImageRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return authService.updateProfileImage(current.getId(), req.getImage()); + } } diff --git a/src/main/java/com/sb/web/auth/domain/Member.java b/src/main/java/com/sb/web/auth/domain/Member.java index eec71c1..98b79df 100644 --- a/src/main/java/com/sb/web/auth/domain/Member.java +++ b/src/main/java/com/sb/web/auth/domain/Member.java @@ -26,8 +26,11 @@ public class Member implements Serializable { private String provider; // LOCAL / NAVER / GOOGLE private String providerId; // 소셜 제공자 측 고유 ID private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지) + private String googlePicture; // 구글 아바타 URL (로그인 시 동기화) + private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜 private String role; // USER / ADMIN private String plan; // FREE / PREMIUM (멤버십) + private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상) private String status; // ACTIVE / SUSPENDED / WITHDRAWN private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/src/main/java/com/sb/web/auth/dto/MemberResponse.java b/src/main/java/com/sb/web/auth/dto/MemberResponse.java index a19e3d9..d8f48da 100644 --- a/src/main/java/com/sb/web/auth/dto/MemberResponse.java +++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java @@ -18,6 +18,9 @@ public class MemberResponse { private String provider; private String role; private String plan; // FREE / PREMIUM + private Long points; // 활동 포인트 + private String googlePicture; // 구글 아바타 URL + private String profileImage; // 사용자 지정 프로필 이미지(data URL) public static MemberResponse from(Member m) { return MemberResponse.builder() @@ -28,6 +31,9 @@ public class MemberResponse { .provider(m.getProvider()) .role(m.getRole()) .plan(m.getPlan()) + .points(m.getPoints()) + .googlePicture(m.getGooglePicture()) + .profileImage(m.getProfileImage()) .build(); } } diff --git a/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java b/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java new file mode 100644 index 0000000..c151f06 --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java @@ -0,0 +1,13 @@ +package com.sb.web.auth.dto; + +import lombok.Data; + +/** + * 프로필 사진(사용자 지정) 변경 요청. image 가 null/빈 값이면 해제(구글 사진으로 폴백). + */ +@Data +public class ProfileImageRequest { + + /** data URL(base64 이미지). null 이면 사용자 지정 사진 해제. */ + private String image; +} diff --git a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java index e497ca8..74d3705 100644 --- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java @@ -40,6 +40,12 @@ public interface MemberMapper { /** 프로필(이름/이메일) 변경 */ int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email); + /** 구글 로그인 시 구글 아바타 URL 동기화 */ + int updateGooglePicture(@Param("id") Long id, @Param("googlePicture") String googlePicture); + + /** 사용자 지정 프로필 이미지 설정/해제(null = 해제 → 구글 사진으로 폴백) */ + int updateProfileImage(@Param("id") Long id, @Param("profileImage") String profileImage); + int updateRole(@Param("id") Long id, @Param("role") String role); int updatePlan(@Param("id") Long id, @Param("plan") String plan); diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 2bc7b6e..49ac6f0 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -186,6 +186,7 @@ public class AuthService { boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified"))); String name = info.get("name") != null ? String.valueOf(info.get("name")) : (email != null ? email.split("@")[0] : "사용자"); + String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null; // 1) 구글 sub 로 이미 연결/가입된 계정 조회 Member member = memberMapper.findByGoogleId(sub); @@ -216,6 +217,7 @@ public class AuthService { .provider("GOOGLE") .providerId(sub) .googleId(sub) + .googlePicture(picture) .role("USER") .status("ACTIVE") .build(); @@ -225,6 +227,11 @@ public class AuthService { if (!"ACTIVE".equals(member.getStatus())) { throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); } + // 구글 아바타가 바뀌었으면 동기화(신규 가입은 이미 반영됨 → 변경 없을 때 불필요한 UPDATE 생략) + if (picture != null && !picture.equals(member.getGooglePicture())) { + memberMapper.updateGooglePicture(member.getId(), picture); + member.setGooglePicture(picture); + } return issueSession(member, rememberMe); } @@ -335,6 +342,31 @@ public class AuthService { return MemberResponse.from(member); } + /** + * 프로필 사진(사용자 지정) 설정/해제. null/빈 값이면 해제 → 구글 사진으로 폴백. + * data URL(base64 이미지)만 허용하고 과도한 크기는 거절한다. + */ + @Transactional + public MemberResponse updateProfileImage(Long memberId, String image) { + Member member = memberMapper.findById(memberId); + if (member == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."); + } + String value = (image == null || image.isBlank()) ? null : image.trim(); + if (value != null) { + if (!value.startsWith("data:image/")) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 형식이 올바르지 않습니다."); + } + if (value.length() > 700_000) { // 약 500KB 이미지 상한 (base64 약 33% 팽창 고려) + throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 용량이 너무 큽니다. 더 작은 사진을 사용하세요."); + } + } + memberMapper.updateProfileImage(memberId, value); + member.setProfileImage(value); + log.info("[profile] image {} for {}", value == null ? "cleared" : "updated", member.getLoginId()); + return MemberResponse.from(member); + } + /** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */ private void syncSessionName(String token, String name) { if (token == null || token.isBlank()) { diff --git a/src/main/java/com/sb/web/board/controller/BoardController.java b/src/main/java/com/sb/web/board/controller/BoardController.java index 00fa962..22d2419 100644 --- a/src/main/java/com/sb/web/board/controller/BoardController.java +++ b/src/main/java/com/sb/web/board/controller/BoardController.java @@ -8,7 +8,10 @@ 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.Recommender; import com.sb.web.board.dto.TagCategoryResponse; +import com.sb.web.board.dto.VoteRequest; +import com.sb.web.board.dto.VoteResponse; import com.sb.web.board.service.BoardService; import com.sb.web.board.service.TagService; import jakarta.validation.Valid; @@ -143,4 +146,28 @@ public class BoardController { boardService.deleteComment(commentId, current); return ResponseEntity.noContent().build(); } + + /** 게시글 추천/비추천 (토글) */ + @PostMapping("/posts/{id}/vote") + public VoteResponse votePost( + @PathVariable Long id, + @Valid @RequestBody VoteRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.votePost(id, req.getType(), current); + } + + /** 게시글 추천(👍)한 사람 목록 */ + @GetMapping("/posts/{id}/recommenders") + public List recommenders(@PathVariable Long id) { + return boardService.recommenders(id); + } + + /** 댓글 추천/비추천 (토글) */ + @PostMapping("/comments/{commentId}/vote") + public VoteResponse voteComment( + @PathVariable Long commentId, + @Valid @RequestBody VoteRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.voteComment(commentId, req.getType(), current); + } } diff --git a/src/main/java/com/sb/web/board/domain/Comment.java b/src/main/java/com/sb/web/board/domain/Comment.java index e9180ab..b2a3398 100644 --- a/src/main/java/com/sb/web/board/domain/Comment.java +++ b/src/main/java/com/sb/web/board/domain/Comment.java @@ -21,6 +21,8 @@ public class Comment implements Serializable { private Long postId; private Long authorId; private String authorName; + private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함) + private String authorProfileImage; // 조회 시 member JOIN (저장 안 함) private String content; private LocalDateTime createdAt; } diff --git a/src/main/java/com/sb/web/board/domain/Post.java b/src/main/java/com/sb/web/board/domain/Post.java index f05bfa5..eeede99 100644 --- a/src/main/java/com/sb/web/board/domain/Post.java +++ b/src/main/java/com/sb/web/board/domain/Post.java @@ -22,6 +22,8 @@ public class Post implements Serializable { private String content; private Long authorId; private String authorName; + private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함) + private String authorProfileImage; // 조회 시 member JOIN (저장 안 함) private Integer viewCount; private Boolean blocked; private String blockReason; diff --git a/src/main/java/com/sb/web/board/dto/CommentResponse.java b/src/main/java/com/sb/web/board/dto/CommentResponse.java index 2e1d644..4ea2d8c 100644 --- a/src/main/java/com/sb/web/board/dto/CommentResponse.java +++ b/src/main/java/com/sb/web/board/dto/CommentResponse.java @@ -16,16 +16,25 @@ public class CommentResponse { private Long id; private Long authorId; private String authorName; + private String authorGooglePicture; + private String authorProfileImage; private String content; private LocalDateTime createdAt; + private Integer upCount; // 추천 수 + private Integer downCount; // 비추천 수 + private String myVote; // 현재 사용자의 투표: UP / DOWN / null public static CommentResponse from(Comment c) { return CommentResponse.builder() .id(c.getId()) .authorId(c.getAuthorId()) .authorName(c.getAuthorName()) + .authorGooglePicture(c.getAuthorGooglePicture()) + .authorProfileImage(c.getAuthorProfileImage()) .content(c.getContent()) .createdAt(c.getCreatedAt()) + .upCount(0) + .downCount(0) .build(); } } diff --git a/src/main/java/com/sb/web/board/dto/CommentVoteSummary.java b/src/main/java/com/sb/web/board/dto/CommentVoteSummary.java new file mode 100644 index 0000000..a5f80b3 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/CommentVoteSummary.java @@ -0,0 +1,15 @@ +package com.sb.web.board.dto; + +import lombok.Data; + +/** + * 한 게시글의 댓글별 추천/비추천 집계 + 현재 사용자 투표 (N+1 방지용 일괄 조회 결과). + */ +@Data +public class CommentVoteSummary { + + private Long commentId; + private int upCount; + private int downCount; + private String myVote; // UP / DOWN / null +} diff --git a/src/main/java/com/sb/web/board/dto/PostDetail.java b/src/main/java/com/sb/web/board/dto/PostDetail.java index 21377de..b64a306 100644 --- a/src/main/java/com/sb/web/board/dto/PostDetail.java +++ b/src/main/java/com/sb/web/board/dto/PostDetail.java @@ -19,6 +19,8 @@ public class PostDetail { private String content; private Long authorId; private String authorName; + private String authorGooglePicture; + private String authorProfileImage; private Integer viewCount; private Boolean blocked; private String blockReason; @@ -28,6 +30,10 @@ public class PostDetail { private LocalDateTime updatedAt; private List tags; private List comments; + private Integer upCount; // 추천 수 + private Integer downCount; // 비추천 수 + private String myVote; // 현재 사용자의 투표: UP / DOWN / null + private List recommenders; // 추천(👍)한 사람 목록 (아바타 표기용) public static PostDetail of(Post p, List tags, List comments) { return PostDetail.builder() @@ -36,6 +42,8 @@ public class PostDetail { .content(p.getContent()) .authorId(p.getAuthorId()) .authorName(p.getAuthorName()) + .authorGooglePicture(p.getAuthorGooglePicture()) + .authorProfileImage(p.getAuthorProfileImage()) .viewCount(p.getViewCount()) .blocked(Boolean.TRUE.equals(p.getBlocked())) .blockReason(p.getBlockReason()) diff --git a/src/main/java/com/sb/web/board/dto/PostSummary.java b/src/main/java/com/sb/web/board/dto/PostSummary.java index 28a9069..3e5cd5f 100644 --- a/src/main/java/com/sb/web/board/dto/PostSummary.java +++ b/src/main/java/com/sb/web/board/dto/PostSummary.java @@ -13,9 +13,13 @@ public class PostSummary { private Long id; private String title; + private Long authorId; private String authorName; + private String authorGooglePicture; + private String authorProfileImage; private Integer viewCount; private Integer commentCount; + private Integer upCount; // 추천 수 private Boolean blocked; private Boolean notice; private LocalDateTime createdAt; diff --git a/src/main/java/com/sb/web/board/dto/Recommender.java b/src/main/java/com/sb/web/board/dto/Recommender.java new file mode 100644 index 0000000..b0635db --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/Recommender.java @@ -0,0 +1,15 @@ +package com.sb.web.board.dto; + +import lombok.Data; + +/** + * 추천(👍)한 사람 (본문 하단 아바타·이름 표기용). + */ +@Data +public class Recommender { + + private Long memberId; + private String name; + private String googlePicture; + private String profileImage; +} diff --git a/src/main/java/com/sb/web/board/dto/VoteRequest.java b/src/main/java/com/sb/web/board/dto/VoteRequest.java new file mode 100644 index 0000000..dccf8bb --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/VoteRequest.java @@ -0,0 +1,14 @@ +package com.sb.web.board.dto; + +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 추천/비추천 요청. 같은 표를 다시 보내면 취소(토글), 반대면 전환. + */ +@Data +public class VoteRequest { + + @Pattern(regexp = "UP|DOWN", message = "투표는 UP 또는 DOWN 이어야 합니다.") + private String type; +} diff --git a/src/main/java/com/sb/web/board/dto/VoteResponse.java b/src/main/java/com/sb/web/board/dto/VoteResponse.java new file mode 100644 index 0000000..830518e --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/VoteResponse.java @@ -0,0 +1,16 @@ +package com.sb.web.board.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 추천/비추천 후 최신 집계 + 현재 사용자 상태. + */ +@Data +@AllArgsConstructor +public class VoteResponse { + + private int upCount; + private int downCount; + private String myVote; // UP / DOWN / null +} diff --git a/src/main/java/com/sb/web/board/mapper/VoteMapper.java b/src/main/java/com/sb/web/board/mapper/VoteMapper.java new file mode 100644 index 0000000..00720fe --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/VoteMapper.java @@ -0,0 +1,48 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.dto.CommentVoteSummary; +import com.sb.web.board.dto.Recommender; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 게시글/댓글 추천·비추천(post_vote / comment_vote) 매퍼. + */ +@Mapper +public interface VoteMapper { + + // ===== 게시글 ===== + /** 현재 사용자의 게시글 투표(UP/DOWN) 또는 null */ + String findPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId); + + /** 투표 등록/변경 (INSERT … ON DUPLICATE KEY UPDATE) */ + int upsertPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId, @Param("type") String type); + + int deletePostVote(@Param("postId") Long postId, @Param("memberId") Long memberId); + + int countPostVotes(@Param("postId") Long postId, @Param("type") String type); + + /** 추천(👍)한 사람 목록 (최신순) */ + List findPostRecommenders(@Param("postId") Long postId); + + int deletePostVotesByPost(@Param("postId") Long postId); + + // ===== 댓글 ===== + String findCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId); + + int upsertCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId, @Param("type") String type); + + int deleteCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId); + + int countCommentVotes(@Param("commentId") Long commentId, @Param("type") String type); + + /** 한 게시글의 댓글별 집계 + 현재 사용자 투표 (일괄) */ + List findCommentVoteSummary(@Param("postId") Long postId, @Param("memberId") Long memberId); + + int deleteCommentVotesByComment(@Param("commentId") Long commentId); + + /** 게시글 삭제 시 그 글의 모든 댓글 투표 정리 */ + int deleteCommentVotesByPost(@Param("postId") Long postId); +} diff --git a/src/main/java/com/sb/web/board/service/BoardService.java b/src/main/java/com/sb/web/board/service/BoardService.java index 7855499..bb0c393 100644 --- a/src/main/java/com/sb/web/board/service/BoardService.java +++ b/src/main/java/com/sb/web/board/service/BoardService.java @@ -5,14 +5,19 @@ 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.CommentVoteSummary; 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.Recommender; +import com.sb.web.board.dto.VoteResponse; 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.board.mapper.VoteMapper; import com.sb.web.common.exception.ApiException; +import com.sb.web.point.PointService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; @@ -20,7 +25,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -33,10 +40,14 @@ public class BoardService { private final PostMapper postMapper; private final TagMapper tagMapper; private final CommentMapper commentMapper; + private final VoteMapper voteMapper; + private final PointService pointService; private final RedisTemplate redisTemplate; /** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */ private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24); + private static final String UP = "UP"; + private static final String DOWN = "DOWN"; /* ===================== 게시글 ===================== */ @@ -80,7 +91,7 @@ public class BoardService { postMapper.increaseViewCount(id); post.setViewCount(post.getViewCount() + 1); } - return assemble(post); + return assemble(post, viewer); } /** 게시글 열람 제한/해제 (관리자 전용) */ @@ -122,7 +133,8 @@ public class BoardService { .build(); postMapper.insert(post); applyTags(post.getId(), req.getTagIds()); - return assemble(mustFindPost(post.getId())); + pointService.awardBoardWrite(author.getId()); // 글 작성 보상(일 3회 한도) + return assemble(mustFindPost(post.getId()), author); } @Transactional @@ -144,13 +156,16 @@ public class BoardService { tagMapper.deletePostTagsByPostId(id); applyTags(id, req.getTagIds()); - return assemble(mustFindPost(id)); + return assemble(mustFindPost(id), user); } @Transactional public void delete(Long id, SessionUser user) { Post post = mustFindPost(id); assertCanModify(post.getAuthorId(), user); + // 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조) + voteMapper.deleteCommentVotesByPost(id); + voteMapper.deletePostVotesByPost(id); tagMapper.deletePostTagsByPostId(id); commentMapper.deleteByPostId(id); postMapper.delete(id); @@ -171,6 +186,7 @@ public class BoardService { .content(req.getContent()) .build(); commentMapper.insert(comment); + pointService.awardBoardWrite(author.getId()); // 댓글 작성 보상(일 3회 한도, 글과 합산) return CommentResponse.from(commentMapper.findById(comment.getId())); } @@ -181,9 +197,57 @@ public class BoardService { throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."); } assertCanModify(comment.getAuthorId(), user); + voteMapper.deleteCommentVotesByComment(commentId); commentMapper.delete(commentId); } + /* ===================== 추천/비추천 ===================== */ + + /** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */ + @Transactional + public VoteResponse votePost(Long postId, String type, SessionUser user) { + mustFindPost(postId); + Long memberId = user.getId(); + String existing = voteMapper.findPostVote(postId, memberId); + boolean cancel = type.equals(existing); + if (cancel) { + voteMapper.deletePostVote(postId, memberId); + } else { + voteMapper.upsertPostVote(postId, memberId, type); + } + return new VoteResponse( + voteMapper.countPostVotes(postId, UP), + voteMapper.countPostVotes(postId, DOWN), + cancel ? null : type); + } + + /** 댓글 추천/비추천 토글 */ + @Transactional + public VoteResponse voteComment(Long commentId, String type, SessionUser user) { + Comment comment = commentMapper.findById(commentId); + if (comment == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."); + } + Long memberId = user.getId(); + String existing = voteMapper.findCommentVote(commentId, memberId); + boolean cancel = type.equals(existing); + if (cancel) { + voteMapper.deleteCommentVote(commentId, memberId); + } else { + voteMapper.upsertCommentVote(commentId, memberId, type); + } + return new VoteResponse( + voteMapper.countCommentVotes(commentId, UP), + voteMapper.countCommentVotes(commentId, DOWN), + cancel ? null : type); + } + + /** 추천(👍)한 사람 목록 (본문 하단 표기/모달용) */ + public List recommenders(Long postId) { + mustFindPost(postId); + return voteMapper.findPostRecommenders(postId); + } + /* ===================== 태그 ===================== */ public List allTags() { @@ -192,11 +256,33 @@ public class BoardService { /* ===================== 내부 ===================== */ - private PostDetail assemble(Post post) { - List tags = tagMapper.findNamesByPostId(post.getId()); - List comments = commentMapper.findByPostId(post.getId()) - .stream().map(CommentResponse::from).toList(); - return PostDetail.of(post, tags, comments); + private PostDetail assemble(Post post, SessionUser viewer) { + Long postId = post.getId(); + Long viewerId = viewer == null ? null : viewer.getId(); + List tags = tagMapper.findNamesByPostId(postId); + + // 댓글별 추천/비추천 집계를 한 번에 조회(N+1 방지) + Map voteByComment = new HashMap<>(); + for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) { + voteByComment.put(s.getCommentId(), s); + } + List comments = commentMapper.findByPostId(postId).stream().map(c -> { + CommentResponse cr = CommentResponse.from(c); + CommentVoteSummary s = voteByComment.get(c.getId()); + if (s != null) { + cr.setUpCount(s.getUpCount()); + cr.setDownCount(s.getDownCount()); + cr.setMyVote(s.getMyVote()); + } + return cr; + }).toList(); + + PostDetail detail = PostDetail.of(post, tags, comments); + detail.setUpCount(voteMapper.countPostVotes(postId, UP)); + detail.setDownCount(voteMapper.countPostVotes(postId, DOWN)); + detail.setMyVote(viewerId == null ? null : voteMapper.findPostVote(postId, viewerId)); + detail.setRecommenders(voteMapper.findPostRecommenders(postId)); + return detail; } /** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */ diff --git a/src/main/java/com/sb/web/common/config/WebConfig.java b/src/main/java/com/sb/web/common/config/WebConfig.java index efe5084..b58a1b6 100644 --- a/src/main/java/com/sb/web/common/config/WebConfig.java +++ b/src/main/java/com/sb/web/common/config/WebConfig.java @@ -25,8 +25,8 @@ public class WebConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { // 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) registry.addInterceptor(authInterceptor) - .addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/auth/password", - "/api/auth/verify-password", "/api/auth/profile", + .addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/logout", "/api/auth/password", + "/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image", "/api/board/**", "/api/admin/**", "/api/account/**"); // 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사) registry.addInterceptor(adminInterceptor) diff --git a/src/main/java/com/sb/web/point/PointService.java b/src/main/java/com/sb/web/point/PointService.java new file mode 100644 index 0000000..02f5829 --- /dev/null +++ b/src/main/java/com/sb/web/point/PointService.java @@ -0,0 +1,41 @@ +package com.sb.web.point; + +import com.sb.web.point.mapper.PointMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 활동 포인트 적립/조회. + * 게시판 글/댓글 작성 시 10점, 하루 3회까지만 지급(글+댓글 합산). + */ +@Service +@RequiredArgsConstructor +public class PointService { + + private final PointMapper pointMapper; + + public static final int BOARD_WRITE_POINT = 10; + public static final int BOARD_WRITE_DAILY_LIMIT = 3; + public static final String REASON_BOARD_WRITE = "BOARD_WRITE"; + + /** + * 게시판 글/댓글 작성 보상. 오늘 지급 횟수가 한도 미만일 때만 지급한다. + * @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0 + */ + @Transactional + public int awardBoardWrite(Long memberId) { + int today = pointMapper.countTodayGrants(memberId, REASON_BOARD_WRITE); + if (today >= BOARD_WRITE_DAILY_LIMIT) { + return 0; + } + pointMapper.insertHistory(memberId, BOARD_WRITE_POINT, REASON_BOARD_WRITE); + pointMapper.addPoints(memberId, BOARD_WRITE_POINT); + return BOARD_WRITE_POINT; + } + + public long getPoints(Long memberId) { + Long p = pointMapper.getPoints(memberId); + return p == null ? 0L : p; + } +} diff --git a/src/main/java/com/sb/web/point/mapper/PointMapper.java b/src/main/java/com/sb/web/point/mapper/PointMapper.java new file mode 100644 index 0000000..2a83005 --- /dev/null +++ b/src/main/java/com/sb/web/point/mapper/PointMapper.java @@ -0,0 +1,20 @@ +package com.sb.web.point.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 포인트(member.points) 및 적립 이력(point_history) 매퍼. + */ +@Mapper +public interface PointMapper { + + /** 오늘 특정 사유로 지급된 횟수 (일일 한도 계산용) */ + int countTodayGrants(@Param("memberId") Long memberId, @Param("reason") String reason); + + int insertHistory(@Param("memberId") Long memberId, @Param("amount") int amount, @Param("reason") String reason); + + int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount); + + Long getPoints(@Param("memberId") Long memberId); +} diff --git a/src/main/resources/db/board.sql b/src/main/resources/db/board.sql index 00fe8b9..a2053b5 100644 --- a/src/main/resources/db/board.sql +++ b/src/main/resources/db/board.sql @@ -95,3 +95,23 @@ CREATE TABLE IF NOT EXISTS comment ( PRIMARY KEY (id), KEY idx_comment_post (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 게시글 추천/비추천 (회원당 1표, UP/DOWN). 같은 표 재요청 시 취소, 반대면 전환. +CREATE TABLE IF NOT EXISTS post_vote ( + post_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + vote_type VARCHAR(4) NOT NULL COMMENT 'UP / DOWN', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (post_id, member_id), + KEY idx_post_vote_post (post_id, vote_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 댓글 추천/비추천 (회원당 1표) +CREATE TABLE IF NOT EXISTS comment_vote ( + comment_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + vote_type VARCHAR(4) NOT NULL COMMENT 'UP / DOWN', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (comment_id, member_id), + KEY idx_comment_vote_comment (comment_id, vote_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index c2d1646..a423668 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -30,6 +30,25 @@ UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_i -- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등). ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role; +-- 프로필 사진. google_picture 는 구글 로그인 시 동기화되는 구글 아바타 URL, +-- profile_image 는 사용자가 직접 올린 커스텀 이미지(data URL). 표시 우선순위: profile_image > google_picture > 이니셜. +ALTER TABLE member ADD COLUMN IF NOT EXISTS google_picture VARCHAR(500) NULL COMMENT '구글 아바타 URL (로그인 시 동기화)' AFTER google_id; +ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMENT '사용자 지정 프로필 이미지(data URL)' AFTER google_picture; + +-- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등). +ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COMMENT '활동 포인트' AFTER plan; + +-- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용). +CREATE TABLE IF NOT EXISTS point_history ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + amount INT NOT NULL COMMENT '증감 포인트(+/-)', + reason VARCHAR(30) NOT NULL COMMENT 'BOARD_WRITE 등', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_point_history_member_date (member_id, reason, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ============================================================ -- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경. -- ============================================================ diff --git a/src/main/resources/mapper/CommentMapper.xml b/src/main/resources/mapper/CommentMapper.xml index db8c319..521cab0 100644 --- a/src/main/resources/mapper/CommentMapper.xml +++ b/src/main/resources/mapper/CommentMapper.xml @@ -8,21 +8,27 @@ + + + + + - id, login_id, password, name, email, provider, provider_id, google_id, role, plan, status, created_at, updated_at + id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, points, status, created_at, updated_at + + SELECT COUNT(*) FROM point_history + WHERE member_id = #{memberId} AND reason = #{reason} AND created_at >= CURDATE() + + + + INSERT INTO point_history (member_id, amount, reason, created_at) + VALUES (#{memberId}, #{amount}, #{reason}, NOW()) + + + + UPDATE member SET points = points + #{amount}, updated_at = NOW() WHERE id = #{memberId} + + + + + diff --git a/src/main/resources/mapper/PostMapper.xml b/src/main/resources/mapper/PostMapper.xml index edea528..f9b1602 100644 --- a/src/main/resources/mapper/PostMapper.xml +++ b/src/main/resources/mapper/PostMapper.xml @@ -35,9 +35,12 @@ + + + + + + + + INSERT INTO post_vote (post_id, member_id, vote_type, created_at) + VALUES (#{postId}, #{memberId}, #{type}, NOW()) + ON DUPLICATE KEY UPDATE vote_type = #{type}, created_at = NOW() + + + + DELETE FROM post_vote WHERE post_id = #{postId} AND member_id = #{memberId} + + + + + + + + DELETE FROM post_vote WHERE post_id = #{postId} + + + + + + + INSERT INTO comment_vote (comment_id, member_id, vote_type, created_at) + VALUES (#{commentId}, #{memberId}, #{type}, NOW()) + ON DUPLICATE KEY UPDATE vote_type = #{type}, created_at = NOW() + + + + DELETE FROM comment_vote WHERE comment_id = #{commentId} AND member_id = #{memberId} + + + + + + + + DELETE FROM comment_vote WHERE comment_id = #{commentId} + + + + DELETE FROM comment_vote WHERE comment_id IN (SELECT id FROM comment WHERE post_id = #{postId}) + + + diff --git a/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java b/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java index 44993e9..b4b681a 100644 --- a/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java +++ b/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java @@ -38,6 +38,7 @@ class AuthControllerWebMvcTest { @Autowired MockMvc mvc; @MockitoBean AuthService authService; @MockitoBean AppSettingService appSettingService; + @MockitoBean com.sb.web.point.PointService pointService; /** * @MapperScan 매퍼 빈들이 SqlSessionTemplate 을 요구한다(웹 슬라이스엔 DataSource 없음). diff --git a/src/test/java/com/sb/web/common/config/WebConfigTest.java b/src/test/java/com/sb/web/common/config/WebConfigTest.java index 04680ae..e78b18d 100644 --- a/src/test/java/com/sb/web/common/config/WebConfigTest.java +++ b/src/test/java/com/sb/web/common/config/WebConfigTest.java @@ -48,7 +48,8 @@ class WebConfigTest { "/api/auth/logout", "/api/auth/password", "/api/auth/verify-password", - "/api/auth/profile"); + "/api/auth/profile", + "/api/auth/profile-image"); // 유료(PREMIUM) 전용 경로가 premiumInterceptor 에 등록되는지 회귀 가드 assertThat(allPatterns).contains( "/api/account/stats",