diff --git a/README.md b/README.md index 8142135..8d9b197 100644 --- a/README.md +++ b/README.md @@ -1 +1,20 @@ -# spring-shopping-product \ No newline at end of file +# 기능 목록 +## 상품 +- [x] 상품을 추가할 수 있다. +- [x] 상품 목록을 조회할 수 있다. +- [x] 상품 상세 정보를 조회할 수 있다. +- [x] 상품을 수정할 수 있다. +- [x] 상품을 삭제할 수 있다. +- [x] 상품 이미지는 URL로 저장한다. +- [x] 상품 이름은 공백 포함 15자까지 입력할 수 있다. +- [x] 상품 이름의 특수 문자는 (), [], +, -, &, /, _만 입력할 수 있다. +- [x] 상품 이름은 비속어를 포함할 수 없다. + - [PurgoMalum](https://www.purgomalum.com/)에서 욕설이 포함되어 있는지 확인한다. +## 회원 +- [x] 회원 가입을 할 수 있다. + - 이메일과 비밀번호를 입력하여 가입한다. +- [x] 회원은 로그인할 수 있다. +## 위시 리스트 +- [ ] 위시 리스트에 상품을 추가할 수 있다. +- [ ] 위시 리스트에 등록된 상품 목록을 조회할 수 있다. +- [ ] 위시 리스트에 담긴 상품을 삭제할 수 있다. diff --git a/build.gradle.kts b/build.gradle similarity index 69% rename from build.gradle.kts rename to build.gradle index 3f75395..31390b5 100644 --- a/build.gradle.kts +++ b/build.gradle @@ -1,9 +1,7 @@ plugins { id("org.springframework.boot") version "3.3.1" id("io.spring.dependency-management") version "1.1.5" - kotlin("plugin.jpa") version "1.9.24" - kotlin("jvm") version "1.9.24" - kotlin("plugin.spring") version "1.9.24" + id "java" } group = "camp.nextstep.edu" @@ -24,23 +22,22 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-mysql") - implementation("org.jetbrains.kotlin:kotlin-reflect") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("io.rest-assured:rest-assured:5.5.0") + // lombok + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + // crypto + implementation("org.springframework.security:spring-security-crypto") + // jwt + implementation("io.jsonwebtoken:jjwt:0.12.6") } -kotlin { - compilerOptions { - freeCompilerArgs.addAll("-Xjsr305=strict") - } -} - -tasks.withType { +tasks.named("test") { useJUnitPlatform() } diff --git a/src/main/java/shopping/config/RestClientConfig.java b/src/main/java/shopping/config/RestClientConfig.java new file mode 100644 index 0000000..71f074f --- /dev/null +++ b/src/main/java/shopping/config/RestClientConfig.java @@ -0,0 +1,14 @@ +package shopping.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + return RestClient.create(); + } +} diff --git a/src/main/java/shopping/config/SecurityConfig.java b/src/main/java/shopping/config/SecurityConfig.java new file mode 100644 index 0000000..457606d --- /dev/null +++ b/src/main/java/shopping/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package shopping.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/shopping/controller/LoginController.java b/src/main/java/shopping/controller/LoginController.java new file mode 100644 index 0000000..75b2c67 --- /dev/null +++ b/src/main/java/shopping/controller/LoginController.java @@ -0,0 +1,23 @@ +package shopping.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import shopping.dto.LoginRequestDto; +import shopping.dto.LoginResponseDto; +import shopping.service.LoginService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/login") +public class LoginController { + private final LoginService loginService; + + @PostMapping + public ResponseEntity login(@RequestBody LoginRequestDto request) { + return ResponseEntity.ok(loginService.login(request.getEmail(), request.getPassword())); + } +} diff --git a/src/main/java/shopping/controller/MemberController.java b/src/main/java/shopping/controller/MemberController.java new file mode 100644 index 0000000..ba2d421 --- /dev/null +++ b/src/main/java/shopping/controller/MemberController.java @@ -0,0 +1,26 @@ +package shopping.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import shopping.entity.Member; +import shopping.service.MemberService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/members") +public class MemberController { + + private final MemberService memberService; + + @PostMapping + public ResponseEntity join(@Valid @RequestBody Member member) { + try { + return ResponseEntity.ok(memberService.join(member)); + } catch (Exception e) { + return ResponseEntity.badRequest().body(null); + } + } +} diff --git a/src/main/java/shopping/controller/ProductController.java b/src/main/java/shopping/controller/ProductController.java new file mode 100644 index 0000000..30661c6 --- /dev/null +++ b/src/main/java/shopping/controller/ProductController.java @@ -0,0 +1,55 @@ +package shopping.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import shopping.service.ProductService; +import shopping.entity.Product; + +import java.util.List; + +@RestController +@RequestMapping("/products") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @GetMapping + public List getAllProducts() { + return productService.getAllProducts(); + } + + @GetMapping("/{id}") + public ResponseEntity getProductById(@PathVariable Long id) { + return productService.getProductById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity addProduct(@Valid @RequestBody Product product) { + try { + return ResponseEntity.ok(productService.addProduct(product)); + } catch (Exception e) { + return ResponseEntity.badRequest().body(null); + } + } + + @PutMapping("/{id}") + public ResponseEntity updateProduct(@PathVariable Long id, @Valid @RequestBody Product productDetails) { + try { + return ResponseEntity.ok(productService.updateProduct(id, productDetails)); + } catch (Exception e) { + return ResponseEntity.badRequest().body(null); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteProduct(@PathVariable Long id) { + productService.deleteProduct(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/shopping/dto/LoginRequestDto.java b/src/main/java/shopping/dto/LoginRequestDto.java new file mode 100644 index 0000000..7516cf7 --- /dev/null +++ b/src/main/java/shopping/dto/LoginRequestDto.java @@ -0,0 +1,13 @@ +package shopping.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class LoginRequestDto { + private String email; + private String password; +} diff --git a/src/main/java/shopping/dto/LoginResponseDto.java b/src/main/java/shopping/dto/LoginResponseDto.java new file mode 100644 index 0000000..8484e58 --- /dev/null +++ b/src/main/java/shopping/dto/LoginResponseDto.java @@ -0,0 +1,12 @@ +package shopping.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class LoginResponseDto { + private String accessToken; +} diff --git a/src/main/java/shopping/entity/Member.java b/src/main/java/shopping/entity/Member.java new file mode 100644 index 0000000..cc34447 --- /dev/null +++ b/src/main/java/shopping/entity/Member.java @@ -0,0 +1,29 @@ +package shopping.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Entity +@Getter +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Email(message = "Invalid email format") + private String email; + + @NotBlank + @Size(min = 6, message = "Password must be at least 6 characters") + private String password; + + public void encodePassword(PasswordEncoder passwordEncoder) { + this.password = passwordEncoder.encode(this.password); + } +} diff --git a/src/main/java/shopping/entity/Product.java b/src/main/java/shopping/entity/Product.java new file mode 100644 index 0000000..49db348 --- /dev/null +++ b/src/main/java/shopping/entity/Product.java @@ -0,0 +1,28 @@ +package shopping.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Entity +@Getter +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Size(max = 15) + @Pattern(regexp = "^[\\w\\s\\-\\&\\(\\)\\[\\]/+가-힣]+$", message = "Invalid product name") + private String name; + + private String imageUrl; + + public void update(Product productDetails) { + this.name = productDetails.getName(); + this.imageUrl = productDetails.getImageUrl(); + } +} diff --git a/src/main/java/shopping/entity/ShoppingToken.java b/src/main/java/shopping/entity/ShoppingToken.java new file mode 100644 index 0000000..abe3236 --- /dev/null +++ b/src/main/java/shopping/entity/ShoppingToken.java @@ -0,0 +1,29 @@ +package shopping.entity; + +import lombok.Getter; + +import java.util.Objects; + +@Getter +public class ShoppingToken { + private final Long id; + private final String email; + + public ShoppingToken(final Long id, final String email) { + this.id = id; + this.email = email; + } + + @Override + public boolean equals(final Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + final ShoppingToken shoppingToken = (ShoppingToken) object; + return Objects.equals(id, shoppingToken.id) && Objects.equals(email, shoppingToken.email); + } + + @Override + public int hashCode() { + return Objects.hash(id, email); + } +} diff --git a/src/main/java/shopping/entity/UserDetail.java b/src/main/java/shopping/entity/UserDetail.java new file mode 100644 index 0000000..63de4b0 --- /dev/null +++ b/src/main/java/shopping/entity/UserDetail.java @@ -0,0 +1,27 @@ +package shopping.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class UserDetail { + private Long id; + private String email; + private String password; + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public boolean isWrongPassword(String password, PasswordEncoder passwordEncoder) { + return !passwordEncoder.matches(password, this.password); + } +} diff --git a/src/main/java/shopping/infra/ProfanityChecker.java b/src/main/java/shopping/infra/ProfanityChecker.java new file mode 100644 index 0000000..6ef742d --- /dev/null +++ b/src/main/java/shopping/infra/ProfanityChecker.java @@ -0,0 +1,20 @@ +package shopping.infra; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +@RequiredArgsConstructor +public class ProfanityChecker { + + private final RestClient restClient; + + public boolean hasProfanity(String text) { + final String response = restClient.get() + .uri("https://www.purgomalum.com/service/containsprofanity?text={text}", text) + .retrieve() + .body(String.class); + return Boolean.parseBoolean(response); + } +} diff --git a/src/main/java/shopping/jwt/JwtTokenProvider.java b/src/main/java/shopping/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..4df5e40 --- /dev/null +++ b/src/main/java/shopping/jwt/JwtTokenProvider.java @@ -0,0 +1,56 @@ +package shopping.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import shopping.entity.ShoppingToken; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class JwtTokenProvider { + private final SecretKey secretKey; + private final long expirationTime; + + public JwtTokenProvider( + @Value("${security.jwt.token.secret-key}") final String secretKey, + @Value("${security.jwt.token.expiration-time}") final long expirationTime) { + this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); + this.expirationTime = expirationTime; + } + + public String createToken(final Long id, final String email) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + expirationTime); + return Jwts.builder() + .subject(String.valueOf(id)) + .claim("email", email) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + public ShoppingToken getTokenInfo(final String token) { + if (!StringUtils.hasText(token)) { + throw new IllegalArgumentException(); + } + try { + JwtParser jwtParser = Jwts.parser() + .verifyWith(secretKey) + .build(); + Claims claims = jwtParser.parseSignedClaims(token).getPayload(); + return new ShoppingToken(Long.parseLong(claims.getSubject()), claims.get("email", String.class)); + } catch (final JwtException e) { + throw new IllegalArgumentException(); + } + } + +} diff --git a/src/main/java/shopping/repository/MemberRepository.java b/src/main/java/shopping/repository/MemberRepository.java new file mode 100644 index 0000000..80744e9 --- /dev/null +++ b/src/main/java/shopping/repository/MemberRepository.java @@ -0,0 +1,12 @@ +package shopping.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shopping.entity.Member; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + boolean existsByEmail(String email); + + Optional findByEmail(String email); +} diff --git a/src/main/java/shopping/repository/ProductRepository.java b/src/main/java/shopping/repository/ProductRepository.java new file mode 100644 index 0000000..9e8dae2 --- /dev/null +++ b/src/main/java/shopping/repository/ProductRepository.java @@ -0,0 +1,7 @@ +package shopping.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shopping.entity.Product; + +public interface ProductRepository extends JpaRepository { +} diff --git a/src/main/java/shopping/service/LoginService.java b/src/main/java/shopping/service/LoginService.java new file mode 100644 index 0000000..ec51c51 --- /dev/null +++ b/src/main/java/shopping/service/LoginService.java @@ -0,0 +1,27 @@ +package shopping.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import shopping.dto.LoginResponseDto; +import shopping.entity.UserDetail; +import shopping.jwt.JwtTokenProvider; + +@Service +@RequiredArgsConstructor +public class LoginService { + private final MemberDetailsService memberDetailsService; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + public LoginResponseDto login(final String email, final String password) { + final UserDetail userDetail = memberDetailsService.loadUserByEmail(email); + + if (userDetail == null || userDetail.isWrongPassword(password, passwordEncoder)) { + throw new IllegalArgumentException("wrong email or password"); + } + + String token = jwtTokenProvider.createToken(userDetail.getId(), userDetail.getEmail()); + return new LoginResponseDto(token); + } +} diff --git a/src/main/java/shopping/service/MemberDetailsService.java b/src/main/java/shopping/service/MemberDetailsService.java new file mode 100644 index 0000000..1a7d82e --- /dev/null +++ b/src/main/java/shopping/service/MemberDetailsService.java @@ -0,0 +1,20 @@ +package shopping.service; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import shopping.entity.Member; +import shopping.entity.UserDetail; +import shopping.repository.MemberRepository; + +@Service +@RequiredArgsConstructor +public class MemberDetailsService { + private final MemberRepository memberRepository; + + public UserDetail loadUserByEmail(final String email) { + final Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new EntityNotFoundException(email)); + return new UserDetail(member.getId(), member.getEmail(), member.getPassword()); + } +} diff --git a/src/main/java/shopping/service/MemberService.java b/src/main/java/shopping/service/MemberService.java new file mode 100644 index 0000000..38591cb --- /dev/null +++ b/src/main/java/shopping/service/MemberService.java @@ -0,0 +1,24 @@ +package shopping.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import shopping.entity.Member; +import shopping.repository.MemberRepository; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + private final PasswordEncoder passwordEncoder; + + public Member join(Member member) { + if (memberRepository.existsByEmail(member.getEmail())) { + throw new IllegalArgumentException("Email already in use"); + } + member.encodePassword(passwordEncoder); + return memberRepository.save(member); + } +} diff --git a/src/main/java/shopping/service/ProductService.java b/src/main/java/shopping/service/ProductService.java new file mode 100644 index 0000000..3257693 --- /dev/null +++ b/src/main/java/shopping/service/ProductService.java @@ -0,0 +1,54 @@ +package shopping.service; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import shopping.entity.Product; +import shopping.infra.ProfanityChecker; +import shopping.repository.ProductRepository; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final ProfanityChecker profanityChecker; + + public List getAllProducts() { + return productRepository.findAll(); + } + + public Optional getProductById(Long id) { + return productRepository.findById(id); + } + + public Product addProduct(Product product) { + if (isProfanity(product.getName())) { + throw new IllegalArgumentException("Product name contains profanity"); + } + return productRepository.save(product); + } + + public Product updateProduct(Long id, Product productDetails) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Product not found")); + + if (isProfanity(productDetails.getName())) { + throw new IllegalArgumentException("Product name contains profanity"); + } + + product.update(productDetails); + return product; + } + + public void deleteProduct(Long id) { + productRepository.deleteById(id); + } + + private boolean isProfanity(String text) { + return profanityChecker.hasProfanity(text); + } +} diff --git a/src/main/kotlin/shopping/.gitkeep b/src/main/kotlin/shopping/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index c33078c..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=spring-shopping diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..38879f4 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,27 @@ +spring: + h2: + console: + enabled: true + path: /h2-console + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + properties: + hibernate: + format_sql: true + show_sql: true +logging: + level: + org.hibernate.SQL: debug + +security: + jwt: + token: + secret-key: ZGJzY29kbXN5b29uY2hhZWV1buycpOyxhOydgGNoZWVzZWdpdGh1Yi5jb20veWNoZWVzZQ== + expiration-time: 864000000 diff --git a/src/test/kotlin/shopping/.gitkeep b/src/test/kotlin/shopping/.gitkeep deleted file mode 100644 index e69de29..0000000