Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1389fd8
feat: Stamp 엔티티 생성
1winhyun Jan 13, 2026
17baa9a
feat: StampRepository 구현
1winhyun Jan 13, 2026
d38cf0f
refactor: Review 생성 Response DTO에 해당 유저의 스탬프 수 추가
1winhyun Jan 13, 2026
bf68938
refactor: getCreateResult 메서드 해당 유저의 스탬프 개수 반환 로직 추가
1winhyun Jan 13, 2026
7f722b8
feat: 스탬프 부여 로직 구현 및 리뷰 작성 로직에 이를 추가
1winhyun Jan 13, 2026
d6cf754
feat: User 엔티티에 isRewardNeeded 컬럼 추가 및 스탬프 10개 획득 시 해당 컬럼을 true로 바꾸도록 구현
1winhyun Jan 13, 2026
d83fc38
refactor: RewardNeeded 컬럼명 수정
1winhyun Jan 13, 2026
fd1aabe
refactor: rewardNeeded 컬럼명 수정
1winhyun Jan 13, 2026
f3e06ca
feat: 관리자 전용 스탬프 보상이 필요한 유저 목록 조회 기능 구현
1winhyun Jan 13, 2026
ec0594c
feat: 관리자 전용 스탬프 보상 제공 기능 구현
1winhyun Jan 13, 2026
4e4e68f
feat: 사용자의 스탬프 보상 조회 기능 구현
1winhyun Jan 13, 2026
ca51498
feat: 사용자의 스탬프 개수 및 스탬프 받은 리뷰 조회 기능 구현
1winhyun Jan 13, 2026
315da79
feat: rewardNeeded가 바뀌는 조건문 수정
1winhyun Jan 13, 2026
d94d70c
refactor: grantRewardToUser 누락된 @RequestBody 어노테이션 추가
1winhyun Jan 14, 2026
2f1e8e8
refactor: 필드명 오류 camelCase 적용
1winhyun Jan 14, 2026
599a53a
refactor: getStampRewardNeededUserList N+1 문제 해결
1winhyun Jan 14, 2026
6dd074c
refactor: 스탬프 보상이 필요한 유저 목록 조회 기능 api 앤드포인트 수정
1winhyun Jan 14, 2026
ea9f0d0
refactor: 미사용 의존성, 메서드 제거
1winhyun Jan 14, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.campus.campus.domain.manager.application.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

