Skip to content

Commit a3d1cbb

Browse files
authored
[feat] ticket transfer history, 양도 이력 기록
* feat: ticket transfer history, 양도 이력 기록 * feat: ticket양도 기능 테스트코드 추가 * feat: 티켓 양도 이력 무결성 검증을 위한 merkle root계산 로직 구현 * feat: merkle 수동 검증용 service구현, 엔드포인트는 의도적으로 추가하지 않음
1 parent 4760603 commit a3d1cbb

10 files changed

Lines changed: 859 additions & 1 deletion

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.back.api.ticket.service;
2+
3+
import java.util.List;
4+
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
8+
import com.back.domain.ticket.entity.TicketTransferHistory;
9+
import com.back.domain.ticket.repository.TicketTransferHistoryRepository;
10+
import com.back.global.utils.MerkleUtil;
11+
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
15+
/**
16+
* 티켓 양도 이력 Merkle Root 검증 서비스
17+
*
18+
* 블록체인의 핵심 원리(위변조 감지)를 차용하여
19+
* 데이터 무결성 검증 기능을 제공합니다.
20+
*
21+
* 사용 시나리오:
22+
* - 문제 발생 시 특정 티켓의 양도 이력 무결성 검증
23+
* - Loki에 기록된 Merkle Root와 현재 DB 상태 비교
24+
*
25+
* 프로덕션 환경 / 추가 구현 시에 해당 서비스 활용해서 양도 이력 검증 구현
26+
*/
27+
@Service
28+
@RequiredArgsConstructor
29+
@Slf4j
30+
public class MerkleAnchorService {
31+
32+
private final TicketTransferHistoryRepository transferHistoryRepository;
33+
34+
// 특정 티켓의 현재 Merkle Root 계산
35+
@Transactional(readOnly = true)
36+
public String computeTicketMerkleRoot(Long ticketId) {
37+
List<TicketTransferHistory> histories =
38+
transferHistoryRepository.findByTicketIdOrderByTransferredAtDesc(ticketId);
39+
40+
if (histories.isEmpty()) {
41+
return "";
42+
}
43+
44+
List<String> hashes = histories.stream()
45+
.map(TicketTransferHistory::computeHash)
46+
.toList();
47+
48+
return MerkleUtil.buildRoot(hashes);
49+
}
50+
51+
/**
52+
* 특정 티켓의 Merkle Root 검증
53+
* Loki에 기록된 expectedRoot와 현재 DB 상태 비교
54+
*
55+
* @param expectedRoot Loki에서 가져온 예상 Merkle Root
56+
*/
57+
@Transactional(readOnly = true)
58+
public boolean verifyTicketHistory(Long ticketId, String expectedRoot) {
59+
String currentRoot = computeTicketMerkleRoot(ticketId);
60+
61+
boolean isValid = currentRoot.equals(expectedRoot);
62+
63+
if (!isValid) {
64+
log.warn("[MERKLE_VERIFY_FAILED] ticketId={}, expected={}, actual={}",
65+
ticketId, expectedRoot, currentRoot);
66+
} else {
67+
log.info("[MERKLE_VERIFY_SUCCESS] ticketId={}, root={}", ticketId, currentRoot);
68+
}
69+
70+
return isValid;
71+
}
72+
}

backend/src/main/java/com/back/api/ticket/service/TicketService.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@
1313
import com.back.domain.event.repository.EventRepository;
1414
import com.back.domain.ticket.entity.Ticket;
1515
import com.back.domain.ticket.entity.TicketStatus;
16+
import com.back.domain.ticket.entity.TicketTransferHistory;
1617
import com.back.domain.ticket.repository.TicketRepository;
18+
import com.back.domain.ticket.repository.TicketTransferHistoryRepository;
1719
import com.back.domain.user.entity.User;
1820
import com.back.domain.user.repository.UserRepository;
1921
import com.back.global.error.code.CommonErrorCode;
2022
import com.back.global.error.code.EventErrorCode;
2123
import com.back.global.error.code.TicketErrorCode;
2224
import com.back.global.error.exception.ErrorException;
2325
import com.back.global.observability.metrics.BusinessMetrics;
26+
import com.back.global.utils.MerkleUtil;
2427

