Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions docs/api-specs/perspectives-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
- 파라미터 | 타입 | 필수 | 설명
- cursor | string | X | 커서 페이지네이션
- size | number | X | 기본값 20 (임의 설정했음)
- option_label | string | X | A or B 투표 옵션 필터
- optionId | number | X | 옵션 ID 필터


#### 성공 응답 `200 OK`
Expand All @@ -115,8 +115,8 @@
},
"option": {
"option_id": "option_A",
"label": "A",
"title": "찬성"
"label": "예술이 아니다",
"title": "예술이 아니다"
},
"content": "자기결정권은 가장 기본적인 인권이라고 생각해요.",
"like_count": 12,
Expand Down Expand Up @@ -372,4 +372,4 @@
| `PERSPECTIVE_POST_VOTE_REQUIRED` | `409` | 사후 투표 미완료 |
| `PERSPECTIVE_400` | `400` | 검수 실패 상태의 관점이 아님 (재시도 불가) |

---
---
2 changes: 2 additions & 0 deletions docs/api-specs/vote-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,5 @@
- Poll 투표 응답: `PollVoteResponse`
- `selectedOptionId`, `totalCount`, `stats[].ratio` 포함
- 배틀 투표 응답: `VoteResultResponse`, `VoteStatsResponse`, `MyVoteResponse`
- `VoteStatsResponse.options[].imageUrl`은 철학자 이미지 리소스 리다이렉트 URL을 반환
- `MyVoteResponse.*Vote.label`은 A/B가 아니라 옵션 `title`과 같은 의견 문구를 반환
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.swyp.picke.domain.battle.entity.Battle;
import com.swyp.picke.domain.battle.entity.BattleOption;
import com.swyp.picke.domain.battle.enums.BattleCreatorType;
import com.swyp.picke.domain.battle.util.BattleOptionDisplay;
import com.swyp.picke.domain.tag.entity.Tag;
import com.swyp.picke.domain.tag.enums.TagType;
import com.swyp.picke.domain.user.entity.User;
Expand Down Expand Up @@ -84,7 +85,7 @@ public AdminBattleDetailResponse toAdminDetailResponse(Battle battle, List<Tag>
battle.getStatus(),
battle.getCreatorType(),
toTagResponses(tags, null),
toOptionResponses(options, optionTagsMap),
toOptionResponses(options, optionTagsMap, false),
battle.getCreatedAt(),
battle.getUpdatedAt()
);
Expand All @@ -103,7 +104,7 @@ public BattleUserDetailResponse toUserDetailResponse(
participantsCount == null ? 0L : participantsCount,
battle.getAudioDuration() == null ? 0 : battle.getAudioDuration(),
toTagResponses(tags, null),
toOptionResponses(options, optionTagsMap)
toOptionResponses(options, optionTagsMap, true)
);

