Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cd20d22
[#630] feat: 박수 알림 전송을 위한 이벤트, 이벤트 리스너 구현
huncozyboy Oct 20, 2025
2438386
[#630] feat: 알림 전송시 파트정보를 찾을 수 없을때 예외처리 추가
huncozyboy Oct 20, 2025
5030c7e
[#630] feat: 박수 관련 알림 전송 비즈니스 로직 구현
huncozyboy Oct 20, 2025
e1c26ae
[#630] feat: 미션 제목 조회 메서드 추가
huncozyboy Oct 20, 2025
92e28b7
[#630] feat: 알림 서버에 전달할 박수 요청 페이로드 구현
huncozyboy Oct 20, 2025
dc2580a
[#630] fix: EventPublisher 의존성 테스트 코드에 주입
huncozyboy Oct 20, 2025
32e36bf
Merge branch 'dev' into feat/#630
huncozyboy Oct 20, 2025
ba16ed5
[#630] feat: 박수 알림 전송을 위한 이벤트, 이벤트 리스너 구현
huncozyboy Oct 20, 2025
9a0f091
[#630] feat: 알림 전송시 파트정보를 찾을 수 없을때 예외처리 추가
huncozyboy Oct 20, 2025
0cd47d7
[#630] feat: 컨플릭트 해결
huncozyboy Oct 20, 2025
801988a
[#630] feat: 미션 제목 조회 메서드 추가
huncozyboy Oct 20, 2025
3d0ea82
[#630] feat: 알림 서버에 전달할 박수 요청 페이로드 구현
huncozyboy Oct 20, 2025
60b3155
[#630] fix: EventPublisher 의존성 테스트 코드에 주입
huncozyboy Oct 20, 2025
b576dfe
Merge remote-tracking branch 'origin/feat/#630' into feat/#630
huncozyboy Oct 20, 2025
a02e204
[#630] fix: 임계값(1, 100/500, 1000 단위)을 지나쳐도 알림이 전송되도록 개선
huncozyboy Oct 20, 2025
0e9270b
[#630] feat: 트랜잭션 import 스프링 표준으로 변경
huncozyboy Oct 20, 2025
941603e
[#630] feat: ClapMilestone 가드 추가
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
Expand Up @@ -5,6 +5,8 @@
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.sopt.app.common.exception.NotFoundException;
import org.sopt.app.common.response.ErrorCode;
import org.sopt.app.domain.entity.soptamp.Mission;
import org.sopt.app.domain.entity.soptamp.Stamp;
import org.sopt.app.interfaces.postgres.MissionRepository;
Expand Down Expand Up @@ -82,6 +84,13 @@ public List<Mission> getIncompleteMission(Long userId) {
return missionRepository.findMissionInOrderByLevelAndTitleAndDisplayTrue(inCompleteIdList);
}

@Transactional(readOnly = true)
public String getMissionTitleById(Long missionId) {
return missionRepository.findById(missionId)
.orElseThrow(() -> new NotFoundException(ErrorCode.MISSION_NOT_FOUND))
.getTitle();
}

public MissionInfo.Level getMissionById(Long missionId) {
val mission = missionRepository.findById(missionId).orElseThrow(
() -> new IllegalArgumentException("해당 미션을 찾을 수 없습니다.")
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/org/sopt/app/application/stamp/ClapEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.sopt.app.application.stamp;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.sopt.app.common.event.Event;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ClapEvent extends Event {

private final Long ownerUserId;
private final Long stampId;
private final int oldClapTotal;
private final int newClapTotal;

public static ClapEvent of(Long ownerUserId, Long stampId, int oldClapTotal, int newClapTotal) {
return new ClapEvent(ownerUserId, stampId, oldClapTotal, newClapTotal);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package org.sopt.app.application.stamp;

import org.sopt.app.interfaces.postgres.ClapMilestoneGuard;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.val;
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.soptamp.SoptampUserFinder;
import org.sopt.app.common.exception.NotFoundException;
import org.sopt.app.common.response.ErrorCode;
import org.sopt.app.common.utils.HttpHeadersUtils;
import org.sopt.app.presentation.poke.PokeResponse;
import org.sopt.app.presentation.stamp.ClapRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
public class ClapEventListener {

private final RestTemplate restTemplate = new RestTemplate();
private final HttpHeadersUtils headersUtils;

private final StampService stampService;
private final MissionService missionService;
private final PlatformService platformService;
private final SoptampUserFinder soptampUserFinder;

private final ClapMilestoneGuard clapMilestoneGuard;

@Value("${makers.push.server}")
private String baseURI;

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onClap(ClapEvent event) {
final int oldClapTotal = event.getOldClapTotal();
final int newClapTotal = event.getNewClapTotal();

Long missionId = stampService.getMissionIdByStampId(event.getStampId());
String missionTitle = missionService.getMissionTitleById(missionId);

val ownerProfile = platformService.getPlatformUserInfoResponse(event.getOwnerUserId());
String ownerName = ownerProfile.name();
String ownerPart = Optional.ofNullable(ownerProfile.getLatestActivity())
.map(PlatformUserInfoResponse.SoptActivities::part)
.orElseThrow(() -> new NotFoundException(ErrorCode.USER_PART_NOT_FOUND));
String nickname = soptampUserFinder.findById(event.getOwnerUserId()).getNickname();

if (crossed(oldClapTotal, newClapTotal, 1) && clapMilestoneGuard.tryMark(event.getStampId(), 1)) {
send(ClapRequest.ClapAlarmRequest.of(event.getOwnerUserId(), missionTitle, nickname));
}

if (crossed(oldClapTotal, newClapTotal, 100)
&& clapMilestoneGuard.tryMark(event.getStampId(), 100)) {
send(ClapRequest.ClapAlarmRequest.of(event.getOwnerUserId(), 100, missionTitle, ownerName, ownerPart, nickname));
} else if (crossed(oldClapTotal, newClapTotal, 500)
&& clapMilestoneGuard.tryMark(event.getStampId(), 500)) {
send(ClapRequest.ClapAlarmRequest.of(event.getOwnerUserId(), 500, missionTitle, ownerName, ownerPart, nickname));
}

// 한 번에 여러 구간(2000, 3000)을 넘어도 낮은 것만 처리
for (int k = 1000; k <= 10000; k += 1000) {
if (crossed(oldClapTotal, newClapTotal, k)
&& clapMilestoneGuard.tryMark(event.getStampId(), k)) {
send(ClapRequest.ClapAlarmRequest.of(k, missionTitle, nickname));
break;
}
}
}
Comment on lines +44 to +81
Copy link
Member

Choose a reason for hiding this comment

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

메세지 전송이 성공한 경우, early return 을 해줘도 좋을 것 같아요~


private boolean crossed(int oldTotal, int newTotal, int threshold) {
return oldTotal < threshold && newTotal >= threshold;
}

private void send(ClapRequest.ClapAlarmRequest body) {
val entity = new HttpEntity<>(body, headersUtils.createHeadersForSend());
restTemplate.exchange(
baseURI,
HttpMethod.POST,
entity,
PokeResponse.PokeAlarmStatusResponse.class
);
}
Comment on lines +87 to +95
Copy link
Member

Choose a reason for hiding this comment

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

알림 메세지의 응답 형식이 고정이면 공통적으로 사용되는 response를 만들어도 좋을 것 같네요 ~

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.Objects;

import java.util.Optional;
import org.sopt.app.common.event.EventPublisher;
import org.sopt.app.common.exception.BadRequestException;
import org.sopt.app.common.exception.ForbiddenException;
import org.sopt.app.common.exception.NotFoundException;
Expand All @@ -25,7 +26,7 @@
public class ClapService {

private static final int MAX_RETRY = 3;

private final EventPublisher eventPublisher;
private final ClapRepository clapRepository;
private final StampRepository stampRepository;

Expand All @@ -46,12 +47,16 @@ public int addClap(Long userId, Long stampId, int increment) {
if (Objects.equals(stamp.getUserId(), userId))
throw new ForbiddenException(ErrorCode.SELF_CLAP_FORBIDDEN);

final int oldClapTotal = stamp.getClapCount();

// 2) Clap upsert (낙관적 락 + 재시도). 실제 적용된 양(applied)을 계산
int applied = upsertUserClapWithRetry(userId, stampId, increment);

// 3) 총합 반영 (네이티브 RETURNING) — applied가 0이면 스킵
if (applied > 0) {
stampRepository.incrementClapCountReturning(stampId, applied);
final int newClapTotal = oldClapTotal + applied;
eventPublisher.raise(ClapEvent.of(stamp.getUserId(), stampId, oldClapTotal, newClapTotal));
}
return applied;
}
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 @@ -55,6 +55,7 @@ public enum ErrorCode {
DUPLICATE_NICKNAME("사용 중인 닉네임입니다.", HttpStatus.CONFLICT),
NICKNAME_IS_FULL("사용 가능한 닉네임이 없습니다.", HttpStatus.CONFLICT),
USER_GENERATION_INFO_NOT_FOUND("기수 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
USER_PART_NOT_FOUND("파트 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),

// MISSION
MISSION_NOT_FOUND("존재하지 않는 미션입니다.", HttpStatus.NOT_FOUND),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.sopt.app.interfaces.postgres;

import lombok.RequiredArgsConstructor;
import org.intellij.lang.annotations.Language;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;


@Component
@RequiredArgsConstructor
public class ClapMilestoneGuard {
private final JdbcTemplate jdbcTemplate;

@Language("PostgreSQL")
private static final String SQL = """

INSERT INTO clap_milestone_hit (stamp_id, milestone, created_at)
VALUES (?, ?, now())
ON CONFLICT (stamp_id, milestone) DO NOTHING
""";

@Transactional(propagation = Propagation.REQUIRES_NEW)
Copy link
Collaborator

Choose a reason for hiding this comment

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

ClapEventListener.onClap가 @transactional(REQUIRES_NEW) 이고,
ClapMilestoneGuard.tryMark도 @transactional(REQUIRES_NEW)라서 tryMark() 호출 때 또 다른 신규 트랜잭션을 열고, 리스너 트랜잭션을 잠시 중단하게 될텐데 딥링크 발송(send)에서 예외가 나도 마킹은 커밋돼서 재시도 로직 없으면 알림이 영영 재발송되지 않을 것 같아요.
마킹의 목적이 발송인 만큼 발송 성공 시에만 마킹을 커밋해야 발송 실패 시 롤백되어 다음 기회에 재시도 가능할 듯합니다!
그래서 마킹과 알림 발송을 같은 트랜잭션에서 처리할 수 있도록
ClapMilestoneGuard.tryMark는 기본 @transactional(REQUIRED)로 둬서 리스너 트랜잭션에 참여시키면 좋을 것 같아요~

public boolean tryMark(long stampId, int milestone) {
return jdbcTemplate.update(SQL, stampId, milestone) == 1;
}
}
73 changes: 73 additions & 0 deletions src/main/java/org/sopt/app/presentation/stamp/ClapRequest.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package org.sopt.app.presentation.stamp;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.sopt.app.domain.enums.NotificationCategory;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ClapRequest {
Expand All @@ -18,4 +22,73 @@ public static class AddClapRequest {
@Positive(message = "clapCount must be > 0")
private int clapCount;
}

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

@Schema(description = "유저 아이디", example = "[1]")
@NotNull
private List<String> userIds;

@Schema(description = "알림 제목") @NotNull private String title;
@Schema(description = "알림 내용") @NotNull private String content;
@Schema(description = "알림 카테고리") @NotNull private String category;
@Schema(description = "딥링크") private String deepLink;

public static ClapAlarmRequest of(Long ownerUserId, String missionTitle, String ownerNickname) {
return ClapAlarmRequest.builder()
.userIds(List.of(String.valueOf(ownerUserId)))
.title(String.format("첫 박수 도착! 💌 ‘%s’ 에 누군가가 박수를 쳤어요 👀", missionTitle))
.content("""
내 미션 사진에 누군가 첫 박수를 남겼어요. 짝짝짝짝! 👏

어떤 솝트인이 박수쳤는 지 확인할 수 있어요!

서로에게 응원의 박수를 보내며 소통해 보세요!
""")
.category(NotificationCategory.NEWS.name())
.deepLink(String.format("/api/v2/rank/detail?nickname=%s", ownerNickname))
.build();
}

public static ClapAlarmRequest of(Long ownerUserId, int targetClapCount, String missionTitle,
String ownerName, String ownerPart, String ownerNickname) {
return ClapAlarmRequest.builder()
.userIds(List.of(String.valueOf(ownerUserId)))
.title(String.format("축하해요! [%d]번째 박수를 받았어요 🎉", targetClapCount))
.content(String.format("""
[%s] [%s]님의 ‘%s’ 미션 사진이 %d번째 박수를 받았습니다. 짝짝짝짝! 👏

정말 대단해요! 앞으로도 계속해서 멋진 미션을 인증하고 파트/개인 랭킹을 올려보세요..

어떤 솝트인이 박수쳤는 지 확인할 수 있어요!

서로에게 응원의 박수를 보내며 소통해 보세요!
""", ownerPart, ownerName, missionTitle, targetClapCount))
.category(NotificationCategory.NEWS.name())
.deepLink(String.format("/api/v2/rank/detail?nickname=%s", ownerNickname))
.build();
}

public static ClapAlarmRequest of(int targetClapCount, String missionTitle, String ownerNickname) {
return ClapAlarmRequest.builder()
.userIds(List.of("ALL"))
.title(String.format("박수 누적 [%d]개 🎉 ‘%s’에 박수 갈채를 받고 있어요.", targetClapCount, missionTitle))
.content(String.format("""
미션 ‘%s’ 사진이 %d번째 박수를 받았습니다. 짝짝짝짝! 👏

정말 대단해요! 앞으로도 계속해서 멋진 미션을 인증하고 파트/개인 랭킹을 올려보세요..

어떤 솝트인이 박수쳤는 지 확인할 수 있어요!

서로에게 응원의 박수를 보내며 소통해 보세요!
""", missionTitle, targetClapCount))
.category(NotificationCategory.NEWS.name())
.deepLink(String.format("/api/v2/rank/detail?nickname=%s", ownerNickname))
.build();
}
}
}
2 changes: 2 additions & 0 deletions src/test/java/org/sopt/app/application/ClapServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.sopt.app.application.stamp.ClapService;
import org.sopt.app.common.event.EventPublisher;
import org.sopt.app.common.exception.BadRequestException;
import org.sopt.app.common.exception.ForbiddenException;
import org.sopt.app.common.exception.NotFoundException;
Expand All @@ -26,6 +27,7 @@ class ClapServiceTest {
@Mock ClapRepository clapRepository;
@Mock StampRepository stampRepository;
@InjectMocks ClapService clapService;
@Mock EventPublisher eventPublisher;

@Test
void addClap_success_applies_fully_and_increments_stamp_total() {
Expand Down