public record RewardRequest(
@NotBlank
@Schema(description = "보상으로 지급 이미지 url", example = "https://www.example.com.png")
String rewardImageUrl
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.campus.campus.domain.manager.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

public record StampRewardNeededUserListResponse(
@Schema(description = "보상 지급이 필요한 사용자 ID", example = "1")
Long userId,

@Schema(description = "보상 지급이 필요한 사용자 이름", example = "한승현")
String nickname,

@Schema(description = "사용자의 스탬프 수", example = "10")
int stampCount,

@Schema(description = "사용자가 보상이 필요한지 여부", example = "true")
boolean rewardNeeded
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
import org.springframework.stereotype.Component;

import com.campus.campus.domain.council.domain.entity.StudentCouncil;
import com.campus.campus.domain.manager.application.dto.request.RewardRequest;
import com.campus.campus.domain.manager.application.dto.response.CertifyRequestCouncilResponse;
import com.campus.campus.domain.manager.application.dto.response.CouncilApproveOrDenyResponse;
import com.campus.campus.domain.manager.application.dto.response.CertifyRequestCouncilListResponse;
import com.campus.campus.domain.manager.application.dto.response.ManagerLoginResponse;
import com.campus.campus.domain.manager.application.dto.response.StampRewardNeededUserListResponse;
import com.campus.campus.domain.manager.domain.entity.Manager;
import com.campus.campus.domain.stamp.domain.entity.Reward;
import com.campus.campus.domain.user.domain.entity.User;

import lombok.RequiredArgsConstructor;

Expand Down Expand Up @@ -45,4 +49,24 @@ public CertifyRequestCouncilResponse toCertifyRequestCouncilResponse(StudentCoun
studentCouncil.getElectionImageUrl()
);
}

public StampRewardNeededUserListResponse toStampRewardNeededUserListResponse(User user, int stampCount) {
String nickname = user.getCampusNickname();
if (nickname == null || nickname.isBlank()) {
nickname = user.getNickname();
}
return new StampRewardNeededUserListResponse(
user.getId(),
nickname,
stampCount,
user.isRewardNeeded()
);
}

public Reward createReward(User user, RewardRequest rewardRequest) {
return Reward.builder()
.user(user)
.rewardImageUrl(rewardRequest.rewardImageUrl())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@
import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository;
import com.campus.campus.domain.manager.application.dto.request.CouncilApproveOrDenyRequest;
import com.campus.campus.domain.manager.application.dto.request.ManagerLoginRequest;
import com.campus.campus.domain.manager.application.dto.request.RewardRequest;
import com.campus.campus.domain.manager.application.dto.response.CertifyRequestCouncilResponse;
import com.campus.campus.domain.manager.application.dto.response.CouncilApproveOrDenyResponse;
import com.campus.campus.domain.manager.application.dto.response.CertifyRequestCouncilListResponse;
import com.campus.campus.domain.manager.application.dto.response.ManagerLoginResponse;
import com.campus.campus.domain.manager.application.dto.response.StampRewardNeededUserListResponse;
import com.campus.campus.domain.manager.application.exception.ManagerNotFoundException;
import com.campus.campus.domain.manager.application.exception.PasswordNotCorrectException;
import com.campus.campus.domain.manager.application.mapper.ManagerMapper;
import com.campus.campus.domain.manager.domain.entity.Manager;
import com.campus.campus.domain.manager.domain.repository.ManagerRepository;
import com.campus.campus.domain.stamp.domain.entity.Reward;
import com.campus.campus.domain.stamp.domain.repository.RewardRepository;
import com.campus.campus.domain.stamp.domain.repository.StampRepository;
import com.campus.campus.domain.user.application.exception.UserNotFoundException;
import com.campus.campus.domain.user.domain.entity.User;
import com.campus.campus.domain.user.domain.repository.UserRepository;
import com.campus.campus.global.util.jwt.JwtProvider;
import com.campus.campus.global.util.jwt.application.service.RedisTokenService;

Expand All @@ -34,6 +42,9 @@
public class ManagerService {
private final StudentCouncilRepository studentCouncilRepository;
private final ManagerRepository managerRepository;
private final UserRepository userRepository;
private final StampRepository stampRepository;
private final RewardRepository rewardRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final RedisTokenService redisTokenService;
Expand Down Expand Up @@ -96,6 +107,31 @@ public CertifyRequestCouncilResponse getCertifyRequestCouncil(Long councilId) {
return managerMapper.toCertifyRequestCouncilResponse(studentCouncil);
}

public List<StampRewardNeededUserListResponse> getStampRewardNeededUserList() {
List<Object[]> rewardNeededUsers = userRepository.findRewardNeededUsersWithStampCount();

return rewardNeededUsers.stream()
.map(rewardNeededUser -> {
User user = (User)rewardNeededUser[0];
Long count = (Long)rewardNeededUser[1];

return managerMapper.toStampRewardNeededUserListResponse(user, count.intValue());
}
).toList();
}

@Transactional
public void grantRewardToUser(Long userId, RewardRequest rewardRequest) {
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(UserNotFoundException::new);

Reward reward = managerMapper.createReward(user, rewardRequest);

rewardRepository.save(reward);

user.updateRewardNeeded(false);
}
Comment on lines +121 to +131
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

보상 지급 전 rewardNeeded 상태 검증이 필요합니다.

현재 구현은 rewardNeededfalse인 사용자에게도 보상을 지급할 수 있습니다. 비즈니스 로직상 보상이 필요한 사용자(rewardNeeded = true)에게만 보상을 지급해야 한다면, 검증 로직을 추가하거나 레포지토리 쿼리에서 조건을 추가하는 것이 좋습니다.

🛠️ 권장 수정 예시
 `@Transactional`
 public void grantRewardToUser(Long userId, RewardRequest rewardRequest) {
     User user = userRepository.findByIdAndDeletedAtIsNull(userId)
         .orElseThrow(UserNotFoundException::new);

+    if (!user.isRewardNeeded()) {
+        throw new IllegalStateException("해당 사용자는 보상 대상이 아닙니다.");
+    }
+
     Reward reward = managerMapper.createReward(user, rewardRequest);

     rewardRepository.save(reward);

     user.updateRewardNeeded(false);
 }
🤖 Prompt for AI Agents
In
`@src/main/java/com/campus/campus/domain/manager/application/service/ManagerService.java`
around lines 121 - 131, The grantRewardToUser method currently grants rewards
regardless of the user's rewardNeeded state; update it to validate that the user
actually needs a reward before creating/saving one by either (A) changing the
retrieval to
userRepository.findByIdAndDeletedAtIsNullAndRewardNeededTrue(userId) and
throwing UserNotFoundException or a new BusinessException if absent, or (B)
after fetching the User via userRepository.findByIdAndDeletedAtIsNull(userId),
check user.isRewardNeeded() (or getRewardNeeded()) and throw a suitable
exception if false; only then call managerMapper.createReward(...),
rewardRepository.save(reward) and user.updateRewardNeeded(false).


private void sendCouncilApprovedMail(String to) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@

import com.campus.campus.domain.manager.application.dto.request.CouncilApproveOrDenyRequest;
import com.campus.campus.domain.manager.application.dto.request.ManagerLoginRequest;
import com.campus.campus.domain.manager.application.dto.request.RewardRequest;
import com.campus.campus.domain.manager.application.dto.response.CertifyRequestCouncilResponse;
import com.campus.campus.domain.manager.application.dto.response.CouncilApproveOrDenyResponse;
import com.campus.campus.domain.manager.application.dto.response.CertifyRequestCouncilListResponse;
import com.campus.campus.domain.manager.application.dto.response.ManagerLoginResponse;
import com.campus.campus.domain.manager.application.dto.response.StampRewardNeededUserListResponse;
import com.campus.campus.domain.manager.application.service.ManagerService;
import com.campus.campus.global.common.response.CommonResponse;

Expand Down Expand Up @@ -68,4 +70,25 @@ public CommonResponse<List<CertifyRequestCouncilListResponse>> getCertifyRequest

return CommonResponse.success(ManagerResponseCode.CERTIFY_REQUEST_LIST_SUCCESS, responses);
}

@GetMapping("/rewards/necessary-users")
@PreAuthorize("hasRole('MANAGER')")
@Operation(summary = "스탬프 보상이 필요한 유저 목록 조회")
public CommonResponse<List<StampRewardNeededUserListResponse>> getStampRewardNeededUserList() {
List<StampRewardNeededUserListResponse> responses = managerService.getStampRewardNeededUserList();

return CommonResponse.success(ManagerResponseCode.REWARD_NEEDED_USER_LIST_SUCCESS, responses);
}

@PostMapping("reward/grant/{userId}")
@PreAuthorize("hasRole('MANAGER')")
@Operation(summary = "스탬프 보상 지급")
public CommonResponse<Void> grantRewardToUser(
@PathVariable Long userId,
@Valid @RequestBody RewardRequest rewardRequest
) {
managerService.grantRewardToUser(userId, rewardRequest);

return CommonResponse.success(ManagerResponseCode.GRANT_REWARD_SUCCESS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public enum ManagerResponseCode implements ResponseCodeInterface {
MANAGER_LOGIN_SUCCESS(200, HttpStatus.OK, "관리자 로그인에 성공했습니다."),
COUNCIL_APPROVE_OR_DENY_SUCCESS(200, HttpStatus.OK, "학생회 승인 혹은 거부 및 메일 전송에 성공했습니다."),
CERTIFY_REQUEST_LIST_SUCCESS(200, HttpStatus.OK, "학생회 인증 요청 목록 조회에 성공했습니다."),
CERTIFY_REQUEST_ELECTION_IMAGE_SUCCESS(200, HttpStatus.OK, "해당 학생회 인증 요청 당선 사진 조회에 성공했습니다.");
CERTIFY_REQUEST_ELECTION_IMAGE_SUCCESS(200, HttpStatus.OK, "해당 학생회 인증 요청 당선 사진 조회에 성공했습니다."),
REWARD_NEEDED_USER_LIST_SUCCESS(200, HttpStatus.OK, "스탬프 보상이 필요한 유저 목록 조회에 성공했습니다."),
GRANT_REWARD_SUCCESS(200, HttpStatus.OK, "스탬프 보상 지급에 성공했습니다.");

private final int code;
private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
@Builder
public record ReviewCreateResult(
boolean isFirstReviewOfPlace,
int userReviewCountOfPlace
// int NumberOfUserStamp,
int userReviewCountOfPlace,
int numberOfUserStamp
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,12 @@ public CursorPageReviewResponse<ReviewResponse> toCursorReviewResponse(List<Revi
.build();
}

public ReviewCreateResult toReviewCreateResult(boolean isFirstReviewOfPlace, long userReviewCountOfPlace) {
public ReviewCreateResult toReviewCreateResult(boolean isFirstReviewOfPlace, long userReviewCountOfPlace,
int numberOfUserStamp) {
return ReviewCreateResult.builder()
.isFirstReviewOfPlace(isFirstReviewOfPlace)
.userReviewCountOfPlace((int)userReviewCountOfPlace)
//스탬프 추가 예정
.numberOfUserStamp(numberOfUserStamp)
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
import com.campus.campus.domain.school.domain.entity.College;
import com.campus.campus.domain.school.domain.entity.Major;
import com.campus.campus.domain.school.domain.entity.School;
import com.campus.campus.domain.stamp.application.service.StampService;
import com.campus.campus.domain.stamp.domain.repository.StampRepository;
import com.campus.campus.domain.user.application.exception.UserNotFoundException;
import com.campus.campus.domain.user.domain.entity.User;
import com.campus.campus.domain.user.domain.repository.UserRepository;
Expand All @@ -55,6 +57,8 @@ public class ReviewService {
private final ReviewImageRepository reviewImageRepository;
private final PresignedUrlService presignedUrlService;
private final StudentCouncilPostRepository studentCouncilPostRepository;
private final StampService stampService;
private final StampRepository stampRepository;

@Transactional
public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) {
Expand All @@ -65,7 +69,6 @@ public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) {
throw new PostImageLimitExceededException();
}

//place 객체 생성
Place place = placeService.findOrCreatePlace(request.place());

Review review = reviewMapper.createReview(request, user, place);
Expand All @@ -77,6 +80,13 @@ public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) {
}
}

// isOcrVerificationSuccess는 ocr이 성공했다고 가정하고 구현했습니다. 이는 ocr을 구현하면서 수정해주시면 됩니다.
boolean isOcrVerificationSuccess = true;
if (isOcrVerificationSuccess) {
review.verify();
stampService.grantStampForReview(user, review);
}

String imageUrl =
(request.imageUrls() == null || request.imageUrls().isEmpty())
? null : request.imageUrls().getFirst();
Expand Down Expand Up @@ -274,14 +284,13 @@ private void cleanupUnusedImages(List<ReviewImage> oldImages, ReviewRequest requ
}

private ReviewCreateResult getCreateResult(Place place, User user) {
//해당 장소 리뷰 개수
long totalReviewCountOfPlace = reviewRepository.countByPlace_PlaceId(place.getPlaceId());

//해당 장소에서 유저가 쓴 리뷰가 몇번째인지
long count = reviewRepository.countByPlaceAndUser(place, user);
boolean isFirstReviewOfPlace = totalReviewCountOfPlace == 1;
int NumberOfStamp = stampRepository.countByUser(user);

return reviewMapper.toReviewCreateResult(isFirstReviewOfPlace, count);
return reviewMapper.toReviewCreateResult(isFirstReviewOfPlace, count, NumberOfStamp);

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ public class Review extends BaseEntity {
@JoinColumn(name = "place_id", nullable = false)
private Place place;

public void update(
String content,
Double star
) {
public void update(String content, Double star) {
this.content = content;
this.star = star;
}

public void verify() {
this.isVerified = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.campus.campus.domain.stamp.application.dto.response;

import java.time.LocalDateTime;

import io.swagger.v3.oas.annotations.media.Schema;

public record RewardResponse(
@Schema(description = "보상 id", example = "1")
Long rewardId,

@Schema(description = "보상 이미지 url", example = "https://www.example.com.png")
String rewardImageUrl,

@Schema(description = "지급일")
LocalDateTime rewardDate
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.campus.campus.domain.stamp.application.dto.response;

import java.util.List;

import io.swagger.v3.oas.annotations.media.Schema;

public record StampInfoResponse(
@Schema(description = "스탬프 개수", example = "3")
int stampCount,

@Schema(description = "스탬프에 해당하는 리뷰 목록")
List<StampReviewResponse> reviews
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.campus.campus.domain.stamp.application.dto.response;

import java.time.LocalDateTime;

import io.swagger.v3.oas.annotations.media.Schema;

public record StampReviewResponse(
@Schema(description = "리뷰 id", example = "1")
Long reviewId,

@Schema(description = "리뷰 장소 이름", example = "투썸 플레이스")
String placeName,

@Schema(description = "리뷰 작성 시간", example = "2024-01-10T18:00:00")
LocalDateTime reviewCreatedAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.campus.campus.domain.stamp.application.mapper;

import org.springframework.stereotype.Component;

import com.campus.campus.domain.review.domain.entity.Review;
import com.campus.campus.domain.stamp.application.dto.response.RewardResponse;
import com.campus.campus.domain.stamp.application.dto.response.StampReviewResponse;
import com.campus.campus.domain.stamp.domain.entity.Reward;
import com.campus.campus.domain.stamp.domain.entity.Stamp;
import com.campus.campus.domain.user.domain.entity.User;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class StampMapper {
public RewardResponse toRewardResponse(Reward reward) {
return new RewardResponse(
reward.getRewardId(),
reward.getRewardImageUrl(),
reward.getCreatedAt()
);
}

public StampReviewResponse toStampReviewResponse(Stamp stamp) {
Review review = stamp.getReview();

return new StampReviewResponse(
review.getId(),
review.getPlace().getPlaceName(),
review.getCreatedAt()
);
}

public Stamp createStamp(User user, Review review) {
return Stamp.builder()
.user(user)
.review(review)
.build();
}
}
Loading