Skip to content
Open
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
103 changes: 102 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,102 @@
# spring-shopping-precourse
# spring-shopping-precourse

---

# 주요 기능
1. 상품
+ 조회, 추가, 수정, 삭제 기능
- 상품에는 이름과 가격, 이미지가 있다.
- 상품 이미지의 경우, 파일을 업로드하지 않고 URL을 직접 입력한다.
+ 상품을 추가하거나 수정하는 경우 유효성 검사를 통해 잘못된 부분을 응답
- 상품 이름은 공백을 포함하여 최대 15자까지 입력할 수 있다
- : ( ), [ ], +, -, &, /, _ 외 특수 문자 사용 불가
- 상품 이름에는 비속어를 포함할 수 없다. (PurgoMalum에서 욕설이 포함되어 있는지 확인)

+ 유효성 검사
- 상품은 최대 15자를 넘는 이름을 가져서는 안된다.
- [x] 동해물과백두산이마르고닳도록
- [ ] 동해물과 백두산이 마르고 닳도록
```gherkin
Given 상품 이름이 "동해물과 백두산이 마르고 닳도록"일 때
When 상품을 생성하면
Or 상품을 수정하면
Then 400 Bad Request를 응답한다
And "상품의 이름은 15자를 넘길 수 없습니다."라고 응답한다.
```
- 상품은 일부 특수 문자는 허용하지 않는다. ( ( ), [ ], +, -, &, /, _ 외)
- [x] (할인) 아메리카노
- [ ] 아메리카노 *할인
```gherkin
Given 상품 이름이 "아메리카노 *할인"일 때
When 상품을 생성하면
Or 상품을 수정하면
Then 400 Bad Request를 응답한다
And "상품의 이름에 ( ), [ ], +, -, &, /, _ 외 특수 문자를 입력할 수 없습니다."라고 응답한다
```
```gherkin
Given 상품 이름이 "(할인) 아메리카노"일 때
When 상품을 생성하면
Or 상품을 수정하면
Then 200 OK를 응답한다
```
- 상품은 비속어를 포함하지 않는다.
- PurgoMalum에서 욕설이 포함되어 있는지 확인한다.
```gherkin
Given 상품을 생성하거나 이름을 수정할 때
When PurgoMalum에서 욕설이 포함되어 있는지 확인한다.
And 욕설이 포함되어 있으면
Then 400 Bad Request를 응답한다
And "상품의 이름에 욕설을 입력할 수 없습니다."라고 응답한다.
```
---
2. 회원
+ 회원 가입, 로그인, 로그인 후 회원별 기능을 이용
- 회원은 이메일과 비밀번호를 입력하여 가입한다.
- 토큰을 받으려면 이메일과 비밀번호를 보내야 하며, 가입한 이메일과 비밀번호가 일치하면 토큰이 발급된다.



---
3. 위시 리스트
+ 로그인 후 받은 토큰을 사용하여 사용자별 위시 리스트 기능
- 위시 리스트에 등록된 상품 목록을 조회할 수 있다.
- 위시 리스트에 상품을 추가할 수 있다.
- 위시 리스트에 담긴 상품을 삭제할 수 있다.


---
# API 정의
### 상품 API
- /api/products POST 상품 생성 새 상품을 등록한다.
- /api/products/{productId} GET 상품 조회 특정 상품의 정보를 조회한다.
- /api/products/{productId} PUT 상품 수정 기존 상품의 정보를 수정한다.
- /api/products/{productId} DELETE 상품 삭제 특정 상품을 삭제한다.
- /api/products GET 상품 목록 조회 모든 상품의 목록을 조회한다.

### 회원 API
- /api/members/register POST 회원 가입 새 회원을 등록하고 토큰을 받는다.
- /api/members/login POST 로그인 회원을 인증하고 토큰을 받는다.

### 위시 리스트 API
- /api/wishes POST 위시 리스트 상품 추가 회원의 위시 리스트에 상품을 추가한다.
- /api/wishes/{wishId} DELETE 위시 리스트 상품 삭제 회원의 위시 리스트에서 상품을 삭제한다.
- /api/wishes GET 위시 리스트 상품 조회 회원의 위시 리스트에 있는 상품을 조회한다.


---
# 용어 사전
| 한글명 | 영문명 | 설명 |
|----------|-------------------------|-------------------------------------------|
| 관리자 | admin | 계정 및 권한을 관리하고 판매 상품을 등록 및 수정한다. |
| 사용자 | users | 관리자가 아닌 쇼핑몰 이용자. 회원과 비회원으로 구분된다. |
| 회원 | member | 로그인한 이용자. 물품 구매/위시리스트 기능을 사용 가능. |
| 비회원 | guest | 로그인하지 않은 이용자. 상품 목록 조회는 가능. |
| 상품 | product | 판매자가 등록하고 사용자가 구매할 수 있다. |
| 위시리스트 | wish list | 장바구니 혹은 찜하기와 같이 회원별로 원하는 상품을 구매전에 담아둔다. |
| 위시리스트 품목 | wish list product | 위시리스트에 포함된 상품 |
| 토큰 | token | 회원가입/로그인 시 발행되는 회원 인증에 사용되는 쿠키 |
| 허용 특수 문자 | allowed special char | 상품명에 사용가능한 특수 문자 ( ), [ ], +, -, &, /, _ |
| 제한 특수 문자 | restricted special char | 상품명에 사용불가능한, 허용 특수 문자 외의 특수 문자. |
| 검출 비속어 | filtered profanity | PurgoMalum에서 검출되는 비속어 |
| 비검출 비속어 | unfiltered profanity | PurgoMalum에서 검출되지않는 비속어 (한국 욕설 등) |
| 아이디 | id | 데이터적으로 고유하게 구분할 수 있는 식별자. 시스템에서 자동 생성. |
10 changes: 10 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

