diff --git a/.gitignore b/.gitignore index c2065bc..546e78e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -HELP.md .gradle + build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ +!**/src/main/resource/application.yaml !**/src/test/**/build/ ### STS ### diff --git a/README.md b/README.md index 611eb8a..7cee267 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,15 @@ ## 리뷰사항 연휴 이후 다음주 부터 본격적인 개발에 들어가려 합니다. 열심히하겠습니다! 감사합니다. + + + +# 5주차 github 코드리뷰 질문 +(윤재용) +몇몇 컨트롤러에 대한 E2E 테스트를 작성하였습니다. +처음에는 @WithMockUser 를 사용해서 테스트를 진행하려고 했는데, Header를 검증하다보니 불가능했습니다. +저희의 요구사항이 특정 url이 아니라면 헤더에 토큰이 필요하다보니 사용이 불가능하였기에 JwtTestUtils 클래스를 통해 테스트 유저를 사용하였습니다. + +이런 전체 테스트를 처음 구현하다 보니 +현재 작성한 테스트가 E2E 테스트라고 불려도 될 지 잘 모르겠습니다..! +추가로 테스트코드의 개선점이 있을지 궁금합니다. diff --git a/build.gradle b/build.gradle index 483de16..1ed3bd7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,18 @@ +buildscript { + ext { + spring_boot_version = '3.3.3' + spring_dependency_management = '1.1.6' + } + + repositories { + mavenCentral() + } +} + plugins { id 'java' - id 'org.springframework.boot' version '3.3.3' - id 'io.spring.dependency-management' version '1.1.6' + id 'org.springframework.boot' version "${spring_boot_version}" + id 'io.spring.dependency-management' version "${spring_dependency_management}" } group = 'com.helpmeCookies' @@ -24,17 +35,33 @@ repositories { } dependencies { + + // Spring implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.boot:spring-boot-starter-security' + + // Lombok compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + + // DB + runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.h2database:h2' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // Spring docs implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Monitoring + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/src/main/java/com/helpmeCookies/global/config/JpaConfig.java b/src/main/java/com/helpmeCookies/global/config/JpaConfig.java new file mode 100644 index 0000000..28fe6fe --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/config/JpaConfig.java @@ -0,0 +1,9 @@ +package com.helpmeCookies.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} \ No newline at end of file diff --git a/src/main/java/com/helpmeCookies/global/jwt/JwtProvider.java b/src/main/java/com/helpmeCookies/global/jwt/JwtProvider.java new file mode 100644 index 0000000..7a1e9c2 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/jwt/JwtProvider.java @@ -0,0 +1,125 @@ +package com.helpmeCookies.global.jwt; + +import java.security.Key; +import java.util.Date; + +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; + +@Component +public class JwtProvider implements InitializingBean { + @Value("${jwt.secret}") + private String secret; + @Value("${jwt.access-token-expire-time}") + private long accessTokenExpireTime; + @Value("${jwt.refresh-token-expire-time}") + private long refreshTokenExpireTime; + private Key secretKey; + private static final String ROLE = "role"; + private static final String IS_ACCESS_TOKEN = "isAccessToken"; + private static final String HEADER_PREFIX = "Bearer "; + + public String parseHeader(String header) { + if (header == null || header.isEmpty()) { + throw new IllegalArgumentException("Authorization 헤더가 없습니다."); + } else if (!header.startsWith(HEADER_PREFIX)) { + throw new IllegalArgumentException("Authorization 올바르지 않습니다."); + } else if (header.split(" ").length != 2) { + throw new IllegalArgumentException("Authorization 올바르지 않습니다."); + } + + return header.split(" ")[1]; + } + + public JwtToken createToken(JwtUser jwtUser) { + String accessToken = generateToken(jwtUser, true); + String refreshToken = generateToken(jwtUser, false); + return JwtToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // 유요한 토큰인지 확인 + public boolean validateToken(String rawToken, boolean isAccessToken) { + try { + // 엑세스 토큰인지 확인 + Claims claims = extractClaims(rawToken); + if (claims.get(IS_ACCESS_TOKEN, Boolean.class) != isAccessToken) { + return false; + } + // 만료시간 확인 + return !claims.getExpiration().before(new Date()); + } catch (Exception e) { + return false; + } + } + + /** + * refreshToken을 통해, accessToken을 재발급하는 메서드. + * refreshToken의 유효성을 검사하고, isAccessToken이 true일때만 accessToken을 재발급한다. + * TODO: refreshToken을 저장하고, 저장된 refreshToken과 비교하는 로직 필요 + */ + public String reissueAccessToken(String refreshToken) { + Claims claims = extractClaims(refreshToken); + if (claims.get(IS_ACCESS_TOKEN, Boolean.class)) { + throw new IllegalArgumentException("리프레시 토큰이 아닙니다."); + } + JwtUser jwtUser = claimsToJwtUser(claims); + return generateToken(jwtUser, true); + } + + /** + * [validateToken] 이후 호출하는 메서드. + * rawToken을 통해 JwtUser를 추출한다. + * [jwtUser]는 userId와 role을 가지고 있다. 즉 JWT에 저장된 정보를 추출한다. + */ + public JwtUser getJwtUser(String rawToken) { + Claims claims = extractClaims(rawToken); + return claimsToJwtUser(claims); + } + + private JwtUser claimsToJwtUser(Claims claims) { + String userId = claims.getSubject(); + return JwtUser.of(Long.parseLong(userId)); + } + + /** + * Jwt 토큰생성 + * accessToken과 refreshToken의 다른점은 만료시간과, isAccessToken이다. + */ + private String generateToken(JwtUser jwtUser, boolean isAccessToken) { + long expireTime = isAccessToken ? accessTokenExpireTime : refreshTokenExpireTime; + Date expireDate = new Date(System.currentTimeMillis() + expireTime); + return Jwts.builder() + .signWith(secretKey) + .claim(IS_ACCESS_TOKEN, isAccessToken) + .setSubject(jwtUser.getId().toString()) + .setExpiration(expireDate) + .compact(); + } + + + private Claims extractClaims(String rawToken) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(rawToken) + .getBody(); + } + + /** + * HS256방식의 키를 생성한다. + */ + @Override + public void afterPropertiesSet() { + secretKey = new SecretKeySpec(secret.getBytes(), SignatureAlgorithm.HS256.getJcaName()); + } +} diff --git a/src/main/java/com/helpmeCookies/global/jwt/JwtToken.java b/src/main/java/com/helpmeCookies/global/jwt/JwtToken.java new file mode 100644 index 0000000..ca5c80e --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/jwt/JwtToken.java @@ -0,0 +1,11 @@ +package com.helpmeCookies.global.jwt; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class JwtToken { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/helpmeCookies/global/jwt/JwtUser.java b/src/main/java/com/helpmeCookies/global/jwt/JwtUser.java new file mode 100644 index 0000000..e66d7b0 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/jwt/JwtUser.java @@ -0,0 +1,62 @@ +package com.helpmeCookies.global.jwt; + +import java.util.Collection; +import java.util.List; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class JwtUser implements UserDetails { + private Long id; + private String username; + private Collection authorities; + + public static JwtUser of(Long id) { + return JwtUser.builder() + .id(id) + .build(); + } + + @Override + // 임시 기본 권한을 USER로 설정 + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + "USER")); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return this.username; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + +} \ No newline at end of file diff --git a/src/main/java/com/helpmeCookies/global/security/JwtAccessDeniedHandler.java b/src/main/java/com/helpmeCookies/global/security/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..f6dde1c --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/security/JwtAccessDeniedHandler.java @@ -0,0 +1,32 @@ +package com.helpmeCookies.global.security; + +import java.io.IOException; +import java.io.PrintWriter; + +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) { + log.error("Token : {}", request.getHeader("Authorization")); + // TODO: 에러코드 추가 + response.setStatus(403); + } +} \ No newline at end of file diff --git a/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..2072bb2 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,29 @@ +package com.helpmeCookies.global.security; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + log.debug("Token : {}", request.getHeader("Authorization")); + response.setStatus(401); + } +} diff --git a/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationFilter.java b/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..1e70986 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.helpmeCookies.global.security; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.helpmeCookies.global.jwt.JwtProvider; +import com.helpmeCookies.global.jwt.JwtUser; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtProvider jwtProvider; + + private static final String AUTHORIZATION_HEADER = "Authorization"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + log.info("JwtAuthenticationFilter"); + String rawToken; + + // 토큰 추출 + try { + rawToken = jwtProvider.parseHeader(request.getHeader(AUTHORIZATION_HEADER)); + } catch (Exception e) { + filterChain.doFilter(request, response); + return; + } + + // TODO: UserDetailsService를 통해 사용자 정보를 가져와 인증을 진행한다. + if (jwtProvider.validateToken(rawToken, true)) { + JwtUser jwtUser = jwtProvider.getJwtUser(rawToken); + Authentication authentication = new UsernamePasswordAuthenticationToken(jwtUser, null, + jwtUser.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/helpmeCookies/global/security/WebSecurityConfig.java b/src/main/java/com/helpmeCookies/global/security/WebSecurityConfig.java new file mode 100644 index 0000000..793f211 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/security/WebSecurityConfig.java @@ -0,0 +1,69 @@ +package com.helpmeCookies.global.security; + +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.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.stereotype.Controller; + +import lombok.RequiredArgsConstructor; + +@EnableMethodSecurity +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor + + +@Controller +public class WebSecurityConfig { + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public WebSecurityCustomizer configure() { + return (web) -> web.ignoring() + .requestMatchers("/static/**") + .requestMatchers("/test/**"); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable); + http.sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.authorizeHttpRequests((authorize) -> + authorize + .requestMatchers( + "/login", "/signup", "/", "/user", + "/api/auth/**", + "/swagger-ui/**", + "/actuator/**" + ).permitAll() + .anyRequest().authenticated() + ); + + http.exceptionHandling((exception) -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ); + + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/helpmeCookies/product/controller/ProductController.java b/src/main/java/com/helpmeCookies/product/controller/ProductController.java new file mode 100644 index 0000000..c87732e --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/controller/ProductController.java @@ -0,0 +1,44 @@ +package com.helpmeCookies.product.controller; + +import com.helpmeCookies.product.dto.ProductRequest; +import com.helpmeCookies.product.dto.ProductResponse; +import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.product.service.ProductService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/products") +public class ProductController { + + private final ProductService productService; + + public ProductController(ProductService productService) { + this.productService = productService; + } + + @PostMapping + public ResponseEntity saveProduct(@RequestBody ProductRequest productRequest) { + Product product = productService.save(productRequest); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{productId}") + public ResponseEntity getProductInfo(@PathVariable("productId") Long productId) { + Product product = productService.find(productId); + return ResponseEntity.ok(ProductResponse.from(product)); + } + + @PutMapping("/{productId}") + public ResponseEntity editProductInfo(@PathVariable("productId") Long productId, + @RequestBody ProductRequest productRequest) { + productService.edit(productId, productRequest); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{productId}") + public ResponseEntity deleteProduct(@PathVariable("productId") Long productId) { + productService.delete(productId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/helpmeCookies/product/dto/ProductRequest.java b/src/main/java/com/helpmeCookies/product/dto/ProductRequest.java new file mode 100644 index 0000000..e1dfa2d --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/dto/ProductRequest.java @@ -0,0 +1,32 @@ +package com.helpmeCookies.product.dto; + +import com.helpmeCookies.product.entity.Category; +import com.helpmeCookies.product.entity.HashTag; +import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.user.entity.ArtistInfo; + +import java.util.List; + +public record ProductRequest( + String name, + String category, + String size, + Long price, + String description, + String preferredLocation, + List hashTags, + Long artistInfo +) { + public Product toEntity(ArtistInfo artistInfo) { + return Product.builder() + .name(name) + .category(Category.fromString(category)) + .size(size) + .price(price) + .description(description) + .preferredLocation(preferredLocation) + .hashTags(hashTags) + .artistInfo(artistInfo) + .build(); + } +} diff --git a/src/main/java/com/helpmeCookies/product/dto/ProductResponse.java b/src/main/java/com/helpmeCookies/product/dto/ProductResponse.java new file mode 100644 index 0000000..36b6e95 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/dto/ProductResponse.java @@ -0,0 +1,53 @@ +package com.helpmeCookies.product.dto; + +import com.helpmeCookies.product.entity.HashTag; +import com.helpmeCookies.product.entity.Product; + +import java.util.List; + +public record ProductResponse( + Long id, + String name, + String category, + String size, + Long price, + String description, + String preferredLocation, + List hashTags, + ArtistInfo artistInfo +) { + public static class ArtistInfo { + private final Long artistId; + private final String name; + + public ArtistInfo(Long artistId, String name) { + this.artistId = artistId; + this.name = name; + } + + public Long getArtistId() { + return artistId; + } + + public String getName() { + return name; + } + } + + public static ProductResponse from(Product product) { + //TODO artistInfo 코드 개발 이후 수정 예정 + return new ProductResponse( + product.getId(), + product.getName(), + product.getCategory().getName(), + product.getSize(), + product.getPrice(), + product.getDescription(), + product.getPreferredLocation(), + product.getHashTags(), + new ArtistInfo( + 1L, "임시" + ) + ); + } +} diff --git a/src/main/java/com/helpmeCookies/product/entity/Category.java b/src/main/java/com/helpmeCookies/product/entity/Category.java index f160ccd..a58a7c0 100644 --- a/src/main/java/com/helpmeCookies/product/entity/Category.java +++ b/src/main/java/com/helpmeCookies/product/entity/Category.java @@ -1,5 +1,43 @@ package com.helpmeCookies.product.entity; +import java.util.HashMap; +import java.util.Map; + public enum Category { - PICTURE; + PICTURE("사진"), + ORIENTAL("동양화"), + WESTERN("서양화"), + PIECE("조각화"), + CERAMIC("도예/공예"), + NEWMEDIA("뉴미디어"), + DESIGN("디자인"), + DRAWING("드로잉/판화"), + CALLIGRAPHY("서예/캘리그라피") + ; + + private final String name; + private static final Map nameToCategoryMap = new HashMap<>(); + + static { + for (Category category : Category.values()) { + nameToCategoryMap.put(category.getName(), category); + } + } + + Category(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public static Category fromString(String name) { + System.out.println(name); + Category category = nameToCategoryMap.get(name); + if (category == null) { + throw new IllegalArgumentException(name + "에 해당하는 카테고리가 없습니다."); + } + return category; + } } diff --git a/src/main/java/com/helpmeCookies/product/entity/HashTag.java b/src/main/java/com/helpmeCookies/product/entity/HashTag.java index 8cb93f3..854c870 100644 --- a/src/main/java/com/helpmeCookies/product/entity/HashTag.java +++ b/src/main/java/com/helpmeCookies/product/entity/HashTag.java @@ -1,7 +1,16 @@ package com.helpmeCookies.product.entity; public enum HashTag { - autumn("가을"), winter("겨울"); + SERENITY("고요함"), + MELANCHOLY("멜랑꼴리"), + VIBRANCE("활기"), + NOSTALGIA("향수"), + MYSTERY("신비로움"), + JOYFUL("기쁨"), + LONELINESS("고독"), + CONTEMPLATION("사색"), + WONDER("경이로움"), + DREAMLIKE("몽환적"); private String name; diff --git a/src/main/java/com/helpmeCookies/product/entity/Product.java b/src/main/java/com/helpmeCookies/product/entity/Product.java index 145631c..d44b972 100644 --- a/src/main/java/com/helpmeCookies/product/entity/Product.java +++ b/src/main/java/com/helpmeCookies/product/entity/Product.java @@ -14,7 +14,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; +import lombok.Builder; @Entity public class Product { @@ -25,22 +25,89 @@ public class Product { @Column(nullable = false) private String name; + @Column(nullable = false) + @Enumerated(EnumType.STRING) private Category category; + @Column(nullable = false) private String size; + @Column(nullable = false) private Long price; + @Column(nullable = false) private String description; + @Column(nullable = false) private String preferredLocation; @ElementCollection(targetClass = HashTag.class) - @CollectionTable(name = "user_hashtags") + @CollectionTable(name = "product_hashtags") @Enumerated(EnumType.STRING) - private List hashTag; + private List hashTags; @ManyToOne private ArtistInfo artistInfo; + + public Product() {} + + @Builder + public Product(String name, Category category, String size, Long price, String description, String preferredLocation, List hashTags, ArtistInfo artistInfo) { + this.name = name; + this.category = category; + this.size = size; + this.price = price; + this.description = description; + this.preferredLocation = preferredLocation; + this.hashTags = hashTags; + this.artistInfo = artistInfo; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Category getCategory() { + return category; + } + + public String getSize() { + return size; + } + + public Long getPrice() { + return price; + } + + public String getDescription() { + return description; + } + + public String getPreferredLocation() { + return preferredLocation; + } + + public List getHashTags() { + return hashTags; + } + + public ArtistInfo getArtistInfo() { + return artistInfo; + } + + public void update(String name, Category category, String size, Long price, String description, String preferredLocation, List hashTags, ArtistInfo artistInfo) { + this.name = name; + this.category = category; + this.size = size; + this.price = price; + this.description = description; + this.preferredLocation = preferredLocation; + this.hashTags = hashTags; + this.artistInfo = artistInfo; + } } diff --git a/src/main/java/com/helpmeCookies/product/repository/ProductRepository.java b/src/main/java/com/helpmeCookies/product/repository/ProductRepository.java new file mode 100644 index 0000000..d8b05f3 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/repository/ProductRepository.java @@ -0,0 +1,9 @@ +package com.helpmeCookies.product.repository; + +import com.helpmeCookies.product.entity.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductRepository extends JpaRepository { +} diff --git a/src/main/java/com/helpmeCookies/product/service/ProductService.java b/src/main/java/com/helpmeCookies/product/service/ProductService.java new file mode 100644 index 0000000..1a16699 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/service/ProductService.java @@ -0,0 +1,48 @@ +package com.helpmeCookies.product.service; + +import com.helpmeCookies.product.dto.ProductRequest; +import com.helpmeCookies.product.entity.Category; +import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.product.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProductService { + private final ProductRepository productRepository; + + public ProductService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public Product save(ProductRequest productSaveRequest) { + //TODO ArtistInfo 코드 병합시 수정 예정 + Product product = productSaveRequest.toEntity(null); + productRepository.save(product); + return product; + } + + public Product find(Long productId) { + return productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 id입니다")); + } + + @Transactional + public void edit(Long productId, ProductRequest productRequest) { + Product product = productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 id입니다")); + //TODO ArtistInfo 코드 병합시 수정 예정 + product.update( + productRequest.name(), + Category.fromString(productRequest.category()), + productRequest.size(), + productRequest.price(), + productRequest.description(), + productRequest.preferredLocation(), + productRequest.hashTags(), + null); + } + + public void delete(Long productId) { + Product product = productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 id입니다")); + productRepository.deleteById(productId); + } +} diff --git a/src/main/java/com/helpmeCookies/user/controller/ArtistController.java b/src/main/java/com/helpmeCookies/user/controller/ArtistController.java new file mode 100644 index 0000000..536d814 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/controller/ArtistController.java @@ -0,0 +1,59 @@ +package com.helpmeCookies.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.helpmeCookies.global.jwt.JwtProvider; +import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.user.dto.request.BusinessArtistReq; +import com.helpmeCookies.user.dto.request.StudentArtistReq; +import com.helpmeCookies.user.dto.response.BusinessArtistRes; +import com.helpmeCookies.user.dto.response.StudentArtistRes; +import com.helpmeCookies.user.service.ArtistService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ArtistController { + private final ArtistService artistService; + private final JwtProvider jwtProvider; + + @PostMapping("/v1/artists/students") + public ResponseEntity registerStudents( + @RequestBody StudentArtistReq studentArtistReq, + @AuthenticationPrincipal JwtUser jwtUser + ) { + StudentArtistRes response = StudentArtistRes.from(artistService.registerStudentsArtist(studentArtistReq, jwtUser.getId())); + return ResponseEntity.ok(response); + } + + @PostMapping("/v1/artists/bussinesses") + public ResponseEntity registerbussinsess( + @RequestBody BusinessArtistReq businessArtistReq, + @AuthenticationPrincipal JwtUser jwtUser + ) { + BusinessArtistRes response = BusinessArtistRes.from(artistService.registerBusinessArtist(businessArtistReq, jwtUser.getId())); + return ResponseEntity.ok(response); + } + + // 작가 목록 조회(페이징) + // TODO: 6주차 회의 이후 추가 + @GetMapping("/v1/artists") + public String getArtists() { + return "ok"; + } + + // 작가 프로필 조회 + // TODO: 6주차 회의 이후 추가 + @GetMapping("/v1/artists/{userId}") + public String getArtist() { + return "ok"; + } +} diff --git a/src/main/java/com/helpmeCookies/user/controller/LoginController.java b/src/main/java/com/helpmeCookies/user/controller/LoginController.java new file mode 100644 index 0000000..c004414 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/controller/LoginController.java @@ -0,0 +1,48 @@ +package com.helpmeCookies.user.controller; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.helpmeCookies.global.jwt.JwtProvider; +import com.helpmeCookies.global.jwt.JwtToken; +import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.product.entity.HashTag; +import com.helpmeCookies.user.entity.User; +import com.helpmeCookies.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +//Todo: Swagger 추가 +public class LoginController { + private final UserRepository userRepository; + private final JwtProvider jwtProvider; + + // 임시 회원가입 url. 유저를 생성하고 jwt 토큰을 반환한다. + @GetMapping("/test/signup") + public JwtToken signup() { + User user = User.builder() + .nickname("test") + .email("test@test") + .birthdate("1999-01-01") + .address("서울시 강남구") + .phone("010-1234-5678") + .hashTags(List.of(HashTag.DREAMLIKE, HashTag.VIBRANCE)) + .build(); + userRepository.save(user); + return jwtProvider.createToken(JwtUser.of(user.getId())); + } + + // 임시 로그인 url. 로그인한 유저의 정보의 일부를 반환한다. + @GetMapping("/login") + public String login(@AuthenticationPrincipal JwtUser jwtUser) { + User user = userRepository.findById(jwtUser.getId()).orElseThrow(); + return user.getEmail(); + } +} diff --git a/src/main/java/com/helpmeCookies/user/controller/UserController.java b/src/main/java/com/helpmeCookies/user/controller/UserController.java new file mode 100644 index 0000000..c123502 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/controller/UserController.java @@ -0,0 +1,36 @@ +package com.helpmeCookies.user.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; + +import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.user.dto.UserDto; +import com.helpmeCookies.user.service.UserService; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + // TODO: 이후 추가되는 요구사항에 따라 별도의 UserRes로 반환 + // TODO: 구매 판매 내역에 대한 추가 정보 필요. Product 도메인 완성이후 추가 + @GetMapping("/v1/users/{userId}") + public UserDto getUsers( + @PathVariable Long userId + ) { + return userService.getUser(userId); + } + + //유저 팔로우 목록 조회 + @GetMapping("/v1/users/{userId}/follows") + public String getFollows() { + + return "ok"; + } +} diff --git a/src/main/java/com/helpmeCookies/user/dto/ArtistInfoDto.java b/src/main/java/com/helpmeCookies/user/dto/ArtistInfoDto.java new file mode 100644 index 0000000..afad531 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/ArtistInfoDto.java @@ -0,0 +1,11 @@ +package com.helpmeCookies.user.dto; + +public record ArtistInfoDto( + Long totalFollowers, + Long totalLikes, + String about +) { + public static ArtistInfoDto of(Long totalFollowers, Long totalLikes, String about) { + return new ArtistInfoDto(totalFollowers, totalLikes, about); + } +} diff --git a/src/main/java/com/helpmeCookies/user/dto/BusinessArtistDto.java b/src/main/java/com/helpmeCookies/user/dto/BusinessArtistDto.java new file mode 100644 index 0000000..a677d95 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/BusinessArtistDto.java @@ -0,0 +1,27 @@ +package com.helpmeCookies.user.dto; + +import com.helpmeCookies.user.entity.BusinessArtist; + +public record BusinessArtistDto( + Long id, + Long userId, + String businessNumber, + String openDate, + String headName, + ArtistInfoDto artistInfo +) { + public static BusinessArtistDto fromEntity(BusinessArtist businessArtist){ + return new BusinessArtistDto( + businessArtist.getId(), + businessArtist.getUserId(), + businessArtist.getBusinessNumber(), + businessArtist.getOpenDate(), + businessArtist.getHeadName(), + ArtistInfoDto.of( + businessArtist.getArtistInfo().getTotalFollowers(), + businessArtist.getArtistInfo().getTotalLikes(), + businessArtist.getArtistInfo().getAbout() + ) + ); + } +} diff --git a/src/main/java/com/helpmeCookies/user/dto/StudentArtistDto.java b/src/main/java/com/helpmeCookies/user/dto/StudentArtistDto.java new file mode 100644 index 0000000..f453c3a --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/StudentArtistDto.java @@ -0,0 +1,27 @@ +package com.helpmeCookies.user.dto; + +import com.helpmeCookies.user.entity.StudentArtist; + +public record StudentArtistDto( + Long id, + Long userId, + String schoolEmail, + String schoolName, + String major, + ArtistInfoDto artistInfo +) { + public static StudentArtistDto fromEntity(StudentArtist studentArtist) { + return new StudentArtistDto( + studentArtist.getId(), + studentArtist.getUserId(), + studentArtist.getSchoolEmail(), + studentArtist.getSchoolName(), + studentArtist.getMajor(), + ArtistInfoDto.of( + studentArtist.getArtistInfo().getTotalFollowers(), + studentArtist.getArtistInfo().getTotalLikes(), + studentArtist.getArtistInfo().getAbout() + ) + ); + } +} diff --git a/src/main/java/com/helpmeCookies/user/dto/UserDto.java b/src/main/java/com/helpmeCookies/user/dto/UserDto.java new file mode 100644 index 0000000..74cc627 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/UserDto.java @@ -0,0 +1,31 @@ +package com.helpmeCookies.user.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.helpmeCookies.product.entity.HashTag; +import com.helpmeCookies.user.entity.User; + +public record UserDto( + Long id, + String nickname, + String email, + String birthdate, + String phone, + String address, + LocalDateTime createdAt, + List hashTags +) { + public static UserDto fromEntity(User user) { + return new UserDto( + user.getId(), + user.getNickname(), + user.getEmail(), + user.getBirthdate(), + user.getPhone(), + user.getAddress(), + user.getCreatedAt(), + user.getHashTags() + ); + } +} diff --git a/src/main/java/com/helpmeCookies/user/dto/request/BusinessArtistReq.java b/src/main/java/com/helpmeCookies/user/dto/request/BusinessArtistReq.java new file mode 100644 index 0000000..607a2f6 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/request/BusinessArtistReq.java @@ -0,0 +1,11 @@ +package com.helpmeCookies.user.dto.request; + +import com.helpmeCookies.user.dto.ArtistInfoDto; + +public record BusinessArtistReq( + String businessNumber, + String openDate, + String headName +) { + +} diff --git a/src/main/java/com/helpmeCookies/user/dto/request/StudentArtistReq.java b/src/main/java/com/helpmeCookies/user/dto/request/StudentArtistReq.java new file mode 100644 index 0000000..8b5d205 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/request/StudentArtistReq.java @@ -0,0 +1,10 @@ +package com.helpmeCookies.user.dto.request; + +import com.helpmeCookies.user.dto.StudentArtistDto; + +public record StudentArtistReq( + String schoolEmail, + String schoolName, + String major +) { +} diff --git a/src/main/java/com/helpmeCookies/user/dto/response/ArtistInfoRes.java b/src/main/java/com/helpmeCookies/user/dto/response/ArtistInfoRes.java new file mode 100644 index 0000000..c6bd44a --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/response/ArtistInfoRes.java @@ -0,0 +1,16 @@ +package com.helpmeCookies.user.dto.response; + +import com.helpmeCookies.user.dto.ArtistInfoDto; + +public record ArtistInfoRes( + Long totalFollowers, + Long totalLikes, + String about +) { + public static ArtistInfoRes from(ArtistInfoDto artistInfoDto) { + return new ArtistInfoRes( + artistInfoDto.totalFollowers(), + artistInfoDto.totalLikes(), + artistInfoDto.about()); + } +} diff --git a/src/main/java/com/helpmeCookies/user/dto/response/BusinessArtistRes.java b/src/main/java/com/helpmeCookies/user/dto/response/BusinessArtistRes.java new file mode 100644 index 0000000..ad95d6c --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/response/BusinessArtistRes.java @@ -0,0 +1,19 @@ +package com.helpmeCookies.user.dto.response; + +import com.helpmeCookies.user.dto.BusinessArtistDto; + +public record BusinessArtistRes( + String businessNumber, + String openDate, + String headName, + ArtistInfoRes artistInfo +) { + public static BusinessArtistRes from(BusinessArtistDto businessArtistDto) { + return new BusinessArtistRes( + businessArtistDto.businessNumber(), + businessArtistDto.openDate(), + businessArtistDto.headName(), + ArtistInfoRes.from(businessArtistDto.artistInfo()) + ); + } +} diff --git a/src/main/java/com/helpmeCookies/user/dto/response/StudentArtistRes.java b/src/main/java/com/helpmeCookies/user/dto/response/StudentArtistRes.java new file mode 100644 index 0000000..af688ca --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/response/StudentArtistRes.java @@ -0,0 +1,19 @@ +package com.helpmeCookies.user.dto.response; + +import com.helpmeCookies.user.dto.StudentArtistDto; + +public record StudentArtistRes( + String schoolEmail, + String schoolName, + String major, + ArtistInfoRes artistInfo +) { + public static StudentArtistRes from(StudentArtistDto studentArtistDto) { + return new StudentArtistRes( + studentArtistDto.schoolEmail(), + studentArtistDto.schoolName(), + studentArtistDto.major(), + ArtistInfoRes.from(studentArtistDto.artistInfo()) + ); + } +} diff --git a/src/main/java/com/helpmeCookies/user/entity/ArtistInfo.java b/src/main/java/com/helpmeCookies/user/entity/ArtistInfo.java index 83d86e7..3f5ba93 100644 --- a/src/main/java/com/helpmeCookies/user/entity/ArtistInfo.java +++ b/src/main/java/com/helpmeCookies/user/entity/ArtistInfo.java @@ -5,8 +5,17 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@AllArgsConstructor +@Builder public class ArtistInfo { @Id @@ -18,5 +27,5 @@ public class ArtistInfo { @Column(nullable = false) private Long totalLikes; - private String about; + private String about = ""; } diff --git a/src/main/java/com/helpmeCookies/user/entity/BusinessArtist.java b/src/main/java/com/helpmeCookies/user/entity/BusinessArtist.java index b5a3835..67334ad 100644 --- a/src/main/java/com/helpmeCookies/user/entity/BusinessArtist.java +++ b/src/main/java/com/helpmeCookies/user/entity/BusinessArtist.java @@ -6,13 +6,26 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@AllArgsConstructor +@Builder public class BusinessArtist { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false) + private Long userId; + @Column(nullable = false) private String businessNumber; diff --git a/src/main/java/com/helpmeCookies/user/entity/Social.java b/src/main/java/com/helpmeCookies/user/entity/Social.java index b56f515..4c06bae 100644 --- a/src/main/java/com/helpmeCookies/user/entity/Social.java +++ b/src/main/java/com/helpmeCookies/user/entity/Social.java @@ -2,6 +2,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -17,7 +18,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Table(name = "users") // 예약어 회피 @AllArgsConstructor @Builder public class Social { @@ -26,9 +26,9 @@ public class Social { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) private User follower; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) private ArtistInfo following; } diff --git a/src/main/java/com/helpmeCookies/user/entity/StudentArtist.java b/src/main/java/com/helpmeCookies/user/entity/StudentArtist.java index aa90533..db44f6d 100644 --- a/src/main/java/com/helpmeCookies/user/entity/StudentArtist.java +++ b/src/main/java/com/helpmeCookies/user/entity/StudentArtist.java @@ -1,19 +1,34 @@ package com.helpmeCookies.user.entity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@AllArgsConstructor +@Builder public class StudentArtist { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false) + private Long userId; + @Column(nullable = false) private String schoolEmail; @Column(nullable = false) @@ -22,6 +37,7 @@ public class StudentArtist { private String major; // 매핑 - @OneToOne + @OneToOne(cascade = CascadeType.ALL) + @JoinColumn(name = "artist_info_id") private ArtistInfo artistInfo; } diff --git a/src/main/java/com/helpmeCookies/user/entity/User.java b/src/main/java/com/helpmeCookies/user/entity/User.java index 08604fb..c62de27 100644 --- a/src/main/java/com/helpmeCookies/user/entity/User.java +++ b/src/main/java/com/helpmeCookies/user/entity/User.java @@ -1,13 +1,19 @@ package com.helpmeCookies.user.entity; +import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.format.annotation.DateTimeFormat; + import com.helpmeCookies.product.entity.HashTag; import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; @@ -26,6 +32,7 @@ @Table(name = "users") // 예약어 회피 @AllArgsConstructor @Builder +@EntityListeners(AuditingEntityListener.class) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -47,9 +54,14 @@ public class User { @Column(nullable = false) private String address; + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @CreatedDate + @Column(nullable = false, updatable = false) + protected LocalDateTime createdAt; + // 별도의 테이블 생성. 문자열로 저장 @ElementCollection(targetClass = HashTag.class) @CollectionTable(name = "user_hashtags") @Enumerated(EnumType.STRING) - private List hashTags; -} + private List hashTags; // 기본 FetchType.LAZY +} \ No newline at end of file diff --git a/src/main/java/com/helpmeCookies/user/repository/ArtistInfoRepository.java b/src/main/java/com/helpmeCookies/user/repository/ArtistInfoRepository.java new file mode 100644 index 0000000..b2120eb --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/repository/ArtistInfoRepository.java @@ -0,0 +1,8 @@ +package com.helpmeCookies.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.helpmeCookies.user.entity.ArtistInfo; + +public interface ArtistInfoRepository extends JpaRepository { +} diff --git a/src/main/java/com/helpmeCookies/user/repository/BusinessArtistRepository.java b/src/main/java/com/helpmeCookies/user/repository/BusinessArtistRepository.java new file mode 100644 index 0000000..3d8e33d --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/repository/BusinessArtistRepository.java @@ -0,0 +1,8 @@ +package com.helpmeCookies.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.helpmeCookies.user.entity.BusinessArtist; + +public interface BusinessArtistRepository extends JpaRepository { +} diff --git a/src/main/java/com/helpmeCookies/user/repository/SocialRepository.java b/src/main/java/com/helpmeCookies/user/repository/SocialRepository.java new file mode 100644 index 0000000..027839c --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/repository/SocialRepository.java @@ -0,0 +1,8 @@ +package com.helpmeCookies.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.helpmeCookies.user.entity.Social; + +public interface SocialRepository extends JpaRepository { +} diff --git a/src/main/java/com/helpmeCookies/user/repository/StudentArtistRepository.java b/src/main/java/com/helpmeCookies/user/repository/StudentArtistRepository.java new file mode 100644 index 0000000..84102a8 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/repository/StudentArtistRepository.java @@ -0,0 +1,10 @@ +package com.helpmeCookies.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.helpmeCookies.user.entity.StudentArtist; + +@Repository +public interface StudentArtistRepository extends JpaRepository { +} diff --git a/src/main/java/com/helpmeCookies/user/repository/UserRepository.java b/src/main/java/com/helpmeCookies/user/repository/UserRepository.java new file mode 100644 index 0000000..c3ce1bc --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.helpmeCookies.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.helpmeCookies.user.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findById(Long id); +} diff --git a/src/main/java/com/helpmeCookies/user/service/ArtistService.java b/src/main/java/com/helpmeCookies/user/service/ArtistService.java new file mode 100644 index 0000000..8941696 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/service/ArtistService.java @@ -0,0 +1,69 @@ +package com.helpmeCookies.user.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.helpmeCookies.user.dto.ArtistInfoDto; +import com.helpmeCookies.user.dto.BusinessArtistDto; +import com.helpmeCookies.user.dto.StudentArtistDto; +import com.helpmeCookies.user.dto.request.BusinessArtistReq; +import com.helpmeCookies.user.dto.request.StudentArtistReq; +import com.helpmeCookies.user.entity.ArtistInfo; +import com.helpmeCookies.user.entity.BusinessArtist; +import com.helpmeCookies.user.entity.StudentArtist; +import com.helpmeCookies.user.repository.ArtistInfoRepository; +import com.helpmeCookies.user.repository.BusinessArtistRepository; +import com.helpmeCookies.user.repository.StudentArtistRepository; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class ArtistService { + private final BusinessArtistRepository businessArtistRepository; + private final StudentArtistRepository studentArtistRepository; + + @Transactional + public StudentArtistDto registerStudentsArtist(StudentArtistReq studentArtistReq, Long userId) { + // StudentArtist 생성 + ArtistInfo artistInfo = ArtistInfo.builder() + .totalFollowers(0L) + .totalLikes(0L) + .build(); + + StudentArtist studentArtist = StudentArtist.builder() + .userId(userId) + .schoolEmail(studentArtistReq.schoolEmail()) + .schoolName(studentArtistReq.schoolName()) + .major(studentArtistReq.major()) + .artistInfo(artistInfo) + .build(); + + studentArtist = studentArtistRepository.save(studentArtist); + + return StudentArtistDto.fromEntity(studentArtist); + } + + @Transactional + public BusinessArtistDto registerBusinessArtist(BusinessArtistReq businessArtistReq, Long userId) { + // BusinessArtist 생성 + ArtistInfo artistInfo = ArtistInfo.builder() + .totalFollowers(0L) + .totalLikes(0L) + .build(); + + BusinessArtist businessArtist = BusinessArtist + .builder() + .userId(userId) + .businessNumber(businessArtistReq.businessNumber()) + .openDate(businessArtistReq.openDate()) + .headName(businessArtistReq.headName()) + .artistInfo(artistInfo) + .build(); + + businessArtist = businessArtistRepository.save(businessArtist); + + return BusinessArtistDto.fromEntity(businessArtist); + } + +} diff --git a/src/main/java/com/helpmeCookies/user/service/UserService.java b/src/main/java/com/helpmeCookies/user/service/UserService.java new file mode 100644 index 0000000..d5b6aef --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/service/UserService.java @@ -0,0 +1,20 @@ +package com.helpmeCookies.user.service; + +import org.springframework.stereotype.Service; + +import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.user.dto.UserDto; +import com.helpmeCookies.user.repository.UserRepository; + +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + public UserDto getUser(Long userId) { + return UserDto.fromEntity(userRepository.findById(userId).orElseThrow()); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8c37f1b..1493b69 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -15,4 +15,18 @@ spring: # test profile logging.level: org.hibernate: orm.jdbc.bind: trace - SQL: debug \ No newline at end of file + SQL: debug + +jwt: + secret: 4099a46b-39db-4860-a61b-2ae76ea24c43 + access-token-expire-time: 1800000 # 30 minutes + refresh-token-expire-time: 2592000000 # 30 days + +management: + endpoints: + web: + exposure: # 외부에 노출할 엔드포인트 + include: prometheus, health, info, swagger-ui + metrics: + tags: + application: "katecam" # 메트릭 데이터에 태그를 추가 \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..f765a99 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,81 @@ +INSERT INTO users (nickname, email, birthdate, phone, address, created_at) +VALUES + ('JohnDoe', 'johndoe@example.com', '1990-01-15', '010-1234-5678', 'Seoul, South Korea', '2024-09-23T12:00:00'), + ('JaneSmith', 'janesmith@example.com', '1985-05-23', '010-9876-5432', 'Busan, South Korea', '2024-09-23T12:00:00'), + ('MikeJohnson', 'mikejohnson@example.com', '1992-03-10', '010-5678-1234', 'Incheon, South Korea', '2024-09-23T12:00:00'), + ('EmilyDavis', 'emilydavis@example.com', '1988-08-17', '010-8765-4321', 'Daegu, South Korea', '2024-09-23T12:00:00'), + ('DavidWilson', 'davidwilson@example.com', '1995-07-07', '010-1111-2222', 'Daejeon, South Korea', '2024-09-23T12:00:00'), + ('SophiaMiller', 'sophiamiller@example.com', '1989-02-25', '010-3333-4444', 'Gwangju, South Korea', '2024-09-23T12:00:00'), + ('JamesBrown', 'jamesbrown@example.com', '1993-11-30', '010-5555-6666', 'Ulsan, South Korea', '2024-09-23T12:00:00'), + ('OliviaTaylor', 'oliviataylor@example.com', '1996-05-05', '010-7777-8888', 'Jeonju, South Korea', '2024-09-23T12:00:00'), + ('BenjaminLee', 'benjaminlee@example.com', '1987-09-15', '010-9999-0000', 'Cheongju, South Korea', '2024-09-23T12:00:00'), + ('IsabellaClark', 'isabellaclark@example.com', '1991-12-12', '010-1122-3344', 'Suwon, South Korea', '2024-09-23T12:00:00'), + ('HenryWhite', 'henrywhite@example.com', '1986-04-18', '010-2233-4455', 'Seongnam, South Korea', '2024-09-23T12:00:00'), + ('MiaHarris', 'miaharris@example.com', '1994-10-10', '010-3344-5566', 'Pohang, South Korea', '2024-09-23T12:00:00'), + ('LucasMartin', 'lucasmartin@example.com', '1997-06-06', '010-4455-6677', 'Changwon, South Korea', '2024-09-23T12:00:00'), + ('EllaYoung', 'ellayoung@example.com', '1998-03-03', '010-5566-7788', 'Yeosu, South Korea', '2024-09-23T12:00:00'), + ('WilliamKing', 'williamking@example.com', '1983-08-08', '010-6677-8899', 'Jeju, South Korea', '2024-09-23T12:00:00'), + ('AmeliaScott', 'ameliascott@example.com', '1990-07-17', '010-7788-9900', 'Gimhae, South Korea', '2024-09-23T12:00:00'), + ('JackMoore', 'jackmoore@example.com', '1984-02-02', '010-8899-0011', 'Ansan, South Korea', '2024-09-23T12:00:00'), + ('AvaWalker', 'avawalker@example.com', '1999-11-11', '010-9900-1122', 'Guri, South Korea', '2024-09-23T12:00:00'), + ('DanielPerez', 'danielperez@example.com', '1992-05-21', '010-0011-2233', 'Yangsan, South Korea', '2024-09-23T12:00:00'), + ('LilyHall', 'lilyhall@example.com', '1991-01-01', '010-1122-3344', 'Iksan, South Korea', '2024-09-23T12:00:00'); +-- User 1 (JohnDoe) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (1, 'DREAMLIKE'); + +-- User 2 (JaneSmith) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (2, 'DREAMLIKE'); + +-- User 3 (MikeJohnson) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (3, 'DREAMLIKE'); + +-- User 4 (EmilyDavis) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (4, 'DREAMLIKE'); + +-- User 5 (DavidWilson) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (5, 'DREAMLIKE'); + +-- User 6 (SophiaMiller) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (6, 'DREAMLIKE'); + +-- User 7 (JamesBrown) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (7, 'DREAMLIKE'); + +-- User 8 (OliviaTaylor) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (8, 'DREAMLIKE'); + +-- User 9 (BenjaminLee) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (9, 'DREAMLIKE'); + +-- User 10 (IsabellaClark) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (10, 'DREAMLIKE'); + +-- User 11 (HenryWhite) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (11, 'DREAMLIKE'); + +-- User 12 (MiaHarris) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (12, 'DREAMLIKE'); + +-- User 13 (LucasMartin) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (13, 'DREAMLIKE'); + +-- User 14 (EllaYoung) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (14, 'DREAMLIKE'); + +-- User 15 (WilliamKing) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (15, 'DREAMLIKE'); + +-- User 16 (AmeliaScott) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (16, 'DREAMLIKE'); + +-- User 17 (JackMoore) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (17, 'DREAMLIKE'); + +-- User 18 (AvaWalker) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (18, 'DREAMLIKE'); + +-- User 19 (DanielPerez) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (19, 'DREAMLIKE'); + +-- User 20 (LilyHall) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (20, 'DREAMLIKE'); diff --git a/src/test/java/com/helpmeCookies/e2e/ArtistE2Etest.java b/src/test/java/com/helpmeCookies/e2e/ArtistE2Etest.java new file mode 100644 index 0000000..6d76c7b --- /dev/null +++ b/src/test/java/com/helpmeCookies/e2e/ArtistE2Etest.java @@ -0,0 +1,67 @@ +package com.helpmeCookies.e2e; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +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.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.helpmeCookies.global.jwt.JwtProvider; +import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.user.dto.request.StudentArtistReq; + +@SpringBootTest +@AutoConfigureMockMvc +public class ArtistE2Etest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Test + public void testRegisterStudents_withValidToken() throws Exception { + // given + String token = jwtProvider.createToken( + JwtUser.builder() + .id(1L) + .username("JohnDoe") + .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER"))) + .build() + ).getAccessToken(); + + StudentArtistReq request = new StudentArtistReq( + "student@example.com", + "Example University", + "Computer Science" + ); + + String requestJson = objectMapper.writeValueAsString(request); + + // when & then + mockMvc.perform(post("/v1/artists/students") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.schoolEmail").value("student@example.com")) + .andExpect(jsonPath("$.schoolName").value("Example University")) + .andExpect(jsonPath("$.major").value("Computer Science")) + .andExpect(jsonPath("$.artistInfo.totalFollowers").value(0)) + .andExpect(jsonPath("$.artistInfo.totalLikes").value(0)) + .andExpect(jsonPath("$.artistInfo.about").doesNotExist()); + } +} diff --git a/src/test/java/com/helpmeCookies/e2e/JwtTestUtils.java b/src/test/java/com/helpmeCookies/e2e/JwtTestUtils.java new file mode 100644 index 0000000..6093b44 --- /dev/null +++ b/src/test/java/com/helpmeCookies/e2e/JwtTestUtils.java @@ -0,0 +1,23 @@ +package com.helpmeCookies.e2e; + +import java.util.List; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import com.helpmeCookies.global.jwt.JwtProvider; +import com.helpmeCookies.global.jwt.JwtToken; +import com.helpmeCookies.global.jwt.JwtUser; + + +public class JwtTestUtils { + + public static JwtToken generateValidToken(JwtProvider jwtProvider, Long userId, String username, String role) { + JwtUser jwtUser = JwtUser.builder() + .id(userId) + .username(username) + .authorities(List.of(new SimpleGrantedAuthority("ROLE_" + role))) + .build(); + + return jwtProvider.createToken(jwtUser); + } +} diff --git a/src/test/java/com/helpmeCookies/e2e/LoginE2ETest.java b/src/test/java/com/helpmeCookies/e2e/LoginE2ETest.java new file mode 100644 index 0000000..c088555 --- /dev/null +++ b/src/test/java/com/helpmeCookies/e2e/LoginE2ETest.java @@ -0,0 +1,42 @@ +package com.helpmeCookies.e2e; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import com.helpmeCookies.e2e.JwtTestUtils; +import com.helpmeCookies.global.jwt.JwtProvider; +import com.helpmeCookies.global.jwt.JwtToken; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") // 테스트용 프로파일 사용 +public class LoginE2ETest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtProvider jwtProvider; + + @Test + public void testRegisterStudents_withValidToken() throws Exception { + // given + JwtToken token = JwtTestUtils.generateValidToken(jwtProvider, 1L, "JohnDoe", "ROLE_USER"); + + // when & then + mockMvc.perform(get("/login") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token.getAccessToken())) + .andExpect(status().isOk()) + .andExpect(content().contentType("text/plain;charset=UTF-8")) + .andExpect(content().string("johndoe@example.com")); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..bf26694 --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,24 @@ +spring: # test profile + datasource: + url: "jdbc:h2:mem:Code2CV" + username: "sa" + password: "" + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + defer-datasource-initialization: true + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + format_sql: true # SQL 포맷팅 + highlight_sql: true +logging.level: + org.hibernate: + orm.jdbc.bind: trace + SQL: debug + +jwt: + secret: testtesttesttesttesttesttesttesttest + access-token-expire-time: 1800000 # 30 minutes + refresh-token-expire-time: 2592000000 # 30 days \ No newline at end of file diff --git a/src/test/resources/data.sql b/src/test/resources/data.sql new file mode 100644 index 0000000..f765a99 --- /dev/null +++ b/src/test/resources/data.sql @@ -0,0 +1,81 @@ +INSERT INTO users (nickname, email, birthdate, phone, address, created_at) +VALUES + ('JohnDoe', 'johndoe@example.com', '1990-01-15', '010-1234-5678', 'Seoul, South Korea', '2024-09-23T12:00:00'), + ('JaneSmith', 'janesmith@example.com', '1985-05-23', '010-9876-5432', 'Busan, South Korea', '2024-09-23T12:00:00'), + ('MikeJohnson', 'mikejohnson@example.com', '1992-03-10', '010-5678-1234', 'Incheon, South Korea', '2024-09-23T12:00:00'), + ('EmilyDavis', 'emilydavis@example.com', '1988-08-17', '010-8765-4321', 'Daegu, South Korea', '2024-09-23T12:00:00'), + ('DavidWilson', 'davidwilson@example.com', '1995-07-07', '010-1111-2222', 'Daejeon, South Korea', '2024-09-23T12:00:00'), + ('SophiaMiller', 'sophiamiller@example.com', '1989-02-25', '010-3333-4444', 'Gwangju, South Korea', '2024-09-23T12:00:00'), + ('JamesBrown', 'jamesbrown@example.com', '1993-11-30', '010-5555-6666', 'Ulsan, South Korea', '2024-09-23T12:00:00'), + ('OliviaTaylor', 'oliviataylor@example.com', '1996-05-05', '010-7777-8888', 'Jeonju, South Korea', '2024-09-23T12:00:00'), + ('BenjaminLee', 'benjaminlee@example.com', '1987-09-15', '010-9999-0000', 'Cheongju, South Korea', '2024-09-23T12:00:00'), + ('IsabellaClark', 'isabellaclark@example.com', '1991-12-12', '010-1122-3344', 'Suwon, South Korea', '2024-09-23T12:00:00'), + ('HenryWhite', 'henrywhite@example.com', '1986-04-18', '010-2233-4455', 'Seongnam, South Korea', '2024-09-23T12:00:00'), + ('MiaHarris', 'miaharris@example.com', '1994-10-10', '010-3344-5566', 'Pohang, South Korea', '2024-09-23T12:00:00'), + ('LucasMartin', 'lucasmartin@example.com', '1997-06-06', '010-4455-6677', 'Changwon, South Korea', '2024-09-23T12:00:00'), + ('EllaYoung', 'ellayoung@example.com', '1998-03-03', '010-5566-7788', 'Yeosu, South Korea', '2024-09-23T12:00:00'), + ('WilliamKing', 'williamking@example.com', '1983-08-08', '010-6677-8899', 'Jeju, South Korea', '2024-09-23T12:00:00'), + ('AmeliaScott', 'ameliascott@example.com', '1990-07-17', '010-7788-9900', 'Gimhae, South Korea', '2024-09-23T12:00:00'), + ('JackMoore', 'jackmoore@example.com', '1984-02-02', '010-8899-0011', 'Ansan, South Korea', '2024-09-23T12:00:00'), + ('AvaWalker', 'avawalker@example.com', '1999-11-11', '010-9900-1122', 'Guri, South Korea', '2024-09-23T12:00:00'), + ('DanielPerez', 'danielperez@example.com', '1992-05-21', '010-0011-2233', 'Yangsan, South Korea', '2024-09-23T12:00:00'), + ('LilyHall', 'lilyhall@example.com', '1991-01-01', '010-1122-3344', 'Iksan, South Korea', '2024-09-23T12:00:00'); +-- User 1 (JohnDoe) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (1, 'DREAMLIKE'); + +-- User 2 (JaneSmith) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (2, 'DREAMLIKE'); + +-- User 3 (MikeJohnson) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (3, 'DREAMLIKE'); + +-- User 4 (EmilyDavis) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (4, 'DREAMLIKE'); + +-- User 5 (DavidWilson) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (5, 'DREAMLIKE'); + +-- User 6 (SophiaMiller) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (6, 'DREAMLIKE'); + +-- User 7 (JamesBrown) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (7, 'DREAMLIKE'); + +-- User 8 (OliviaTaylor) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (8, 'DREAMLIKE'); + +-- User 9 (BenjaminLee) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (9, 'DREAMLIKE'); + +-- User 10 (IsabellaClark) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (10, 'DREAMLIKE'); + +-- User 11 (HenryWhite) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (11, 'DREAMLIKE'); + +-- User 12 (MiaHarris) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (12, 'DREAMLIKE'); + +-- User 13 (LucasMartin) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (13, 'DREAMLIKE'); + +-- User 14 (EllaYoung) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (14, 'DREAMLIKE'); + +-- User 15 (WilliamKing) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (15, 'DREAMLIKE'); + +-- User 16 (AmeliaScott) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (16, 'DREAMLIKE'); + +-- User 17 (JackMoore) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (17, 'DREAMLIKE'); + +-- User 18 (AvaWalker) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (18, 'DREAMLIKE'); + +-- User 19 (DanielPerez) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (19, 'DREAMLIKE'); + +-- User 20 (LilyHall) +INSERT INTO user_hashtags (user_id, hash_tags) VALUES (20, 'DREAMLIKE');