Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
f7a44f7
chore(build.gradle): 의존성 추가
threepebbles Jun 10, 2025
9f7cd73
docs: 기능 설계서 초안 작성
threepebbles Jun 10, 2025
ddf02b9
chore(build.gradle): jpa 의존성 추가
threepebbles Jun 10, 2025
e799804
feat(Member): 회원 엔티티 추가
threepebbles Jun 10, 2025
0032974
chore(application.yml): 스프링 설정 파일 추가
threepebbles Jun 10, 2025
913117c
chore: Swagger 의존성, 설정 추가
threepebbles Jun 10, 2025
6dd504a
feat: 커스텀 예외, 글로벌 예외 처리 기능 추가
threepebbles Jun 10, 2025
5f835c1
feat: JWT 기반의 클라이언트 요청 인증, 인가 기능 추가
threepebbles Jun 10, 2025
a194b97
feat: 회원 가입, 회원 탈퇴, 회원 목록 조회 기능 추가
threepebbles Jun 10, 2025
e98cc97
test(Member): 회원 엔티티 생성 테스트 추가
threepebbles Jun 10, 2025
c48d851
refactor(Member): 정적 팩터리 메서드 대신 생성자 사용
threepebbles Jun 10, 2025
3ad38ee
feat(ReservationTime): 예약 시간 엔티티 추가
threepebbles Jun 10, 2025
270e456
feat(ReservationSlot): 예약 슬롯 value object 추가
threepebbles Jun 10, 2025
18b19a9
feat(Restaurant): 음식점 엔티티 추가
threepebbles Jun 10, 2025
1564772
feat: 예약 시간 추가, 삭제, 조회 기능 추가
threepebbles Jun 10, 2025
f789679
test(ReservationTime): 예약 시간 생성 테스트 추가
threepebbles Jun 10, 2025
bd1aa61
refactor: 패키지 이동
threepebbles Jun 10, 2025
280cea3
feat: Restaurant(음식점) 추가, 삭제, 전체 목록 조회 기능 추가
threepebbles Jun 10, 2025
0325a9d
feat(ReservationSlot): 예약 슬롯 필드 수정
threepebbles Jun 10, 2025
980fa01
feat(Reservation): 예약 엔티티 추가
threepebbles Jun 10, 2025
4ebce2b
feat: 관리자의 예약 추가, 삭제, 전체 목록 기능 추가
threepebbles Jun 10, 2025
211a600
docs: 기능 설계서 수정
threepebbles Jun 10, 2025
f4002da
feat: 회원의 예약 추가, 취소, 내 예약 목록 조회 기능 추가
threepebbles Jun 10, 2025
92548b0
feat(Waiting): 예약 대기 엔티티 추가
threepebbles Jun 10, 2025
f780332
feat: 관리자 권한 예약 대기 추가, 삭제, 조회 기능 추가
threepebbles Jun 10, 2025
f673019
feat: 회원의 예약 대기 추가, 취소, 내 예약 대기 목록 조회 기능 추가
threepebbles Jun 10, 2025
7d51d1b
feat(EmailClient): 외부 API를 이용하여 이메일 전송 기능 추가
threepebbles Jun 10, 2025
35cfb34
chore: TODO 추가. 현재 TWILIO SendGrid 콘솔 로그인 및 접속이 안됨.
threepebbles Jun 10, 2025
7e325cf
fix: 필드명 요청 양식에 맞게 수정
threepebbles Jun 10, 2025
fb58324
docs: 중복 기능 명세 삭제
threepebbles Jun 10, 2025
0b77090
test: Sendgrid 메일 전송 API 테스트 추가
threepebbles Jun 17, 2025
ad689f4
refactor: Builder 패턴 적용
threepebbles Jun 17, 2025
41f24aa
fix(RestaurantService): 누락된 Bean 등록
threepebbles Jun 17, 2025
e30fba9
docs: 헤딩 추가 및 정리
threepebbles Jun 17, 2025
d40da61
test: 잘못된 given 수정
threepebbles Jun 17, 2025
62d2146
test: 주석 추가
threepebbles Jun 17, 2025
af59ab8
docs: 사용자 예약, 예약 대기 내용 추가
threepebbles Jun 17, 2025
1c0d4d8
feat(Restaurant): 음식점 최대 예약 수 추가
threepebbles Jun 17, 2025
c01912d
test(RestaurantRestController): 음식점 API 테스트 추가
threepebbles Jun 17, 2025
46d46c1
test(ReservationTimeRestController): 예약 시간 API 테스트 추가
threepebbles Jun 17, 2025
befa3cf
fix: 예약 추가 시, 예약 슬롯이 존재하지 않는 경우 추가하도록 수정
threepebbles Jun 17, 2025
c538a8f
chore: 어드민 이메일 수정
threepebbles Jun 17, 2025
50d0564
test: 예약 API 테스트 추가
threepebbles Jun 17, 2025
bc67a65
fix(Restaurant): 장소, 전화번호 unique 제약 조건 추가
threepebbles Jun 17, 2025
b0483a8
feat: 예약 취소 시, 예약 대기자들에게 전송하는 이메일 양식 수정
threepebbles Jun 17, 2025
462bc21
test: 예약 대기 알림 이메일 전송 테스트 추가
threepebbles Jun 17, 2025
2408603
test: 예약 성공 시, 이메일 발송 메서드 호출 여부 검증 추가
threepebbles Jun 18, 2025
05732b5
feat(Waiting): 불필요한 필드(생성 시간) 제거
threepebbles Jun 18, 2025
b9d5f45
test: 예약 생성 응답 dto의 예약 id 검증 추가
threepebbles Jun 18, 2025
5e114b4
fix: 예약 대기 추가 시, ReservationSlot이 없으면 생성하도록 수정
threepebbles Jun 18, 2025
8069012
fix: 예약 삭제 시, 해당 예약의 예약 대기자들에게만 메일이 전송되도록 수정
threepebbles Jun 18, 2025
a4f26f2
test: 예약 삭제 시, 예약 대기자들에게 메일 전송하는 메서드를 호출하는지 여부 검증 추가
threepebbles Jun 18, 2025
481b8ec
test: 예약 대기 API 테스트 추가
threepebbles Jun 18, 2025
077dcdc
refactor: http 요청 dto 유효성 검증 실패 시 예외 메시지 추가
threepebbles Jun 18, 2025
18744bc
docs: 기능 설명 구체화
threepebbles Jun 18, 2025
bfde9aa
docs: 미션 요구 사항 추가, 기능 설명 구체화
threepebbles Jun 18, 2025
a654035
test, fix: 형식이 잘못된 테스트 픽스처 수정
threepebbles Jun 26, 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
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/
/src/main/resources/application-secret.yml
/src/test/resources/application-secret.yml
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 음식점 예약 서비스