//lombok 사용
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
testCompileOnly("org.projectlombok:lombok")
testAnnotationProcessor("org.projectlombok:lombok")
}

kotlin {
Expand All @@ -41,6 +47,10 @@ kotlin {
}
}

tasks.withType<JavaCompile> {
options.compilerArgs.add("-parameters")
}

tasks.withType<Test> {
useJUnitPlatform()
}
10 changes: 10 additions & 0 deletions src/main/java/shopping/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import shopping.repository.MemberRepository;
import shopping.repository.ProductRepository;

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

@Bean
@Profile("local")
public TestDataInit testDataInit(ProductRepository productRepository, MemberRepository memberRepository) {
return new TestDataInit(productRepository);
}
}
26 changes: 26 additions & 0 deletions src/main/java/shopping/TestDataInit.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package shopping;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import shopping.domain.Member;
import shopping.domain.Product;
import shopping.repository.MemberRepository;
import shopping.repository.ProductRepository;

@Slf4j
@RequiredArgsConstructor
public class TestDataInit {
private final ProductRepository productRepository;

/**
* 확인용 초기 데이터 추가
*/
@EventListener(ApplicationReadyEvent.class)
public void initData() {
log.info("test data init");
productRepository.save(new Product("Whiskey", 50000, "https://images.unsplash.com/photo-1716043657397-92666764b512?q=80&w=2614&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"));
productRepository.save(new Product("beer", 10000, "https://images.unsplash.com/photo-1634604536807-3dcaf9a6b688?q=80&w=2565&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"));
}
}
17 changes: 17 additions & 0 deletions src/main/java/shopping/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package shopping;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import shopping.web.interceptor.LoginCheckInterceptor;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginCheckInterceptor())
.order(1)
.addPathPatterns("/wishes/**");
}
}
25 changes: 25 additions & 0 deletions src/main/java/shopping/domain/Member.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package shopping.domain;


import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@Entity
public class Member {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@Email
private String email;
private String password;

public Member(String email, String password) {
this.email = email;
this.password = password;
}
}
27 changes: 27 additions & 0 deletions src/main/java/shopping/domain/Product.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package shopping.domain;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@Entity
public class Product {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Integer price;
private String imageUrl;

public Product(String name, Integer price, String imageUrl) {
this.name = name;
this.price = price;
this.imageUrl = imageUrl;
}
}
21 changes: 21 additions & 0 deletions src/main/java/shopping/domain/Wish.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package shopping.domain;

import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@Entity
public class Wish {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long memberId;
private Long productId;

public Wish(Long memberId, Long productId) {
this.memberId = memberId;
this.productId = productId;
}
}
24 changes: 24 additions & 0 deletions src/main/java/shopping/domain/dto/MemberDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package shopping.domain.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import shopping.domain.Member;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberDto {
@NotBlank
@Email
private String email;
@NotBlank
private String password;

public Member toEntity() {
return new Member(this.email, this.password);
}
}
27 changes: 27 additions & 0 deletions src/main/java/shopping/domain/dto/ProductUpdateDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package shopping.domain.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;
import shopping.domain.Product;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductUpdateDto {

@NotBlank
@Length(max=15)
private String name;
@NotNull
private Integer price;
@NotBlank
private String imageUrl;

public Product toEntity() {
return new Product(this.name, this.price, this.imageUrl);
}
}
13 changes: 13 additions & 0 deletions src/main/java/shopping/repository/MemberRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package shopping.repository;

import jakarta.validation.constraints.Email;
import org.springframework.data.jpa.repository.JpaRepository;
import shopping.domain.Member;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmailAndPassword(String email, String password);

Optional<Member> findByEmail(@Email String email);
}
7 changes: 7 additions & 0 deletions src/main/java/shopping/repository/ProductRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package shopping.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import shopping.domain.Product;

public interface ProductRepository extends JpaRepository<Product, Long> {
}
10 changes: 10 additions & 0 deletions src/main/java/shopping/repository/WishRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package shopping.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import shopping.domain.Wish;

import java.util.List;

public interface WishRepository extends JpaRepository<Wish, Long> {
List<Wish> findByMemberId(Long memberId);
}
32 changes: 32 additions & 0 deletions src/main/java/shopping/service/MemberService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package shopping.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import shopping.domain.Member;
import shopping.repository.MemberRepository;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class MemberService {

private final MemberRepository memberRepository;

public Member register(Member member) {
if (findByEmail(member.getEmail()).isPresent()) {
throw new RuntimeException("registeredEmail");
}
memberRepository.save(member);
return member;
}

public Optional<Member> login(String email, String password) {
return memberRepository.findByEmailAndPassword(email, password);
}

public Optional<Member> findByEmail(String email) {
return memberRepository.findByEmail(email);
}
}
Loading