return new BattleUserDetailResponse(
Expand All @@ -130,15 +131,21 @@ public BattleScenarioResponse toScenarioResponse(Battle battle, List<BattleOptio
return new BattleScenarioResponse(battle.getTitle(), profiles);
}

private List<BattleOptionResponse> toOptionResponses(List<BattleOption> options, Map<Long, List<Tag>> optionTagsMap) {
private List<BattleOptionResponse> toOptionResponses(
List<BattleOption> options,
Map<Long, List<Tag>> optionTagsMap,
boolean useDisplayLabel
) {
if (options == null) return List.of();
return options.stream()
.sorted(OPTION_SORTER)
.map(option -> {
List<Tag> optionTags = optionTagsMap.getOrDefault(option.getId(), List.of());
return new BattleOptionResponse(
option.getId(),
option.getLabel(),
useDisplayLabel
? BattleOptionDisplay.opinion(option)
: (option.getLabel() == null ? null : option.getLabel().name()),
option.getTitle(),
option.getStance(),
option.getRepresentative(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
package com.swyp.picke.domain.battle.dto.response;

import com.swyp.picke.domain.battle.enums.BattleOptionLabel;

import java.util.List;

public record BattleOptionResponse(
Long optionId,
BattleOptionLabel label,
String label,
String title,
String stance,
String representative,
String imageUrl,
List<BattleTagResponse> tags
) {}
) {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.swyp.picke.domain.battle.dto.response;

import com.swyp.picke.domain.battle.enums.BattleOptionLabel;

/**
* 유저 - 옵션별 실시간 통계
* 역할: 각 선택지별로 몇 명이 선택했는지, 퍼센트(%)는 얼마인지 담습니다.
Expand All @@ -12,4 +13,4 @@ public record OptionStatResponse(
String title, // 옵션 명칭
Long voteCount, // 해당 옵션의 득표 수
Double ratio // 해당 옵션의 득표 비율 (0~100.0)
) {}
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.swyp.picke.domain.battle.util;

import com.swyp.picke.domain.battle.entity.BattleOption;

public final class BattleOptionDisplay {

private BattleOptionDisplay() {
}

public static String opinion(BattleOption option) {
if (option == null) {
return null;
}
String title = option.getTitle();
if (title != null && !title.isBlank()) {
return title;
}
return option.getLabel() == null ? null : option.getLabel().name();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public ApiResponse<CommentListResponse> getComments(
return ApiResponse.onSuccess(commentService.getComments(perspectiveId, userId, cursor, size));
}

@Operation(summary = "댓글 목록 조회 (옵션 라벨)", description = "특정 관점의 댓글 목록을 커서 기반 페이지네이션으로 조회하며, stance를 투표한 옵션 라벨(A/B)로 반환합니다.")
@Operation(summary = "댓글 목록 조회 (옵션 의견)", description = "특정 관점의 댓글 목록을 커서 기반 페이지네이션으로 조회하며, stance를 투표한 옵션 의견 문구로 반환합니다.")
@GetMapping("/perspectives/{perspectiveId}/comments/labeled")
public ApiResponse<CommentListResponse> getCommentsWithLabel(
@PathVariable Long perspectiveId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ public ApiResponse<PerspectiveListResponse> getPerspectives(
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) Integer size,
@RequestParam(required = false) String optionLabel,
@RequestParam(required = false) Long optionId,
@RequestParam(required = false, defaultValue = "latest") String sort
) {
return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionLabel, sort));
return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionId, sort));
}

@Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점을 조회합니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.swyp.picke.domain.battle.entity.BattleOption;
import com.swyp.picke.domain.battle.service.BattleService;
import com.swyp.picke.domain.battle.util.BattleOptionDisplay;
import com.swyp.picke.domain.perspective.dto.request.CreateCommentRequest;
import com.swyp.picke.domain.perspective.dto.request.UpdateCommentRequest;
import com.swyp.picke.domain.perspective.dto.response.CommentListResponse;
Expand Down Expand Up @@ -65,7 +66,7 @@ public CreateCommentResponse createComment(Long perspectiveId, Long userId, Crea
Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(perspective.getBattle().getId(), userId);
String stance = null;
if (postVoteOptionId != null) {
stance = battleService.findOptionById(postVoteOptionId).getLabel().name();
stance = BattleOptionDisplay.opinion(battleService.findOptionById(postVoteOptionId));
}
return new CreateCommentResponse(
comment.getId(),
Expand Down Expand Up @@ -100,7 +101,7 @@ public CommentListResponse getComments(Long perspectiveId, Long userId, String c
String stance = null;
if (postVoteOptionId != null) {
BattleOption option = battleService.findOptionById(postVoteOptionId);
stance = option.getLabel().name();
stance = BattleOptionDisplay.opinion(option);
}
boolean isLiked = commentLikeRepository.existsByCommentAndUserId(c, userId);
return new CommentListResponse.Item(
Expand Down Expand Up @@ -144,7 +145,7 @@ public CommentListResponse getCommentsWithLabel(Long perspectiveId, Long userId,
String stance = null;
if (postVoteOptionId != null) {
BattleOption option = battleService.findOptionById(postVoteOptionId);
stance = option.getLabel().name();
stance = BattleOptionDisplay.opinion(option);
}
boolean isLiked = commentLikeRepository.existsByCommentAndUserId(c, userId);
return new CommentListResponse.Item(
Expand Down Expand Up @@ -209,4 +210,4 @@ private String resolveCharacterImageUrl(String characterType) {
}
return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import com.swyp.picke.domain.battle.entity.Battle;
import com.swyp.picke.domain.battle.entity.BattleOption;
import com.swyp.picke.domain.battle.enums.BattleOptionLabel;
import com.swyp.picke.domain.battle.service.BattleService;
import com.swyp.picke.domain.battle.util.BattleOptionDisplay;
import com.swyp.picke.domain.perspective.enums.PerspectiveStatus;
import com.swyp.picke.domain.user.entity.User;
import com.swyp.picke.domain.user.repository.UserRepository;
Expand Down Expand Up @@ -62,7 +62,7 @@ public PerspectiveDetailResponse getPerspectiveDetail(Long perspectiveId, Long u
return new PerspectiveDetailResponse(
perspective.getId(),
new PerspectiveDetailResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl),
new PerspectiveDetailResponse.OptionSummary(option.getId(), option.getLabel().name(), option.getTitle(), option.getStance()),
new PerspectiveDetailResponse.OptionSummary(option.getId(), BattleOptionDisplay.opinion(option), option.getTitle(), option.getStance()),
perspective.getContent(),
perspective.getLikeCount(),
perspective.getCommentCount(),
Expand Down Expand Up @@ -97,18 +97,17 @@ public CreatePerspectiveResponse createPerspective(Long battleId, Long userId, C
return new CreatePerspectiveResponse(saved.getId(), saved.getStatus(), saved.getCreatedAt());
}

public PerspectiveListResponse getPerspectives(Long battleId, Long userId, String cursor, Integer size, String optionLabel, String sort) {
battleService.findById(battleId);
public PerspectiveListResponse getPerspectives(Long battleId, Long userId, String cursor, Integer size, Long optionId, String sort) {
Battle battle = battleService.findById(battleId);

int pageSize = (size == null || size <= 0) ? DEFAULT_PAGE_SIZE : size;
PageRequest pageable = PageRequest.of(0, pageSize);

boolean isPopular = "popular".equalsIgnoreCase(sort);
List<Perspective> perspectives;

if (optionLabel != null) {
BattleOptionLabel label = BattleOptionLabel.valueOf(optionLabel.toUpperCase());
BattleOption option = battleService.findOptionByBattleIdAndLabel(battleId, label);
if (optionId != null) {
BattleOption option = findFilterOption(battle, optionId);
perspectives = isPopular
? perspectiveRepository.findByBattleIdAndOptionIdAndStatusOrderByLikeCountDescCreatedAtDesc(battleId, option.getId(), PerspectiveStatus.PUBLISHED, pageable)
: cursor == null
Expand All @@ -131,7 +130,7 @@ public PerspectiveListResponse getPerspectives(Long battleId, Long userId, Strin
return new PerspectiveListResponse.Item(
p.getId(),
new PerspectiveListResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl),
new PerspectiveListResponse.OptionSummary(option.getId(), option.getLabel().name(), option.getTitle(), option.getStance()),
new PerspectiveListResponse.OptionSummary(option.getId(), BattleOptionDisplay.opinion(option), option.getTitle(), option.getStance()),
p.getContent(),
p.getLikeCount(),
p.getCommentCount(),
Expand Down Expand Up @@ -180,7 +179,7 @@ public MyPerspectiveResponse getMyPerspective(Long battleId, Long userId) {
return new MyPerspectiveResponse(
perspective.getId(),
new MyPerspectiveResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl),
new MyPerspectiveResponse.OptionSummary(option.getId(), option.getLabel().name(), option.getTitle(), option.getStance()),
new MyPerspectiveResponse.OptionSummary(option.getId(), BattleOptionDisplay.opinion(option), option.getTitle(), option.getStance()),
perspective.getContent(),
perspective.getLikeCount(),
perspective.getCommentCount(),
Expand Down Expand Up @@ -212,10 +211,18 @@ private void validateOwnership(Perspective perspective, Long userId) {
}
}

private BattleOption findFilterOption(Battle battle, Long optionId) {
BattleOption option = battleService.findOptionById(optionId);
if (option.getBattle() == null || !battle.getId().equals(option.getBattle().getId())) {
throw new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND);
}
return option;
}

private String resolveCharacterImageUrl(String characterType) {
if (characterType == null || characterType.isBlank()) {
return null;
}
return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.swyp.picke.domain.vote.converter;

import com.swyp.picke.domain.battle.entity.BattleOption;
import com.swyp.picke.domain.battle.util.BattleOptionDisplay;
import com.swyp.picke.domain.user.enums.UserBattleStep;
import com.swyp.picke.domain.vote.dto.response.MyVoteResponse;
import com.swyp.picke.domain.vote.dto.response.VoteResultResponse;
Expand Down Expand Up @@ -41,6 +42,6 @@ private static MyVoteResponse.OptionInfo toOptionInfo(BattleOption option) {
if (option == null) {
return null;
}
return new MyVoteResponse.OptionInfo(option.getId(), option.getLabel().name(), option.getTitle());
return new MyVoteResponse.OptionInfo(option.getId(), BattleOptionDisplay.opinion(option), option.getTitle());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ public record VoteStatsResponse(
) {
public record OptionStat(
Long optionId,
String label,
String title,
String imageUrl,
long voteCount,
double ratio
) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import com.swyp.picke.domain.vote.sse.VoteUpdatedEvent;
import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.common.exception.ErrorCode;
import com.swyp.picke.global.infra.s3.enums.FileCategory;
import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
Expand All @@ -45,6 +47,7 @@ public class BattleVoteServiceImpl implements BattleVoteService {
private final UserBattleService userBattleService;
private final CreditService creditService;
private final ApplicationEventPublisher eventPublisher;
private final ResourceUrlProvider urlProvider;

@Override
public BattleOption findPreVoteOption(Long battleId, Long userId) {
Expand Down Expand Up @@ -82,8 +85,7 @@ public VoteStatsResponse getVoteStats(Long battleId) {
: 0.0;
return new VoteStatsResponse.OptionStat(
option.getId(),
option.getLabel().name(),
option.getTitle(),
urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()),
count,
ratio
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.swyp.picke.domain.oauth.client.GoogleOAuthClient;
import com.swyp.picke.domain.oauth.client.KakaoOAuthClient;
import com.swyp.picke.domain.oauth.client.AppleOAuthClient;
import com.swyp.picke.domain.oauth.dto.LoginRequest;
import com.swyp.picke.domain.oauth.dto.LoginResponse;
import com.swyp.picke.domain.oauth.dto.OAuthUserInfo;
Expand Down Expand Up @@ -41,6 +42,7 @@ class OAuthServiceTest {

@Mock private KakaoOAuthClient kakaoOAuthClient;
@Mock private GoogleOAuthClient googleOAuthClient;
@Mock private AppleOAuthClient appleOAuthClient;
@Mock private UserRepository userRepository;
@Mock private UserSocialAccountRepository socialAccountRepository;
@Mock private AuthRefreshTokenRepository refreshTokenRepository;
Expand All @@ -57,7 +59,7 @@ class OAuthServiceTest {
void setUp() {
// 수동 주입으로 안정성 확보
authService = new AuthService(
kakaoOAuthClient, googleOAuthClient, userRepository,
kakaoOAuthClient, googleOAuthClient, appleOAuthClient, userRepository,
socialAccountRepository, refreshTokenRepository,
userProfileRepository, userSettingsRepository, userTendencyScoreRepository,
userWithdrawalRepository,
Expand All @@ -69,7 +71,7 @@ void setUp() {
void login_카카오_기존유저_로그인_성공() {
// 1. 준비 (Given)
String provider = "KAKAO";
LoginRequest request = new LoginRequest("auth-code", "redirect-uri");
LoginRequest request = new LoginRequest("auth-code", "redirect-uri", null);
OAuthUserInfo userInfo = new OAuthUserInfo("kakao_123", "bex@test.com", "profile_url");

// 유저 엔티티에 ID가 없으므로 식별자 필드만 세팅 (UserTag 등)
Expand Down Expand Up @@ -104,7 +106,7 @@ void setUp() {
@Test
void login_구글_신규유저_기본_user_domain_초기화() {
String provider = "GOOGLE";
LoginRequest request = new LoginRequest("auth-code", "redirect-uri");
LoginRequest request = new LoginRequest("auth-code", "redirect-uri", null);
OAuthUserInfo userInfo = new OAuthUserInfo("google_123", "new@test.com", "profile_url");

User savedUser = User.builder()
Expand Down
Loading