---

# 미션 요구 사항

- [x] 사용자는 특정 대상(회의실, 맛집, 클래스 등)을 예약할 수 있습니다.
- [x] 모든 사용자는 예약 현황을 확인할 수 있습니다.
- [x] 사용자 본인은 자신이 한 예약의 상세 정보까지 확인할 수 있습니다.
- [x] 사용자는 본인의 예약만 수정하고 삭제할 수 있습니다.
- [x] 자신이 구현한 기능은 README.md 파일에 명확히 작성해 주세요.

- [x] 데이터베이스: H2 또는 MySQL을 활용하여 데이터를 관리합니다.
- [x] 테스트: 애플리케이션이 의도한 대로 잘 동작하는지 테스트 코드를 통해 검증해야 합니다.

-> RestAssured를 이용해 E2E 통합 테스트 작성

- [x] 외부 API 연동: 메일 전송 API (Sendgrid) 이용

# 기능 요구 사항

## 관리자 시스템

### 음식점 관리

- 음식점 추가
- 필수 입력 필드: 음식점 이름, 음식점 설명, 음식점 주소, 음식점 전화번호, 최대 예약 인원
- 음식점 삭제
- 음식점 전체 목록 조회

### 예약 시간 관리

- 예약 시간 추가
- 필수 입력 필드: 시간
- 검증
- 예약 시간은 10~22시만 추가 가능합니다.
- 예약 시간 삭제
- 예약 시간 전체 목록 조회

