Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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,9 +5,11 @@
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication(scanBasePackages = "com.example")
@EnableJpaAuditing
@EnableScheduling
@EnableJpaRepositories(basePackages = "com.example")
@EntityScan(basePackages = "com.example")
public class TokkitServerApplication {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public enum ErrorStatus implements BaseErrorCode {
// Wallet 관련
USER_WALLET_NOT_FOUND(HttpStatus.NOT_FOUND, "WALLET_001", "사용자 지갑이 존재하지 않습니다."),
MERCHANT_WALLET_NOT_FOUND(HttpStatus.NOT_FOUND, "WALLET_002", "가맹점 지갑이 존재하지 않습니다."),
INSUFFICIENT_BALANCE(HttpStatus.BAD_REQUEST, "WALLET_003", "토큰 잔액이 부족합니다."),
INSUFFICIENT_BALANCE(HttpStatus.BAD_REQUEST, "WALLET_003", "토큰/바우처 잔액이 부족합니다."),
INSUFFICIENT_TOKEN_BALANCE(HttpStatus.BAD_REQUEST,"WALLET_004", "토큰 잔액이 부족합니다."),

// Transaction 관련
Expand Down Expand Up @@ -94,7 +94,10 @@ public enum ErrorStatus implements BaseErrorCode {
TOKEN_TRANSFER_FAILED(HttpStatus.INTERNAL_SERVER_ERROR,"TOKEN_001" , "스마트컨트랙트 전송 중 오류가 발생했습니다."),
TOKEN_MINT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR,"TOKEN_002" , "토큰 발행(mint) 처리 중 오류가 발생했습니다."),
TOKEN_BALANCE_QUERY_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "TOKEN_003", "스마트컨트랙트 잔액 조회 중 오류가 발생했습니다."),
TOKEN_BURN_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "TOKEN_004", "토큰 소각(burn) 처리 중 오류가 발생했습니다.");
TOKEN_BURN_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "TOKEN_004", "토큰 소각(burn) 처리 중 오류가 발생했습니다."),
BALANCE_MISMATCH(HttpStatus.INTERNAL_SERVER_ERROR, "TOKEN_005","스마트컨트랙트와 DB의 토큰 잔액이 일치하지 않습니다."),
BALANCE_VERIFICATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "TOKEN_005", "스마트컨트랙트 잔액 조회 중 오류가 발생했습니다.");



