Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5094dcd
[#624] feat: 페이지네이션 적용 ClapUser 리스트 응답 DTO와 매퍼 추가
huncozyboy Oct 18, 2025
bc4ae35
[#624] feat: 본인의 솝탬프 완료 미션에 대해서만 리스트 조회되도록 예외처리 추가
huncozyboy Oct 18, 2025
9669731
[#624] feat: 박수친 유저들 닉네임 + 솝마디 조회 및 매핑 반환
huncozyboy Oct 18, 2025
ed05094
[#624] feat: 박수친 유저들 프로필 정보 조회 메서드 구현
huncozyboy Oct 18, 2025
be760a4
[#624] feat: 박수 친 유저 목록 조회 파사드 구현
huncozyboy Oct 18, 2025
4017bc2
[#624] feat: 박수 친 유저 목록 조회 컨트롤러 구현
huncozyboy Oct 18, 2025
06171b2
Merge branch 'dev' into feat/#624
huncozyboy Oct 20, 2025
cf8f8a2
[#624] feat: 페이지네이션 적용 ClapUser 리스트 응답 DTO와 매퍼 추가
huncozyboy Oct 18, 2025
6938afe
[#624] feat: 본인의 솝탬프 완료 미션에 대해서만 리스트 조회되도록 예외처리 추가
huncozyboy Oct 18, 2025
be60bcb
[#624] feat: 박수친 유저들 닉네임 + 솝마디 조회 및 매핑 반환
huncozyboy Oct 18, 2025
22f249a
[#624] feat: 박수친 유저들 프로필 정보 조회 메서드 구현
huncozyboy Oct 18, 2025
ab8380a
[#624] feat: 컨플릭트 해결
huncozyboy Oct 18, 2025
4743664
[#624] feat: 박수 친 유저 목록 조회 컨트롤러 구현
huncozyboy Oct 18, 2025
2dbd1d0
Merge remote-tracking branch 'origin/feat/#624' into feat/#624
huncozyboy Oct 20, 2025
50823c7
[#629] feat: 명시적인 변수명으로 수정
huncozyboy Oct 20, 2025
65c7e77
[#629] feat: 권한체크 메서드 분리
huncozyboy Oct 20, 2025
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package org.sopt.app.application.soptamp;

import static java.util.function.UnaryOperator.identity;

import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.sopt.app.common.exception.BadRequestException;
import org.sopt.app.common.response.ErrorCode;
Expand All @@ -9,6 +12,7 @@
import org.sopt.app.interfaces.postgres.SoptampUserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -44,4 +48,17 @@ public SoptampUserInfo findById(Long userId) {
.orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND));
return SoptampUserInfo.of(soptampUser);
}

@Transactional(readOnly = true)
public Map<Long, SoptampUserInfo> findUserInfosByIdsAsMap(List<Long> userIds) {

return soptampUserRepository.findAllByUserIdIn(userIds).stream()
.map(SoptampUserInfo::of)
.collect(java.util.stream.Collectors.toMap(
SoptampUserInfo::getUserId,
identity(),
(a, b) -> a,
java.util.LinkedHashMap::new
));
}
}
8 changes: 8 additions & 0 deletions src/main/java/org/sopt/app/application/stamp/ClapService.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import org.sopt.app.interfaces.postgres.ClapRepository;
import org.sopt.app.interfaces.postgres.StampRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -29,6 +31,12 @@ public class ClapService {
private final ClapRepository clapRepository;
private final StampRepository stampRepository;

@Transactional(readOnly = true)
public Page<Clap> getClapsOfMyStamp(Long stampId, Pageable pageable) {

return clapRepository.findAllByStampIdOrderByClapCountDescUpdatedAtDesc(stampId, pageable);
}

/**
* 사용자(userId)가 스탬프(stampId)에 increment만큼 박수를 친다.
* - 자기 글이면 금지
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/org/sopt/app/application/stamp/StampService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Collection;
import java.util.List;
import jakarta.validation.Valid;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
Expand Down Expand Up @@ -148,6 +149,15 @@ public void checkDuplicateStamp(Long userId, Long missionId) {
}
}

@Transactional(readOnly = true)
public void checkOwnedStamp(Long stampId, Long userId) {
var stamp = stampRepository.findById(stampId)
.orElseThrow(() -> new NotFoundException(ErrorCode.STAMP_NOT_FOUND));
if (!Objects.equals(stamp.getUserId(), userId)) {
throw new ForbiddenException(ErrorCode.CLAP_LIST_FORBIDDEN);
}
}

@Transactional
public void deleteStampById(Long stampId) {

Expand Down
1 change: 1 addition & 0 deletions src/main/java/org/sopt/app/common/response/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public enum ErrorCode {
// CLAP
SELF_CLAP_FORBIDDEN("타인의 스탬프에만 박수 칠 수 있습니다.", HttpStatus.FORBIDDEN),
INVALID_CLAP_COUNT("잘못된 박수 횟수입니다.", HttpStatus.BAD_REQUEST),
CLAP_LIST_FORBIDDEN("내 미션에서만 박수 목록을 조회할 수 있습니다.", HttpStatus.FORBIDDEN),

// NOTIFICATION
NOTIFICATION_NOT_FOUND("존재하지 않는 알림입니다.", HttpStatus.NOT_FOUND),
Expand Down
33 changes: 31 additions & 2 deletions src/main/java/org/sopt/app/facade/SoptampFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,27 @@
import lombok.val;
import org.sopt.app.application.mission.MissionInfo.Level;
import org.sopt.app.application.mission.MissionService;
import org.sopt.app.application.platform.PlatformService;
import org.sopt.app.application.platform.dto.PlatformUserInfoResponse;
import org.sopt.app.application.platform.PlatformService;
import org.sopt.app.application.soptamp.*;
import org.sopt.app.application.stamp.ClapService;
import org.sopt.app.application.stamp.StampInfo;
import org.sopt.app.application.stamp.StampInfo.Stamp;
import org.sopt.app.application.stamp.StampInfo.StampView;
import org.sopt.app.application.stamp.StampInfo;
import org.sopt.app.application.stamp.StampService;
import org.sopt.app.domain.entity.soptamp.Clap;
import org.sopt.app.domain.entity.soptamp.Mission;
import org.sopt.app.presentation.rank.*;
import org.sopt.app.presentation.stamp.ClapResponse;
import org.sopt.app.presentation.stamp.StampRequest;
import org.sopt.app.presentation.stamp.StampRequest.RegisterStampRequest;
import org.sopt.app.presentation.stamp.StampResponse;
import org.sopt.app.presentation.stamp.StampRequest.RegisterStampRequest;
import org.sopt.app.presentation.stamp.StampResponse.SoptampReportResponse;
import org.sopt.app.presentation.stamp.StampResponseMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -33,6 +40,7 @@ public class SoptampFacade {
private final StampService stampService;
private final MissionService missionService;
private final SoptampUserService soptampUserService;
private final PlatformService platformService;
private final SoptampUserFinder soptampUserFinder;
private final ClapService clapService;

Expand Down Expand Up @@ -84,7 +92,28 @@ public StampInfo.StampView getStampInfo(Long requestUserId, Long missionId, Stri
stampService.increaseViewCountById(stamp.getId());

return StampInfo.StampView.of(
stamp, requestUserClapCount, Objects.equals(requestUserId, soptampUserId));
stamp, requestUserClapCount, Objects.equals(requestUserId, soptampUserId));
}

@Transactional(readOnly = true)
public ClapResponse.ClapUsersPage getClapUsersPage(Long userId, Long stampId, Pageable pageable) {
stampService.checkOwnedStamp(stampId, userId);

val page = clapService.getClapsOfMyStamp(stampId, pageable);
val userIds = page.getContent().stream()
.map(Clap::getUserId)
.distinct()
.toList();

val profiles = soptampUserFinder.findUserInfosByIdsAsMap(userIds);
val platformInfos = platformService.getPlatformUserInfosResponse(userIds);
val imageMap = platformInfos.stream()
.collect(java.util.stream.Collectors.toMap(
p -> (long) p.userId(),
PlatformUserInfoResponse::profileImage
));

return new ClapResponse.ClapUsersPage(page, profiles, imageMap);
}

public RankResponse.Detail findSoptampUserAndCompletedMissionByNickname(String nickname) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
import java.util.Optional;

import org.sopt.app.domain.entity.soptamp.Clap;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ClapRepository extends JpaRepository<Clap, Long> {

Optional<Clap> findByUserIdAndStampId(Long userId, Long stampId);

Page<Clap> findAllByStampIdOrderByClapCountDescUpdatedAtDesc(Long stampId, Pageable pageable);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CREATE INDEX IF NOT EXISTS idx_clap_stamp_sort
  ON clap (stamp_id, clap_count DESC, updated_at DESC);

정렬 자체는 좋지만 인덱스가 없으면 성능 이슈가 생길 수 있을 것 같아요! 리뷰처럼 db 인덱스 추가 하면 좋을 듯합니다~

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ public interface SoptampUserRepository extends JpaRepository<SoptampUser, Long>
boolean existsByNickname(String nickname);

void deleteByUserId(Long userId);

List<SoptampUser> findAllByUserIdIn(List<Long> userIds);
}
50 changes: 50 additions & 0 deletions src/main/java/org/sopt/app/presentation/stamp/ClapResponse.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package org.sopt.app.presentation.stamp;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Map;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.sopt.app.application.soptamp.SoptampUserInfo;
import org.sopt.app.domain.entity.soptamp.Clap;
import org.springframework.data.domain.Page;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ClapResponse {
Expand All @@ -22,4 +27,49 @@ public static class AddClapResponse {
@Schema(description = "스탬프 총 박수 합계(상한 없음)", example = "203")
private int totalClapCount;
}

@Getter
@AllArgsConstructor(access = AccessLevel.PUBLIC)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class ClapUsersPage {
private Page<Clap> page;
private Map<Long, SoptampUserInfo> profiles;
private Map<Long, String> imageMap;
}

@Getter
@AllArgsConstructor(access = AccessLevel.PUBLIC)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class ClapUserList {

@Schema(description = "박수 친 유저 목록")
private List<ClapUserProfile> users;

@Schema(description = "총 페이지 수", example = "5")
private int totalPageSize;

@Schema(description = "현재 페이지 크기", example = "20")
private Integer pageSize;

@Schema(description = "현재 페이지 번호(0부터 시작)", example = "0")
private Integer pageNum;
}

@Getter
@AllArgsConstructor(access = AccessLevel.PUBLIC)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class ClapUserProfile {

@Schema(description = "닉네임", example = "서버이지훈")
private String nickname;

@Schema(description = "프로필 이미지 URL", example = "https://cdn.sopt.org/profile/1024.jpg")
private String profileImageUrl;

@Schema(description = "프로필 한마디", example = "뒹굴뒹굴 ~,~")
private String profileMessage;

@Schema(description = "해당 스탬프에 박수친 횟수 (0~50)", example = "12")
private int clapCount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.val;
import org.sopt.app.domain.entity.User;
import org.sopt.app.facade.SoptampFacade;
import org.sopt.app.presentation.stamp.StampResponse.SoptampReportResponse;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -117,6 +118,23 @@ public ResponseEntity<ClapResponse.AddClapResponse> addClap(
return ResponseEntity.ok(response);
}

@Operation(summary = "박수 친 유저 목록 조회 (본인 미션)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "success"),
@ApiResponse(responseCode = "500", description = "server error", content = @Content)
})
@GetMapping("/{stampId}/clappers")
public ResponseEntity<ClapResponse.ClapUserList> getClappersByStampId(
@AuthenticationPrincipal Long userId,
@PathVariable Long stampId,
@PageableDefault(size = 25) Pageable pageable
) {
val page = soptampFacade.getClapUsersPage(userId, stampId, pageable);
val response = stampResponseMapper.of(page.getPage(), page.getProfiles(), page.getImageMap());

return ResponseEntity.ok(response);
}

@Operation(summary = "솝탬프 신고 URL 조회하기")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "success", content = @Content),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package org.sopt.app.presentation.stamp;

import java.util.List;
import java.util.Map;
import org.mapstruct.InjectionStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
import org.sopt.app.application.soptamp.SoptampUserInfo;
import org.sopt.app.application.stamp.StampInfo;
import org.sopt.app.domain.entity.soptamp.Clap;
import org.springframework.data.domain.Page;

@Mapper(
componentModel = "spring",
Expand Down Expand Up @@ -35,4 +40,26 @@ default StampResponse.StampView from(StampInfo.StampView stampView) {
default ClapResponse.AddClapResponse of(Long stampId, int appliedCount, int totalClapCount) {
return new ClapResponse.AddClapResponse(stampId, appliedCount, totalClapCount);
}

default ClapResponse.ClapUserList of(Page<Clap> page, Map<Long, SoptampUserInfo> profileMap, Map<Long, String> imageMap) {
List<ClapResponse.ClapUserProfile> users = page.getContent().stream()
.map(clap -> {
var soptampUserInfo = profileMap.get(clap.getUserId());

return new ClapResponse.ClapUserProfile(
soptampUserInfo.getNickname(),
imageMap.getOrDefault(clap.getUserId(), ""),
soptampUserInfo.getProfileMessage(),
clap.getClapCount()
);
})
.toList();

return new ClapResponse.ClapUserList(
users,
page.getTotalPages(),
page.getSize(),
page.getNumber()
);
}
}