### 예약 관리

- 예약 추가
- 필수 입력 필드: 날짜, 예약 시간 id, 음식점 id, 회원 id
- 예약 삭제
- 예약 전체 목록 조회

## 사용자 시스템

### 회원 가입/로그인

- 회원 가입
- 필수 입력 필드: 닉네임, 이메일, 비밀번호
- 검증
- 닉네임은 중복될 수 없습니다.
- 이메일은 중복될 수 없습니다.

- 회원 탈퇴
- 현재 로그인된 회원 계정을 삭제합니다.

- 로그인
- 필수 입력 필드: 이메일, 비밀번호
- 이메일, 비밀번호를 입력 받아 로그인합니다.

- 로그아웃
- 현재 로그인된 계정을 로그아웃합니다.

### 예약 관리

- 예약 추가
- 필수 입력 필드: 날짜, 예약 시간 id, 음식점 id, 회원 id
- 예약 슬롯: (날짜, 시간, 음식점, 최대 수용 인원) 묶음을 뜻합니다.
- 예약 슬롯에 한 명의 예약을 추가합니다. 예약 슬롯의 최대 수용 인원을 1 감소시킵니다. -> 1명이 아닌 n명 예약 추가로 확장 가능합니다.
- 예약에 성공하면 예약이 성공했음을 알리는 메일을 전송합니다.

- 예약 취소
- 취소된 예약의 대기자들에게 자리가 생겼음을 이메일로 알립니다.

- 내 예약 목록 조회

### 예약 대기 관리

- 예약 대기 추가
- 필수 입력 필드: 날짜, 예약 시간 id, 음식점 id, 회원 id
- 이미 예약이 되어있는 시간에 예약 대기를 할 수 있습니다.
- 예약 대기자들 간의 우선순위는 없습니다.
- 나중에 해당 예약 슬롯의 예약이 취소되면 예약 대기자들에게 메일 알람이 전송됩니다. -> n명으로 예약 대기를 걸어놓고 n명 이상이 비었을 시에만 알람이 가도록 확장 가능합니다.

- 예약 대기 취소
- 내 예약 대기 목록 조회
23 changes: 20 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,30 @@ 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-validation'

implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-gson:0.11.2'
// Spring Retry
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'

// jpa
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

// Lombok
annotationProcessor 'org.projectlombok:lombok'
compileOnly 'org.projectlombok:lombok'

// 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'

// h2
runtimeOnly 'com.h2database:h2'

// springdoc
implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.8'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:5.3.1'
}
Expand Down
9 changes: 5 additions & 4 deletions src/main/java/finalmission/FinalMissionApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication
@EnableRetry
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);
}
}
29 changes: 29 additions & 0 deletions src/main/java/finalmission/auth/application/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package finalmission.auth.application;

import finalmission.auth.domain.AuthTokenProvider;
import finalmission.auth.ui.dto.LoginRequest;
import finalmission.exception.auth.AuthenticationException;
import finalmission.exception.resource.ResourceNotFoundException;
import finalmission.member.domain.Member;
import finalmission.member.domain.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthService {

private final AuthTokenProvider authTokenProvider;
private final MemberRepository memberRepository;

public String createAccessToken(final LoginRequest request) {
final Member member = memberRepository.findByEmail(request.email())
.orElseThrow(() -> new ResourceNotFoundException("해당 이메일을 가진 회원이 존재하지 않습니다."));

if (member.isWrongPassword(request.password())) {
throw new AuthenticationException("비밀번호가 올바르지 않습니다.");
}

return authTokenProvider.createAccessToken(member.getId().toString(), member.getRole());
}
}
34 changes: 34 additions & 0 deletions src/main/java/finalmission/auth/config/AuthWebMvcConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package finalmission.auth.config;