private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ public CorsConfigurationSource apiConfigurationSource() {
"http://localhost:3000",
"http://localhost:8000",
"http://localhost:8080",
"http://localhost:8800"
"http://localhost:8800",
"https://www.tokkit.site",
"https://admin.tokkit.site"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class NotificationResponseDto {
private String title;
private String content;
private NotificationCategory category;
private String deleted;
private LocalDateTime createdAt;

public static NotificationResponseDto from(Notification notification) {
Expand All @@ -22,6 +23,7 @@ public static NotificationResponseDto from(Notification notification) {
.title(notification.getTitle())
.content(notification.getContent())
.category(notification.getCategory())
.deleted(notification.isDeleted() ? "deleted" : "active")
.createdAt(notification.getCreatedAt())
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,5 @@
public enum NotificationCategory {
SYSTEM, // 시스템 점검 등
PAYMENT, // 결제
VOUCHER, // 바우처
TOKEN, // 지갑/토큰
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ public enum NotificationTemplate {
// PAYMENT 알림
PAYMENT_SUCCESS(NotificationCategory.PAYMENT, "결제 완료", "%d원이 결제되었습니다."),
PAYMENT_REFUND(NotificationCategory.PAYMENT, "환불 완료", "%d원이 환불되었습니다."),

// VOUCHER 알림
VOUCHER_EXPIRED(NotificationCategory.VOUCHER, "바우처 만료", "[%s] 바우처가 만료되었습니다."),
VOUCHER_PURCHASED(NotificationCategory.PAYMENT, "바우처 구매 완료", "[%s] 바우처를 %d원에 구매하였습니다."),
VOUCHER_EXPIRED(NotificationCategory.PAYMENT, "바우처 만료", "[%s] 바우처가 만료되었습니다."),

// TOKEN 알림
TOKEN_CONVERTED(NotificationCategory.TOKEN, "토큰 전환 완료", "토큰이 성공적으로 전환되었습니다.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
Expand Down Expand Up @@ -68,15 +72,47 @@ public void sendNotification(User user, NotificationTemplate template, Object...
}
}


@Transactional
public SseEmitter subscribe(Long userId) {
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
sseEmitters.add(userId, emitter);
log.info("[SSE] 유저 {} 구독 등록됨", userId);

try {
emitter.send(SseEmitter.event()
.name("connect")
.data("SSE 연결이 완료되었습니다."));
} catch (IOException e) {
emitter.completeWithError(e);
sseEmitters.remove(userId);
}

emitter.onCompletion(() -> sseEmitters.remove(userId));
emitter.onTimeout(() -> sseEmitters.remove(userId));
emitter.onError((e) -> sseEmitters.remove(userId));
// 연결 유지용 ping
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event()
.name("ping")
.data("keep-alive"));
} catch (Exception e) {
emitter.complete();
}
}, 30, 30, TimeUnit.SECONDS);

emitter.onCompletion(() -> {
sseEmitters.remove(userId);
scheduler.shutdown();
});

emitter.onTimeout(() -> {
sseEmitters.remove(userId);
scheduler.shutdown();
});

emitter.onError((e) -> {
sseEmitters.remove(userId);
scheduler.shutdown();
});

return emitter;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import java.io.IOException;

import static org.apache.commons.lang3.StringEscapeUtils.escapeJson;

@Slf4j
@Service
@RequiredArgsConstructor
Expand All @@ -20,13 +22,18 @@ public void sendSse(Long userId, String title, String content) {
SseEmitter emitter = sseEmitters.get(userId);
if (emitter != null) {
try {
log.info("[SSE] 유저 {}에게 알림 전송 시도", userId); // 로그 추가
String json = String.format("{\"title\": \"%s\", \"content\": \"%s\"}", title, content);
emitter.send(SseEmitter.event()
.name("notification")
.data(title + ": " + content, MediaType.valueOf("text/plain;charset=UTF-8")));
.data(json, MediaType.APPLICATION_JSON));
log.info("[SSE] 유저 {}에게 알림 전송 성공", userId);
} catch (IOException e) {
sseEmitters.remove(userId);
log.error("[SseNotificationService] SSE 전송 실패 - 연결 제거됨: {}", e.getMessage());
log.error("[SSE] 전송 실패 - emitter 제거됨: {}", e.getMessage());
}
} else {
log.warn("[SSE] emitter 없음 → 전송 실패 (userId: {})", userId); // 로그 추가
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
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 java.time.LocalDateTime;
import java.util.List;
Expand All @@ -14,4 +16,29 @@ public interface VoucherOwnershipRepository extends JpaRepository<VoucherOwnersh
Page<VoucherOwnership> findByWalletUserId(Long userId, Pageable pageable);
Optional<VoucherOwnership> findByIdAndWalletUserId(Long id, Long userId);
List<VoucherOwnership> findByStatusAndVoucher_ValidDateBefore(VoucherOwnershipStatus status, LocalDateTime time);

// 바우처 + 바우처 스토어 + 스토어를 모두 fetch join으로 한 번에 조회
@Query("""
SELECT vo
FROM VoucherOwnership vo
JOIN FETCH vo.voucher v
LEFT JOIN FETCH v.voucherStores vs
LEFT JOIN FETCH vs.store s
WHERE vo.wallet.user.id = :userId
""")
List<VoucherOwnership> findAllWithVoucherAndStoresByUserId(@Param("userId") Long userId);

// 만료된 바우처만 fetch join으로 조회 (성능 개선용)
@Query("""
SELECT vo
FROM VoucherOwnership vo
JOIN FETCH vo.voucher v
WHERE vo.status = :status
AND v.validDate < :now
""")
List<VoucherOwnership> findByStatusAndVoucherValidDateBeforeWithFetchJoin(
@Param("status") VoucherOwnershipStatus status,
@Param("now") LocalDateTime now
);

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

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

import com.example.Tokkit_server.voucher_ownership.dto.request.VoucherOwnershipSearchRequest;
import com.example.Tokkit_server.voucher_ownership.entity.VoucherOwnership;

interface VoucherOwnershipRepositoryCustom {
Page<VoucherOwnership> searchMyVoucher(VoucherOwnershipSearchRequest request, Long userId, Pageable pageable);
List<VoucherOwnership> findAllWithVoucherAndStoresByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,6 @@ public Page<VoucherOwnership> searchMyVoucher(VoucherOwnershipSearchRequest requ
return new PageImpl<>(result, pageable, total);
}

@Override
public List<VoucherOwnership> findAllWithVoucherAndStoresByUserId(Long userId) {
return em.createQuery("""
SELECT vo FROM VoucherOwnership vo
JOIN FETCH vo.voucher v
LEFT JOIN FETCH v.voucherStores vs
LEFT JOIN FETCH vs.store s
JOIN vo.wallet w
JOIN w.user u
WHERE u.id = :userId
""", VoucherOwnership.class)
.setParameter("userId", userId)
.getResultList();
}

}


Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public void expireVouchers() {
log.info("바우처 유효기간 만료 체크 시작");

List<VoucherOwnership> expiredList =
voucherOwnershipRepository.findByStatusAndVoucher_ValidDateBefore(
voucherOwnershipRepository.findByStatusAndVoucherValidDateBeforeWithFetchJoin(
VoucherOwnershipStatus.AVAILABLE, LocalDateTime.now());

expiredList.forEach(VoucherOwnership::expire);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.example.Tokkit_server.global.apiPayload.exception.GeneralException;
import com.example.Tokkit_server.merchant.entity.Merchant;
import com.example.Tokkit_server.merchant.repository.MerchantRepository;
import com.example.Tokkit_server.notification.enums.NotificationTemplate;
import com.example.Tokkit_server.notification.service.NotificationService;
import com.example.Tokkit_server.store.entity.Store;
import com.example.Tokkit_server.store.repository.StoreRepository;
import com.example.Tokkit_server.transaction.entity.Transaction;
Expand All @@ -29,6 +31,7 @@
import com.example.contract.service.TokkitTokenService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
Expand All @@ -44,6 +47,7 @@

@Service
@RequiredArgsConstructor
@Slf4j
public class WalletCommandService {

private final WalletRepository walletRepository;
Expand Down Expand Up @@ -235,6 +239,30 @@ public VoucherPurchaseResponse purchaseVoucher(Long userId,VoucherPurchaseReques
// 7. 수량 차감
voucher.decreaseRemainingCount();

// 7.1 온체인 잔액 검증
try {

BigInteger onChainBalance = tokkitTokenService.getBalanceOf(wallet.getWalletAddress());
if (!onChainBalance.equals(BigInteger.valueOf(wallet.getTokenBalance()))) {
throw new GeneralException(ErrorStatus.BALANCE_MISMATCH);
}
} catch (Exception e) {
throw new GeneralException(ErrorStatus.BALANCE_VERIFICATION_FAILED);
}

// 8. 토큰 소각
TransactionReceipt receipt;
try {
receipt = tokkitTokenService.burn(
wallet.getWalletAddress(),
BigInteger.valueOf(amount)
);
} catch (Exception e) {
throw new GeneralException(ErrorStatus.TOKEN_BURN_FAILED);
}

String txHash = receipt.getTransactionHash();

// 8. 토큰 차감
wallet.updateBalance(wallet.getDepositBalance(), wallet.getTokenBalance() - amount);

Expand All @@ -254,7 +282,7 @@ public VoucherPurchaseResponse purchaseVoucher(Long userId,VoucherPurchaseReques
String displayDescription = voucher.getName();

logAndSave(wallet, user.getId(), null, TransactionType.PURCHASE, TransactionStatus.SUCCESS,
(long) amount, logDescription, displayDescription);
(long) amount, logDescription, displayDescription,txHash);


// 11. 응답 반환
Expand Down Expand Up @@ -326,6 +354,7 @@ public VoucherPaymentResponse payWithVoucher(Long userId,VoucherPaymentRequest r

String userDisplayDescription = store.getStoreName();


// 9. 사용자 거래 기록 생성
logAndSave(ownership.getWallet(), user.getId(), null, TransactionType.PURCHASE, TransactionStatus.SUCCESS,
request.getAmount(), userLogDescription, userDisplayDescription);
Expand All @@ -342,10 +371,10 @@ public VoucherPaymentResponse payWithVoucher(Long userId,VoucherPaymentRequest r
// 스마트 컨트랙트 적용
TransactionReceipt receipt;
try{
receipt = tokkitTokenService.payToMerchant(
receipt = tokkitTokenService.mint(
merchantWallet.getWalletAddress(),
BigInteger.valueOf(request.getAmount()),
"Voucher settlement from User ID : " + user.getId());
BigInteger.valueOf(request.getAmount())
);
} catch (Exception e) {
throw new GeneralException(ErrorStatus.TOKEN_TRANSFER_FAILED);
}
Expand Down Expand Up @@ -404,6 +433,18 @@ public DirectPaymentResponse payDirectlyWithToken(Long userId,DirectPaymentReque
throw new GeneralException(ErrorStatus.INVALID_SIMPLE_PASSWORD);
}


// 5.1 스마트컨트랙트 이전 상태 검증 (user)
try {
BigInteger userOnChainBalance = tokkitTokenService.getBalanceOf(userWallet.getWalletAddress());
if (!userOnChainBalance.equals(BigInteger.valueOf(userWallet.getTokenBalance()))) {
throw new GeneralException(ErrorStatus.BALANCE_MISMATCH);
}
} catch (Exception e) {
throw new GeneralException(ErrorStatus.BALANCE_VERIFICATION_FAILED);
}


// 스마트 컨트랙트 적용
TransactionReceipt receipt;
try {
Expand Down
Loading
Loading