Skip to content

Commit 3f7696c

Browse files
authored
Merge pull request #247 from prgrms-web-devcourse-final-project/fix#246
[Fix]: 지갑 이동 수정 -2
2 parents 42c3d55 + 8b7d623 commit 3f7696c

File tree

4 files changed

+203
-82
lines changed

4 files changed

+203
-82
lines changed

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

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.swagger.v3.oas.annotations.responses.ApiResponses;
2121
import io.swagger.v3.oas.annotations.tags.Tag;
2222
import lombok.RequiredArgsConstructor;
23+
import lombok.extern.slf4j.Slf4j;
2324
import org.springframework.beans.factory.annotation.Value;
2425
import org.springframework.http.HttpStatus;
2526
import org.springframework.http.ResponseEntity;
@@ -31,6 +32,7 @@
3132

3233
import java.util.List;
3334

35+
@Slf4j
3436
@Tag(name = "PaymentMethod", description = "결제 수단 관련 API")
3537
@RestController
3638
@RequiredArgsConstructor
@@ -170,36 +172,42 @@ public ResponseEntity<Void> confirmCallback(
170172
) {
171173
try {
172174
if (!"success".equalsIgnoreCase(result)) {
173-
// 실패: FE로 실패 리다이렉트
174-
String location = frontendBaseUrl + "/wallet?billing=fail";
175-
return ResponseEntity.status(302).header("Location", location).build();
175+
return redirect("/wallet?billing=fail&reason=result_not_success");
176176
}
177-
178-
// 파라미터 체크
179177
if (customerKey == null || authKey == null) {
180-
String location = frontendBaseUrl + "/wallet?billing=fail&reason=missing_param";
181-
return ResponseEntity.status(302).header("Location", location).build();
178+
return redirect("/wallet?billing=fail&reason=missing_param");
182179
}
183180

184-
// 1) authKey → billingKey 교환(Confirm)
185-
TossIssueBillingKeyResponse confirm = tossBillingClientService.issueBillingKey(customerKey, authKey);
181+
log.info("[TOSS CALLBACK] result={}, customerKey={}, authKey={}", result, customerKey, mask(authKey));
186182

187-
// 2) customerKey("user-123")에서 회원 ID 추출
183+
TossIssueBillingKeyResponse confirm = tossBillingClientService.issueBillingKey(customerKey, authKey);
188184
Long memberId = parseMemberIdFromCustomerKey(customerKey);
189-
190-
// 3) 결제수단 저장/업데이트 (brand/last4 등 스냅샷 포함)
191185
paymentMethodService.saveOrUpdateBillingKey(memberId, confirm);
192186

193-
// 4) 성공 → FE /wallet 으로 리다이렉트
194-
String location = frontendBaseUrl + "/wallet";
195-
return ResponseEntity.status(302).header("Location", location).build();
187+
log.info("[TOSS CALLBACK] save success: billingKey={}, brand={}, last4={}",
188+
confirm.getBillingKey(), confirm.getBrand(), confirm.getLast4());
196189

190+
return redirect("/wallet"); // 성공은 깔끔하게 /wallet
191+
192+
} catch (org.springframework.web.server.ResponseStatusException e) {
193+
log.warn("[TOSS CALLBACK] pg error: {}", e.getReason(), e);
194+
return redirect("/wallet?billing=fail&reason=" + urlEnc(compact(e.getReason())));
197195
} catch (Exception e) {
198-
String location = frontendBaseUrl + "/wallet?billing=fail&reason=server_error";
199-
return ResponseEntity.status(302).header("Location", location).build();
196+
log.error("[TOSS CALLBACK] server error", e);
197+
return redirect("/wallet?billing=fail&reason=server_error");
200198
}
201199
}
202200

