Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
19c76e4
feat: JPA 엔티티 추가 및 의존성 업데이트
jye326 Jun 10, 2025
b143429
feat: MemberService 및 MemberRepository 추가
jye326 Jun 10, 2025
271d55e
feat: ReservationService 및 ReservationRepository 추가
jye326 Jun 10, 2025
aee806e
feat: 인증 및 권한 처리 로직 추가
jye326 Jun 10, 2025
afa6a3d
feat: 더미 데이터 초기화 로직 추가 및 설정 파일 yaml로 변경
jye326 Jun 10, 2025
0bf982c
test: ReservationControllerTest 및 ReservationServiceTest 테스트 케이스 추가
jye326 Jun 17, 2025
61e7d0d
feat: 예약 생성 API 추가 및 관련 로직 개선
jye326 Jun 17, 2025
740c19f
feat: Toss Payments 클라이언트 추가
jye326 Jun 18, 2025
6aff6c7
feat: 결제 승인 및 저장 로직 구현
jye326 Jun 18, 2025
f538781
feat: 글로벌 예외 처리 로직 추가
jye326 Jun 18, 2025
1bd143b
feat: 예약 관리 API 테스트 및 엔드포인트 개선
jye326 Jun 18, 2025
e7da756
feat: 예약 서비스에 결제 및 삭제 로직 추가
jye326 Jun 18, 2025
9f61d68
feat: ReservationRepository 및 DTO 개선
jye326 Jun 18, 2025
b5a1b60
test: 결제 승인 클라이언트 테스트 추가
jye326 Jun 18, 2025
99f5a50
style: 코드 스타일 및 형식 정리
jye326 Jun 18, 2025
f5aa8cf
test: ReservationServiceTest에서 불필요한 Mock 제거
jye326 Jun 18, 2025
604ab57
docs: README.md 추가
jye326 Jun 18, 2025
22362a9
feat: 환경변수 추가
jye326 Sep 3, 2025
bc86e42
feat: DB 변경 h2 -> mysql
jye326 Sep 3, 2025
4950273
feat: cd 구축
jye326 Sep 3, 2025
9212be9
feat: cd 구축
jye326 Sep 3, 2025
a74fd40
feat: cd 구축
jye326 Sep 3, 2025
6f3d4d2
feat: 개발 cd 구축
jye326 Sep 3, 2025
f75b91c
feat: 운영 cd 구축
jye326 Sep 3, 2025
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
15 changes: 15 additions & 0 deletions .github/workflows/deployProd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# .github/workflows/deployProd.yml
name: Deploy-Prod (self-hosted)

on:
push:
branches: [ "main" ]
workflow_dispatch:

jobs:
deploy:
runs-on: [ self-hosted, Linux, ARM64 ]
steps:
- name: Run deploy
run: |
BRANCH_OVERRIDE="${{ github.ref_name }}" bash ~/deploy.sh
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ out/

### VS Code ###
.vscode/

.env
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 주제 : 항공사 항공권 예약
- 항공권 예약 프로그램
- 기본적인 예약 CRUD 구현, 토스 페이먼츠 결제 API 연동 (예약 등록은 결제 승인이 필요함)
---
# Entity 설계
### Member

- Id(pk)
- 이름: String
- email(Id로 사용): String
- password: String

### Reservation

- Id (Pk)
- Member (Fk)
- `departureDateTime`: LocalDateTime
- `arrivalDateTime`: LocalDateTime
- 출발지: String
- 도착지: String
- passportId: String
- 항공기 편명: String

### Payment
- Id(pk)
- Reservation(Fk)
- paymentKey: String
- orderId: String
- amount: Long
---

# 기능 api 목록

1. 내 예약 조회 GET("/reservations/{id}")
2. 내 예약 전체 조회 GET("/reservations")
3. 내 예약 삭제 GET("/reservations/{id}")
4. 내 예약 여권 번호 수정 POST("/reservations/{id}"), RequestBody = String passportId
5. 결제 승인 및 예약 POST("/reservations"), RequestBody = ReservationRequest

13 changes: 10 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,22 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-gson:0.11.2'
// JWT 의존성
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

// 롬복
implementation 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

runtimeOnly 'com.h2database:h2'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:5.3.1'
runtimeOnly("com.mysql:mysql-connector-j")
}

