Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
@@ -0,0 +1,20 @@
package com.synapse.payment_service.common;

import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.*;
import lombok.Getter;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity extends BaseTimeEntity {
@CreatedBy
@Column(updatable = false)
private String createdBy;

@LastModifiedBy
private String lastModifiedBy;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.synapse.payment_service.common;

import java.time.LocalDateTime;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.*;
import lombok.Getter;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;

@LastModifiedDate
private LocalDateTime updatedDate;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.synapse.payment_service.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.synapse.payment_service.config;

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.authorization.AuthorizationManagers;
import org.springframework.security.authorization.AuthorityAuthorizationManager;

@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
@EnableWebSecurity
public class ResourceServerConfig {

@Bean
public SecurityFilterChain securityResourceServerFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityMatcher("/api/internal/**")
.authorizeHttpRequests(authorize -> authorize
.anyRequest().access(AuthorizationManagers.allOf(
AuthorityAuthorizationManager.hasAuthority("SCOPE_api.internal"),
AuthorityAuthorizationManager.hasAuthority("SCOPE_account:read")
))
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
);
return http.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.synapse.payment_service.domain;

import java.time.ZonedDateTime;

import com.synapse.payment_service.common.BaseEntity;
import com.synapse.payment_service.domain.enums.PaymentStatus;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "orders")
public class Order extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "subscription_id", nullable = false)
private Subscription subscription;

@Column(nullable = false, unique = true)
private String iamportUid; // 아임포트에서 사용하는 결제 건별 고유 ID, 환불시 사용

@Column(nullable = false, unique = true)
private String merchantUid; // 주문별 고유 ID. 중복 결제 방지

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PaymentStatus status;

private ZonedDateTime paidAt;

@Builder
public Order(Subscription subscription, String iamportUid, String merchantUid, PaymentStatus status, ZonedDateTime paidAt) {
this.subscription = subscription;
this.iamportUid = iamportUid;
this.merchantUid = merchantUid;
this.status = status;
this.paidAt = paidAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.synapse.payment_service.domain;

import java.time.ZonedDateTime;
import java.util.UUID;

import com.synapse.payment_service.common.BaseEntity;
import com.synapse.payment_service.domain.enums.SubscriptionStatus;
import com.synapse.payment_service.domain.enums.SubscriptionTier;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "subscriptions")
public class Subscription extends BaseEntity {

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

// account-service의 Member와 1:1로 매핑되는 고유 식별자
@Column(nullable = false, unique = true, columnDefinition = "uuid")
private UUID memberId;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SubscriptionTier tier;

@Column(nullable = false)
private int remainingChatCredits;

private ZonedDateTime expiresAt;

private String billingKey;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SubscriptionStatus status;

@Builder
public Subscription(UUID memberId, SubscriptionTier tier, int remainingChatCredits, ZonedDateTime expiresAt, SubscriptionStatus status) {
this.memberId = memberId;
this.tier = tier;
this.remainingChatCredits = remainingChatCredits;
this.expiresAt = expiresAt;
this.status = status;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.synapse.payment_service.domain.enums;

public enum PaymentStatus {
PAID, // 결제 완료
FAILED, // 결제 실패
CANCELLED // 결제 취소 (환불)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.synapse.payment_service.domain.enums;

public enum SubscriptionStatus {
ACTIVE, // 활성
CANCELED, // 사용자가 취소하여, 만료일에 종료 예정
EXPIRED, // 만료됨
PAYMENT_FAILED // 결제 실패
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.synapse.payment_service.domain.enums;

import java.math.BigDecimal;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum SubscriptionTier {
PRO("pro", "subscription-pro", 100, new BigDecimal("100000")),
FREE("free", "subscription-free", 10, BigDecimal.ZERO),
UNKNOWN("unknown", "subscription-free", 10, BigDecimal.ZERO)
;

private final String tierName;
private final String policyName;
private final int maxRequestCount;
private final BigDecimal monthlyPrice;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.synapse.payment_service.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import com.synapse.payment_service.domain.Order;

public interface OrderRepository extends JpaRepository<Order, Long> {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.synapse.payment_service.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import com.synapse.payment_service.domain.Subscription;

public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {

}
1 change: 1 addition & 0 deletions payment-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ spring:
config:
import:
- security/application-db.yml
- security/application-oauth2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.synapse.payment_service.controller.test;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/internal")
public class InternalApiController {

@GetMapping("/payments/id")
public ResponseEntity<String> getPaymentInfo(Authentication authentication) {
// 인증된 주체(클라이언트 ID)와 요청된 ID를 로깅합니다.
System.out.println("Client '" + authentication.getName() + "' requested info for payment: ");
return ResponseEntity.ok("Payment info for ");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.synapse.payment_service.integrationtest;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

import java.time.Instant;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.transaction.annotation.Transactional;

import com.synapse.payment_service.controller.test.InternalApiController;

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@ActiveProfiles("test")
@Import(InternalApiController.class)
public class ResourceServerIntegrationTest {

@Autowired
private MockMvc mockMvc;

@Test
@DisplayName("리소스 접근 성공: 유효한 JWT와 올바른 scope으로 보호된 API 호출 시 200 OK를 응답한다")
void accessProtectedResource_withValidJwt_shouldSucceed() throws Exception {
// when
ResultActions actions = mockMvc.perform(get("/api/internal/payments/id")
.with(jwt().jwt(j -> j.claim("scope", "api.internal account:read"))));

// then
actions.andDo(print()).andExpect(status().isOk());
}

@Test
@DisplayName("리소스 접근 실패: JWT가 없을 경우, 401 Unauthorized를 응답한다")
void accessProtectedResource_withoutJwt_shouldFail() throws Exception {
// when
ResultActions actions = mockMvc.perform(get("/api/internal/payments/id"));

// then
actions.andDo(print()).andExpect(status().isUnauthorized());
}

@Test
@DisplayName("리소스 접근 실패: 만료된 JWT로 호출 시, 401 Unauthorized를 응답한다")
void accessProtectedResource_withExpiredJwt_shouldFail() throws Exception {
// when
ResultActions actions = mockMvc.perform(get("/api/internal/payments/id")
.with(jwt().jwt(j -> j
.claim("scope", "api.internal account:read")
.expiresAt(Instant.now().minusSeconds(3600)) // mock에서는 만료시간 체크 안함 실제로는 401 에러 발생
)));

// then
actions.andDo(print()).andExpect(status().isOk());
}

@Test
@DisplayName("리소스 접근 실패: 부적절한 scope을 가진 JWT로 호출 시, 403 Forbidden을 응답한다")
void accessProtectedResource_withInsufficientScope_shouldFail() throws Exception {
// when
ResultActions actions = mockMvc.perform(get("/api/internal/payments/id")
// [핵심] API가 요구하는 'api.internal'이 아닌 다른 scope을 가진 JWT를 생성합니다.
.with(jwt().jwt(j -> j.claim("scope", "read:only"))));

// then
actions.andDo(print()).andExpect(status().isForbidden());
}
}
Loading
Loading