Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public enum ErrorStatus {
ALREADY_EXIST_NICKNAME_EXCEPTION(HttpStatus.CONFLICT, "중복된 닉네임입니다."),
ALREADY_UPLOAD_COURSE_EXCEPTION(HttpStatus.CONFLICT, "이미 업로드된 코스입니다."),
ALREADY_EXIST_HEALTH_DATA_EXCEPTION(HttpStatus.CONFLICT, "이미 건강 데이터가 등록된 기록입니다"),
INSUFFICIENT_MARKER_QUOTA_EXCEPTION(HttpStatus.CONFLICT, "마커 수량이 부족합니다."),

/**
* 500 INTERNAL SERVER ERROR
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public enum SuccessStatus {
GET_HEALTH_DATA_SUCCESS(HttpStatus.OK, "건강 데이터 조회 성공"),
GET_HEALTH_SUMMARY_SUCCESS(HttpStatus.OK, "건강 통계 조회 성공"),
GET_BANNER_SUCCESS(HttpStatus.OK, "배너 조회 성공"),
GET_MARKER_QUOTA_SUCCESS(HttpStatus.OK, "마커 잔여 수량 조회 성공"),
CONSUME_MARKER_QUOTA_SUCCESS(HttpStatus.OK, "마커 소비 성공"),
GRANT_MARKER_REWARD_SUCCESS(HttpStatus.OK, "마커 리워드 지급 처리 성공"),


UPDATE_RECORD_SUCCESS(HttpStatus.OK, "활동 기록 수정 성공"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.runnect.server.markerquota.controller;

import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.runnect.server.common.constant.SuccessStatus;
import org.runnect.server.common.dto.ApiResponseDto;
import org.runnect.server.common.resolver.userId.UserId;
import org.runnect.server.markerquota.dto.request.ConsumeMarkerQuotaRequestDto;
import org.runnect.server.markerquota.dto.request.GrantMarkerRewardRequestDto;
import org.runnect.server.markerquota.dto.response.GrantMarkerRewardResponseDto;
import org.runnect.server.markerquota.dto.response.MarkerQuotaResponseDto;
import org.runnect.server.markerquota.service.MarkerQuotaService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/marker-quota")
public class MarkerQuotaController {

private final MarkerQuotaService markerQuotaService;

@GetMapping
@ResponseStatus(HttpStatus.OK)
public ApiResponseDto<MarkerQuotaResponseDto> getBalance(@UserId Long userId) {
return ApiResponseDto.success(SuccessStatus.GET_MARKER_QUOTA_SUCCESS,
MarkerQuotaResponseDto.of(markerQuotaService.getBalance(userId)));
}

@PostMapping("/consume")
@ResponseStatus(HttpStatus.OK)
public ApiResponseDto<MarkerQuotaResponseDto> consume(
@UserId Long userId,
@RequestBody @Valid ConsumeMarkerQuotaRequestDto request) {
return ApiResponseDto.success(SuccessStatus.CONSUME_MARKER_QUOTA_SUCCESS,
MarkerQuotaResponseDto.of(markerQuotaService.consume(userId, request.getAmount())));
}

@PostMapping("/reward")
@ResponseStatus(HttpStatus.OK)
public ApiResponseDto<GrantMarkerRewardResponseDto> grantFromAd(
@UserId Long userId,
@RequestBody @Valid GrantMarkerRewardRequestDto request) {
return ApiResponseDto.success(SuccessStatus.GRANT_MARKER_REWARD_SUCCESS,
markerQuotaService.grantFromAd(userId, request.getAmount(), request.getRewardTransactionId()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.runnect.server.markerquota.dto.request;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class ConsumeMarkerQuotaRequestDto {
@NotNull
@Min(1)
private Integer amount;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.runnect.server.markerquota.dto.request;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class GrantMarkerRewardRequestDto {
@NotNull
@Min(1)
private Integer amount;

/**
* 광고 SDK가 발급하는, 시청 1건당 고유한 식별자. 같은 값이 중복으로 들어와도
* MarkerRewardClaim의 유니크 제약으로 한 번만 지급된다.
*/
@NotBlank
private String rewardTransactionId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.runnect.server.markerquota.dto.response;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class GrantMarkerRewardResponseDto {
private boolean granted;
private int balance;

public static GrantMarkerRewardResponseDto of(boolean granted, int balance) {
return new GrantMarkerRewardResponseDto(granted, balance);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.runnect.server.markerquota.dto.response;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class MarkerQuotaResponseDto {
private int balance;

public static MarkerQuotaResponseDto of(int balance) {
return new MarkerQuotaResponseDto(balance);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package org.runnect.server.markerquota.entity;

import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import javax.persistence.Column;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.runnect.server.common.entity.AuditingTimeEntity;
import org.runnect.server.user.entity.RunnectUser;

/**
* 영상 광고 시청 보상으로 마커 재화를 지급받은 기록. (user_id, rewardTransactionId) 유니크
* 제약이 같은 리워드가 중복 지급되는 걸 막는 유일한 장치다 — 네트워크 재시도나 광고 SDK 콜백
* 중복으로 같은 시청 건에 대한 지급 요청이 여러 번 들어와도, 두 번째 이후 저장은 제약 위반으로
* 실패해 잔액이 두 번 늘어나지 않는다. 지급 성공 여부를 애플리케이션에서 먼저 조회해 판단하지
* 않는 이유는, 조회와 저장 사이에도 같은 레이스가 생길 수 있기 때문이다 — DB 유니크 제약만이
* 이 판단을 원자적으로 보장한다.
*/
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(uniqueConstraints = {
@UniqueConstraint(
columnNames = {"user_id", "reward_transaction_id"}
)
})
public class MarkerRewardClaim extends AuditingTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private RunnectUser runnectUser;

@Column(name = "reward_transaction_id", nullable = false, length = 100)
private String rewardTransactionId;

@Column(nullable = false)
private int grantedAmount;

@Builder
public MarkerRewardClaim(RunnectUser runnectUser, String rewardTransactionId, int grantedAmount) {
this.runnectUser = runnectUser;
this.rewardTransactionId = rewardTransactionId;
this.grantedAmount = grantedAmount;
}

@Override
public void updateDeletedAt() {
throw new RuntimeException("MarkerRewardClaim은 삭제되지 않는 지급 이력입니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.runnect.server.markerquota.repository;

import org.runnect.server.markerquota.entity.MarkerRewardClaim;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MarkerRewardClaimRepository extends JpaRepository<MarkerRewardClaim, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package org.runnect.server.markerquota.service;

import lombok.RequiredArgsConstructor;
import org.runnect.server.common.constant.ErrorStatus;
import org.runnect.server.common.exception.ConflictException;
import org.runnect.server.common.exception.NotFoundException;
import org.runnect.server.markerquota.dto.response.GrantMarkerRewardResponseDto;
import org.runnect.server.user.entity.RunnectUser;
import org.runnect.server.user.repository.UserRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class MarkerQuotaService {

private final UserRepository userRepository;
private final MarkerRewardGrantExecutor markerRewardGrantExecutor;

@Transactional(readOnly = true)
public int getBalance(Long userId) {
return findUser(userId).getMarkerQuotaBalance();
}

/**
* amount만큼 마커 재화를 소비한다(코스 저장 시점에 호출). 잔액 확인과 차감이 UserRepository의
* 조건부 UPDATE 한 문장으로 원자적으로 처리되므로, 같은 유저의 요청이 겹쳐도 잔액이 실제보다
* 잘못 계산될 여지가 없다. 갱신된 행이 없으면(잔액 부족) 409로 알린다.
*/
@Transactional
public int consume(Long userId, int amount) {
int updatedRows = userRepository.consumeMarkerQuota(userId, amount);
if (updatedRows == 0) {
throw new ConflictException(
ErrorStatus.INSUFFICIENT_MARKER_QUOTA_EXCEPTION,
ErrorStatus.INSUFFICIENT_MARKER_QUOTA_EXCEPTION.getMessage());
}
return findUser(userId).getMarkerQuotaBalance();
}

/**
* 광고 시청 보상을 지급한다. 같은 rewardTransactionId로 중복 요청이 들어오면
* MarkerRewardGrantExecutor의 유니크 제약 위반으로 두 번째부터는 조용히 무시(granted=false)된다.
*
* 이 메서드는 의도적으로 @Transactional을 붙이지 않는다 — 그래야 markerRewardGrantExecutor.grant()
* (REQUIRES_NEW)가 이 스레드의 유일한 트랜잭션으로 실행되고 끝나며, 그 뒤에 잔액 조회가 별도의
* 새 트랜잭션으로 열린다. 이 메서드에 @Transactional을 붙이면 그 트랜잭션의 커넥션을 쥔 채로
* REQUIRES_NEW 커넥션을 하나 더 요구하게 돼, 요청이 몰릴 때 커넥션 풀이 두 배 빠르게 고갈된다.
*/
public GrantMarkerRewardResponseDto grantFromAd(Long userId, int amount, String rewardTransactionId) {
boolean granted = true;
try {
markerRewardGrantExecutor.grant(userId, rewardTransactionId, amount);
} catch (DataIntegrityViolationException e) {
granted = false;
}
return GrantMarkerRewardResponseDto.of(granted, getBalance(userId));
}

private RunnectUser findUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException(
ErrorStatus.NOT_FOUND_USER_EXCEPTION, ErrorStatus.NOT_FOUND_USER_EXCEPTION.getMessage()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.runnect.server.markerquota.service;

import lombok.RequiredArgsConstructor;
import org.runnect.server.common.constant.ErrorStatus;
import org.runnect.server.common.exception.NotFoundException;
import org.runnect.server.markerquota.entity.MarkerRewardClaim;
import org.runnect.server.markerquota.repository.MarkerRewardClaimRepository;
import org.runnect.server.user.entity.RunnectUser;
import org.runnect.server.user.repository.UserRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

/**
* 같은 광고 리워드 요청이 재시도나 콜백 중복으로 짧은 시간에 두 번 들어오면, MarkerRewardClaim의
* (user_id, rewardTransactionId) 유니크 제약이 두 번째 저장을 거부한다. 이 저장은 REQUIRES_NEW로
* 호출부(MarkerQuotaService)와 분리된 트랜잭션에서 실행한다 — 제약 위반 예외를 여기서 삼키지
* 않고 그대로 던져야 이 트랜잭션이 정상적으로 롤백된다(SocialSignUpRegistrar와 동일한 이유 —
* Postgres는 실패한 문장 이후의 트랜잭션을 "aborted" 상태로 만들어, 캐치하고 계속 진행하면
* 커밋 시점에 UnexpectedRollbackException으로 다시 실패한다). 예외는 그대로 전파시켜
* 호출부(MarkerQuotaService.grantFromAd)에서 잡아 "이미 지급됨"으로 처리한다.
*
* 호출부는 이 메서드를 감싸는 별도의 @Transactional을 두지 않는다. 만약 호출부가 트랜잭션을 이미
* 열어둔 채로 이 REQUIRES_NEW를 호출하면, 그 스레드는 호출부의 커넥션과 이 메서드의 커넥션을
* 동시에 점유하게 된다 — 동시 요청이 많아지면 스레드마다 커넥션을 2개씩 필요로 하게 돼 커넥션
* 풀이 예상보다 훨씬 빨리 고갈될 수 있다(실제로 동시성 테스트에서 스레드 10개로 이 상태를
* 재현해 확인함). 그래서 userId만 받아 이 메서드 안에서 직접 조회하고, 호출부는 이 호출이 끝난
* 뒤에야 결과 조회용 트랜잭션을 새로 연다.
*/
@Component
@RequiredArgsConstructor
public class MarkerRewardGrantExecutor {

private final UserRepository userRepository;
private final MarkerRewardClaimRepository markerRewardClaimRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void grant(Long userId, String rewardTransactionId, int amount) {
RunnectUser runnectUser = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException(
ErrorStatus.NOT_FOUND_USER_EXCEPTION, ErrorStatus.NOT_FOUND_USER_EXCEPTION.getMessage()));

markerRewardClaimRepository.saveAndFlush(
MarkerRewardClaim.builder()
.runnectUser(runnectUser)
.rewardTransactionId(rewardTransactionId)
.grantedAmount(amount)
.build()
);
userRepository.grantMarkerQuota(userId, amount);
}
}
11 changes: 11 additions & 0 deletions src/main/java/org/runnect/server/user/entity/RunnectUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
})
public class RunnectUser extends AuditingTimeEntity {

private static final int DEFAULT_MARKER_QUOTA_BALANCE = 20;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down Expand Up @@ -68,6 +70,14 @@ public class RunnectUser extends AuditingTimeEntity {
@Column(nullable = false)
private Long createdScrap;

/**
* 코스를 그릴 때 찍을 수 있는 마커 재화. 소비(코스 저장)와 적립(광고 시청 보상)이 각각
* UserRepository의 조건부 UPDATE(consumeMarkerQuota/grantMarkerQuota) 한 쿼리로 원자적으로
* 처리되므로, 이 필드는 벌크 UPDATE를 통해서만 바뀌고 엔티티 세터를 거치지 않는다.
*/
@Column(nullable = false, columnDefinition = "integer default 20")
private int markerQuotaBalance;

/**
* 카운터 필드(createdCourse 등)를 다른 트랜잭션이 동시에 읽고 수정하면 Lost Update가
* 발생할 수 있어 낙관적 락을 건다. 충돌 시 UserStampService.recordActivityAndAwardStamp가
Expand Down Expand Up @@ -100,6 +110,7 @@ public RunnectUser(String nickname, String socialId, String email, SocialType pr
this.createdRecord = 0L;
this.createdPublicCourse = 0L;
this.createdScrap = 0L;
this.markerQuotaBalance = DEFAULT_MARKER_QUOTA_BALANCE;
}

public RunnectUser(String nickname) {
Expand Down
Loading
Loading