test {
Expand Down
64 changes: 64 additions & 0 deletions src/main/java/finalmission/DummyDataInitializer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package finalmission;

import finalmission.business.model.entity.Member;
import finalmission.business.model.entity.Reservation;
import finalmission.business.service.AuthService;
import finalmission.business.service.MemberService;
import finalmission.business.service.ReservationService;
import jakarta.annotation.PostConstruct;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Component
@Profile("local")
@RequiredArgsConstructor
public class DummyDataInitializer {
private final MemberService memberService;
private final ReservationService reservationService;
private final AuthService authService;

@PostConstruct
public void initialize() {
Member ddiyong = Member.create("띠용", "ddiyong@gmail.com", "1234");
String passportId1 = "DDI1YONG2";
Member nakamura = Member.create("나까무라", "nkmr@gmail.com", "1234");
String passportId2 = "NKMRNKMR1";
LocalDateTime tomorrow = LocalDateTime.now().plusDays(1);
LocalDateTime afterTomorrow = tomorrow.plusDays(1);
String incheon = "인천국제공항";
String osaka = "간사이국제공항";
String flightCode = "BHANG123";
Reservation reservation = Reservation.create(ddiyong, passportId1,
tomorrow, tomorrow.plusHours(2), incheon, osaka,
flightCode);
Reservation reservation2 = Reservation.create(ddiyong, passportId1,
tomorrow.plusHours(2), tomorrow.plusHours(4), osaka, incheon,
flightCode);

Reservation reservation3 = Reservation.create(nakamura, passportId2,
tomorrow.plusHours(2), tomorrow.plusHours(4), osaka, incheon,
flightCode);

Reservation reservation4 = Reservation.create(nakamura, passportId2,
afterTomorrow, afterTomorrow.plusHours(2), incheon, osaka,
flightCode);

saveAllMember(ddiyong, nakamura);
saveAllReservation(reservation, reservation2, reservation3, reservation4);
}

private void saveAllMember(final Member... members) {
for (Member member : members) {
memberService.save(member);
}
}

private void saveAllReservation(final Reservation... reservations) {
for (Reservation reservation : reservations) {
reservationService.save(reservation);
}
}

}
6 changes: 3 additions & 3 deletions src/main/java/finalmission/FinalMissionApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
@SpringBootApplication
public class FinalMissionApplication {

public static void main(String[] args) {
SpringApplication.run(FinalMissionApplication.class, args);
}
public static void main(String[] args) {
SpringApplication.run(FinalMissionApplication.class, args);
}

}
11 changes: 11 additions & 0 deletions src/main/java/finalmission/auth/AuthRequired.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package finalmission.auth;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthRequired {
}
37 changes: 37 additions & 0 deletions src/main/java/finalmission/auth/AuthToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package finalmission.auth;

import finalmission.exception.AuthenticationException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.web.server.Cookie.SameSite;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;

public record AuthToken(
String value
) {
private static final String TOKEN_NAME = "auth_token";

public static AuthToken extract(final HttpServletRequest request) {
Cookie[] cookies = request.getCookies();

if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(TOKEN_NAME)) {
return new AuthToken(cookie.getValue());
}
}
}
throw new AuthenticationException("[ERROR] No auth token found");
}

public HttpHeaders toHttpHeaders() {
HttpHeaders headers = new HttpHeaders();
ResponseCookie cookie = ResponseCookie.from(TOKEN_NAME, value)
.httpOnly(true)
.sameSite(SameSite.STRICT.name())
.build();
headers.add(HttpHeaders.SET_COOKIE, cookie.toString());
return headers;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package finalmission.auth.config;

import finalmission.auth.AuthRequired;
import finalmission.auth.AuthToken;
import finalmission.auth.jwt.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {

private final JwtUtil jwtUtil;

private boolean isAuthenticationNotRequired(final Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
AuthRequired authRequired = handlerMethod.getMethodAnnotation(AuthRequired.class);
return authRequired == null;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (isAuthenticationNotRequired(handler)) {
return true;
}
AuthToken authToken = AuthToken.extract(request);
Long memberId = jwtUtil.validateAndResolveToken(authToken);
request.setAttribute("authorization", memberId);
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package finalmission.auth.config;

import finalmission.exception.AuthorizationException;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@RequiredArgsConstructor
public class AuthorizationArgumentResolver implements HandlerMethodArgumentResolver {

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(Long.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
final Object authorization = webRequest.getAttribute("authorization", RequestAttributes.SCOPE_REQUEST);
if (authorization == null) {
throw new AuthorizationException("Authorization required");
}
return authorization;
}
}
25 changes: 25 additions & 0 deletions src/main/java/finalmission/auth/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package finalmission.auth.config;

import finalmission.auth.jwt.JwtUtil;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final JwtUtil jwtUtil;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthenticationInterceptor(jwtUtil));
}

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(0, new AuthorizationArgumentResolver());
}
}
53 changes: 53 additions & 0 deletions src/main/java/finalmission/auth/jwt/JJWTJwtUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package finalmission.auth.jwt;

import finalmission.auth.AuthToken;
import finalmission.business.model.entity.Member;
import io.jsonwebtoken.ClaimJwtException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class JJWTJwtUtil implements JwtUtil {

private final SecretKey secretKey;
private final long expirationTime;

public JJWTJwtUtil(@Value("${spring.jwt.secret}") String secret,
@Value("${spring.jwt.expirationMinute}") long expirationTime) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationTime = expirationTime * 60 * 1000;
}

@Override
public AuthToken createToken(Member member) {
String tokenValue = Jwts.builder()
.subject(member.getId().toString())
.expiration(calculateExp())
.signWith(secretKey)
.compact();
return new AuthToken(tokenValue);
}

private Date calculateExp() {
return new Date(new Date().getTime() + expirationTime);
}

@Override
public Long validateAndResolveToken(AuthToken authToken) {
try {
final Claims claims = Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(authToken.value()).getPayload();
return Long.parseLong(claims.getSubject());
} catch (ClaimJwtException e1) {
throw new RuntimeException("expired token", e1);
} catch (Exception e2) {
throw new RuntimeException("invalid token", e2);
}
}
}
10 changes: 10 additions & 0 deletions src/main/java/finalmission/auth/jwt/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package finalmission.auth.jwt;

import finalmission.auth.AuthToken;
import finalmission.business.model.entity.Member;

public interface JwtUtil {
AuthToken createToken(Member member);

Long validateAndResolveToken(final AuthToken authToken);
}
Loading