import finalmission.auth.domain.AuthTokenExtractor;
import finalmission.auth.domain.AuthTokenProvider;
import finalmission.auth.ui.AuthRoleCheckInterceptor;
import finalmission.auth.ui.MemberAuthInfoArgumentResolver;
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 AuthWebMvcConfig implements WebMvcConfigurer {

private final AuthTokenExtractor<String> authTokenExtractor;
private final AuthTokenProvider authTokenProvider;

@Override
public void addInterceptors(final InterceptorRegistry registry) {
registry.addInterceptor(
new AuthRoleCheckInterceptor(authTokenExtractor, authTokenProvider)
)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/js/**", "/image/**", "/login", "/signup", "/");
}

@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new MemberAuthInfoArgumentResolver(authTokenExtractor, authTokenProvider));
}
}
18 changes: 18 additions & 0 deletions src/main/java/finalmission/auth/domain/AuthRole.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package finalmission.auth.domain;

import lombok.Getter;

@Getter
public enum AuthRole {

ADMIN("어드민"),
MEMBER("회원"),
GUEST("게스트"),
;

private final String roleName;

AuthRole(final String roleName) {
this.roleName = roleName;
}
}
12 changes: 12 additions & 0 deletions src/main/java/finalmission/auth/domain/AuthTokenExtractor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package finalmission.auth.domain;

import jakarta.annotation.Nullable;
import jakarta.servlet.http.HttpServletRequest;

public interface AuthTokenExtractor<T> {

String AUTH_TOKEN_NAME = "token";

@Nullable
T extract(HttpServletRequest request);
}
12 changes: 12 additions & 0 deletions src/main/java/finalmission/auth/domain/AuthTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package finalmission.auth.domain;

public interface AuthTokenProvider {

String createAccessToken(String principal, AuthRole role);

String getPrincipal(String token);

AuthRole getRole(String token);

boolean isValidToken(String token);
}
8 changes: 8 additions & 0 deletions src/main/java/finalmission/auth/domain/MemberAuthInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package finalmission.auth.domain;

public record MemberAuthInfo(
Long id,
AuthRole authRole
) {

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

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

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresRole {

AuthRole[] authRoles();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package finalmission.auth.infrastructure;

import finalmission.auth.domain.AuthTokenExtractor;
import finalmission.exception.auth.AuthTokenNotFoundException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import org.springframework.stereotype.Component;

@Component
public class JwtTokenExtractor implements AuthTokenExtractor<String> {

public String extract(final HttpServletRequest request) {
final Cookie[] cookies = request.getCookies();
if (cookies == null) {
throw new AuthTokenNotFoundException("쿠키가 존재하지 않습니다.");
}

return Arrays.stream(cookies)
.filter(cookie -> AUTH_TOKEN_NAME.equals(cookie.getName()))
.findFirst()
.map(Cookie::getValue)
.orElseThrow(() -> new AuthTokenNotFoundException("쿠키에 " + AUTH_TOKEN_NAME + "필드가 존재하지 않습니다."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package finalmission.auth.infrastructure;

import finalmission.auth.domain.AuthRole;
import finalmission.auth.domain.AuthTokenProvider;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
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 JwtTokenProvider implements AuthTokenProvider {

private final SecretKey secretKey;
@Value("${security.jwt.access-token.validity-in-milliseconds}")
private long validityInMilliseconds;

public JwtTokenProvider(@Value("${security.jwt.access-token.secret-key}") final String secretKeyValue) {
this.secretKey = Keys.hmacShaKeyFor(secretKeyValue.getBytes(StandardCharsets.UTF_8));
}

public String createAccessToken(final String principal, final AuthRole role) {
Claims claims = Jwts.claims()
.subject(principal)
.build();
Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds);

return Jwts.builder()
.claims(claims)
.issuedAt(now)
.expiration(validity)
.claim("role", role.name())
.signWith(secretKey)
.compact();
}

public String getPrincipal(final String token) {
if (token == null || token.isEmpty()) {
return null;
}

return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}

public AuthRole getRole(final String token) {
if (token == null || token.isEmpty()) {
return AuthRole.GUEST;
}

String role = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("role", String.class);

return AuthRole.valueOf(role);
}

public boolean isValidToken(final String token) {
if (token == null || token.isEmpty()) {
return false;
}

try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}
}
Loading