Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
@@ -1,6 +1,7 @@
package hanium.modic.backend.domain.ai.aiChat.service;

import static hanium.modic.backend.common.error.ErrorCode.*;
import static hanium.modic.backend.domain.transaction.enums.HistoryType.*;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -16,6 +17,7 @@
import hanium.modic.backend.domain.post.repository.PostEntityRepository;
import hanium.modic.backend.domain.ticket.service.TicketService;
import hanium.modic.backend.domain.transaction.service.AccountService;
import hanium.modic.backend.domain.transaction.service.HistoryService;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.repository.UserEntityRepository;
import hanium.modic.backend.infra.redis.distributedLock.LockManager;
Expand All @@ -27,6 +29,7 @@
public class AiImagePermissionService {

private final AccountService accountService;
private final HistoryService historyService;
private final TicketService ticketService;
private final PostEntityRepository postRepository;
private final AiChatRoomRepository aiChatRoomRepository;
Expand All @@ -50,6 +53,10 @@ public void buyAiImagePermissionByCoin(Long userId, Long postId) {
// 3) 코인을 원작자에게 전송, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파
accountService.transferCoin(userId, post.getUserId(), post.getNonCommercialPrice());

// 4) 히스토리 저장
historyService.saveTransferHistories(userId, post.getNonCommercialPrice(), POST_PURCHASE, post.getTitle(),
post.getTitle());

// 4) 알림
UserEntity user = userEntityRepository.findById(userId)
.orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package hanium.modic.backend.domain.transaction.entity;

import static jakarta.persistence.EnumType.*;

import hanium.modic.backend.common.entity.BaseEntity;
import hanium.modic.backend.domain.transaction.enums.TransactionDirection;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

// 코인 거래에 대한 이력 관리
@Table(name = "histories")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class History extends BaseEntity {

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

@Column(name = "user_id", nullable = false)
private Long userId;

@Column(name = "history_type", nullable = false)
@Enumerated(STRING)
private String historyType;

@Column(name = "body", nullable = false, length = 500)
private String body;

@Column(name = "direction", nullable = false, updatable = false)
@Enumerated(STRING)
private TransactionDirection direction;

@Column(name = "amount", nullable = false)
private Long amount;

@Builder
private History(
Long userId,
String historyType,
String body,
TransactionDirection direction,
Long amount
) {
this.userId = userId;
this.historyType = historyType;
this.body = body;
this.direction = direction;
this.amount = amount;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package hanium.modic.backend.domain.transaction.enums;

public enum HistoryType {
POST_PURCHASE,
COIN_TRANSFER,
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
package hanium.modic.backend.domain.transaction.repository;

import java.time.LocalDateTime;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

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

public interface CoinTransactionEntityRepository extends JpaRepository<CoinTransactionEntity, Long> {

@Query("""
SELECT e FROM CoinTransactionEntity e
WHERE e.accountId = :accountId
AND e.effectiveAt <= :timestampA
AND (e.discardedAt IS NULL OR e.discardedAt >= :timestampA)
AND e.accountVersion <= :version
""")
Page<CoinTransactionEntity> findSnapshot(
@Param("accountId") Long accountId,
@Param("timestampA") LocalDateTime timestampA,
@Param("version") Long version,
Pageable pageable
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package hanium.modic.backend.domain.transaction.repository;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

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

public interface HistoryRepository extends JpaRepository<History, Long> {

// 최신 순 페이지 조회
Page<History> findAllByUserIdOrderByCreateAtDesc(Long userId, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
package hanium.modic.backend.domain.transaction.service;

import static hanium.modic.backend.common.error.ErrorCode.*;
import static hanium.modic.backend.domain.transaction.enums.HistoryType.*;

import java.time.LocalDateTime;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.common.response.PageResponse;
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.entity.CoinTransactionEntity;
import hanium.modic.backend.domain.transaction.repository.AccountRepository;
import hanium.modic.backend.domain.transaction.repository.CoinTransactionEntityRepository;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.repository.UserEntityRepository;
import hanium.modic.backend.web.transaction.dto.response.GetTransactionEntityResponse;
import hanium.modic.backend.web.transaction.dto.response.GetTransactionsResponse;
import hanium.modic.backend.web.transaction.dto.response.GetCoinBalanceResponse;
import lombok.RequiredArgsConstructor;

Expand All @@ -23,13 +34,17 @@ public class AccountService {
// 계좌, 거래 관련
private final AccountRepository accountRepository;
private final LedgerService ledgerService;
private final CoinTransactionEntityRepository coinTransactionEntityRepository;

// 알림 관련
private final NotificationService notificationService;

// 유저 관련
private final UserEntityRepository userEntityRepository;

// 히스토리 관련
private final HistoryService historyService;

// 코인 잔액 조회
public GetCoinBalanceResponse getCoinBalance(final Long userId) {
Account account = accountRepository.findById(userId)
Expand All @@ -46,13 +61,26 @@ public void transferCoin(final long fromUserId, final long toUserId, long coin)
throw new AppException(COIN_TRANSFER_SAME_USER_EXCEPTION);
}

UserEntity fromUser = userEntityRepository.findById(fromUserId)
.orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION));
// 받는 사람 존재 확인
UserEntity toUser = userEntityRepository.findById(toUserId)
.orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION));


// 양도 처리
ledgerService.transfer(fromUserId, toUserId, coin);

// 히스토리 저장
historyService.saveTransferHistories(
fromUserId,
coin,
COIN_TRANSFER,
toUser.getName() + "(" + toUser.getEmail() + ")",
fromUser.getName() + "(" + fromUser.getEmail() + ")"
);


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

// 코인 거래 내역 조회
public GetTransactionsResponse getTransactions(
final long userId,
final int page,
final int size
) {
// 현재 시점
LocalDateTime now = LocalDateTime.now();

// 계좌 조회
Account account = accountRepository.findByUserId(userId)
.orElseThrow(() -> new AppException(ACCOUNT_NOT_FOUND_EXCEPTION));

// 거래 내역 조회 및 응답 생성
Pageable pageable = PageRequest.of(page, size);
Page<CoinTransactionEntity> coinTransactionEntities = coinTransactionEntityRepository.findSnapshot(
account.getId(),
now,
account.getLedgerVersion(),
pageable
);
PageResponse<GetTransactionEntityResponse> pageResponse = PageResponse.of(
coinTransactionEntities.map(GetTransactionEntityResponse::from)
);

return new GetTransactionsResponse(
account.getPostedBalance(),
pageResponse
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.common.response.PageResponse;
import hanium.modic.backend.domain.transaction.entity.Account;
import hanium.modic.backend.domain.transaction.entity.History;
import hanium.modic.backend.domain.transaction.enums.HistoryType;
import hanium.modic.backend.domain.transaction.enums.TransactionDirection;
import hanium.modic.backend.domain.transaction.repository.AccountRepository;
import hanium.modic.backend.domain.transaction.repository.HistoryRepository;
import hanium.modic.backend.web.history.dto.response.GetHistoriesResponse;
import hanium.modic.backend.web.history.dto.response.GetHistoryEntityResponse;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class HistoryService {

private final HistoryRepository historyRepository;
private final AccountRepository accountRepository;

@Transactional
public void saveTransferHistories(
long userId,
long amount,
HistoryType historyType,
String fromBody,
String toBody
) {
History senderHistory = History.builder()
.userId(userId)
.historyType(historyType.name())
.body(fromBody)
.direction(TransactionDirection.DEBIT)
.amount(amount)
.build();
History receiverHistory = History.builder()
.userId(userId)
.historyType(historyType.name())
.body(toBody)
.direction(TransactionDirection.CREDIT)
.amount(amount)
.build();

historyRepository.saveAll(List.of(senderHistory, receiverHistory));
}

// 코인 거래 내역 조회
public GetHistoriesResponse getHistories(
final long userId,
final int page,
final int size
) {
// 계좌 조회
Account account = accountRepository.findByUserId(userId)
.orElseThrow(() -> new AppException(ACCOUNT_NOT_FOUND_EXCEPTION));

// 거래 내역 조회 및 응답 생성
Pageable pageable = PageRequest.of(page, size);
Page<History> histories = historyRepository.findAllByUserIdOrderByCreateAtDesc(
userId,
pageable
);
PageResponse<GetHistoryEntityResponse> pageResponse = PageResponse.of(
histories.map(GetHistoryEntityResponse::from)
);

return new GetHistoriesResponse(
account.getPostedBalance(),
pageResponse
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package hanium.modic.backend.web.history.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import hanium.modic.backend.common.annotation.user.CurrentUser;
import hanium.modic.backend.common.response.AppResponse;
import hanium.modic.backend.domain.transaction.service.HistoryService;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.web.history.dto.response.GetHistoriesResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;

@Tag(name = "거래 기록", description = "거래기록 관련 API")
@RestController
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
@RequestMapping("/api/history")
@Validated
public class HistoryController {

private final HistoryService historyService;

@GetMapping
@Operation(
summary = "유저 거래 내역 조회 API",
description = """
로그인한 유저의 코인 거래 내역 및 잔액을 조회합니다.
</br>
주의 : 유저 코인 조회 API와 순간적으로 조회 결과가 불일치할 수 있습니다.
해당 API의 거래 내역은 해당 API의 계좌 잔액과 결과가 항상 일치합니다.
따라서 반드시 해당 API를 통해 잔액과 거래 내역을 함께 조회하시길 권장합니다.
"""
)
public ResponseEntity<AppResponse<GetHistoriesResponse>> getTransactions(
@CurrentUser UserEntity user,
@RequestParam(required = false, defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다") Integer page,
@RequestParam(required = false, defaultValue = "10") @Min(value = 10, message = "페이지 크기는 10 이상이어야 합니다.") @Max(value = 20, message = "페이지 크기는 20 이하여야 합니다.") Integer size
) {
GetHistoriesResponse response = historyService.getHistories(user.getId(), page, size);
return ResponseEntity.ok(AppResponse.ok(response));
}
}
Loading
Loading