Skip to content

Commit 7b5ed47

Browse files
authored
Merge pull request #243 from prgrms-web-devcourse-final-project/fix#235
[Fix]: 카드 등록 후 지갑으로 이동
2 parents 089a993 + 83ef260 commit 7b5ed47

File tree

9 files changed

+172
-29
lines changed

9 files changed

+172
-29
lines changed

src/main/java/com/backend/domain/payment/controller/ApiV1PaymentController.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ public class ApiV1PaymentController {
3939
@Value("${pg.toss.clientKey}")
4040
private String tossClientKey;
4141

42+
@Value("${app.backend.base-url}")
43+
private String backendBaseUrl;
44+
4245
private final MemberService memberService;
4346
private final PaymentService paymentService;
4447
private final TossBillingClientService tossBillingClientService;
@@ -120,11 +123,16 @@ public RsData<TossBillingAuthParamsResponse> getBillingAuthParams(
120123
String origin = req.getScheme() + "://" + req.getServerName()
121124
+ ((req.getServerPort()==80 || req.getServerPort()==443) ? "" : ":" + req.getServerPort());
122125

126+
String base = (backendBaseUrl != null && !backendBaseUrl.isBlank()) ? backendBaseUrl : origin;
127+
128+
String success = base + "/api/v1/paymentMethods/toss/confirm-callback?result=success";
129+
String fail = base + "/api/v1/paymentMethods/toss/confirm-callback?result=fail";
130+
123131
TossBillingAuthParamsResponse data = new TossBillingAuthParamsResponse(
124132
tossClientKey,
125133
customerKey,
126-
origin + "/payments/toss/billing-success.html",
127-
origin + "/payments/toss/billing-fail.html"
134+
success,
135+
fail
128136
);
129137
return RsData.ok("ok", data);
130138
}

src/main/java/com/backend/domain/payment/controller/ApiV1PaymentMethodController.java

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33

44
import com.backend.domain.member.entity.Member;
5-
import com.backend.domain.member.repository.MemberRepository;
65
import com.backend.domain.member.service.MemberService;
76
import com.backend.domain.payment.dto.request.PaymentMethodCreateRequest;
87
import com.backend.domain.payment.dto.response.PaymentMethodDeleteResponse;
98
import com.backend.domain.payment.dto.request.PaymentMethodEditRequest;
109
import com.backend.domain.payment.dto.response.PaymentMethodResponse;
10+
import com.backend.domain.payment.dto.response.TossConfirmResultResponse;
11+
import com.backend.domain.payment.dto.response.TossIssueBillingKeyResponse;
1112
import com.backend.domain.payment.service.PaymentMethodService;
13+
import com.backend.domain.payment.service.TossBillingClientService;
1214
import com.backend.global.response.RsData;
1315
import io.swagger.v3.oas.annotations.Operation;
1416
import io.swagger.v3.oas.annotations.Parameter;
@@ -18,6 +20,7 @@
1820
import io.swagger.v3.oas.annotations.responses.ApiResponses;
1921
import io.swagger.v3.oas.annotations.tags.Tag;
2022
import lombok.RequiredArgsConstructor;
23+
import org.springframework.beans.factory.annotation.Value;
2124
import org.springframework.http.HttpStatus;
2225
import org.springframework.http.ResponseEntity;
2326
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -36,6 +39,10 @@ public class ApiV1PaymentMethodController {
3639

3740
private final MemberService memberService;
3841
private final PaymentMethodService paymentMethodService;
42+
private final TossBillingClientService tossBillingClientService;
43+
44+
@Value("${app.frontend.base-url}")
45+
private String frontendBaseUrl;
3946

4047
// 공통: 인증 사용자(Member) 가져오기..
4148
private Member getActor(User user) {
@@ -154,5 +161,49 @@ public RsData<PaymentMethodDeleteResponse> delete(
154161
return RsData.ok("결제수단이 삭제되었습니다.", data);
155162
}
156163

164+
// Toss success/fail 리다이렉트가 도달하는 콜백
165+
@GetMapping("/toss/confirm-callback")
166+
public ResponseEntity<Void> confirmCallback(
167+
@RequestParam(required = false) String customerKey,
168+
@RequestParam(required = false) String authKey,
169+
@RequestParam(required = false, defaultValue = "") String result
170+
) {
171+
try {
172+
if (!"success".equalsIgnoreCase(result)) {
173+
// 실패: FE로 실패 리다이렉트
174+
String location = frontendBaseUrl + "/wallet?billing=fail";
175+
return ResponseEntity.status(302).header("Location", location).build();
176+
}
177+
178+
// 파라미터 체크
179+
if (customerKey == null || authKey == null) {
180+
String location = frontendBaseUrl + "/wallet?billing=fail&reason=missing_param";
181+
return ResponseEntity.status(302).header("Location", location).build();
182+
}
183+
184+
// 1) authKey → billingKey 교환(Confirm)
185+
TossIssueBillingKeyResponse confirm = tossBillingClientService.issueBillingKey(customerKey, authKey);
186+
187+
// 2) customerKey("user-123")에서 회원 ID 추출
188+
Long memberId = parseMemberIdFromCustomerKey(customerKey);
189+
190+
// 3) 결제수단 저장/업데이트 (brand/last4 등 스냅샷 포함)
191+
paymentMethodService.saveOrUpdateBillingKey(memberId, confirm);
192+
193+
// 4) 성공 → FE /wallet 으로 리다이렉트
194+
String location = frontendBaseUrl + "/wallet?billing=success";
195+
return ResponseEntity.status(302).header("Location", location).build();
196+
197+
} catch (Exception e) {
198+
String location = frontendBaseUrl + "/wallet?billing=fail&reason=server_error";
199+
return ResponseEntity.status(302).header("Location", location).build();
200+
}
201+
}
157202

203+
private Long parseMemberIdFromCustomerKey(String customerKey) {
204+
if (customerKey != null && customerKey.startsWith("user-")) {
205+
return Long.parseLong(customerKey.substring("user-".length()));
206+
}
207+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid customerKey");
208+
}
158209
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.backend.domain.payment.dto.response;
2+
3+
import lombok.Builder;
4+
import lombok.Value;
5+
6+
@Value
7+
@Builder
8+
public class TossConfirmResultResponse {
9+
String billingKey; // 토스에서 최종 발급되는 billingKey (DB에 token으로 저장)
10+
String cardBrand; // 예: "KB", "Hyundai" ...
11+
String last4; // 카드번호 끝 4자리
12+
Integer expMonth; // 유효기간 MM
13+
Integer expYear; // 유효기간 YYYY
14+
}

src/main/java/com/backend/domain/payment/repository/PaymentMethodRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,8 @@ public interface PaymentMethodRepository extends JpaRepository<PaymentMethod, Lo
2929

3030
// 기본 수단을 삭제했을 때 가장 최근에 만든 다른 결제수단을 자동으로 기본으로 승계..
3131
Optional<PaymentMethod> findFirstByMemberAndDeletedFalseOrderByCreateDateDesc(Member member);
32+
33+
// 해당 회원의 결제수단들 중에서, 삭제되지 않았고(token 일치)한 레코드 하나를 찾아 반환(없으면 empty) — 가장 먼저 찾은 1건만..
34+
Optional<PaymentMethod> findFirstByMemberAndTokenAndDeletedFalse(Member member, String token);
35+
3236
}

src/main/java/com/backend/domain/payment/service/PaymentMethodService.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.backend.domain.member.entity.Member;
44
import com.backend.domain.member.repository.MemberRepository;
5+
import com.backend.domain.payment.dto.response.TossIssueBillingKeyResponse;
56
import com.backend.domain.payment.enums.PaymentMethodType;
67
import com.backend.domain.payment.dto.request.PaymentMethodCreateRequest;
78
import com.backend.domain.payment.dto.response.PaymentMethodDeleteResponse;
@@ -244,6 +245,70 @@ public PaymentMethodDeleteResponse deleteAndReport(Long memberId, Long paymentMe
244245
.build();
245246
}
246247

248+
@Transactional
249+
public PaymentMethodResponse saveOrUpdateBillingKey(Long memberId, TossIssueBillingKeyResponse res) {
250+
// 0) 파라미터 검증
251+
if (res == null || res.getBillingKey() == null || res.getBillingKey().isBlank()) {
252+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "유효하지 않은 billingKey 응답입니다.");
253+
}
254+
255+
// 1) 회원 조회
256+
Member member = memberRepository.findById(memberId)
257+
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원이 존재하지 않습니다."));
258+
259+
// 2) 기존 billingKey 보유 수단 조회(있으면 업데이트, 없으면 생성)
260+
PaymentMethod pm = paymentMethodRepository
261+
.findFirstByMemberAndTokenAndDeletedFalse(member, res.getBillingKey())
262+
.orElse(null);
263+
264+
// 공통 스냅샷 값 정리
265+
String brand = res.getBrand();
266+
String last4 = res.getLast4();
267+
if (last4 != null) {
268+
String digits = last4.replaceAll("\\D", "");
269+
if (digits.length() >= 4) last4 = digits.substring(digits.length() - 4);
270+
else last4 = null;
271+
}
272+
Integer expMonth = res.getExpMonth();
273+
Integer expYear = res.getExpYear();
274+
275+
if (pm == null) {
276+
// 첫 등록이면 기본수단으로 지정
277+
boolean shouldBeDefault = paymentMethodRepository.countByMemberAndDeletedFalse(member) == 0;
278+
279+
pm = PaymentMethod.builder()
280+
.member(member)
281+
.methodType(PaymentMethodType.CARD)
282+
.provider("toss")
283+
.token(res.getBillingKey()) // ⬅ billingKey 저장 (결제에 쓰는 token)
284+
.alias(null) // 필요하면 FE에서 별칭 입력받아 수정 가능
285+
.isDefault(shouldBeDefault)
286+
.brand(brand)
287+
.last4(last4)
288+
.expMonth(expMonth)
289+
.expYear(expYear)
290+
.active(true)
291+
.deleted(false)
292+
.build();
293+
294+
// 기존 기본 해제는 shouldBeDefault일 때만
295+
if (shouldBeDefault) {
296+
paymentMethodRepository.findFirstByMemberAndIsDefaultTrueAndDeletedFalse(member)
297+
.ifPresent(old -> old.setIsDefault(false));
298+
}
299+
} else {
300+
// 기존 수단 스냅샷 갱신
301+
pm.setBrand(brand);
302+
pm.setLast4(last4);
303+
pm.setExpMonth(expMonth);
304+
pm.setExpYear(expYear);
305+
pm.setActive(true);
306+
}
307+
308+
paymentMethodRepository.save(pm);
309+
return toResponse(pm);
310+
}
311+
247312
//엔티티 → 응답 DTO 매핑..
248313
private PaymentMethodResponse toResponse(PaymentMethod e) {
249314
return PaymentMethodResponse.builder()

src/main/java/com/backend/domain/payment/service/TossBillingClientService.java

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,35 +62,43 @@ public TossIssueBillingKeyResponse issueBillingKey(String customerKey, String au
6262
String billingKey = (String) resp.get("billingKey"); // 카드 토큰..
6363
Map<?, ?> card = (Map<?, ?>) resp.get("card");
6464

65-
String cardNumber = null;
6665
String cardBrand = null;
66+
String last4 = null;
6767
Integer expMonth = null;
6868
Integer expYear = null;
6969

7070
if (card != null) {
7171
// 번호(마스킹일 수 있음) → 마지막 4자리 추출에 사용
72-
Object number = card.get("number");
73-
cardNumber = number == null ? null : number.toString();
74-
75-
// 브랜드/발급사
76-
// 환경에 따라 company/issuerCode 중 하나가 내려옵니다.
77-
Object company = card.get("company");
72+
Object company = card.get("company");
7873
Object issuerCode = card.get("issuerCode");
7974
cardBrand = company != null ? company.toString()
8075
: issuerCode != null ? issuerCode.toString()
8176
: null;
8277

78+
// 브랜드/발급사
79+
// 환경에 따라 company/issuerCode 중 하나가 내려옵니다.
80+
Object number = card.get("number"); // "1234-****-****-5678" 등
81+
if (number != null) {
82+
String digits = number.toString().replaceAll("\\D", "");
83+
if (digits.length() >= 4) last4 = digits.substring(digits.length() - 4);
84+
}
85+
8386
// 만료월/만료년 (키명이 expire* 또는 expiry* 로 다를 수 있음)
8487
expMonth = parseInt(card.get("expireMonth"), card.get("expiryMonth"));
8588
expYear = parseInt(card.get("expireYear"), card.get("expiryYear"));
89+
if (expYear != null && expYear < 100) expYear = 2000 + expYear;
8690
}
8791

8892
log.info("[ISSUE] customerKey={}, authKey={}, billingKey={}", customerKey, authKey, billingKey);
8993

94+
if (billingKey == null) {
95+
throw new IllegalStateException("Toss 응답에 billingKey가 없습니다.");
96+
}
97+
9098
return TossIssueBillingKeyResponse.builder()
9199
.billingKey(billingKey)
92100
.brand(cardBrand)
93-
.last4(cardNumber)
101+
.last4(last4)
94102
.expMonth(expMonth)
95103
.expYear(expYear)
96104
.build();

src/main/resources/application.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ pg:
4343
clientKey: ${PG_TOSS_CLIENT_KEY}
4444
secretKey: ${PG_TOSS_SECRET_KEY}
4545

46+
app:
47+
frontend:
48+
base-url: https://www.bid-market.shop
49+
backend:
50+
base-url: https://api.bid-market.shop
51+
52+
4653
springdoc:
4754
default-produces-media-type: application/json;charset=UTF-8
4855
management:

src/main/resources/static/billing.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
const body = await r.json();
1414
const p = body.data || body;
1515

16+
// 서버가 내려준 clientKey/customerKey/successUrl/failUrl 사용
1617
const toss = TossPayments(p.clientKey);
1718
toss.requestBillingAuth('카드', {
18-
customerKey: p.customerKey, // 서버가 계산해 준 값
19-
successUrl: p.successUrl, // 예: http://localhost:8080/payments/toss/billing-success.html
19+
customerKey: p.customerKey,
20+
// 백엔드 콜백 URL로 이동 → 서버가 Confirm&저장 → FE /wallet로 302 리다이렉트
21+
successUrl: p.successUrl,
2022
failUrl: p.failUrl
2123
});
2224
};

src/main/resources/static/payments/toss/billing-success.html

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,6 @@ <h2>빌링키 발급 완료</h2>
4949
const alias = document.getElementById('alias').value || '내 카드';
5050
const isDefault = document.getElementById('isDefault').checked;
5151

52-
const r2 = await fetch('/api/v1/paymentMethods', {
53-
method: 'POST',
54-
headers: { 'Content-Type': 'application/json' },
55-
credentials: 'include',
56-
body: JSON.stringify({
57-
provider: 'toss', // ★ 서버가 기대하는 값
58-
type: 'CARD',
59-
alias: alias,
60-
isDefault: isDefault,
61-
token: billingKey, // ★ billingKey를 token으로 저장
62-
brand: brand, // ★ cardBrand → brand
63-
last4: last4,
64-
expMonth: expMon,
65-
expYear: expYear
66-
})
67-
});
6852
const res2 = await r2.json();
6953
if (!r2.ok) throw new Error(`저장 실패: ${r2.status} ${res2.msg || ''}`);
7054

0 commit comments

Comments
 (0)