201+
// 아래 헬퍼들 추가
202+
private ResponseEntity<Void> redirect(String pathAndQuery) {
203+
String location = frontendBaseUrl + pathAndQuery;
204+
return ResponseEntity.status(302).header("Location", location).build();
205+
}
206+
private static String compact(String s){ return s == null ? "error" : s.replaceAll("\\s+","_"); }
207+
private static String urlEnc(String s){ return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8); }
208+
209+
private String mask(String s){ return (s==null||s.length()<6)?s:s.substring(0,3)+"***"+s.substring(s.length()-3); }
210+
203211
private Long parseMemberIdFromCustomerKey(String customerKey) {
204212
if (customerKey != null && customerKey.startsWith("user-")) {
205213
return Long.parseLong(customerKey.substring("user-".length()));

src/main/java/com/backend/global/security/SecurityConfig.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,12 @@ public class SecurityConfig {
3232
@Bean
3333
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
3434
http.csrf(AbstractHttpConfigurer::disable)
35+
.cors(c -> c.configurationSource(corsConfigurationSource()))
3536
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
3637
.authorizeHttpRequests(auth -> auth
3738
// 정적 리소스(/static, /public, /resources, /META-INF/resources)..
3839
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
3940

40-
// 토스 리다이렉트용 정적 페이지..
41-
.requestMatchers("/billing.html", "/payments/**", "/toss/**").permitAll()
42-
4341
// 공개 API - 루트, 파비콘, h2-console, actuator health
4442
.requestMatchers("/", "/favicon.ico", "/h2-console/**", "/actuator/health").permitAll()
4543
.requestMatchers("/api/v1/auth/**", "/swagger-ui/**", "/v3/api-docs/**",
@@ -48,15 +46,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
4846
.requestMatchers(HttpMethod.GET,
4947
"/api/*/products", "/api/*/products/{productId:\\d+}", "/api/*/products/es",
5048
"/api/*/products/members/{memberId:\\d+}",
51-
"/api/v1/members/{memberId:\\d+}").permitAll()
49+
"/api/v1/members/{memberId:\\d+}", "/api/v1/paymentMethods/toss/confirm-callback").permitAll()
5250
.requestMatchers("/api/v1/products/reload-analyzers").permitAll() // 관리자 기능 도입 시 ROLE 인증 추가
5351
.requestMatchers("/uploads/**").permitAll()
5452
.requestMatchers("/api/*/test-data/**").permitAll()
5553

5654
.anyRequest().permitAll() // 임시 허용 authenticated()로 고칠 것
5755
)
5856
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
59-
.csrf(AbstractHttpConfigurer::disable)
6057
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
6158
.formLogin(AbstractHttpConfigurer::disable)
6259
.httpBasic(AbstractHttpConfigurer::disable)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// src/test/java/com/backend/domain/payment/controller/ApiV1PaymentMethodControllerCallbackTest.java
2+
package com.backend.domain.payment.controller;
3+
4+
import com.backend.domain.payment.dto.response.PaymentMethodResponse;
5+
import com.backend.domain.payment.dto.response.TossIssueBillingKeyResponse;
6+
import com.backend.domain.payment.service.PaymentMethodService;
7+
import com.backend.domain.payment.service.TossBillingClientService;
8+
import com.backend.global.elasticsearch.TestElasticsearchConfiguration;
9+
import com.backend.global.redis.TestRedisConfiguration;
10+
import org.junit.jupiter.api.DisplayName;
11+
import org.junit.jupiter.api.Test;
12+
import org.mockito.Mockito;
13+
import org.springframework.beans.factory.annotation.Autowired;
14+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
15+
import org.springframework.boot.test.context.SpringBootTest;
16+
import org.springframework.context.annotation.Import;
17+
import org.springframework.http.HttpStatus;
18+
import org.springframework.test.context.ActiveProfiles;
19+
import org.springframework.test.context.TestPropertySource;
20+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
21+
import org.springframework.test.web.servlet.MockMvc;
22+
import org.springframework.web.server.ResponseStatusException;
23+
24+
import static org.mockito.ArgumentMatchers.*;
25+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
26+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
27+
28+
@SpringBootTest
29+
@AutoConfigureMockMvc
30+
@ActiveProfiles("test")
31+
@Import({TestElasticsearchConfiguration.class, TestRedisConfiguration.class})
32+
@TestPropertySource(properties = {
33+
"app.frontend.base-url=https://www.bid-market.shop"
34+
})
35+
class ApiV1PaymentMethodControllerCallbackTest {
36+
37+
@Autowired
38+
MockMvc mvc;
39+
40+
// 콜백 로직에서 쓰는 서비스들만 Mock
41+
@MockitoBean TossBillingClientService tossBillingClientService;
42+
@MockitoBean PaymentMethodService paymentMethodService;
43+
44+
@Test
45+
@DisplayName("성공: result=success + 파라미터 정상 → /wallet 으로 302")
46+
void callback_success() throws Exception {
47+
var confirm = TossIssueBillingKeyResponse.builder()
48+
.billingKey("BILL-123")
49+
.brand("KB")
50+
.last4("1234")
51+
.expMonth(12)
52+
.expYear(2028)
53+
.build();
54+
55+
Mockito.when(tossBillingClientService.issueBillingKey(eq("user-123"), eq("AUTH-XYZ")))
56+
.thenReturn(confirm);
57+
Mockito.when(paymentMethodService.saveOrUpdateBillingKey(eq(123L), any()))
58+
.thenReturn(PaymentMethodResponse.builder().id(1L).build());
59+
60+
mvc.perform(get("/api/v1/paymentMethods/toss/confirm-callback")
61+
.param("result", "success")
62+
.param("customerKey", "user-123")
63+
.param("authKey", "AUTH-XYZ"))
64+
.andExpect(status().isFound())
65+
.andExpect(header().string("Location", "https://www.bid-market.shop/wallet"));
66+
}
67+
68+
@Test
69+
@DisplayName("실패: result!=success → /wallet?billing=fail&reason=result_not_success")
70+
void callback_result_not_success() throws Exception {
71+
mvc.perform(get("/api/v1/paymentMethods/toss/confirm-callback")
72+
.param("result", "fail"))
73+
.andExpect(status().isFound())
74+
.andExpect(header().string("Location",
75+
"https://www.bid-market.shop/wallet?billing=fail&reason=result_not_success"));
76+
}
77+
78+
@Test
79+
@DisplayName("실패: 필수 파라미터 누락 → /wallet?billing=fail&reason=missing_param")
80+
void callback_missing_param() throws Exception {
81+
mvc.perform(get("/api/v1/paymentMethods/toss/confirm-callback")
82+
.param("result", "success")
83+
.param("customerKey", "user-123"))
84+
.andExpect(status().isFound())
85+
.andExpect(header().string("Location",
86+
"https://www.bid-market.shop/wallet?billing=fail&reason=missing_param"));
87+
}
88+
89+
@Test
90+
@DisplayName("실패: PG 4xx/5xx → /wallet?billing=fail&reason=...")
91+
void callback_pg_error() throws Exception {
92+
Mockito.when(tossBillingClientService.issueBillingKey(eq("user-123"), eq("BAD")))
93+
.thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "PG 4xx: invalid"));
94+
95+
mvc.perform(get("/api/v1/paymentMethods/toss/confirm-callback")
96+
.param("result", "success")
97+
.param("customerKey", "user-123")
98+
.param("authKey", "BAD"))
99+
.andExpect(status().isFound())
100+
.andExpect(header().string("Location",
101+
org.hamcrest.Matchers.startsWith("https://www.bid-market.shop/wallet?billing=fail&reason=")));
102+
}
103+
}

src/test/java/com/backend/domain/payment/service/PaymentMethodServiceTest.java

Lines changed: 73 additions & 60 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;
@@ -439,31 +440,16 @@ void edit_card_success_updateCardFields() {
439440
// then
440441
assertThat(res.getAlias()).isEqualTo("경조/여행 전용");
441442
assertThat(res.getIsDefault()).isTrue();
442-
assertThat(res.getBrand()).isEqualTo("SHINHAN");
443-
assertThat(res.getLast4()).isEqualTo("2222");
444-
assertThat(res.getExpMonth()).isEqualTo(5);
445-
assertThat(res.getExpYear()).isEqualTo(2035);
443+
assertThat(res.getBrand()).isEqualTo("VISA");
444+
assertThat(res.getLast4()).isEqualTo("1111");
445+
assertThat(res.getExpMonth()).isEqualTo(12);
446+
assertThat(res.getExpYear()).isEqualTo(2030);
446447

447448
assertThat(res.getBankCode()).isNull();
448449
assertThat(res.getBankName()).isNull();
449450
assertThat(res.getAcctLast4()).isNull();
450451
}
451452

452-
@Test
453-
@DisplayName("CARD: 교차 타입(BANK) 필드가 값으로 오면 400")
454-
void edit_card_reject_bankFields() {
455-
PaymentMethod entity = cardEntity(10L, member);
456-
when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
457-
when(paymentMethodRepository.findByIdAndMemberAndDeletedFalse(10L, member)).thenReturn(Optional.of(entity));
458-
459-
PaymentMethodEditRequest req = new PaymentMethodEditRequest();
460-
461-
assertThatThrownBy(() -> paymentMethodService.edit(1L, 10L, req))
462-
.isInstanceOf(ResponseStatusException.class)
463-
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode().value())
464-
.isEqualTo(HttpStatus.BAD_REQUEST.value());
465-
}
466-
467453
@Test
468454
@DisplayName("CARD: BANK 필드가 빈문자/공백이면 무시(정규화)되어 성공")
469455
void edit_card_blank_bankFields_areIgnored() {
@@ -523,47 +509,6 @@ void edit_card_switch_default() {
523509
assertThat(otherDefault.getIsDefault()).isFalse(); // 기존 기본 해제 확인
524510
}
525511

526-
@Test
527-
@DisplayName("BANK: 은행 필드만 부분 수정, CARD 필드는 null로 보장")
528-
void edit_bank_success_updateBankFields() {
529-
PaymentMethod entity = bankEntity(11L, member);
530-
when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
531-
when(paymentMethodRepository.findByIdAndMemberAndDeletedFalse(11L, member)).thenReturn(Optional.of(entity));
532-
when(paymentMethodRepository.existsByMemberAndAliasAndIdNotAndDeletedFalse(any(), anyString(), anyLong()))
533-
.thenReturn(false);
534-
535-
PaymentMethodEditRequest req = new PaymentMethodEditRequest();
536-
req.setAlias("월급통장");
537-
req.setIsDefault(false);
538-
539-
PaymentMethodResponse res = paymentMethodService.edit(1L, 11L, req);
540-
541-
assertThat(res.getAlias()).isEqualTo("월급통장");
542-
assertThat(res.getBankCode()).isEqualTo("088");
543-
assertThat(res.getBankName()).isEqualTo("신한");
544-
assertThat(res.getAcctLast4()).isEqualTo("9999");
545-
546-
assertThat(res.getBrand()).isNull();
547-
assertThat(res.getLast4()).isNull();
548-
assertThat(res.getExpMonth()).isNull();
549-
assertThat(res.getExpYear()).isNull();
550-
}
551-
552-
@Test
553-
@DisplayName("BANK: 교차 타입(CARD) 필드가 값으로 오면 400")
554-
void edit_bank_reject_cardFields() {
555-
PaymentMethod entity = bankEntity(11L, member);
556-
when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
557-
when(paymentMethodRepository.findByIdAndMemberAndDeletedFalse(11L, member)).thenReturn(Optional.of(entity));
558-
559-
PaymentMethodEditRequest req = new PaymentMethodEditRequest();
560-
561-
assertThatThrownBy(() -> paymentMethodService.edit(1L, 11L, req))
562-
.isInstanceOf(ResponseStatusException.class)
563-
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode().value())
564-
.isEqualTo(HttpStatus.BAD_REQUEST.value());
565-
}
566-
567512
@Test
568513
@DisplayName("BANK: CARD 필드가 빈문자/공백이면 무시(정규화)되어 성공")
569514
void edit_bank_blank_cardFields_areIgnored() {
@@ -659,5 +604,73 @@ void delete_default_withoutSuccessor_success() {
659604
verify(paymentMethodRepository).delete(target);
660605
verify(paymentMethodRepository).findFirstByMemberAndDeletedFalseOrderByCreateDateDesc(member);
661606
}
607+
608+
@Test
609+
@DisplayName("billingKey 신규 등록: 첫 수단이면 기본 지정 + 스냅샷 저장")
610+
void saveOrUpdate_create_new_default() {
611+
// given
612+
when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
613+
when(paymentMethodRepository.findFirstByMemberAndTokenAndDeletedFalse(member, "BILL-1"))
614+
.thenReturn(Optional.empty());
615+
when(paymentMethodRepository.countByMemberAndDeletedFalse(member)).thenReturn(0L);
616+
// ★ 기존 기본 없음
617+
when(paymentMethodRepository.findFirstByMemberAndIsDefaultTrueAndDeletedFalse(member))
618+
.thenReturn(Optional.empty());
619+
// ★ save 시 id 주입
620+
when(paymentMethodRepository.save(any(PaymentMethod.class)))
621+
.thenAnswer(inv -> {
622+
PaymentMethod pm = inv.getArgument(0);
623+
ReflectionTestUtils.setField(pm, "id", 77L);
624+
return pm;
625+
});
626+
627+
TossIssueBillingKeyResponse res = TossIssueBillingKeyResponse.builder()
628+
.billingKey("BILL-1").brand("KB").last4("1234").expMonth(12).expYear(2028).build();
629+
630+
// when
631+
PaymentMethodResponse out = paymentMethodService.saveOrUpdateBillingKey(1L, res);
632+
633+
// then
634+
assertThat(out).isNotNull();
635+
assertThat(out.getId()).isEqualTo(77L);
636+
assertThat(out.getIsDefault()).isTrue();
637+
assertThat(out.getBrand()).isEqualTo("KB");
638+
assertThat(out.getLast4()).isEqualTo("1234");
639+
640+
verify(paymentMethodRepository).save(any(PaymentMethod.class));
641+
}
642+
643+
644+
@Test
645+
@DisplayName("billingKey 기존 존재: 스냅샷(brand/last4/exp)만 갱신")
646+
void saveOrUpdate_update_snapshot_only() {
647+
PaymentMethod existing = PaymentMethod.builder()
648+
.member(member)
649+
.methodType(PaymentMethodType.CARD)
650+
.token("BILL-1")
651+
.brand("OLD")
652+
.last4("0000")
653+
.isDefault(false)
654+
.active(true)
655+
.deleted(false)
656+
.build();
657+
658+
ReflectionTestUtils.setField(existing, "id", 10L);
659+
when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
660+
when(paymentMethodRepository.findFirstByMemberAndTokenAndDeletedFalse(member, "BILL-1"))
661+
.thenReturn(Optional.of(existing));
662+
663+
var res = TossIssueBillingKeyResponse.builder()
664+
.billingKey("BILL-1").brand("NEW").last4("9999").expMonth(1).expYear(2030).build();
665+
666+
PaymentMethodResponse out = paymentMethodService.saveOrUpdateBillingKey(1L, res);
667+
668+
assertThat(existing.getBrand()).isEqualTo("NEW");
669+
assertThat(existing.getLast4()).isEqualTo("9999");
670+
assertThat(existing.getExpMonth()).isEqualTo(1);
671+
assertThat(existing.getExpYear()).isEqualTo(2030);
672+
assertThat(out.getId()).isEqualTo(10L);
673+
verify(paymentMethodRepository).save(existing);
674+
}
662675
}
663676

0 commit comments

Comments
 (0)