Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -47,8 +47,8 @@ public void buyAiImagePermissionByCoin(Long userId, Long postId) {
// 2) 권한 업서트 + 증가 (원자적)
aiChatRoomRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);

// 3) 코인 후차감, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파
accountService.consumeCoin(userId, post.getNonCommercialPrice());
// 3) 코인을 원작자에게 전송, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파
accountService.transferCoin(userId, post.getUserId(), post.getNonCommercialPrice());

// 4) 알림
UserEntity user = userEntityRepository.findById(userId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.domain.follow.repository.FollowEntityRepository;
import hanium.modic.backend.domain.post.repository.PostEntityRepository;
import hanium.modic.backend.domain.transaction.entity.Account;
import hanium.modic.backend.domain.transaction.repository.AccountRepository;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.entity.UserImageEntity;
Expand Down Expand Up @@ -48,7 +49,7 @@ public GetMyProfileResponse getMyProfile(final UserEntity user) {
.map(UserImageEntity::getId);
final boolean hasUserImage = userImageUrl.isPresent();
final long coinAmount = accountRepository.findById(user.getId())
.map(account -> account.getCoin())
.map(Account::getPostedBalance)
.orElseThrow(() -> new AppException(ACCOUNT_NOT_FOUND_EXCEPTION));

return new GetMyProfileResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -29,12 +28,12 @@ public class Account {
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;

@Column(name = "coin", nullable = false)
private Long coin = 0L;
// Ledger가 관리하는 version
@Column(name = "ledger_version", nullable = false)
private Long ledgerVersion = 0L;

@Version
@Column(name = "version", nullable = false)
private Long version = 0L;
@Column(name = "posted_balance", nullable = false)
private Long postedBalance = 0L;

/**
* 사용자 아이디로 계좌 생성
Expand All @@ -45,10 +44,13 @@ private Account(Long userId) {
this.userId = userId;
}

public void addCoin(Long coin) {
if (this.coin + coin < 0) {
// 잔액 업데이트
public void updateBalance(Long newBalance) {
if (newBalance < 0) {
throw new AppException(COIN_NOT_ENOUGH_EXCEPTION);
}
this.coin += coin;

this.postedBalance = newBalance;
this.ledgerVersion++; // Balance 변경 시마다 version 증가
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
/**
* Transaction 거래의 단위 거래(debit, credit)
*/
@Table(name = "coin_tranaction_entities")
@Table(name = "coin_transaction_entities")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package hanium.modic.backend.domain.transaction.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

import hanium.modic.backend.domain.transaction.entity.Account;

public interface AccountRepository extends JpaRepository<Account, Long> {

Optional<Account> findByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package hanium.modic.backend.domain.transaction.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import hanium.modic.backend.domain.transaction.entity.CoinTransactionEntity;

public interface CoinTransactionEntityRepository extends JpaRepository<CoinTransactionEntity, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package hanium.modic.backend.domain.transaction.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import hanium.modic.backend.domain.transaction.entity.CoinTransaction;

public interface CoinTransactionRepository extends JpaRepository<CoinTransaction, Long> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@

import static hanium.modic.backend.common.error.ErrorCode.*;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.common.error.exception.LockException;
import hanium.modic.backend.domain.notification.dto.NotificationPayload;
import hanium.modic.backend.domain.notification.enums.NotificationType;
import hanium.modic.backend.domain.notification.service.NotificationService;
import hanium.modic.backend.domain.transaction.entity.Account;
import hanium.modic.backend.domain.transaction.repository.AccountRepository;
import hanium.modic.backend.infra.redis.distributedLock.LockManager;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.repository.UserEntityRepository;
import hanium.modic.backend.web.transaction.dto.response.GetCoinBalanceResponse;
Expand All @@ -23,35 +20,27 @@
@RequiredArgsConstructor
public class AccountService {

// 계좌, 거래 관련
private final AccountRepository accountRepository;
private final LedgerService ledgerService;

private final LockManager lockManager;

// 알림 관련
private final NotificationService notificationService;

// 유저 관련
private final UserEntityRepository userEntityRepository;

// 코인 잔액 조회
public GetCoinBalanceResponse getCoinBalance(final Long userId) {
Account account = accountRepository.findById(userId)
.orElseThrow(() -> new AppException(ACCOUNT_NOT_FOUND_EXCEPTION));

return new GetCoinBalanceResponse(account.getCoin());
}

// 코인 충전
public void chargeCoin(final long userId, final long coin) {
try {
lockManager.accountLock(userId, () -> {
addCoin(userId, coin);
});
} catch (LockException e) {
// Todo: 추후 결제 포함될 시, 결제 취소 로직 필요
throw new AppException(COIN_TRANSFER_FAIL_EXCEPTION);
}
return new GetCoinBalanceResponse(account.getPostedBalance());
}

// 코인 양도
public void transferCoin(final long fromUserId, final long toUserId, long coin) throws AppException {
@Transactional
public void transferCoin(final long fromUserId, final long toUserId, long coin) {
// 자기 자신에게 양도 불가
if (fromUserId == toUserId) {
throw new AppException(COIN_TRANSFER_SAME_USER_EXCEPTION);
Expand All @@ -60,20 +49,9 @@ public void transferCoin(final long fromUserId, final long toUserId, long coin)
// 받는 사람 존재 확인
UserEntity toUser = userEntityRepository.findById(toUserId)
.orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION));
accountRepository.findById(toUserId)
.orElseThrow(() -> new AppException(ACCOUNT_NOT_FOUND_EXCEPTION));

// 락 걸고 양도 처리
try {
lockManager.multipleUserLock(List.of(fromUserId, toUserId), () -> {
// 출금
addCoin(fromUserId, -coin);
// 입금
addCoin(toUserId, coin);
});
} catch (LockException e) {
throw new AppException(COIN_TRANSFER_FAIL_EXCEPTION);
}
// 양도 처리
ledgerService.transfer(fromUserId, toUserId, coin);

// 알림
notificationService.createNotification(
Expand All @@ -84,24 +62,4 @@ public void transferCoin(final long fromUserId, final long toUserId, long coin)
.build()
);
}

// 코인 소비
public void consumeCoin(final long userId, long coin) throws AppException {
try {
lockManager.accountLock(userId, () -> {
addCoin(userId, -coin);
});
} catch (LockException e) {
throw new AppException(COIN_TRANSFER_FAIL_EXCEPTION);
}
}

// 코인 추가, 트랜잭션은 lockManager에 의해 관리됨
private void addCoin(final long userId, final long coin) {
Account account = accountRepository.findById(userId)
.orElseThrow(() -> new AppException(ACCOUNT_NOT_FOUND_EXCEPTION));

account.addCoin(coin);
accountRepository.save(account);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package hanium.modic.backend.domain.transaction.service;

import static hanium.modic.backend.common.error.ErrorCode.*;

import java.time.LocalDateTime;
import java.util.List;

import org.springframework.stereotype.Service;

import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.common.error.exception.LockException;
import hanium.modic.backend.domain.transaction.entity.Account;
import hanium.modic.backend.domain.transaction.entity.CoinTransaction;
import hanium.modic.backend.domain.transaction.entity.CoinTransactionEntity;
import hanium.modic.backend.domain.transaction.enums.TransactionDirection;
import hanium.modic.backend.domain.transaction.enums.TransactionStatus;
import hanium.modic.backend.domain.transaction.repository.AccountRepository;
import hanium.modic.backend.domain.transaction.repository.CoinTransactionEntityRepository;
import hanium.modic.backend.domain.transaction.repository.CoinTransactionRepository;
import hanium.modic.backend.infra.redis.distributedLock.LockManager;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class LedgerService {

// 계좌, 거래 관련
private final AccountRepository accountRepository;
private final CoinTransactionRepository coinTransactionRepository;
private final CoinTransactionEntityRepository entryRepository;

// 기타
private final LockManager lockManager;

// 코인 전송(게좌 변경, 트랜잭션, 엔티티 저장) 후 알림 처리
public void transfer(final long fromUserId, final long toUserId, final long amount) {
// 락 걸고 양도 처리
try {
lockManager.multipleAccountLock(List.of(fromUserId, toUserId), () -> {
// 1) 계좌 읽기
Account fromAccount = accountRepository.findByUserId(fromUserId)
.orElseThrow(() -> new AppException(ACCOUNT_NOT_FOUND_EXCEPTION));
Account toAccount = accountRepository.findByUserId(toUserId)
.orElseThrow(() -> new AppException(ACCOUNT_NOT_FOUND_EXCEPTION));

// 2) posted_balance 업데이트
fromAccount.updateBalance(fromAccount.getPostedBalance() - amount);
toAccount.updateBalance(toAccount.getPostedBalance() + amount);

// 3) 버전 캡처, 반드시 Account 업데이트 이후에 호출되어야 함
long fromVersionBefore = fromAccount.getLedgerVersion();
long toVersionBefore = toAccount.getLedgerVersion();

LocalDateTime now = LocalDateTime.now();

// 4) 트랜잭션 생성 (posted)
CoinTransaction txn = coinTransactionRepository.save(
CoinTransaction.builder()
.status(TransactionStatus.POSTED)
.effectiveAt(now)
.build()
);

// 5) 엔트리 생성
entryRepository.save(CoinTransactionEntity.createPostedTransaction(
fromAccount.getId(), fromVersionBefore, txn.getId(),
TransactionDirection.DEBIT, amount, now
));
entryRepository.save(CoinTransactionEntity.createPostedTransaction(
toAccount.getId(), toVersionBefore, txn.getId(),
TransactionDirection.CREDIT, amount, now
));
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
});
} catch (LockException e) {
throw new AppException(COIN_TRANSFER_FAIL_EXCEPTION);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ public class LockManager {
private final String VOTE_STREAK_PREFIX = "lock:vote:streak:";
private final String ACCOUNT_PREFIX = "lock:account:";

public void accountLock(long userId, Runnable block) throws LockException {
exec.withLock(ACCOUNT_PREFIX + userId, block);
// 유저당 계쫘가 한 개라 userId로 락을 걸어도 무방
public void multipleAccountLock(List<Long> userIds, Runnable block) throws LockException {
List<String> keys = userIds.stream()
.map(id -> ACCOUNT_PREFIX + id)
.toList();
exec.withMultiLock(keys, block);
}

public void multipleUserLock(List<Long> userIds, Runnable block) throws LockException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public ResponseEntity<AppResponse<GetCoinBalanceResponse>> getUserCoins(@Current
description = "유저가 다른 유저에게 코인을 송금합니다."
)
@ApiErrorMapping({
USER_NOT_FOUND_EXCEPTION,
COIN_NOT_ENOUGH_EXCEPTION,
COIN_TRANSFER_SAME_USER_EXCEPTION,
COIN_TRANSFER_FAIL_EXCEPTION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ void buyAiImagePermissionByCoin_Success() {
// then
verify(postRepository).findById(testPost.getId());
verify(aiChatRoomRepository).upsertAndIncrease(testUser.getId(), testPost.getId(), 20);
verify(accountService).consumeCoin(testUser.getId(), testPost.getNonCommercialPrice());
verify(accountService).transferCoin(testUser.getId(), testPost.getUserId(), testPost.getNonCommercialPrice());
}

@Test
Expand All @@ -96,7 +96,7 @@ void buyAiImagePermissionByCoin_PostNotFound() {

verify(postRepository).findById(testPost.getId());
verify(aiChatRoomRepository, never()).upsertAndIncrease(anyLong(), anyLong(), anyInt());
verify(accountService, never()).consumeCoin(anyLong(), anyLong());
verify(accountService, never()).transferCoin(anyLong(), anyLong(), anyLong());
}

@Test
Expand All @@ -110,7 +110,7 @@ void buyAiImagePermissionByCoin_InsufficientCoin() {
when(aiChatRoomRepository.upsertAndIncrease(anyLong(), anyLong(), anyInt()))
.thenReturn(1);
doThrow(new AppException(COIN_NOT_ENOUGH_EXCEPTION))
.when(accountService).consumeCoin(anyLong(), anyLong());
.when(accountService).transferCoin(anyLong(), anyLong(), anyLong());

// when & then
AppException exception = assertThrows(AppException.class,
Expand All @@ -120,7 +120,7 @@ void buyAiImagePermissionByCoin_InsufficientCoin() {

verify(postRepository).findById(testPost.getId());
verify(aiChatRoomRepository).upsertAndIncrease(testUser.getId(), testPost.getId(), 20);
verify(accountService).consumeCoin(testUser.getId(), testPost.getNonCommercialPrice());
verify(accountService).transferCoin(testUser.getId(), testPost.getUserId(), testPost.getNonCommercialPrice());
}

@Test
Expand Down
Loading