Skip to content
Merged

Dev #268

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5a1451a
feat : purge 로그 추가
5solbin Jul 9, 2026
535d897
fix : 대댓글 조회 페이지네이션 추가
5solbin Jul 10, 2026
02caaec
fix : 내가 쓴 댓글 조회 페이지네이션 추가
5solbin Jul 10, 2026
f8ad802
fix : 신고된 게시물 조회 페이지네이션 적용
5solbin Jul 10, 2026
50df920
fix : 내가 만든, 투표한 투표 목록 페이지네이션 적용
5solbin Jul 10, 2026
438aea7
fix : 포인트 지급 내역 페이지네이션 적용
5solbin Jul 10, 2026
8310c87
Merge pull request #229 from ValanSee/feat/#186-hardDelete
5solbin Jul 10, 2026
2b23643
Merge pull request #233 from ValanSee/feat/#231-dbMigration
5solbin Jul 14, 2026
d02cb59
Merge pull request #235 from ValanSee/main
5solbin Jul 14, 2026
09dd9ed
Merge pull request #236 from ValanSee/fix/#231-duplicationChek
5solbin Jul 15, 2026
86a209e
fix : trending 개선사항 반영
5solbin Jul 20, 2026
b8e457d
Merge pull request #239 from ValanSee/fix/#238-hotIssueTrending
5solbin Jul 21, 2026
18e1e5a
Merge pull request #240 from ValanSee/feat/#220-500Discord
5solbin Jul 24, 2026
9b7cd4e
Merge pull request #241 from ValanSee/feat/#220-500Discord
5solbin Jul 24, 2026
553ef21
Merge pull request #242 from ValanSee/feat/#220-500Discord
5solbin Jul 24, 2026
a40c1b1
Merge pull request #243 from ValanSee/feat/#220-500Discord
5solbin Jul 24, 2026
467dc70
Merge pull request #244 from ValanSee/feat/#220-500Discord
5solbin Jul 24, 2026
2f2546f
Merge pull request #245 from ValanSee/feat/#220-500Discord
5solbin Jul 26, 2026
4fa4d3c
Merge pull request #255 from ValanSee/main
5solbin Aug 3, 2026
8c42c36
test : 회원 투표 목록 페이지 응답 검증
5solbin Aug 3, 2026
0d5e1c3
Merge pull request #257 from ValanSee/fix/#250-dev-vote-test
5solbin Aug 3, 2026
6bbecf6
Merge origin/main into dev
5solbin Aug 7, 2026
4cbc7b2
Merge pull request #260 from ValanSee/merge/main-into-dev
5solbin Aug 7, 2026
c25e3eb
Merge pull request #264 from ValanSee/main
5solbin Aug 7, 2026
3e34b94
feat : 봇 계정 필드 및 시드 데이터 추가
5solbin Aug 26, 2026
5ed07dd
feat : 작성자 API 응답에 봇 여부 추가
5solbin Aug 26, 2026
3d532ea
feat : 봇 회원 포인트 지급 차단
5solbin Aug 26, 2026
39149b4
feat : 성별·연령·MBTI 통계 및 핫이슈·트렌딩 집계에서 봇 활동 제외
5solbin Aug 26, 2026
7d510df
feat : 트렌딩 랭킹 반응성 점수에서 봇 투표·댓글 제외
5solbin Aug 26, 2026
dded3bf
Merge pull request #267 from ValanSee/feat/#265-autoGeneration
5solbin Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ plugins {
group = 'com.valanse'
version = '0.0.1-SNAPSHOT'

ext['testcontainers.version'] = '2.0.2'
ext['commons-compress.version'] = '1.28.0'
ext['commons-lang3.version'] = '3.18.0'

java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
Expand Down Expand Up @@ -48,6 +52,13 @@ dependencies {

testImplementation 'org.springframework.boot:spring-boot-starter-test'

// 마이그레이션 SQL을 실제 MySQL에 적용해 검증하는 Testcontainers 설정
testImplementation enforcedPlatform('org.testcontainers:testcontainers-bom:2.0.2')
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-mysql'
testImplementation 'org.apache.commons:commons-compress:1.28.0'
testImplementation 'org.apache.commons:commons-lang3:3.18.0'

// JWT 설정
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -89,6 +92,30 @@ public ResponseEntity<?> handleMethodArgumentNotValidException(MethodArgumentNot
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}

@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<?> handleMissingServletRequestParameterException(
MissingServletRequestParameterException e
) {
return badRequest(e.getParameterName() + " 파라미터를 입력해주세요.");
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<?> handleMethodArgumentTypeMismatchException(
MethodArgumentTypeMismatchException e
) {
return badRequest(e.getName() + " 파라미터 형식이 올바르지 않습니다.");
}

@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<Map<String, Object>> handleNoResourceFoundException(
NoResourceFoundException e
) {
Map<String, Object> error = new HashMap<>();
error.put("error", "요청한 API를 찾을 수 없습니다.");
error.put("status", HttpStatus.NOT_FOUND.value());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}

/**
* GlobalExceptionHandler의 handleUnexpectedException 기능을 수행하는 메서드입니다.
*/
Expand Down Expand Up @@ -146,4 +173,11 @@ private boolean isProdProfile() {
.map(String::trim)
.anyMatch("prod"::equals);
}

private ResponseEntity<Map<String, Object>> badRequest(String message) {
Map<String, Object> error = new HashMap<>();
error.put("error", message);
error.put("status", HttpStatus.BAD_REQUEST.value());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/valanse/valanse/common/config/ClockConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.valanse.valanse.common.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Clock;
import java.time.ZoneId;

@Configuration
public class ClockConfig {

@Bean
public Clock applicationClock() {
return Clock.system(ZoneId.of("Asia/Seoul"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public enum VoteErrorMessage {
CATEGORY_REQUIRED("카테고리를 입력해주세요."),
HOT_ISSUE_VOTE_NOT_FOUND("핫이슈 투표를 찾을 수 없습니다."),
TRENDING_VOTE_NOT_FOUND("인기 급상승 투표를 찾을 수 없습니다."),
TRENDING_PERIOD_INVALID("조회 기간은 1일 이상 30일 이하여야 합니다."),
VOTE_NOT_FOUND("투표가 존재하지 않습니다."),
VOTE_DETAIL_NOT_FOUND("투표를 찾을 수 없습니다."),
VOTE_OPTION_NOT_FOUND("투표 선택지가 존재하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,14 @@ public ResponseEntity<CommentLikeResponseDto> likeComment(
* 특정 부모 댓글의 대댓글 목록과 작성자/삭제 가능 여부 정보를 조회하는 메서드입니다.
*/
@GetMapping("/{commentId}/replies")
public ResponseEntity<List<CommentReplyResponseDto>> getReplies(
public ResponseEntity<PagedCommentReplyResponse> getReplies(
@PathVariable("voteId") Long voteId,
@PathVariable("commentId") Long commentId
@PathVariable("commentId") Long commentId,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size
) {
PaginationValidator.validatePageAndSize(page, size);

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Member member = null;
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) {
Expand All @@ -138,7 +142,8 @@ public ResponseEntity<List<CommentReplyResponseDto>> getReplies(
}
}

List<CommentReplyResponseDto> replies = commentService.getReplies(member, voteId, commentId);
Pageable pageable = PageRequest.of(page, size);
PagedCommentReplyResponse replies = commentService.getReplies(member, voteId, commentId, pageable);
return ResponseEntity.ok(replies);
}
}
13 changes: 11 additions & 2 deletions src/main/java/com/valanse/valanse/controller/MemberController.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.valanse.valanse.controller;

import com.valanse.valanse.common.api.ApiException;
import com.valanse.valanse.common.api.PaginationValidator;
import com.valanse.valanse.common.message.MemberErrorMessage;
import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse;
import com.valanse.valanse.dto.MemberProfile.MemberProfileImageResponse;
Expand All @@ -22,6 +23,8 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
Expand Down Expand Up @@ -152,9 +155,15 @@ public ResponseEntity<MemberMyPageResponse> getMyProfile() {
* 회원의 포인트 변동 이력을 최신순 응답으로 조회하는 메서드입니다.
*/
@GetMapping("/point-history")
public ResponseEntity<PointHistoryResponse> getPointHistory() {
public ResponseEntity<PointHistoryResponse> getPointHistory(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size
) {
PaginationValidator.validatePageAndSize(page, size);

Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
PointHistoryResponse response = pointService.getPointHistory(userId);
Pageable pageable = PageRequest.of(page, size);
PointHistoryResponse response = pointService.getPointHistory(userId, pageable);
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.valanse.valanse.controller;

import com.valanse.valanse.common.api.PaginationValidator;
import com.valanse.valanse.common.auth.SecurityUtils;
import com.valanse.valanse.domain.Member;
import com.valanse.valanse.domain.enums.Role;
Expand All @@ -9,13 +10,13 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;

import java.util.List;

@Tag(name = "내 댓글 API", description = "내가 작성한 댓글 조회 및 삭제 기능")
@RestController
@RequiredArgsConstructor
Expand Down Expand Up @@ -53,13 +54,17 @@ public ResponseEntity<?> deleteMyComment(@PathVariable Long commentId) {
/**
* 사용자가 작성한 댓글 목록을 최신순 또는 오래된순으로 조회하는 메서드입니다.
*/
public ResponseEntity<List<MyCommentResponseDto>> getMyComments(
@RequestParam(defaultValue = "latest") String sort) {
public ResponseEntity<PagedMyCommentResponse> getMyComments(
@RequestParam(defaultValue = "latest") String sort,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size) {
PaginationValidator.validatePageAndSize(page, size);

Long loginId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
var member = memberService.findById(loginId);
Pageable pageable = PageRequest.of(page, size);

List<MyCommentResponseDto> myComments = commentService.getMyComments(member, sort);
PagedMyCommentResponse myComments = commentService.getMyComments(member, sort, pageable);

return ResponseEntity.ok(myComments);
}
Expand Down
18 changes: 12 additions & 6 deletions src/main/java/com/valanse/valanse/controller/ReportController.java
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
package com.valanse.valanse.controller;

import com.valanse.valanse.common.api.PaginationValidator;
import com.valanse.valanse.common.auth.SecurityUtils;
import com.valanse.valanse.domain.Member;
import com.valanse.valanse.domain.enums.ReportType;
import com.valanse.valanse.domain.enums.Role;
import com.valanse.valanse.dto.Report.PagedReportedTargetResponse;
import com.valanse.valanse.dto.Report.ReportRequest;
import com.valanse.valanse.dto.Report.ReportDetailResponse;
import com.valanse.valanse.dto.Report.ReportedTargetResponse;
import com.valanse.valanse.service.MemberService.MemberService;
import com.valanse.valanse.service.ReportService.ReportService;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Tag(name = "신고 관련 API")
@RestController
@RequestMapping("/report")
Expand Down Expand Up @@ -46,15 +47,20 @@ public ResponseEntity<Void> Report(@PathVariable Long targetId, @Valid @RequestB
* 관리자가 신고 누적 대상 목록을 조회하는 메서드입니다.
*/
@GetMapping
public ResponseEntity<List<ReportedTargetResponse>> getReportedTargets(
public ResponseEntity<PagedReportedTargetResponse> getReportedTargets(
@RequestParam ReportType type,
@RequestParam(defaultValue = "latest") String sort) {
@RequestParam(defaultValue = "latest") String sort,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size) {
PaginationValidator.validatePageAndSize(page, size);

Long loginId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
Member member = SecurityUtils.isCurrentUserAdmin()
? Member.builder().id(loginId).role(Role.ADMIN).build()
: memberService.findById(loginId);

List<ReportedTargetResponse> results = reportService.getReportedTargets(member, type, sort);
Pageable pageable = PageRequest.of(page, size);
PagedReportedTargetResponse results = reportService.getReportedTargets(member, type, sort, pageable);
return ResponseEntity.ok().body(results);
}

Expand Down
71 changes: 26 additions & 45 deletions src/main/java/com/valanse/valanse/controller/VoteController.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.valanse.valanse.common.api.ApiException;
import com.valanse.valanse.common.api.PaginationValidator;
import com.valanse.valanse.common.auth.SecurityUtils;
import com.valanse.valanse.common.message.VoteErrorMessage;
import com.valanse.valanse.domain.Member;
Expand All @@ -20,6 +21,8 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -58,19 +61,23 @@ public class VoteController {
/**
* 사용자가 직접 생성한 투표 목록을 정렬과 카테고리 조건으로 조회하는 메서드입니다.
*/
public ResponseEntity<List<VoteResponseDto>> getMyCreatedVotes(
public ResponseEntity<PagedVoteResponse> getMyCreatedVotes(
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) String category, // 추가
@RequestParam(defaultValue = "latest") String sort
@RequestParam(defaultValue = "latest") String sort,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size
) {
PaginationValidator.validatePageAndSize(page, size);
Long memberId = Long.parseLong(userDetails.getUsername());
VoteCategory voteCategory = null;

if (category != null && !category.isEmpty()) {
voteCategory = convertCategory(category);
}

List<VoteResponseDto> votes = voteService.getMyCreatedVotes(memberId, sort, voteCategory);
Pageable pageable = PageRequest.of(page, size);
PagedVoteResponse votes = voteService.getMyCreatedVotes(memberId, sort, voteCategory, pageable);
return ResponseEntity.ok(votes);
}

Expand All @@ -82,19 +89,23 @@ public ResponseEntity<List<VoteResponseDto>> getMyCreatedVotes(
/**
* 사용자가 참여한 투표 목록을 정렬과 카테고리 조건으로 조회하는 메서드입니다.
*/
public ResponseEntity<List<VoteResponseDto>> getMyVotedVotes(
public ResponseEntity<PagedVoteResponse> getMyVotedVotes(
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) String category, // 추가
@RequestParam(defaultValue = "latest") String sort
@RequestParam(defaultValue = "latest") String sort,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size
) {
PaginationValidator.validatePageAndSize(page, size);
Long memberId = Long.parseLong(userDetails.getUsername());
VoteCategory voteCategory = null;

if (category != null && !category.isEmpty()) {
voteCategory = convertCategory(category);
}

List<VoteResponseDto> votes = voteService.getMyVotedVotes(memberId, sort, voteCategory);
Pageable pageable = PageRequest.of(page, size);
PagedVoteResponse votes = voteService.getMyVotedVotes(memberId, sort, voteCategory, pageable);
return ResponseEntity.ok(votes);
}

Expand All @@ -105,47 +116,17 @@ private VoteCategory convertCategory(String category) {
.orElseThrow(() -> new ApiException(VoteErrorMessage.CATEGORY_INVALID.message(), HttpStatus.BAD_REQUEST));
}

//여기서 부터 영서 부분
@Operation(
summary = "오늘의 핫이슈 밸런스 게임 선택지들 반환",
description = "가장 많이 참여한 밸런스 게임 투표의 상세 정보와 옵션 목록을 조회합니다. " +
"총 참여자 수가 가장 많은 투표를 반환하며, 참여자 수가 동일한 경우 가장 최근에 생성된 투표를 우선합니다. \n" +
" voteId -> 가장 투표 참여 횟수가 많은 투표의 id\n" +
" title -> 가장 투표 참여 횟수가 많은 투표의 제목\n" +
" category -> 가장 투표 참여 횟수가 많은 투표의 카테고리\n" +
" totalParticipants -> 가장 투표 참여 횟수가 많은 투표의 총 투표 수\n" +
" createdBy -> 가장 투표 참여 횟수가 많은 투표를 생성한 사람의 닉네임\n" +
" createdAt -> 투표 생성 날짜\n" +
" options -> 투표 옵션 리스트 (content, vote_count)"
summary = "기간별 인기 급상승 밸런스 게임 반환",
description = "최근 days일 동안 생성된 유효 투표 참여와 삭제되지 않은 댓글/대댓글을 합산해 " +
"반응성이 높은 투표를 최대 5개 반환합니다. 기간 내 반응이 없으면 전체 누적 반응성으로 조회합니다. " +
"관리자 고정 투표가 있으면 첫 번째에 배치하고 나머지를 반응성 순위로 채웁니다."
)
/**
* 핫이슈로 노출할 투표를 고정값 또는 반응성 기준으로 선정하는 메서드입니다.
*/
@GetMapping("/best") // 새로운 엔드포인트: /votes/best (GET 메서드)
public ResponseEntity<HotIssueVoteResponse> getHotIssueVote() {
HotIssueVoteResponse response = voteService.getHotIssueVote();
return ResponseEntity.ok(response);
}

// 인기 급상승 토픽
@Operation(
summary = "인기 급상승 밸런스 게임 선택지들 반환",
description = "최근 7일 이내 반응성(투표수 + 댓글수)이 가장 높은 밸런스 게임을 반환합니다. " +
"7일 이내 새로 추가되는 반응이 없을 경우 이전 데이터를 유지합니다.\n" +
" voteId -> 7일 내 반응성이 가장 높은 투표의 id\n" +
" title -> 7일 내 반응성이 가장 높은 투표의 제목\n" +
" category -> 7일 내 반응성이 가장 높은 투표의 카테고리\n" +
" totalParticipants -> 7일 내 반응성이 가장 높은 투표의 총 투표 수\n" +
" createdBy -> 7일 내 반응성이 가장 높은 투표를 생성한 사람의 닉네임\n" +
" createdAt -> 투표 생성 날짜\n" +
" options -> 투표 옵션 리스트 (content, vote_count)"
)
/**
* 최근 기간의 반응성을 기준으로 인기 급상승 투표를 선정하는 메서드입니다.
*/
@GetMapping("/trending")
public ResponseEntity<HotIssueVoteResponse> getTrendingVote() {
HotIssueVoteResponse response = voteService.getTrendingVote();
public ResponseEntity<TrendingVotesResponse> getTrendingVotes(
@RequestParam(name = "days") int days
) {
TrendingVotesResponse response = voteService.getTrendingVotes(days);
return ResponseEntity.ok(response);
}

Expand Down Expand Up @@ -296,7 +277,7 @@ public ResponseEntity<Void> deleteVote(@PathVariable("voteId") Long voteId) {
}

/**
* 관리자 권한으로 투표의 HOT/TRENDING 고정 상태를 변경하는 메서드입니다.
* 관리자 권한으로 투표의 트렌딩 고정 상태를 변경하는 메서드입니다.
*/
@PatchMapping("/{voteId}/pin")
@Operation(summary = "고정", description = "관리자 권한으로 게시물을 고정합니다.")
Expand Down
Loading
Loading