2528
import lombok.RequiredArgsConstructor;
2629
import lombok.extern.slf4j.Slf4j;
@@ -34,6 +37,7 @@
3437
public class TicketService {
3538

3639
private final TicketRepository ticketRepository;
40+
private final TicketTransferHistoryRepository transferHistoryRepository;
3741
private final UserRepository userRepository;
3842
private final EventRepository eventRepository;
3943
private final SeatService seatService;
@@ -220,6 +224,42 @@ public void transferTicket(Long ticketId, Long userId, String targetNickname) {
220224
User target = userRepository.findByNickname(targetNickname)
221225
.orElseThrow(() -> new ErrorException(TicketErrorCode.TRANSFER_TARGET_NOT_FOUND));
222226

227+
Long fromUserId = ticket.getOwner().getId();
228+
229+
// 양도 처리
223230
ticket.transferTo(target);
231+
232+
// 양도 이력 저장
233+
TicketTransferHistory history = TicketTransferHistory.record(ticketId, fromUserId, target.getId());
234+
transferHistoryRepository.save(history);
235+
236+
// Merkle Root 앵커링 (Loki로 전송됨)
237+
anchorTransferHistory(ticketId, history);
238+
239+
log.debug("[Ticket Transfer] ticketId={}, from={}, to={}", ticketId, fromUserId, target.getId());
240+
}
241+
242+
/**
243+
* 양도 이력 Merkle Root 앵커링
244+
* 블록체인의 핵심 원리(위변조 감지)를 차용하여
245+
* 외부 로그 시스템(Loki)에 앵커링
246+
*/
247+
private void anchorTransferHistory(Long ticketId, TicketTransferHistory latestHistory) {
248+
List<TicketTransferHistory> histories =
249+
transferHistoryRepository.findByTicketIdOrderByTransferredAtDesc(ticketId);
250+
251+
List<String> hashes = histories.stream()
252+
.map(TicketTransferHistory::computeHash)
253+
.toList();
254+
255+
String merkleRoot = MerkleUtil.buildRoot(hashes);
256+
257+
// 구조화된 로그 - Loki에서 파싱 가능 (외부 앵커)
258+
log.info("[MERKLE_ANCHOR] ticketId={}, root={}, count={}, latestHash={}",
259+
ticketId,
260+
merkleRoot,
261+
histories.size(),
262+
latestHistory.computeHash()
263+
);
224264
}
225265
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.back.domain.ticket.entity;
2+
3+
import java.nio.charset.StandardCharsets;
4+
import java.security.MessageDigest;
5+
import java.security.NoSuchAlgorithmException;
6+
import java.time.LocalDateTime;
7+
import java.time.format.DateTimeFormatter;
8+
9+
import com.back.global.entity.BaseEntity;
10+
11+
import jakarta.persistence.Column;
12+
import jakarta.persistence.Entity;
13+
import jakarta.persistence.GeneratedValue;
14+
import jakarta.persistence.GenerationType;
15+
import jakarta.persistence.Id;
16+
import jakarta.persistence.Index;
17+
import jakarta.persistence.Table;
18+
import lombok.AccessLevel;
19+
import lombok.Builder;
20+
import lombok.Getter;
21+
import lombok.NoArgsConstructor;
22+
23+
@Entity
24+
@Table(
25+
name = "ticket_transfer_history",
26+
indexes = {
27+
@Index(name = "idx_transfer_history_ticket_id", columnList = "ticket_id")
28+
}
29+
)
30+
@Getter
31+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
32+
public class TicketTransferHistory extends BaseEntity {
33+
34+
@Id
35+
@GeneratedValue(strategy = GenerationType.IDENTITY)
36+
private Long id;
37+
38+
@Column(name = "ticket_id", nullable = false)
39+
private Long ticketId;
40+
41+
@Column(name = "from_user_id", nullable = false)
42+
private Long fromUserId;
43+
44+
@Column(name = "to_user_id", nullable = false)
45+
private Long toUserId;
46+
47+
@Column(name = "transferred_at", nullable = false)
48+
private LocalDateTime transferredAt;
49+
50+
@Builder
51+
private TicketTransferHistory(Long ticketId, Long fromUserId, Long toUserId) {
52+
this.ticketId = ticketId;
53+
this.fromUserId = fromUserId;
54+
this.toUserId = toUserId;
55+
this.transferredAt = LocalDateTime.now();
56+
}
57+
58+
// 양도 이력 기록
59+
public static TicketTransferHistory record(Long ticketId, Long fromUserId, Long toUserId) {
60+
return TicketTransferHistory.builder()
61+
.ticketId(ticketId)
62+
.fromUserId(fromUserId)
63+
.toUserId(toUserId)
64+
.build();
65+
}
66+
67+
/**
68+
* Merkle Tree용 SHA-256 해시 생성
69+
* 데이터 무결성 검증을 위한 해시값 계산
70+
*/
71+
public String computeHash() {
72+
String data = String.join(":",
73+
String.valueOf(ticketId),
74+
String.valueOf(fromUserId),
75+
String.valueOf(toUserId),
76+
transferredAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
77+
);
78+
79+
try {
80+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
81+
byte[] hashBytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
82+
return bytesToHex(hashBytes);
83+
} catch (NoSuchAlgorithmException e) {
84+
throw new RuntimeException("SHA-256 algorithm not available", e);
85+
}
86+
}
87+
88+
private String bytesToHex(byte[] bytes) {
89+
StringBuilder sb = new StringBuilder();
90+
for (byte b : bytes) {
91+
sb.append(String.format("%02x", b));
92+
}
93+
return sb.toString();
94+
}
95+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.back.domain.ticket.repository;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
7+
import com.back.domain.ticket.entity.TicketTransferHistory;
8+
9+
public interface TicketTransferHistoryRepository extends JpaRepository<TicketTransferHistory, Long> {
10+
11+
List<TicketTransferHistory> findByTicketIdOrderByTransferredAtDesc(Long ticketId);
12+
}

backend/src/main/java/com/back/global/error/code/CommonErrorCode.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ public enum CommonErrorCode implements ErrorCode {
2626
RECAPTCHA_TOKEN_MISSING(HttpStatus.BAD_REQUEST, "reCAPTCHA 토큰이 누락되었습니다."),
2727

2828
// ===== 서버 내부 오류 =====
29-
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");
29+
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."),
30+
31+
// ===== Merkle Tree 관련 =====
32+
MERKLE_TOO_MANY_LEAVES(HttpStatus.BAD_REQUEST, "처리할 데이터가 너무 많습니다."),
33+
MERKLE_BUILD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Merkle Tree 생성에 실패했습니다.");
3034

3135
private final HttpStatus httpStatus;
3236
private final String message;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.back.global.utils;
2+
3+
import java.nio.charset.StandardCharsets;
4+
import java.security.MessageDigest;
5+
import java.security.NoSuchAlgorithmException;
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
9+
import com.back.global.error.code.CommonErrorCode;
10+
import com.back.global.error.exception.ErrorException;
11+
12+
/**
13+
* Merkle Tree 유틸리티 클래스
14+
* 티켓 양도 이력의 무결성 검증을 위한 Merkle Root 계산
15+
*/
16+
public final class MerkleUtil {
17+
18+
/**
19+
* 최대 처리 가능한 리프 노드 수 (메모리 보호)
20+
* 10,000개 = log2(10000) ≈ 14 레벨, 충분히 안전
21+
*/
22+
public static final int MAX_LEAVES = 10_000;
23+
24+
/**
25+
* 무한루프 방지용 최대 반복 횟수
26+
* log2(MAX_LEAVES) + 여유분
27+
*/
28+
private static final int MAX_ITERATIONS = 20;
29+
30+
private MerkleUtil() {
31+
// 유틸리티 클래스 - 인스턴스화 방지
32+
}
33+
34+
// 해시 리스트로부터 Merkle Root 계산
35+
public static String buildRoot(List<String> hashes) {
36+
if (hashes == null || hashes.isEmpty()) {
37+
return "";
38+
}
39+
40+
if (hashes.size() == 1) {
41+
return hashes.get(0);
42+
}
43+
44+
if (hashes.size() > MAX_LEAVES) {
45+
throw new ErrorException(CommonErrorCode.MERKLE_TOO_MANY_LEAVES);
46+
}
47+
48+
List<String> currentLevel = new ArrayList<>(hashes);
49+
int iterations = 0;
50+
51+
// root가 나올때까지 반복 (무한루프 방지)
52+
while (currentLevel.size() > 1) {
53+
if (++iterations > MAX_ITERATIONS) {
54+
throw new ErrorException(CommonErrorCode.MERKLE_BUILD_FAILED);
55+
}
56+
currentLevel = buildNextLevel(currentLevel);
57+
}
58+
59+
return currentLevel.get(0);
60+
}
61+
62+
/**
63+
* 현재 레벨에서 다음 레벨의 해시들을 계산
64+
* 홀수 개인 경우 마지막 해시를 복제하여 짝수로 맞춤
65+
*/
66+
private static List<String> buildNextLevel(List<String> currentLevel) {
67+
List<String> nextLevel = new ArrayList<>();
68+
69+
// 홀수인 경우 마지막 요소 복제
70+
if (currentLevel.size() % 2 != 0) {
71+
currentLevel.add(currentLevel.get(currentLevel.size() - 1));
72+
}
73+
74+
for (int i = 0; i < currentLevel.size(); i += 2) {
75+
String left = currentLevel.get(i);
76+
String right = currentLevel.get(i + 1);
77+
String combined = sha256(left + right);
78+
nextLevel.add(combined);
79+
}
80+
81+
return nextLevel;
82+
}
83+
84+
// SHA-256 해시 계산
85+
public static String sha256(String input) {
86+
try {
87+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
88+
byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
89+
return bytesToHex(hashBytes);
90+
} catch (NoSuchAlgorithmException e) {
91+
throw new RuntimeException("SHA-256 algorithm not available", e);
92+
}
93+
}
94+
95+
private static String bytesToHex(byte[] bytes) {
96+
StringBuilder sb = new StringBuilder();
97+
for (byte b : bytes) {
98+
sb.append(String.format("%02x", b));
99+
}
100+
return sb.toString();
101+
}
102+
103+
/**
104+
* 특정 데이터가 Merkle Root에 포함되어 있는지 검증
105+
* (Merkle Proof 검증 - 선택적 구현)
106+
*
107+
* @param leafHash 검증할 리프 해시
108+
* @param proof Merkle Proof (형제 해시들의 경로)
109+
* @param root 검증 대상 Merkle Root
110+
* @return 검증 성공 여부
111+
*/
112+
public static boolean verify(String leafHash, List<ProofNode> proof, String root) {
113+
String currentHash = leafHash;
114+
115+
for (ProofNode node : proof) {
116+
if (node.isLeft()) {
117+
currentHash = sha256(node.hash() + currentHash);
118+
} else {
119+
currentHash = sha256(currentHash + node.hash());
120+
}
121+
}
122+
123+
return currentHash.equals(root);
124+
}
125+
126+
// Merkle Proof 노드
127+
public record ProofNode(String hash, boolean isLeft) {
128+
}
129+
}

0 commit comments

Comments
 (0)