diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eb21e9f..851f6e2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -66,10 +66,6 @@ jobs: key: ${{ secrets.AWS_EC2_PRIVATE_KEY }} # EC2 인스턴스 pem key port: ${{ secrets.REMOTE_SSH_PORT }} # 접속 포트(생략 시 22번 기본 사용) script: | - echo '${{ secrets.APPLICATION_YAML }}' > test.yaml - cat test.yaml - docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - docker pull ${{ secrets.DOCKER_USERNAME }}/katecam-backend:latest - docker stop katecam-backend - docker rm $(docker ps --filter 'status=exited' -a -q) - docker run -d --name katecam-backend --network katecam-backend --log-driver=syslog -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/katecam-backend:latest + cd /home/ubuntu # EC2 인스턴스의 배포 스크립트 파일 경로로 이동 + chmod +x deploy.sh # 배포 스크립트 실행 권한 부여 + ./deploy.sh # 배포 스크립트 실행 diff --git a/build.gradle b/build.gradle index 2000e5e..a8dbafa 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-oauth2-client' // Lombok compileOnly 'org.projectlombok:lombok' @@ -70,7 +71,14 @@ dependencies { //S3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + //Test + testImplementation 'org.testcontainers:testcontainers:1.20.2' + testImplementation 'org.testcontainers:junit-jupiter:1.20.2' + testImplementation 'org.testcontainers:mysql:1.20.2' + } + tasks.named('test') { useJUnitPlatform() } diff --git a/src/main/java/com/helpmeCookies/Step3Application.java b/src/main/java/com/helpmeCookies/Step3Application.java index c6bad29..bc273eb 100644 --- a/src/main/java/com/helpmeCookies/Step3Application.java +++ b/src/main/java/com/helpmeCookies/Step3Application.java @@ -2,6 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication public class Step3Application { diff --git a/src/main/java/com/helpmeCookies/global/entity/BaseTimeEntity.java b/src/main/java/com/helpmeCookies/global/entity/BaseTimeEntity.java new file mode 100644 index 0000000..9279fc3 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/entity/BaseTimeEntity.java @@ -0,0 +1,21 @@ +package com.helpmeCookies.global.entity; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseTimeEntity { + + @CreatedDate + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime modifiedDate; +} diff --git a/src/main/java/com/helpmeCookies/global/infra/CustomDatabaseHealthIndicator.java b/src/main/java/com/helpmeCookies/global/infra/CustomDatabaseHealthIndicator.java new file mode 100644 index 0000000..fabd642 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/infra/CustomDatabaseHealthIndicator.java @@ -0,0 +1,27 @@ +package com.helpmeCookies.global.infra; + +import com.helpmeCookies.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomDatabaseHealthIndicator implements HealthIndicator { + + private final UserRepository userRepository; + @Override + public Health health() { + try { + // 테이블이 존재하는지 확인하는 쿼리 (Users 테이블 사용) + long count = userRepository.count(); + // 테이블 존재 시 0 이상을 반환 + return Health.up().withDetail("Users table exists, count: ", count).build(); + } catch (Exception e) { + // 테이블이 없거나 데이터베이스 연결에 문제가 있는 경우 + return Health.down(e).withDetail("Users table", "Table missing or database issue") + .build(); + } + } +} diff --git a/src/main/java/com/helpmeCookies/global/jwt/JwtProvider.java b/src/main/java/com/helpmeCookies/global/jwt/JwtProvider.java index 89662cd..6eb0844 100644 --- a/src/main/java/com/helpmeCookies/global/jwt/JwtProvider.java +++ b/src/main/java/com/helpmeCookies/global/jwt/JwtProvider.java @@ -44,15 +44,16 @@ public JwtToken createToken(JwtUser jwtUser) { .build(); } - // 유요한 토큰인지 확인 + /* + 토큰 검증시 rawToken을 Claims로 변환하고, 해당 토큰이 accessToken이면서 만료되어있지 않다면 True를 반환한다. + */ + 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; @@ -62,22 +63,24 @@ public boolean validateToken(String rawToken, boolean isAccessToken) { /** * refreshToken을 통해, accessToken을 재발급하는 메서드. * refreshToken의 유효성을 검사하고, isAccessToken이 true일때만 accessToken을 재발급한다. - * TODO: refreshToken을 저장하고, 저장된 refreshToken과 비교하는 로직 필요 + * TODO: refreshToken을 저장하고, 저장된 refreshToken과 비교하는 로직 필요 redis 추가 후 구현 */ public String reissueAccessToken(String refreshToken) { Claims claims = extractClaims(refreshToken); if (claims.get(IS_ACCESS_TOKEN, Boolean.class)) { throw new IllegalArgumentException("리프레시 토큰이 아닙니다."); } + + Date expiration = claims.getExpiration(); + if (expiration.before(new Date())) { + 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); @@ -88,10 +91,6 @@ private JwtUser claimsToJwtUser(Claims claims) { 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); @@ -103,7 +102,6 @@ private String generateToken(JwtUser jwtUser, boolean isAccessToken) { .compact(); } - private Claims extractClaims(String rawToken) { return Jwts.parserBuilder() .setSigningKey(secretKey) @@ -112,9 +110,6 @@ private Claims extractClaims(String rawToken) { .getBody(); } - /** - * HS256방식의 키를 생성한다. - */ @Override public void afterPropertiesSet() { secretKey = new SecretKeySpec(secret.getBytes(), SignatureAlgorithm.HS256.getJcaName()); diff --git a/src/main/java/com/helpmeCookies/global/security/JwtAccessDeniedHandler.java b/src/main/java/com/helpmeCookies/global/security/JwtAccessDeniedHandler.java index f6dde1c..f11567c 100644 --- a/src/main/java/com/helpmeCookies/global/security/JwtAccessDeniedHandler.java +++ b/src/main/java/com/helpmeCookies/global/security/JwtAccessDeniedHandler.java @@ -26,7 +26,5 @@ public class JwtAccessDeniedHandler implements AccessDeniedHandler { 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 index 2072bb2..2f9a5a8 100644 --- a/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationEntryPoint.java @@ -24,6 +24,5 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { 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 index 1e70986..4570b65 100644 --- a/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/helpmeCookies/global/security/JwtAuthenticationFilter.java @@ -41,7 +41,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - // TODO: UserDetailsService를 통해 사용자 정보를 가져와 인증을 진행한다. if (jwtProvider.validateToken(rawToken, true)) { JwtUser jwtUser = jwtProvider.getJwtUser(rawToken); Authentication authentication = new UsernamePasswordAuthenticationToken(jwtUser, null, diff --git a/src/main/java/com/helpmeCookies/global/security/Oauth2CustomUserService.java b/src/main/java/com/helpmeCookies/global/security/Oauth2CustomUserService.java new file mode 100644 index 0000000..6041900 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/security/Oauth2CustomUserService.java @@ -0,0 +1,22 @@ +package com.helpmeCookies.global.security; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.helpmeCookies.user.service.UserService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class Oauth2CustomUserService implements OAuth2UserService { + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + return oAuth2User; + } +} diff --git a/src/main/java/com/helpmeCookies/global/security/UserDetailService.java b/src/main/java/com/helpmeCookies/global/security/UserDetailService.java new file mode 100644 index 0000000..3523705 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/security/UserDetailService.java @@ -0,0 +1,34 @@ +package com.helpmeCookies.global.security; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.user.entity.User; +import com.helpmeCookies.user.entity.UserInfo; +import com.helpmeCookies.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserDetailService { + private final UserRepository userRepository; + + + public JwtUser loadUserByEmail(String email,String nickname) throws UsernameNotFoundException { + // 만약 유저가 존재하지 않는다면 저장 + User user = userRepository.findByUserInfoEmail(email) + .orElseGet(() -> { + User newUser = User.builder() + .userInfo(UserInfo.builder() + .email(email) + .build()) + .nickname(nickname) + .build(); + return userRepository.save(newUser); + }); + return JwtUser.of(user.getId()); + } +} diff --git a/src/main/java/com/helpmeCookies/global/security/WebSecurityConfig.java b/src/main/java/com/helpmeCookies/global/security/WebSecurityConfig.java index 1d36144..d84cc09 100644 --- a/src/main/java/com/helpmeCookies/global/security/WebSecurityConfig.java +++ b/src/main/java/com/helpmeCookies/global/security/WebSecurityConfig.java @@ -19,8 +19,6 @@ @Configuration @EnableWebSecurity @RequiredArgsConstructor - - @Controller public class WebSecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @@ -30,8 +28,33 @@ public class WebSecurityConfig { @Bean public WebSecurityCustomizer configure() { return (web) -> web.ignoring() - .requestMatchers("/static/**") - .requestMatchers("/test/**"); + .requestMatchers("/swagger-ui") + .requestMatchers("/static/**"); + } + + @Bean + public SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/oauth2/**") + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/oauth2/authorization/**", + "/oauth2/code/kakao/**" + ).permitAll() + .anyRequest().authenticated() + ) + .oauth2Login((oauth2) -> oauth2 + .redirectionEndpoint(redirection -> redirection + .baseUri("/oauth2/code/*")) + .userInfoEndpoint((userInfo) -> userInfo + .userService(new Oauth2CustomUserService()) + ) + // 추후 로그인 방식이 다양해지면, Handler의 세부 내용을 변경. + .successHandler((request, response, authentication) -> { + response.sendRedirect("/oauth2/login/kakao"); + }) + ); + return http.build(); } @Bean @@ -43,19 +66,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((authorize) -> authorize .requestMatchers( - "/login", "/signup", "/", "/user", + "/login", "/signup", "/ttt/*", "/user", "/api/auth/**", "/swagger-ui/**", "/swagger-resources", "/v3/api-docs/**", "/actuator/**", "/v1/**", - "swagger-ui/**" + "swagger-ui/**", + "/test/signup" ).permitAll() .anyRequest().authenticated() - ); - - http.exceptionHandling((exception) -> exception + ).exceptionHandling((exception) -> exception .authenticationEntryPoint(jwtAuthenticationEntryPoint) .accessDeniedHandler(jwtAccessDeniedHandler) ); diff --git a/src/main/java/com/helpmeCookies/global/utils/AwsS3FileUtils.java b/src/main/java/com/helpmeCookies/global/utils/AwsS3FileUtils.java index afc9043..f6d76d3 100644 --- a/src/main/java/com/helpmeCookies/global/utils/AwsS3FileUtils.java +++ b/src/main/java/com/helpmeCookies/global/utils/AwsS3FileUtils.java @@ -4,7 +4,7 @@ import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; -import com.helpmeCookies.product.dto.FileUploadResponse; +import com.helpmeCookies.product.dto.ImageUpload; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -14,7 +14,6 @@ import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.UUID; @@ -28,26 +27,21 @@ public class AwsS3FileUtils { private String bucket; //다중파일 업로드후 url 반환 - public List uploadMultiImages(List multipartFiles) { - List fileList = new ArrayList<>(); + public ImageUpload uploadMultiImages(MultipartFile multipartFile) { - multipartFiles.forEach(file -> { - String fileName = createFileName(file.getOriginalFilename()); //파일 이름 난수화 - ObjectMetadata objectMetadata = new ObjectMetadata(); - objectMetadata.setContentLength(file.getSize()); - objectMetadata.setContentType(file.getContentType()); + String fileName = createFileName(multipartFile.getOriginalFilename()); //파일 이름 난수화 + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(multipartFile.getSize()); + objectMetadata.setContentType(multipartFile.getContentType()); - try (InputStream inputStream = file.getInputStream()) { - amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) - .withCannedAcl(CannedAccessControlList.PublicRead)); - } catch (IOException e) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 실패" + fileName); - } - - fileList.add(new FileUploadResponse(amazonS3.getUrl(bucket,fileName).toString(),fileName)); - }); + try (InputStream inputStream = multipartFile.getInputStream()) { + amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + } catch (IOException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 실패" + fileName); + } - return fileList; + return new ImageUpload(amazonS3.getUrl(bucket,fileName).toString()); } public String createFileName(String fileName) { diff --git a/src/main/java/com/helpmeCookies/product/controller/ProductController.java b/src/main/java/com/helpmeCookies/product/controller/ProductController.java index c655ee7..59e4985 100644 --- a/src/main/java/com/helpmeCookies/product/controller/ProductController.java +++ b/src/main/java/com/helpmeCookies/product/controller/ProductController.java @@ -1,44 +1,51 @@ package com.helpmeCookies.product.controller; -import com.helpmeCookies.product.dto.FileUploadResponse; +import com.helpmeCookies.product.dto.ImageUpload; +import static com.helpmeCookies.product.util.SortUtil.convertProductSort; + +import com.helpmeCookies.product.controller.docs.ProductApiDocs; import com.helpmeCookies.product.dto.ProductImageResponse; +import com.helpmeCookies.product.dto.ProductPage; import com.helpmeCookies.product.dto.ProductRequest; import com.helpmeCookies.product.dto.ProductResponse; import com.helpmeCookies.product.entity.Product; import com.helpmeCookies.product.service.ProductImageService; import com.helpmeCookies.product.service.ProductService; +import com.helpmeCookies.product.util.ProductSort; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.util.List; @RestController -@RequestMapping("/api/v1/products") +@RequestMapping("/v1/products") @RequiredArgsConstructor -public class ProductController { +public class ProductController implements ProductApiDocs { private final ProductService productService; private final ProductImageService productImageService; @PostMapping public ResponseEntity saveProduct(@RequestBody ProductRequest productRequest) { - productService.save(productRequest); + Product product = productService.save(productRequest); + productImageService.saveImages(product.getId(),productRequest.imageUrls()); return ResponseEntity.ok().build(); } - @PostMapping("/{productId}/images") - public ResponseEntity uploadImages(@PathVariable("productId") Long productId, List files) throws IOException { - List responses = productImageService.uploadMultiFiles(productId,files); - return ResponseEntity.ok(new ProductImageResponse(responses.stream().map(FileUploadResponse::photoUrl).toList())); + @PostMapping("/images") + public ResponseEntity uploadImages(List files) { + List responses = productImageService.uploadMultiFiles(files); + return ResponseEntity.ok(new ProductImageResponse(responses.stream().map(ImageUpload::photoUrl).toList())); } @GetMapping("/{productId}") public ResponseEntity getProductInfo(@PathVariable("productId") Long productId) { Product product = productService.find(productId); - return ResponseEntity.ok(ProductResponse.from(product)); + List urls = productImageService.getImages(productId); + return ResponseEntity.ok(ProductResponse.from(product,urls)); } @PutMapping("/{productId}") @@ -49,8 +56,11 @@ public ResponseEntity editProductInfo(@PathVariable("productId") Long prod } @PutMapping("/{productId}/images") - public ResponseEntity editImages(@PathVariable("productId") Long productId, List files) throws IOException { + public ResponseEntity editImages(@PathVariable("productId") Long productId, List files) { productImageService.editImages(productId, files); + List images = productImageService.uploadMultiFiles(files).stream() + .map(ImageUpload::photoUrl).toList(); + productImageService.saveImages(productId,images); return ResponseEntity.ok().build(); } @@ -59,4 +69,17 @@ public ResponseEntity deleteProduct(@PathVariable("productId") Long produc productService.delete(productId); return ResponseEntity.noContent().build(); } + + @GetMapping + public ResponseEntity getProductsByPage( + @RequestParam("query") String query, + @RequestParam(name = "size", required = false, defaultValue = "20") int size, + @RequestParam("page") int page, + @RequestParam("sort") ProductSort productSort + ) { + var sort = convertProductSort(productSort); + var pageable = PageRequest.of(page, size, sort); + + return ResponseEntity.ok(productService.getProductsByPage(query, pageable)); + } } diff --git a/src/main/java/com/helpmeCookies/product/controller/docs/ProductApiDocs.java b/src/main/java/com/helpmeCookies/product/controller/docs/ProductApiDocs.java new file mode 100644 index 0000000..e0097b5 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/controller/docs/ProductApiDocs.java @@ -0,0 +1,21 @@ +package com.helpmeCookies.product.controller.docs; + +import com.helpmeCookies.product.dto.ProductPage.Paging; +import com.helpmeCookies.product.util.ProductSort; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "상품 관련 기능", description = "상품 관련 API") +public interface ProductApiDocs { + + @Operation(summary = "상품 검색") + ResponseEntity getProductsByPage( + String query, + @Parameter(description = "default value 20") int size, + int page, + ProductSort productSort + ); + +} diff --git a/src/main/java/com/helpmeCookies/product/dto/FileUploadResponse.java b/src/main/java/com/helpmeCookies/product/dto/ImageUpload.java similarity index 73% rename from src/main/java/com/helpmeCookies/product/dto/FileUploadResponse.java rename to src/main/java/com/helpmeCookies/product/dto/ImageUpload.java index ad03aff..fa847b3 100644 --- a/src/main/java/com/helpmeCookies/product/dto/FileUploadResponse.java +++ b/src/main/java/com/helpmeCookies/product/dto/ImageUpload.java @@ -2,15 +2,13 @@ import com.helpmeCookies.product.entity.ProductImage; -public record FileUploadResponse( - String photoUrl, - String uuid +public record ImageUpload( + String photoUrl ) { public ProductImage toEntity(Long productId) { return ProductImage.builder() .productId(productId) .photoUrl(photoUrl) - .uuid(uuid) .build(); } } diff --git a/src/main/java/com/helpmeCookies/product/dto/ProductPage.java b/src/main/java/com/helpmeCookies/product/dto/ProductPage.java new file mode 100644 index 0000000..9778e2f --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/dto/ProductPage.java @@ -0,0 +1,46 @@ +package com.helpmeCookies.product.dto; + +import com.helpmeCookies.product.repository.dto.ProductSearch; +import java.util.List; +import org.springframework.data.domain.Page; + +public class ProductPage { + + public record Info( + Long id, + String name, + String artist, + Long price, + String thumbnailUrl + ) { + public static Info from(ProductSearch productSearch) { + return new Info( + productSearch.getId(), + productSearch.getName(), + productSearch.getArtist(), + productSearch.getPrice(), + productSearch.getThumbnailUrl() + ); + } + + public static List of(List content) { + return content.stream() + .map(Info::from) + .toList(); + } + } + + public record Paging ( + boolean hasNext, + List products + ) { + + public static Paging from(Page productPage) { + return new Paging( + productPage.hasNext(), + Info.of(productPage.getContent()) + ); + } + } + +} diff --git a/src/main/java/com/helpmeCookies/product/dto/ProductRequest.java b/src/main/java/com/helpmeCookies/product/dto/ProductRequest.java index e1dfa2d..b0f73d5 100644 --- a/src/main/java/com/helpmeCookies/product/dto/ProductRequest.java +++ b/src/main/java/com/helpmeCookies/product/dto/ProductRequest.java @@ -15,7 +15,9 @@ public record ProductRequest( String description, String preferredLocation, List hashTags, - Long artistInfo + Long artistInfoId, + List imageUrls + ) { public Product toEntity(ArtistInfo artistInfo) { return Product.builder() diff --git a/src/main/java/com/helpmeCookies/product/dto/ProductResponse.java b/src/main/java/com/helpmeCookies/product/dto/ProductResponse.java index 36b6e95..4e927e6 100644 --- a/src/main/java/com/helpmeCookies/product/dto/ProductResponse.java +++ b/src/main/java/com/helpmeCookies/product/dto/ProductResponse.java @@ -14,28 +14,13 @@ public record ProductResponse( String description, String preferredLocation, List hashTags, - ArtistInfo artistInfo + ArtistInfo artistInfo, + List imageUrls ) { - 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 record ArtistInfo(Long artistId, String artistName) { } - public static ProductResponse from(Product product) { - //TODO artistInfo 코드 개발 이후 수정 예정 + public static ProductResponse from(Product product, List urls) { return new ProductResponse( product.getId(), product.getName(), @@ -45,9 +30,8 @@ public static ProductResponse from(Product product) { product.getDescription(), product.getPreferredLocation(), product.getHashTags(), - new ArtistInfo( - 1L, "임시" - ) + new ArtistInfo(product.getArtistInfo().getId(),product.getArtistInfo().getNickname()), + urls ); } } diff --git a/src/main/java/com/helpmeCookies/product/entity/Product.java b/src/main/java/com/helpmeCookies/product/entity/Product.java index d44b972..7f07fa3 100644 --- a/src/main/java/com/helpmeCookies/product/entity/Product.java +++ b/src/main/java/com/helpmeCookies/product/entity/Product.java @@ -1,5 +1,7 @@ package com.helpmeCookies.product.entity; +import com.helpmeCookies.global.entity.BaseTimeEntity; +import jakarta.persistence.JoinColumn; import java.util.List; import com.helpmeCookies.user.entity.ArtistInfo; @@ -17,7 +19,7 @@ import lombok.Builder; @Entity -public class Product { +public class Product extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -42,12 +44,15 @@ public class Product { @Column(nullable = false) private String preferredLocation; + private String thumbnailUrl; + @ElementCollection(targetClass = HashTag.class) @CollectionTable(name = "product_hashtags") @Enumerated(EnumType.STRING) private List hashTags; @ManyToOne + @JoinColumn(name = "artist_info_id") private ArtistInfo artistInfo; public Product() {} @@ -110,4 +115,8 @@ public void update(String name, Category category, String size, Long price, Stri this.hashTags = hashTags; this.artistInfo = artistInfo; } + + public void updateThumbnail(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } } diff --git a/src/main/java/com/helpmeCookies/product/entity/ProductImage.java b/src/main/java/com/helpmeCookies/product/entity/ProductImage.java index 0a23f4f..ed6cf36 100644 --- a/src/main/java/com/helpmeCookies/product/entity/ProductImage.java +++ b/src/main/java/com/helpmeCookies/product/entity/ProductImage.java @@ -25,4 +25,9 @@ public ProductImage(String photoUrl, Long productId, String uuid) { this.productId = productId; this.uuid = uuid; } + + public String getPhotoUrl() { + return photoUrl; + } } + diff --git a/src/main/java/com/helpmeCookies/product/repository/ProductRepository.java b/src/main/java/com/helpmeCookies/product/repository/ProductRepository.java index d8b05f3..4b23b79 100644 --- a/src/main/java/com/helpmeCookies/product/repository/ProductRepository.java +++ b/src/main/java/com/helpmeCookies/product/repository/ProductRepository.java @@ -1,9 +1,23 @@ package com.helpmeCookies.product.repository; import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.product.repository.dto.ProductSearch; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface ProductRepository extends JpaRepository { + + @Query(value = "SELECT p.id, p.name, p.thumbnail_url, a.nickname AS artist, p.price " + + "FROM product p JOIN artist_info a ON p.artist_info_id = a.id " + + "WHERE MATCH(p.name) AGAINST (:query IN BOOLEAN MODE)", + countQuery = "SELECT COUNT(*) " + + "FROM product p JOIN artist_info a ON p.artist_info_id = a.id " + + "WHERE MATCH(p.name) AGAINST (:query IN BOOLEAN MODE)", + nativeQuery = true) // Index 사용 + Page findByNameWithIdx(@Param("query") String query, Pageable pageable); } diff --git a/src/main/java/com/helpmeCookies/product/repository/dto/ProductSearch.java b/src/main/java/com/helpmeCookies/product/repository/dto/ProductSearch.java new file mode 100644 index 0000000..60ca2e0 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/repository/dto/ProductSearch.java @@ -0,0 +1,10 @@ +package com.helpmeCookies.product.repository.dto; + + +public interface ProductSearch { + Long getId(); + String getName(); + String getThumbnailUrl(); + String getArtist(); + Long getPrice(); +} diff --git a/src/main/java/com/helpmeCookies/product/service/ProductImageService.java b/src/main/java/com/helpmeCookies/product/service/ProductImageService.java index 25215c5..f8de688 100644 --- a/src/main/java/com/helpmeCookies/product/service/ProductImageService.java +++ b/src/main/java/com/helpmeCookies/product/service/ProductImageService.java @@ -1,14 +1,16 @@ package com.helpmeCookies.product.service; import com.helpmeCookies.global.utils.AwsS3FileUtils; -import com.helpmeCookies.product.dto.FileUploadResponse; +import com.helpmeCookies.product.dto.ImageUpload; +import com.helpmeCookies.product.entity.ProductImage; import com.helpmeCookies.product.repository.ProductImageRepository; +import java.util.ArrayList; +import com.helpmeCookies.product.repository.ProductRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.util.List; @Service @@ -16,20 +18,33 @@ public class ProductImageService { private final AwsS3FileUtils awsS3FileUtils; private final ProductImageRepository productImageRepository; + private final ProductRepository productRepository; @Transactional - public List uploadMultiFiles(Long productId, List files) throws IOException { - List uploadResponses = awsS3FileUtils.uploadMultiImages(files); - uploadResponses.forEach(response -> - productImageRepository.save(response.toEntity(productId))); - return uploadResponses; + public List uploadMultiFiles(List files) { + List imageUploads = new ArrayList<>(); + for (MultipartFile multipartFile:files) { + imageUploads.add(awsS3FileUtils.uploadMultiImages(multipartFile)); + } + return imageUploads; } @Transactional - public void editImages(Long productId, List files) throws IOException { + public void saveImages(Long productId,List urls) { + //DTO 변환 + List files = urls.stream().map(ImageUpload::new).toList(); + files.forEach(image -> productImageRepository.save(image.toEntity(productId))); + } + + @Transactional + public void editImages(Long productId, List files) { //우선은 전부 삭제하고 다시 업로드 //추후에 개선 예정 + //TODO s3서버에서 기존 사진들을 제거하는 기능 productImageRepository.deleteAllByProductId(productId); - uploadMultiFiles(productId, files); + } + + public List getImages(Long productId) { + return productImageRepository.findAllByProductId(productId).stream().map(ProductImage::getPhotoUrl).toList(); } } diff --git a/src/main/java/com/helpmeCookies/product/service/ProductService.java b/src/main/java/com/helpmeCookies/product/service/ProductService.java index c9b22d4..999e247 100644 --- a/src/main/java/com/helpmeCookies/product/service/ProductService.java +++ b/src/main/java/com/helpmeCookies/product/service/ProductService.java @@ -5,7 +5,11 @@ import com.helpmeCookies.product.entity.Product; import com.helpmeCookies.product.repository.ProductImageRepository; import com.helpmeCookies.product.repository.ProductRepository; +import com.helpmeCookies.user.entity.ArtistInfo; +import com.helpmeCookies.user.repository.ArtistInfoRepository; +import com.helpmeCookies.product.dto.ProductPage; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,10 +18,18 @@ public class ProductService { private final ProductRepository productRepository; private final ProductImageRepository productImageRepository; + private final ArtistInfoRepository artistInfoRepository; + + @Transactional(readOnly = true) + public ProductPage.Paging getProductsByPage(String query, Pageable pageable) { + var productPage = productRepository.findByNameWithIdx(query, pageable); + return ProductPage.Paging.from(productPage); + } public Product save(ProductRequest productSaveRequest) { - //TODO ArtistInfo 코드 병합시 수정 예정 - Product product = productSaveRequest.toEntity(null); + ArtistInfo artistInfo = artistInfoRepository.findById(productSaveRequest.artistInfoId()) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 작가 정보입니다.")); + Product product = productSaveRequest.toEntity(artistInfo); productRepository.save(product); return product; } @@ -29,7 +41,8 @@ public Product find(Long productId) { @Transactional public void edit(Long productId, ProductRequest productRequest) { Product product = productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 id입니다")); - //TODO ArtistInfo 코드 병합시 수정 예정 + ArtistInfo artistInfo = artistInfoRepository.findById(productRequest.artistInfoId()) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 작가 정보입니다.")); product.update( productRequest.name(), Category.fromString(productRequest.category()), @@ -38,7 +51,7 @@ public void edit(Long productId, ProductRequest productRequest) { productRequest.description(), productRequest.preferredLocation(), productRequest.hashTags(), - null); + artistInfo); } public void delete(Long productId) { diff --git a/src/main/java/com/helpmeCookies/product/service/dto/ProductPage.java b/src/main/java/com/helpmeCookies/product/service/dto/ProductPage.java new file mode 100644 index 0000000..0b2b920 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/service/dto/ProductPage.java @@ -0,0 +1,44 @@ +package com.helpmeCookies.product.service.dto; + +import com.helpmeCookies.product.repository.dto.ProductSearch; +import java.util.List; +import org.springframework.data.domain.Page; + +public class ProductPage { + + public record Info( + Long id, + String name, + String artist, + Long price + ) { + public static Info from(ProductSearch productSearch) { + return new Info( + productSearch.getId(), + productSearch.getName(), + productSearch.getArtist(), + productSearch.getPrice() + ); + } + + public static List of(List content) { + return content.stream() + .map(Info::from) + .toList(); + } + } + + public record Paging ( + boolean hasNext, + List products + ) { + + public static Paging from(Page productPage) { + return new Paging( + productPage.hasNext(), + Info.of(productPage.getContent()) + ); + } + } + +} diff --git a/src/main/java/com/helpmeCookies/product/util/ProductSort.java b/src/main/java/com/helpmeCookies/product/util/ProductSort.java new file mode 100644 index 0000000..1a37cec --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/util/ProductSort.java @@ -0,0 +1,5 @@ +package com.helpmeCookies.product.util; + +public enum ProductSort { + RELEVANCE, LATEST +} diff --git a/src/main/java/com/helpmeCookies/product/util/SortUtil.java b/src/main/java/com/helpmeCookies/product/util/SortUtil.java new file mode 100644 index 0000000..e85e068 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/util/SortUtil.java @@ -0,0 +1,14 @@ +package com.helpmeCookies.product.util; + +import org.springframework.data.domain.Sort; + +public class SortUtil { + + public static Sort convertProductSort(ProductSort productSort) { + return switch (productSort) { + case LATEST -> Sort.by(Sort.Order.desc("created_date")); + default -> Sort.unsorted(); + }; + } + +} diff --git a/src/main/java/com/helpmeCookies/review/controller/ReviewController.java b/src/main/java/com/helpmeCookies/review/controller/ReviewController.java new file mode 100644 index 0000000..9987947 --- /dev/null +++ b/src/main/java/com/helpmeCookies/review/controller/ReviewController.java @@ -0,0 +1,45 @@ +package com.helpmeCookies.review.controller; + +import com.helpmeCookies.review.dto.ReviewRequest; +import com.helpmeCookies.review.dto.ReviewResponse; +import com.helpmeCookies.review.entity.Review; +import com.helpmeCookies.review.service.ReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/reviews") +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + //TODO 감상평 삭제 + //TODO 해당 상품의 감상평 조회 + //TODO 내 감상평 조회 + + @PostMapping("/{productId}") + public ResponseEntity postReview(@RequestBody ReviewRequest request, @PathVariable Long productId) { + reviewService.saveReview(request, productId); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{productId}/{reviewId}") + public ResponseEntity editReview(@RequestBody ReviewRequest request, @PathVariable Long productId, @PathVariable Long reviewId) { + reviewService.editReview(request, productId, reviewId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{reviewId}") + public ResponseEntity getReview(@PathVariable Long reviewId) { + Review response = reviewService.getReview(reviewId); + return ResponseEntity.ok(ReviewResponse.fromEntity(response)); + } +} diff --git a/src/main/java/com/helpmeCookies/review/dto/ReviewRequest.java b/src/main/java/com/helpmeCookies/review/dto/ReviewRequest.java new file mode 100644 index 0000000..17a2600 --- /dev/null +++ b/src/main/java/com/helpmeCookies/review/dto/ReviewRequest.java @@ -0,0 +1,15 @@ +package com.helpmeCookies.review.dto; + +import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.review.entity.Review; +import com.helpmeCookies.user.entity.User; + +public record ReviewRequest(Long writerId, String content) { + public Review toEntity(User writer, Product product) { + return Review.builder() + .content(content) + .writer(writer) + .product(product) + .build(); + } +} diff --git a/src/main/java/com/helpmeCookies/review/dto/ReviewResponse.java b/src/main/java/com/helpmeCookies/review/dto/ReviewResponse.java new file mode 100644 index 0000000..044f00f --- /dev/null +++ b/src/main/java/com/helpmeCookies/review/dto/ReviewResponse.java @@ -0,0 +1,11 @@ +package com.helpmeCookies.review.dto; + +import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.review.entity.Review; +import com.helpmeCookies.user.entity.User; + +public record ReviewResponse(Long id, String content,User writer, Product product) { + public static ReviewResponse fromEntity(Review review) { + return new ReviewResponse(review.getId(), review.getContent(),review.getWriter(), review.getProduct()); + } +} diff --git a/src/main/java/com/helpmeCookies/product/entity/Review.java b/src/main/java/com/helpmeCookies/review/entity/Review.java similarity index 52% rename from src/main/java/com/helpmeCookies/product/entity/Review.java rename to src/main/java/com/helpmeCookies/review/entity/Review.java index 854fdbb..1c9af76 100644 --- a/src/main/java/com/helpmeCookies/product/entity/Review.java +++ b/src/main/java/com/helpmeCookies/review/entity/Review.java @@ -1,5 +1,6 @@ -package com.helpmeCookies.product.entity; +package com.helpmeCookies.review.entity; +import com.helpmeCookies.product.entity.Product; import com.helpmeCookies.user.entity.User; import jakarta.persistence.Column; @@ -9,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import lombok.Builder; @Entity public class Review { @@ -27,4 +29,34 @@ public class Review { @ManyToOne @JoinColumn(name = "product_id", nullable = false) private Product product; + + @Builder + public Review(Long id, String content, User writer, Product product) { + this.id = id; + this.content = content; + this.writer = writer; + this.product = product; + } + + public Review() {} + + public void updateContent(String content) { + this.content = content; + } + + public Long getId() { + return id; + } + + public String getContent() { + return content; + } + + public User getWriter() { + return writer; + } + + public Product getProduct() { + return product; + } } diff --git a/src/main/java/com/helpmeCookies/review/repository/ReviewRepository.java b/src/main/java/com/helpmeCookies/review/repository/ReviewRepository.java new file mode 100644 index 0000000..a64c2ff --- /dev/null +++ b/src/main/java/com/helpmeCookies/review/repository/ReviewRepository.java @@ -0,0 +1,9 @@ +package com.helpmeCookies.review.repository; + +import com.helpmeCookies.review.entity.Review; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ReviewRepository extends JpaRepository { +} diff --git a/src/main/java/com/helpmeCookies/review/service/ReviewService.java b/src/main/java/com/helpmeCookies/review/service/ReviewService.java new file mode 100644 index 0000000..a7f873a --- /dev/null +++ b/src/main/java/com/helpmeCookies/review/service/ReviewService.java @@ -0,0 +1,35 @@ +package com.helpmeCookies.review.service; + +import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.product.repository.ProductRepository; +import com.helpmeCookies.review.dto.ReviewRequest; +import com.helpmeCookies.review.entity.Review; +import com.helpmeCookies.review.repository.ReviewRepository; +import com.helpmeCookies.user.entity.User; +import com.helpmeCookies.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ReviewService { + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + private final ProductRepository productRepository; + + public void saveReview(ReviewRequest request, Long productId) { + User writer = userRepository.findById(request.writerId()).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 writerID입니다.")); + Product product = productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 productID입니다.")); + + reviewRepository.save(request.toEntity(writer,product)); + } + + public void editReview(ReviewRequest request, Long productId, Long reviewId) { + Review review = reviewRepository.findById(reviewId).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 reviewId 입니다.")); + review.updateContent(request.content()); + } + + public Review getReview(Long reviewId) { + return reviewRepository.findById(reviewId).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 reviewId 입니다.")); + } + } diff --git a/src/main/java/com/helpmeCookies/user/controller/ArtistController.java b/src/main/java/com/helpmeCookies/user/controller/ArtistController.java index 607f425..4b00d3b 100644 --- a/src/main/java/com/helpmeCookies/user/controller/ArtistController.java +++ b/src/main/java/com/helpmeCookies/user/controller/ArtistController.java @@ -1,33 +1,30 @@ 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.RequestBody; -import org.springframework.web.bind.annotation.RestController; - -import com.helpmeCookies.global.jwt.JwtProvider; import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.user.controller.apiDocs.ArtistApiDocs; +import com.helpmeCookies.user.dto.ArtistInfoPage; import com.helpmeCookies.user.dto.request.BusinessArtistReq; import com.helpmeCookies.user.dto.request.StudentArtistReq; import com.helpmeCookies.user.dto.response.ArtistDetailsRes; import com.helpmeCookies.user.service.ArtistService; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor -@Tag(name = "작가 관련 기능", description = "작가 관련 API") -public class ArtistController { +public class ArtistController implements ArtistApiDocs { private final ArtistService artistService; - private final JwtProvider jwtProvider; - - @Operation(summary = "학생 작가 등록", description = "학생 작가 등록") @PostMapping("/v1/artists/students") public ResponseEntity registerStudents( @RequestBody StudentArtistReq artistDetailsReq, @@ -37,7 +34,6 @@ public ResponseEntity registerStudents( return ResponseEntity.ok().build(); } - @Operation(summary = "사업자 작가 등록", description = "사업자 작가 등록") @PostMapping("/v1/artists/bussinesses") public ResponseEntity registerbussinsess( @RequestBody BusinessArtistReq businessArtistReq, @@ -47,20 +43,27 @@ public ResponseEntity registerbussinsess( return ResponseEntity.ok().build(); } - @Operation(summary = "작가 프로필 조회", description = "작가 프로필 조회") @GetMapping("/v1/artists/{userId}") public ArtistDetailsRes getArtist( - @AuthenticationPrincipal JwtUser jwtUser, @PathVariable Long userId ) { return artistService.getArtistDetails(userId); } - @Operation(summary = "작가 프로필 조회", description = "자신의 작가 프로필 조회") @GetMapping("/v1/artist") public ArtistDetailsRes getArtist( @AuthenticationPrincipal JwtUser jwtUser ) { return artistService.getArtistDetails(jwtUser.getId()); } + + @GetMapping("/v1/artists") + public ResponseEntity getArtistsByPage( + @RequestParam("query") String query, + @RequestParam(name = "size", required = false, defaultValue = "20") int size, + @RequestParam("page") int page + ) { + var pageable = PageRequest.of(page, size); + return ResponseEntity.ok(artistService.getArtistsByPage(query, pageable)); + } } diff --git a/src/main/java/com/helpmeCookies/user/controller/LoginController.java b/src/main/java/com/helpmeCookies/user/controller/LoginController.java index 319af2a..71e4391 100644 --- a/src/main/java/com/helpmeCookies/user/controller/LoginController.java +++ b/src/main/java/com/helpmeCookies/user/controller/LoginController.java @@ -1,9 +1,12 @@ package com.helpmeCookies.user.controller; import java.util.List; +import java.util.Map; +import java.util.Objects; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -11,10 +14,13 @@ import com.helpmeCookies.global.jwt.JwtProvider; import com.helpmeCookies.global.jwt.JwtToken; import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.global.security.UserDetailService; import com.helpmeCookies.product.entity.HashTag; +import com.helpmeCookies.user.dto.KakaoOAuth2Response; import com.helpmeCookies.user.entity.User; import com.helpmeCookies.user.entity.UserInfo; import com.helpmeCookies.user.repository.UserRepository; +import com.helpmeCookies.user.service.UserService; import lombok.RequiredArgsConstructor; @@ -23,34 +29,34 @@ //Todo: Swagger 추가 public class LoginController { private final UserRepository userRepository; + private final UserDetailService userDetailsService; private final JwtProvider jwtProvider; // 임시 회원가입 url. 유저를 생성하고 jwt 토큰을 반환한다. @GetMapping("/test/signup") public JwtToken signup() { UserInfo userInfo = UserInfo.builder() - .userImageUrl("https://www.naver.com") .email("test@test") .birthdate("1995-01-01") .phone("010-1234-5678") .hashTags(List.of(HashTag.DREAMLIKE)) .name("test") - .nickname("test") .address("서울시 강남구") .build(); User user = User.builder() .userInfo(userInfo) + .nickname("test") + .userImageUrl("test") .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.getUserInfo().getEmail(); + @GetMapping("/oauth2/login/kakao") + public JwtToken ttt(@AuthenticationPrincipal OAuth2User oAuth2User) { + KakaoOAuth2Response kakaoUser = KakaoOAuth2Response.from(oAuth2User.getAttributes()); + return jwtProvider.createToken(userDetailsService.loadUserByEmail(kakaoUser.email(), kakaoUser.nickname())); } } diff --git a/src/main/java/com/helpmeCookies/user/controller/UserController.java b/src/main/java/com/helpmeCookies/user/controller/UserController.java index dbbe184..945471d 100644 --- a/src/main/java/com/helpmeCookies/user/controller/UserController.java +++ b/src/main/java/com/helpmeCookies/user/controller/UserController.java @@ -4,8 +4,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; 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; @@ -15,94 +15,79 @@ import org.springframework.web.bind.annotation.RestController; import com.helpmeCookies.global.jwt.JwtUser; -import com.helpmeCookies.user.dto.UserFollowingDto; +import com.helpmeCookies.user.controller.apiDocs.UserApiDocs; import com.helpmeCookies.user.dto.response.UserCommonInfoRes; -import com.helpmeCookies.user.dto.request.UserInfoReq; +import com.helpmeCookies.user.dto.request.UserReq; import com.helpmeCookies.user.dto.response.UserDetailsInfoRes; import com.helpmeCookies.user.dto.UserTypeDto; import com.helpmeCookies.user.dto.response.UserFollowingRes; import com.helpmeCookies.user.service.UserService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor -@Tag(name = "유저 및 팔로우 기능", description = "유저 및 팔로우 기능과 관련된 API") -public class UserController { +public class UserController implements UserApiDocs { private final UserService userService; - @Operation(summary = "유저 일반 정보 조회", description = "로그인한 유저의 username, imageUrl, hashtag를 조회한다.") @GetMapping("/v1/users") - public UserCommonInfoRes getUsers( + public ResponseEntity getUsers( @AuthenticationPrincipal JwtUser jwtUser ) { - return UserCommonInfoRes.fromDto(userService.getUserInfo(jwtUser.getId())); + return ResponseEntity.ok(UserCommonInfoRes.fromDto(userService.getUserInfo(jwtUser.getId()))); } - // 유저 상세 정보 조회 - @Operation(summary = "유저 상세 정보 조회", description = "로그인한 유저의 이름, 주소를 비롯한 개인정보를 함께 조회한다.") @GetMapping("/v1/users/details") - public UserDetailsInfoRes getUserDetails( + public ResponseEntity getUserDetails( @AuthenticationPrincipal JwtUser jwtUser ) { - return UserDetailsInfoRes.fromDto(userService.getUserInfo(jwtUser.getId())); + return ResponseEntity.ok(UserDetailsInfoRes.fromDto(userService.getUserInfo(jwtUser.getId()))); } - // 유저 타입 조회 - @Operation(summary = "유저 타입 조회", description = "로그인한 유저의 타입과 권한을 조회한다.") @GetMapping("/v1/users/type") - public UserTypeDto getUserType( + public ResponseEntity getUserType( @AuthenticationPrincipal JwtUser jwtUser ) { - return userService.getUserType(jwtUser.getId()); + return ResponseEntity.ok(userService.getUserType(jwtUser.getId())); } - // 유저 정보 수정 - @Operation(summary = "유저 정보 수정", description = "로그인한 유저의 개인정보를 수정한다.") @PutMapping("/v1/users") - public String updateUserInfo( + public String updateUser( @AuthenticationPrincipal JwtUser jwtUser, - @RequestBody UserInfoReq userInfoReq + @RequestBody UserReq userReq ) { // UserInfoDto를 통해서 유저 정보를 수정한다. - userService.updateUserInfo(userInfoReq.toDto(), jwtUser.getId()); + userService.updateUser(userReq, jwtUser.getId()); return "ok"; } - @Operation(summary = "아티스트 팔로우하기", description = "로그인한 유저가 특정 아티스트를 팔로우한다.") @PostMapping("/v1/users/following/{artistId}") - public String followArtist( + public ResponseEntity followArtist( @AuthenticationPrincipal JwtUser jwtUser, @PathVariable Long artistId ) { userService.followArtist(jwtUser.getId(), artistId); - return "ok"; + return ResponseEntity.ok().build(); } - @Operation(summary = "아티스트 팔로우 취소하기", description = "로그인한 유저가 특정 아티스트를 팔로우 취소한다.") @DeleteMapping("/v1/users/following/{artistId}") - public String unfollowArtist( + public ResponseEntity unfollowArtist( @AuthenticationPrincipal JwtUser jwtUser, @PathVariable Long artistId ) { userService.unfollowArtist(jwtUser.getId(), artistId); - return "ok"; + return ResponseEntity.ok().build(); } - @Operation(summary = "팔로잉 목록 조회", description = "로그인한 유저의 팔로우한 아티스트 목록을 조회한다.") @GetMapping("/v1/users/following") - public Page getFollowingList( + public ResponseEntity> getFollowingList( @AuthenticationPrincipal JwtUser jwtUser, @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable ) { - return userService.getFollowingWithPaging(jwtUser.getId(),pageable); + return ResponseEntity.ok(userService.getFollowingWithPaging(jwtUser.getId(), pageable)); } - // 유저 탈퇴 - @Operation(summary = "유저 탈퇴", description = "로그인한 유저의 정보를 삭제한다.") @DeleteMapping("/v1/users") public String deleteUser( @AuthenticationPrincipal JwtUser jwtUser diff --git a/src/main/java/com/helpmeCookies/user/controller/apiDocs/ArtistApiDocs.java b/src/main/java/com/helpmeCookies/user/controller/apiDocs/ArtistApiDocs.java new file mode 100644 index 0000000..4df8e2d --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/controller/apiDocs/ArtistApiDocs.java @@ -0,0 +1,53 @@ +package com.helpmeCookies.user.controller.apiDocs; + +import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.user.dto.ArtistInfoPage.Paging; +import com.helpmeCookies.user.dto.request.BusinessArtistReq; +import com.helpmeCookies.user.dto.request.StudentArtistReq; +import com.helpmeCookies.user.dto.response.ArtistDetailsRes; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +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.RequestBody; + +@Tag(name = "작가 관련 기능", description = "작가 관련 API, 작가 프로필 조회 API를 제외한 모든 API는 인증 이후 사용할 수 있습니다.(Authorization: Bearer {token}이 필요합니다.)") +public interface ArtistApiDocs { + + @Operation(summary = "학생 작가 등록", description = "학생 작가 등록") + @PostMapping("/v1/artists/students") + ResponseEntity registerStudents( + @RequestBody StudentArtistReq artistDetailsReq, + @AuthenticationPrincipal JwtUser jwtUser + ); + + @Operation(summary = "사업자 작가 등록", description = "사업자 작가 등록") + @PostMapping("/v1/artists/bussinesses") + ResponseEntity registerbussinsess( + @RequestBody BusinessArtistReq businessArtistReq, + @AuthenticationPrincipal JwtUser jwtUser + ); + + @Operation(summary = "작가 프로필 조회", description = "작가 프로필 조회") + @GetMapping("/v1/artists/{userId}") + ArtistDetailsRes getArtist( + @PathVariable Long userId + ); + + @Operation(summary = "작가 자신의 프로필 조회", description = "작가 자신의 프로필 조회") + @GetMapping("/v1/artist") + ArtistDetailsRes getArtist( + @AuthenticationPrincipal JwtUser jwtUser + ); + + @Operation(summary = "작가 검색") + ResponseEntity getArtistsByPage( + String query, + @Parameter(description = "default value 20") int size, + int page + ); +} diff --git a/src/main/java/com/helpmeCookies/user/controller/apiDocs/UserApiDocs.java b/src/main/java/com/helpmeCookies/user/controller/apiDocs/UserApiDocs.java new file mode 100644 index 0000000..1eaff9c --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/controller/apiDocs/UserApiDocs.java @@ -0,0 +1,57 @@ +package com.helpmeCookies.user.controller.apiDocs; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.PostMapping; + +import com.helpmeCookies.global.jwt.JwtUser; +import com.helpmeCookies.user.dto.UserTypeDto; +import com.helpmeCookies.user.dto.response.UserCommonInfoRes; +import com.helpmeCookies.user.dto.response.UserDetailsInfoRes; +import com.helpmeCookies.user.dto.response.UserFollowingRes; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "유저 및 팔로우 기능", description = "유저 및 팔로우 기능과 관련된 API") +public interface UserApiDocs { + + @Operation(summary = "유저 일반 정보 조회", description = "로그인한 유저의 username, imageUrl, hashtag를 조회한다.") + @GetMapping("/v1/users") + ResponseEntity getUsers(@AuthenticationPrincipal JwtUser jwtUser); + + @Operation(summary = "유저 상세 정보 조회", description = "로그인한 유저의 상세 정보를 조회한다. 유저의 모든 정보를 조회 할 수 있다.") + @GetMapping("/v1/users/details") + ResponseEntity getUserDetails(@AuthenticationPrincipal JwtUser jwtUser); + + @Operation(summary = "유저 타입 조회", description = "로그인한 유저의 타입과 권한을 조회한다.") + @GetMapping("/v1/users/type") + public ResponseEntity getUserType(@AuthenticationPrincipal JwtUser jwtUser); + + @Operation(summary = "아티스트 팔로우하기", description = "로그인한 유저가 특정 아티스트를 팔로우한다.") + @PostMapping("/v1/users/following/{artistId}") + public ResponseEntity followArtist( + @AuthenticationPrincipal JwtUser jwtUser, @PathVariable Long artistId + ); + + @Operation(summary = "아티스트 팔로우 취소하기", description = "로그인한 유저가 특정 아티스트를 팔로우 취소한다.") + @DeleteMapping("/v1/users/following/{artistId}") + public ResponseEntity unfollowArtist( + @AuthenticationPrincipal JwtUser jwtUser, + @PathVariable Long artistId + ); + + @Operation(summary = "팔로잉 목록 조회", description = "로그인한 유저의 팔로우한 아티스트 목록을 조회한다.") + @GetMapping("/v1/users/following") + public ResponseEntity> getFollowingList( + @AuthenticationPrincipal JwtUser jwtUser, + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ); +} diff --git a/src/main/java/com/helpmeCookies/user/dto/ArtistInfoPage.java b/src/main/java/com/helpmeCookies/user/dto/ArtistInfoPage.java new file mode 100644 index 0000000..725fb2e --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/ArtistInfoPage.java @@ -0,0 +1,46 @@ +package com.helpmeCookies.user.dto; + +import com.helpmeCookies.user.repository.dto.ArtistSearch; +import java.util.List; +import org.springframework.data.domain.Page; + +public class ArtistInfoPage { + + public record Info( + Long id, + String nickname, + String artistImageUrl, + Long totalFollowers, + Long totalLikes + ) { + + private static Info from(ArtistSearch artistSearch) { + return new Info( + artistSearch.getId(), + artistSearch.getNickname(), + artistSearch.getArtistImageUrl(), + artistSearch.getTotalFollowers(), + artistSearch.getTotalLikes() + ); + } + + public static List of(List content) { + return content.stream() + .map(Info::from) + .toList(); + } + } + + public record Paging ( + boolean hasNext, + List artists + ) { + public static Paging from(Page artistPage) { + return new Paging( + artistPage.hasNext(), + Info.of(artistPage.getContent()) + ); + } + } + +} diff --git a/src/main/java/com/helpmeCookies/user/dto/KakaoOAuth2Response.java b/src/main/java/com/helpmeCookies/user/dto/KakaoOAuth2Response.java new file mode 100644 index 0000000..034e722 --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/dto/KakaoOAuth2Response.java @@ -0,0 +1,58 @@ +package com.helpmeCookies.user.dto; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +public record KakaoOAuth2Response( + Long id, + LocalDateTime connectedAt, + Map properties, + KakaoAccount kakaoAccount +) { + public record KakaoAccount( + Boolean profileNicknameNeedsAgreement, + Profile profile, + Boolean hasEmail, + Boolean emailNeedsAgreement, + Boolean isEmailValid, + Boolean isEmailVerified, + String email + ) { + public record Profile(String nickname) { + public static Profile from(Map attributes) { + return new Profile(String.valueOf(attributes.get("nickname"))); + } + } + + public static KakaoAccount from(Map attributes) { + return new KakaoAccount( + Boolean.valueOf(String.valueOf(attributes.get("profile_nickname_needs_agreement"))), + Profile.from((Map) attributes.get("profile")), + Boolean.valueOf(String.valueOf(attributes.get("has_email"))), + Boolean.valueOf(String.valueOf(attributes.get("email_needs_agreement"))), + Boolean.valueOf(String.valueOf(attributes.get("is_email_valid"))), + Boolean.valueOf(String.valueOf(attributes.get("is_email_verified"))), + String.valueOf(attributes.get("email")) + ); + } + + public String nickname() { return this.profile().nickname(); } + } + + public static KakaoOAuth2Response from(Map attributes) { + return new KakaoOAuth2Response( + Long.valueOf(String.valueOf(attributes.get("id"))), + LocalDateTime.parse( + String.valueOf(attributes.get("connected_at")), + DateTimeFormatter.ISO_INSTANT.withZone(ZoneId.systemDefault()) + ), + (Map) attributes.get("properties"), + KakaoAccount.from((Map) attributes.get("kakao_account")) + ); + } + + public String email() { return this.kakaoAccount().email(); } + public String nickname() { return this.kakaoAccount().nickname(); } +} diff --git a/src/main/java/com/helpmeCookies/user/dto/UserDto.java b/src/main/java/com/helpmeCookies/user/dto/UserDto.java index d5544d5..b3c46e2 100644 --- a/src/main/java/com/helpmeCookies/user/dto/UserDto.java +++ b/src/main/java/com/helpmeCookies/user/dto/UserDto.java @@ -9,12 +9,16 @@ public record UserDto( Long id, UserInfoDto userInfo, + String userImageUrl, + String nickname, LocalDateTime createdAt ) { public static UserDto fromEntity(User user) { return new UserDto( user.getId(), UserInfoDto.fromEntity(user.getUserInfo()), + user.getUserImageUrl(), + user.getNickname(), user.getCreatedAt() ); } diff --git a/src/main/java/com/helpmeCookies/user/dto/UserInfoDto.java b/src/main/java/com/helpmeCookies/user/dto/UserInfoDto.java index 28099ff..618903a 100644 --- a/src/main/java/com/helpmeCookies/user/dto/UserInfoDto.java +++ b/src/main/java/com/helpmeCookies/user/dto/UserInfoDto.java @@ -6,10 +6,10 @@ import com.helpmeCookies.product.entity.HashTag; import com.helpmeCookies.user.entity.UserInfo; +import lombok.Builder; + public record UserInfoDto( String name, - String userImageUrl, - String nickname, String email, String birthdate, String phone, @@ -19,8 +19,6 @@ public record UserInfoDto( public static UserInfoDto fromEntity(UserInfo userInfo) { return new UserInfoDto( userInfo.getName(), - userInfo.getUserImageUrl(), - userInfo.getNickname(), userInfo.getEmail(), userInfo.getBirthdate(), userInfo.getPhone(), @@ -32,8 +30,6 @@ public static UserInfoDto fromEntity(UserInfo userInfo) { public UserInfo toEntity() { return new UserInfo( name, - userImageUrl, - nickname, email, birthdate, phone, diff --git a/src/main/java/com/helpmeCookies/user/dto/request/UserInfoReq.java b/src/main/java/com/helpmeCookies/user/dto/request/UserReq.java similarity index 53% rename from src/main/java/com/helpmeCookies/user/dto/request/UserInfoReq.java rename to src/main/java/com/helpmeCookies/user/dto/request/UserReq.java index d5c44a1..ff9055b 100644 --- a/src/main/java/com/helpmeCookies/user/dto/request/UserInfoReq.java +++ b/src/main/java/com/helpmeCookies/user/dto/request/UserReq.java @@ -3,28 +3,18 @@ import java.util.List; import com.helpmeCookies.product.entity.HashTag; +import com.helpmeCookies.user.dto.UserDto; import com.helpmeCookies.user.dto.UserInfoDto; -public record UserInfoReq( +public record UserReq( String name, - String userImageUrl, - String nickname, String email, String birthdate, String phone, String address, - List hashTags + List hashTags, + String userImageUrl, + String nickname ) { - public UserInfoDto toDto() { - return new UserInfoDto( - name, - userImageUrl, - nickname, - email, - birthdate, - phone, - address, - hashTags - ); - } -} + +} \ No newline at end of file diff --git a/src/main/java/com/helpmeCookies/user/dto/response/UserCommonInfoRes.java b/src/main/java/com/helpmeCookies/user/dto/response/UserCommonInfoRes.java index 5cc9ce4..0f270df 100644 --- a/src/main/java/com/helpmeCookies/user/dto/response/UserCommonInfoRes.java +++ b/src/main/java/com/helpmeCookies/user/dto/response/UserCommonInfoRes.java @@ -3,6 +3,7 @@ import java.util.List; import com.helpmeCookies.product.entity.HashTag; +import com.helpmeCookies.user.dto.UserDto; import com.helpmeCookies.user.dto.UserInfoDto; public record UserCommonInfoRes( @@ -10,11 +11,11 @@ public record UserCommonInfoRes( List hashTags, String userImageUrl ) { - public static UserCommonInfoRes fromDto(UserInfoDto userInfoDto) { + public static UserCommonInfoRes fromDto(UserDto userDto) { return new UserCommonInfoRes( - userInfoDto.nickname(), - userInfoDto.hashTags(), - userInfoDto.userImageUrl() + userDto.userInfo().name(), + userDto.userInfo().hashTags(), + userDto.userImageUrl() ); } diff --git a/src/main/java/com/helpmeCookies/user/dto/response/UserDetailsInfoRes.java b/src/main/java/com/helpmeCookies/user/dto/response/UserDetailsInfoRes.java index 67fface..579e250 100644 --- a/src/main/java/com/helpmeCookies/user/dto/response/UserDetailsInfoRes.java +++ b/src/main/java/com/helpmeCookies/user/dto/response/UserDetailsInfoRes.java @@ -3,6 +3,7 @@ import java.util.List; import com.helpmeCookies.product.entity.HashTag; +import com.helpmeCookies.user.dto.UserDto; import com.helpmeCookies.user.dto.UserInfoDto; import lombok.Builder; @@ -18,16 +19,16 @@ public record UserDetailsInfoRes( String address, List hashTags ) { - public static UserDetailsInfoRes fromDto(UserInfoDto userInfoDto) { + public static UserDetailsInfoRes fromDto(UserDto userDto) { return UserDetailsInfoRes.builder() - .name(userInfoDto.name()) - .userImageUrl(userInfoDto.userImageUrl()) - .nickname(userInfoDto.nickname()) - .email(userInfoDto.email()) - .birthdate(userInfoDto.birthdate()) - .phone(userInfoDto.phone()) - .address(userInfoDto.address()) - .hashTags(userInfoDto.hashTags()) + .name(userDto.userInfo().name()) + .userImageUrl(userDto.userImageUrl()) + .nickname(userDto.nickname()) + .email(userDto.userInfo().email()) + .birthdate(userDto.userInfo().birthdate()) + .phone(userDto.userInfo().phone()) + .address(userDto.userInfo().address()) + .hashTags(userDto.userInfo().hashTags()) .build(); } } diff --git a/src/main/java/com/helpmeCookies/user/entity/User.java b/src/main/java/com/helpmeCookies/user/entity/User.java index 4fee3a0..2ca3903 100644 --- a/src/main/java/com/helpmeCookies/user/entity/User.java +++ b/src/main/java/com/helpmeCookies/user/entity/User.java @@ -41,6 +41,10 @@ public class User { @Column(nullable = false) private Long id; + private String nickname; + + private String userImageUrl; + @Embedded private UserInfo userInfo; @@ -49,9 +53,13 @@ public class User { @Column(nullable = false, updatable = false) protected LocalDateTime createdAt; + public void updateUserCommonInfo(String nickname, String userImageUrl) { + this.nickname = nickname; + this.userImageUrl = userImageUrl; + } + public void updateUserInfo(UserInfo userInfo) { - // TODO: 유저 정보 업데이트시 유효성 검사 - setUserInfo(userInfo); + this.userInfo = userInfo; } private User setUserInfo(UserInfo userInfo) { diff --git a/src/main/java/com/helpmeCookies/user/entity/UserInfo.java b/src/main/java/com/helpmeCookies/user/entity/UserInfo.java index e70ac05..8b5453b 100644 --- a/src/main/java/com/helpmeCookies/user/entity/UserInfo.java +++ b/src/main/java/com/helpmeCookies/user/entity/UserInfo.java @@ -25,10 +25,6 @@ public class UserInfo { private String name; - private String nickname; - - private String userImageUrl; - private String email; private String birthdate; diff --git a/src/main/java/com/helpmeCookies/user/repository/ArtistInfoRepository.java b/src/main/java/com/helpmeCookies/user/repository/ArtistInfoRepository.java index 49847d2..86da115 100644 --- a/src/main/java/com/helpmeCookies/user/repository/ArtistInfoRepository.java +++ b/src/main/java/com/helpmeCookies/user/repository/ArtistInfoRepository.java @@ -1,12 +1,24 @@ package com.helpmeCookies.user.repository; +import com.helpmeCookies.user.entity.ArtistInfo; +import com.helpmeCookies.user.repository.dto.ArtistSearch; import java.util.Optional; - +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; - -import com.helpmeCookies.user.entity.ArtistInfo; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ArtistInfoRepository extends JpaRepository { Optional findByUserId(Long userId); boolean existsByUserId(Long userId); + + @Query(value = "SELECT a.id, a.nickname, a.artist_image_url, a.total_followers, a.total_likes " + + "FROM artist_info a " + + "WHERE MATCH(a.nickname) AGAINST (:query IN BOOLEAN MODE)", + countQuery = "SELECT COUNT(*) " + + "FROM artist_info a " + + "WHERE MATCH(a.nickname) AGAINST (:query IN BOOLEAN MODE)", + nativeQuery = true) // Index 사용 + Page findByNicknameWithIdx(@Param("query") String query, Pageable pageable); } diff --git a/src/main/java/com/helpmeCookies/user/repository/UserRepository.java b/src/main/java/com/helpmeCookies/user/repository/UserRepository.java index 7aa80d0..b6ce04c 100644 --- a/src/main/java/com/helpmeCookies/user/repository/UserRepository.java +++ b/src/main/java/com/helpmeCookies/user/repository/UserRepository.java @@ -11,4 +11,5 @@ @Repository public interface UserRepository extends JpaRepository, UserCustomRepository { Optional findById(Long id); + Optional findByUserInfoEmail(String email); } diff --git a/src/main/java/com/helpmeCookies/user/repository/dto/ArtistSearch.java b/src/main/java/com/helpmeCookies/user/repository/dto/ArtistSearch.java new file mode 100644 index 0000000..777ed6a --- /dev/null +++ b/src/main/java/com/helpmeCookies/user/repository/dto/ArtistSearch.java @@ -0,0 +1,9 @@ +package com.helpmeCookies.user.repository.dto; + +public interface ArtistSearch { + Long getId(); + String getNickname(); + String getArtistImageUrl(); + Long getTotalFollowers(); + Long getTotalLikes(); +} diff --git a/src/main/java/com/helpmeCookies/user/repository/querydsl/UserCustomRepositoryImpl.java b/src/main/java/com/helpmeCookies/user/repository/querydsl/UserCustomRepositoryImpl.java index ca3164e..2b176f0 100644 --- a/src/main/java/com/helpmeCookies/user/repository/querydsl/UserCustomRepositoryImpl.java +++ b/src/main/java/com/helpmeCookies/user/repository/querydsl/UserCustomRepositoryImpl.java @@ -27,15 +27,14 @@ public class UserCustomRepositoryImpl implements UserCustomRepository { public Page findFollowingUsers(Long userId, Pageable pageable) { QUser user = QUser.user; QArtistInfo artistInfo = QArtistInfo.artistInfo; - QSocial social = QSocial.social; // Social 테이블 추가 + QSocial social = QSocial.social; - // JPQLQuery query = queryFactory .select(Projections.constructor( UserFollowingDto.class, user.id, - user.userInfo.userImageUrl, - user.userInfo.nickname, + user.userImageUrl, + user.nickname, artistInfo.totalFollowers, artistInfo.totalLikes )) diff --git a/src/main/java/com/helpmeCookies/user/service/ArtistService.java b/src/main/java/com/helpmeCookies/user/service/ArtistService.java index 015f319..c36475e 100644 --- a/src/main/java/com/helpmeCookies/user/service/ArtistService.java +++ b/src/main/java/com/helpmeCookies/user/service/ArtistService.java @@ -1,10 +1,8 @@ package com.helpmeCookies.user.service; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.helpmeCookies.global.exception.user.ResourceNotFoundException; import com.helpmeCookies.user.dto.ArtistInfoDto; +import com.helpmeCookies.user.dto.ArtistInfoPage; import com.helpmeCookies.user.dto.BusinessArtistDto; import com.helpmeCookies.user.dto.StudentArtistDto; import com.helpmeCookies.user.dto.request.BusinessArtistReq; @@ -20,8 +18,10 @@ import com.helpmeCookies.user.repository.StudentArtistRepository; import com.helpmeCookies.user.repository.UserRepository; import com.sun.jdi.request.DuplicateRequestException; - import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service @@ -45,7 +45,8 @@ public void registerStudentsArtist(StudentArtistReq studentArtistReq, Long userI ArtistInfo artistInfo = ArtistInfo.builder() .userId(userId) .artistType(ArtistType.STUDENT) - .nickname(user.getUserInfo().getNickname()) + .artistImageUrl(user.getUserImageUrl()) + .nickname(user.getNickname()) .totalFollowers(0L) .totalLikes(0L) .about(studentArtistReq.about()) @@ -74,8 +75,9 @@ public void registerBusinessArtist(BusinessArtistReq businessArtistReq, Long use // BusinessArtist 생성 ArtistInfo artistInfo = ArtistInfo.builder() .userId(userId) + .artistImageUrl(user.getUserImageUrl()) .artistType(ArtistType.BUSINESS) - .nickname(user.getUserInfo().getNickname()) + .nickname(user.getNickname()) .totalFollowers(0L) .totalLikes(0L) .about(businessArtistReq.about()) @@ -113,4 +115,10 @@ public ArtistDetailsRes getArtistDetails(Long userId) { throw new ResourceNotFoundException("존재하지 않는 아티스트입니다."); } } + + @Transactional(readOnly = true) + public ArtistInfoPage.Paging getArtistsByPage(String query, Pageable pageable) { + var artistInfoPage = artistInfoRepository.findByNicknameWithIdx(query, pageable); + return ArtistInfoPage.Paging.from(artistInfoPage); + } } diff --git a/src/main/java/com/helpmeCookies/user/service/UserService.java b/src/main/java/com/helpmeCookies/user/service/UserService.java index 139405b..39abc2a 100644 --- a/src/main/java/com/helpmeCookies/user/service/UserService.java +++ b/src/main/java/com/helpmeCookies/user/service/UserService.java @@ -9,6 +9,7 @@ import com.helpmeCookies.user.dto.UserDto; import com.helpmeCookies.user.dto.UserInfoDto; import com.helpmeCookies.user.dto.UserTypeDto; +import com.helpmeCookies.user.dto.request.UserReq; import com.helpmeCookies.user.dto.response.UserFollowingRes; import com.helpmeCookies.user.entity.ArtistInfo; import com.helpmeCookies.user.entity.Social; @@ -30,21 +31,29 @@ public class UserService { @Transactional - public UserInfoDto getUserInfo(Long userId) { - UserInfo userInfo = userRepository.findById(userId) - .orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 유저입니다.")) - .getUserInfo(); + public UserDto getUserInfo(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 유저입니다.")); - return UserInfoDto.fromEntity(userInfo); + return UserDto.fromEntity(user); } @Transactional - public UserDto updateUserInfo(UserInfoDto userInfoDto, Long userId) { + public UserDto updateUser(UserReq userReq, Long userId) { User existingUser = userRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 유저입니다.")); - existingUser.updateUserInfo(userInfoDto.toEntity()); + existingUser.updateUserCommonInfo(userReq.nickname(), userReq.userImageUrl()); + UserInfo userInfo = UserInfo.builder().name(userReq.name()) + .email(userReq.email()) + .birthdate(userReq.birthdate()) + .phone(userReq.phone()) + .address(userReq.address()) + .hashTags(userReq.hashTags()) + .build(); + + existingUser.updateUserInfo(userInfo); return UserDto.fromEntity(userRepository.save(existingUser)); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7922dd4..6827d3e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -21,7 +21,7 @@ management: endpoints: web: exposure: # 외부에 노출할 엔드포인트 - include: prometheus, health, info, swagger-ui + include: prometheus, health, info metrics: tags: application: "katecam" # 메트릭 데이터에 태그를 추가 \ No newline at end of file diff --git a/src/test/java/com/helpmeCookies/e2e/ArtistE2Etest.java b/src/test/java/com/helpmeCookies/e2e/ArtistE2Etest.java index 6d76c7b..3522fdc 100644 --- a/src/test/java/com/helpmeCookies/e2e/ArtistE2Etest.java +++ b/src/test/java/com/helpmeCookies/e2e/ArtistE2Etest.java @@ -45,7 +45,8 @@ public void testRegisterStudents_withValidToken() throws Exception { StudentArtistReq request = new StudentArtistReq( "student@example.com", "Example University", - "Computer Science" + "Computer Science", + "" ); String requestJson = objectMapper.writeValueAsString(request); diff --git a/src/test/java/com/helpmeCookies/product/service/ProductImageServiceTest.java b/src/test/java/com/helpmeCookies/product/service/ProductImageServiceTest.java index b0b3653..23cc74d 100644 --- a/src/test/java/com/helpmeCookies/product/service/ProductImageServiceTest.java +++ b/src/test/java/com/helpmeCookies/product/service/ProductImageServiceTest.java @@ -41,7 +41,7 @@ class ProductImageServiceTest { @BeforeEach void setUp() { - productImageService = new ProductImageService(awsS3FileUtils, productImageRepository); + productImageService = new ProductImageService(awsS3FileUtils, productImageRepository, productRepository); } @AfterEach diff --git a/src/test/java/com/helpmeCookies/search/SearchControllerTest.java b/src/test/java/com/helpmeCookies/search/SearchControllerTest.java new file mode 100644 index 0000000..f212587 --- /dev/null +++ b/src/test/java/com/helpmeCookies/search/SearchControllerTest.java @@ -0,0 +1,116 @@ +package com.helpmeCookies.search; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.helpmeCookies.global.jwt.JwtProvider; +import com.helpmeCookies.product.controller.ProductController; +import com.helpmeCookies.product.dto.ProductPage; +import com.helpmeCookies.product.repository.dto.ProductSearch; +import com.helpmeCookies.product.service.ProductImageService; +import com.helpmeCookies.product.service.ProductService; +import com.helpmeCookies.product.util.ProductSort; +import com.helpmeCookies.user.controller.ArtistController; +import com.helpmeCookies.user.dto.ArtistInfoPage; +import com.helpmeCookies.user.repository.dto.ArtistSearch; +import com.helpmeCookies.user.service.ArtistService; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest({ProductController.class, ArtistController.class}) +@AutoConfigureMockMvc(addFilters = false) +@Import(JwtProvider.class) +public class SearchControllerTest { + + @Autowired + private MockMvc mvc; + + @MockBean + private ProductService productService; + + @MockBean + private ProductImageService productImageService; + + @MockBean + private ArtistService artistService; + + + @Test + @DisplayName("상품 검색 컨트롤러") + void 상품_검색() throws Exception { + // given + String query = "product"; + int size = 10; + int page = 0; + ProductSort productSort = ProductSort.LATEST; + + var productSearch = mock(ProductSearch.class); + given(productSearch.getId()).willReturn(1L); + given(productSearch.getName()).willReturn("product1"); + given(productSearch.getArtist()).willReturn("artist"); + given(productSearch.getPrice()).willReturn(10000L); + given(productSearch.getThumbnailUrl()).willReturn("thumbnailUrl"); + var paging = ProductPage.Paging.from(new PageImpl<>(List.of(productSearch))); + given(productService.getProductsByPage(eq(query), any(Pageable.class))) + .willReturn(paging); + + // when & then + mvc.perform(get("/v1/products") + .param("query", query) + .param("size", String.valueOf(size)) + .param("page", String.valueOf(page)) + .param("sort", productSort.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hasNext").value(false)) + .andExpect(jsonPath("$.products[0].name").value("product1")) + .andExpect(jsonPath("$.products[0].artist").value("artist")) + .andExpect(jsonPath("$.products[0].price").value(10000L)) + .andExpect(jsonPath("$.products[0].thumbnailUrl").value("thumbnailUrl")); + } + + @Test + @DisplayName("작가 검색 컨트롤러") + void 작가_검색() throws Exception { + // given + String query = "nickname"; + int size = 10; + int page = 0; + + var artistSearch = mock(ArtistSearch.class); + given(artistSearch.getId()).willReturn(1L); + given(artistSearch.getNickname()).willReturn("nickname"); + given(artistSearch.getArtistImageUrl()).willReturn("artistImageUrl"); + given(artistSearch.getTotalFollowers()).willReturn(10000L); + given(artistSearch.getTotalLikes()).willReturn(10000L); + var paging = ArtistInfoPage.Paging.from(new PageImpl<>(List.of(artistSearch))); + given(artistService.getArtistsByPage(eq(query), any(Pageable.class))) + .willReturn(paging); + + // when & then + mvc.perform(get("/v1/artists") + .param("query", query) + .param("size", String.valueOf(size)) + .param("page", String.valueOf(page))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hasNext").value(false)) + .andExpect(jsonPath("$.artists[0].nickname").value("nickname")) + .andExpect(jsonPath("$.artists[0].artistImageUrl").value("artistImageUrl")) + .andExpect(jsonPath("$.artists[0].totalFollowers").value(10000L)) + .andExpect(jsonPath("$.artists[0].totalLikes").value(10000L)); + } + +} diff --git a/src/test/java/com/helpmeCookies/search/SearchRepositoryTest.java b/src/test/java/com/helpmeCookies/search/SearchRepositoryTest.java new file mode 100644 index 0000000..9b00725 --- /dev/null +++ b/src/test/java/com/helpmeCookies/search/SearchRepositoryTest.java @@ -0,0 +1,84 @@ +package com.helpmeCookies.search; + +import static com.helpmeCookies.product.util.SortUtil.convertProductSort; +import static org.assertj.core.api.Assertions.assertThat; + +import com.helpmeCookies.global.config.QueryDSLConfig; +import com.helpmeCookies.product.repository.ProductRepository; +import com.helpmeCookies.product.util.ProductSort; +import com.helpmeCookies.user.repository.ArtistInfoRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@ExtendWith(OutputCaptureExtension.class) +@Import(QueryDSLConfig.class) +public class SearchRepositoryTest { + + @Autowired + private ProductRepository productRepository; + + @Autowired + private ArtistInfoRepository artistInfoRepository; + + + @Test + @DisplayName("상품 검색 쿼리 확인") + void 상품_검색(CapturedOutput out) { + // given + var sort = convertProductSort(ProductSort.LATEST); + var pageRequest = PageRequest.of(0, 10, sort); + + // when + try{ + productRepository.findByNameWithIdx("product", pageRequest); + } catch (Exception ignored) { + } + + // then + assertThat(out.getOut()) + .contains("SELECT") + .contains("p.id,") + .contains("p.name,") + .contains("p.thumbnail_url,") + .contains("a.nickname AS artist,") + .contains("p.price") + .contains("FROM product p") + .contains("JOIN artist_info a ON p.artist_info_id = a.id") + .contains("WHERE MATCH(p.name) AGAINST (? IN BOOLEAN MODE)") + .contains("order by p.created_date desc"); + //.contains("limit ?") 테스트 dbh2 db에서는 limit이 없어서 제외 + } + + @Test + @DisplayName("작가 검색 쿼리 확인") + void 작가_검색(CapturedOutput out) { + // given + var pageRequest = PageRequest.of(0, 10); + + // when + try{ + artistInfoRepository.findByNicknameWithIdx("nickname", pageRequest); + } catch (Exception ignored) { + } + + // then + assertThat(out.getOut()) + .contains("SELECT") + .contains("a.id,") + .contains("a.nickname,") + .contains("a.artist_image_url,") + .contains("a.total_followers,") + .contains("a.total_likes") + .contains("FROM artist_info a") + .contains("WHERE MATCH(a.nickname) AGAINST (? IN BOOLEAN MODE)"); + } + +} diff --git a/src/test/java/com/helpmeCookies/search/SearchServiceTest.java b/src/test/java/com/helpmeCookies/search/SearchServiceTest.java new file mode 100644 index 0000000..4a9b738 --- /dev/null +++ b/src/test/java/com/helpmeCookies/search/SearchServiceTest.java @@ -0,0 +1,104 @@ +package com.helpmeCookies.search; + +import static com.helpmeCookies.product.util.SortUtil.convertProductSort; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import com.helpmeCookies.product.repository.ProductRepository; +import com.helpmeCookies.product.repository.dto.ProductSearch; +import com.helpmeCookies.product.service.ProductService; +import com.helpmeCookies.product.util.ProductSort; +import com.helpmeCookies.user.repository.ArtistInfoRepository; +import com.helpmeCookies.user.repository.dto.ArtistSearch; +import com.helpmeCookies.user.service.ArtistService; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@TestComponent +@ExtendWith(MockitoExtension.class) +public class SearchServiceTest { + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private ProductService productService; + + @Mock + private ArtistInfoRepository artistInfoRepository; + + @InjectMocks + private ArtistService artistService; + + @Test + @DisplayName("상품 검색 서비스") + void 상품_검색() { + // given + var sort = convertProductSort(ProductSort.LATEST); + var pageRequest = PageRequest.of(0, 10, sort); + var productSearch = mock(ProductSearch.class); + given(productSearch.getId()).willReturn(1L); + given(productSearch.getName()).willReturn("product1"); + given(productSearch.getArtist()).willReturn("artist"); + given(productSearch.getPrice()).willReturn(10000L); + given(productSearch.getThumbnailUrl()).willReturn("thumbnailUrl"); + + var productPage = new PageImpl<>(List.of(productSearch)); + given(productRepository.findByNameWithIdx("roduct", pageRequest)) + .willReturn(productPage); + + // when + var result = productService.getProductsByPage("roduct", pageRequest); + + // then + assertAll( + () -> assertThat(result.hasNext()).isFalse(), + () -> assertThat(result.products().size()).isEqualTo(1L), + () -> assertThat(result.products().getFirst().name()).isEqualTo("product1"), + () -> assertThat(result.products().getFirst().artist()).isEqualTo("artist"), + () -> assertThat(result.products().getFirst().price()).isEqualTo(10000L), + () -> assertThat(result.products().getFirst().thumbnailUrl()).isEqualTo("thumbnailUrl") + ); + } + + @Test + @DisplayName("작가 검색 서비스") + void 작가_검색() { + // given + var pageRequest = PageRequest.of(0, 10); + var artistSearch = mock(ArtistSearch.class); + given(artistSearch.getId()).willReturn(1L); + given(artistSearch.getNickname()).willReturn("nickname"); + given(artistSearch.getArtistImageUrl()).willReturn("artistImageUrl"); + given(artistSearch.getTotalFollowers()).willReturn(10000L); + given(artistSearch.getTotalLikes()).willReturn(10000L); + + var artistPage = new PageImpl<>(List.of(artistSearch)); + given(artistInfoRepository.findByNicknameWithIdx("nickname", pageRequest)) + .willReturn(artistPage); + + // when + var result = artistService.getArtistsByPage("nickname", pageRequest); + + // then + assertAll( + () -> assertThat(result.hasNext()).isFalse(), + () -> assertThat(result.artists().size()).isEqualTo(1L), + () -> assertThat(result.artists().getFirst().nickname()).isEqualTo("nickname"), + () -> assertThat(result.artists().getFirst().artistImageUrl()).isEqualTo("artistImageUrl"), + () -> assertThat(result.artists().getFirst().totalFollowers()).isEqualTo(10000L), + () -> assertThat(result.artists().getFirst().totalLikes()).isEqualTo(10000L) + ); + } + +}