From d757fee0fe9ddc3468c38958fce08a0deabd02ff Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:30:35 +0900 Subject: [PATCH 001/191] =?UTF-8?q?feat:=20JWT=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../User/controller/UserController.java | 96 ++++++++++++++++ .../User/domain/entity/User.java | 37 +++++++ .../User/domain/entity/UserRole.java | 5 + .../User/domain/request/JoinRequest.java | 52 +++++++++ .../User/domain/request/LoginRequest.java | 14 +++ .../request/UserProfileUpdateRequest.java | 17 +++ .../User/domain/response/LoginResponse.java | 11 ++ .../domain/response/UserProfileResponse.java | 22 ++++ .../User/repository/UserRepository.java | 11 ++ .../User/service/UserService.java | 104 ++++++++++++++++++ .../common/config/SecurityConfig.java | 46 ++++++++ .../security/filter/JwtTokenFilter.java | 60 ++++++++++ .../handler/MyAccessDeniedHandler.java | 20 ++++ .../handler/MyAuthenticationEntryPoint.java | 19 ++++ .../security/service/PrincipalDetails.java | 56 ++++++++++ .../common/util/JwtTokenUtil.java | 37 +++++++ 17 files changed, 611 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/entity/UserRole.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/JoinRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/LoginRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/UserProfileUpdateRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/response/LoginResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/service/UserService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAccessDeniedHandler.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAuthenticationEntryPoint.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java diff --git a/build.gradle b/build.gradle index aa16805..9e23bc0 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,10 @@ dependencies { implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.9' // S3 implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' + + //jwt + implementation 'io.jsonwebtoken:jjwt:0.9.1' + } tasks.named('test') { diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java new file mode 100644 index 0000000..246dce1 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -0,0 +1,96 @@ +package com.swyp.catsgotogedog.User.controller; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.request.JoinRequest; +import com.swyp.catsgotogedog.User.domain.request.LoginRequest; +import com.swyp.catsgotogedog.User.domain.request.UserProfileUpdateRequest; +import com.swyp.catsgotogedog.User.domain.response.LoginResponse; +import com.swyp.catsgotogedog.User.domain.response.UserProfileResponse; +import com.swyp.catsgotogedog.User.service.UserService; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/jwt-login") +@Tag(name="로그인 및 회원가입 api") +public class UserController { + + private final UserService userService; + + @Value("${jwt.secret}") + private String secretKey; + + @Operation(summary="회원가입") + @PostMapping("/join") + public ResponseEntity join(@RequestBody JoinRequest joinRequest) { + if (userService.checkLoginIdDuplicate(joinRequest.getLoginId())) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body("로그인 아이디가 중복됩니다."); + } + if (!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body("비밀번호가 일치하지 않습니다."); + } + userService.join(joinRequest); + return ResponseEntity.ok("회원가입 성공"); + } + + @Operation(summary="아이디 중복체크") + @GetMapping("/check-duplicate") + public ResponseEntity> checkDuplicate(@RequestParam("loginId") String loginId) { + boolean duplicate = userService.checkLoginIdDuplicate(loginId); + Map response = new HashMap<>(); + if (duplicate) { + response.put("duplicate", true); + response.put("message", "이미 사용중인 아이디입니다."); + } else { + response.put("duplicate", false); + response.put("message", "사용 가능한 아이디입니다."); + } + return ResponseEntity.ok(response); + } + + @Operation(summary="로그인") + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest) { + User user = userService.login(loginRequest); + if(user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("로그인 아이디 또는 비밀번호가 틀렸습니다."); + } + + long expireTimeMs = 1000 * 60 * 60; // 60분 유효 + String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs); + LoginResponse response = new LoginResponse("로그인 성공", jwtToken); + return ResponseEntity.ok(response); + } + + @Operation(summary="프로필 업데이트") + @PutMapping("/update-profile") + public ResponseEntity updateProfile(@RequestBody UserProfileUpdateRequest req, Authentication auth) { + String loginId = auth.getName(); + userService.updateUserProfile(loginId, req); + return ResponseEntity.ok("프로필 업데이트 성공"); + } + + @Operation(summary="프로필 조회") + @GetMapping("/profile") + public ResponseEntity getProfile(Authentication authentication) { + String loginId = authentication.getName(); + UserProfileResponse userProfile = userService.getUserProfile(loginId); + return ResponseEntity.ok(userProfile); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java new file mode 100644 index 0000000..acfc09c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -0,0 +1,37 @@ +package com.swyp.catsgotogedog.User.domain.entity; + + +import jakarta.persistence.*; +import lombok.*; + +import java.util.Date; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="user_id") + private Long userId; + + private String name; + private String loginId; + private String password; + private Date birth; + private String phoneNumber; + private String email; + private UserRole role; + private String address; + private String visaType; + private Integer age; + private String education; + private String experience; + private String koreanProficiency; + private String region; + private String visaDescription; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/UserRole.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/UserRole.java new file mode 100644 index 0000000..22e460e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/UserRole.java @@ -0,0 +1,5 @@ +package com.swyp.catsgotogedog.User.domain.entity; + +public enum UserRole { + USER, ADMIN; +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/JoinRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/JoinRequest.java new file mode 100644 index 0000000..24dfcc6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/request/JoinRequest.java @@ -0,0 +1,52 @@ +package com.swyp.catsgotogedog.User.domain.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.entity.UserRole; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Date; + +@Getter +@Setter +@NoArgsConstructor +public class JoinRequest { + + @NotBlank(message = "이름이 비어있습니다.") + private String name; + + @NotBlank(message = "로그인 아이디가 비어있습니다.") + private String loginId; + + @NotBlank(message = "비밀번호가 비어있습니다.") + private String password; + private String passwordCheck; + + private String address; + private String visaType; + + private String email; + private String phoneNumber; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "Asia/Seoul") + private Date birth; + + + public User toEntity(String encodedPassword) { + return User.builder() + .name(this.name) + .loginId(this.loginId) + .password(encodedPassword) + .birth(this.birth) + .address(this.address) + .visaType(this.visaType) + .email(this.email) + .phoneNumber(this.phoneNumber) + .role(UserRole.USER) + .build(); + } +} + diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/LoginRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/LoginRequest.java new file mode 100644 index 0000000..b97b5e1 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/request/LoginRequest.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.User.domain.request; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class LoginRequest { + private String loginId; + private String password; +} + diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserProfileUpdateRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserProfileUpdateRequest.java new file mode 100644 index 0000000..f8962d4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserProfileUpdateRequest.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.User.domain.request; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class UserProfileUpdateRequest { + private Integer age; + private String education; + private String experience; + private String koreanProficiency; + private String region; + private String visaDescription; +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/response/LoginResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/response/LoginResponse.java new file mode 100644 index 0000000..40647c2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/response/LoginResponse.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.User.domain.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class LoginResponse { + private String message; + private String jwtToken; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java new file mode 100644 index 0000000..d99bbe1 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java @@ -0,0 +1,22 @@ +package com.swyp.catsgotogedog.User.domain.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class UserProfileResponse { + private String loginId; + private String name; + private String birth; + private String phoneNumber; + private String email; + private String address; + private String visaType; + private Integer age; + private String education; + private String experience; + private String koreanProficiency; + private String region; + private String visaDescription; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java new file mode 100644 index 0000000..1a2c1f4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.User.repository; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + boolean existsByLoginId(String loginId); + Optional findByLoginId(String loginId); +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java new file mode 100644 index 0000000..6155266 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -0,0 +1,104 @@ +package com.swyp.catsgotogedog.User.service; + + + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.request.JoinRequest; +import com.swyp.catsgotogedog.User.domain.request.LoginRequest; +import com.swyp.catsgotogedog.User.domain.request.UserProfileUpdateRequest; +import com.swyp.catsgotogedog.User.domain.response.UserProfileResponse; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.Optional; + +@Service +@Transactional +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + private final BCryptPasswordEncoder encoder; + + public boolean checkLoginIdDuplicate(String loginId) { + return userRepository.existsByLoginId(loginId); + } + + public void join(JoinRequest req) { + userRepository.save(req.toEntity(encoder.encode(req.getPassword()))); + } + + public User login(LoginRequest req) { + Optional optionalUser = userRepository.findByLoginId(req.getLoginId()); + + if(optionalUser.isEmpty()) { + return null; + } + + User user = optionalUser.get(); + + if (!encoder.matches(req.getPassword(), user.getPassword())) { + return null; + } + + return user; + } + + public User getLoginUserByLoginId(String loginId) { + if(loginId == null) return null; + + Optional optionalUser = userRepository.findByLoginId(loginId); + if(optionalUser.isEmpty()) return null; + + return optionalUser.get(); + } + + public void updateUserProfile(String loginId, UserProfileUpdateRequest req) { + + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("User not found with loginId: " + loginId)); + + + user.setAge(req.getAge()); + user.setEducation(req.getEducation()); + user.setExperience(req.getExperience()); + user.setKoreanProficiency(req.getKoreanProficiency()); + user.setRegion(req.getRegion()); + user.setVisaDescription(req.getVisaDescription()); + + userRepository.save(user); + } + + public UserProfileResponse getUserProfile(String loginId) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("User not found with loginId: " + loginId)); + + String formattedBirth = null; + if (user.getBirth() != null) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + formattedBirth = sdf.format(user.getBirth()); + } + + return new UserProfileResponse( + user.getLoginId(), + user.getName(), + formattedBirth, + user.getPhoneNumber(), + user.getEmail(), + user.getAddress(), + user.getVisaType(), + user.getAge(), + user.getEducation(), + user.getExperience(), + user.getKoreanProficiency(), + user.getRegion(), + user.getVisaDescription() + ); + } +} + diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java new file mode 100644 index 0000000..eecb691 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -0,0 +1,46 @@ +package com.swyp.catsgotogedog.common.config; + +import com.swyp.catsgotogedog.User.domain.entity.UserRole; +import com.swyp.catsgotogedog.User.service.UserService; +import com.swyp.catsgotogedog.common.security.filter.JwtTokenFilter; +import com.swyp.catsgotogedog.common.security.handler.MyAccessDeniedHandler; +import com.swyp.catsgotogedog.common.security.handler.MyAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final UserService userService; + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/jwt-login/profile").authenticated() + .requestMatchers("/jwt-login/admin/**").hasAuthority(UserRole.ADMIN.name()) + .anyRequest().permitAll() + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new MyAuthenticationEntryPoint()) + .accessDeniedHandler(new MyAccessDeniedHandler()) + ); + + http.addFilterBefore(new JwtTokenFilter(userService, jwtSecret), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java new file mode 100644 index 0000000..f5f9d57 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java @@ -0,0 +1,60 @@ +package com.swyp.catsgotogedog.common.security.filter; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.service.UserService; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@RequiredArgsConstructor +public class JwtTokenFilter extends OncePerRequestFilter { + + private final UserService userService; + private final String secretKey; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authorizationHeader.split(" ")[1]; + + if (JwtTokenUtil.isExpired(token, secretKey)) { + filterChain.doFilter(request, response); + return; + } + + String loginId = JwtTokenUtil.getLoginId(token, secretKey); + + User loginUser = userService.getLoginUserByLoginId(loginId); + if (loginUser == null) { + filterChain.doFilter(request, response); + return; + } + + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(loginUser.getLoginId(), null, + List.of(new SimpleGrantedAuthority(loginUser.getRole().name()))); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAccessDeniedHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAccessDeniedHandler.java new file mode 100644 index 0000000..284f0bb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAccessDeniedHandler.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.common.security.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class MyAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied"); + } +} + diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAuthenticationEntryPoint.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAuthenticationEntryPoint.java new file mode 100644 index 0000000..61566c6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAuthenticationEntryPoint.java @@ -0,0 +1,19 @@ +package com.swyp.catsgotogedog.common.security.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java new file mode 100644 index 0000000..f7eee19 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java @@ -0,0 +1,56 @@ +package com.swyp.catsgotogedog.common.security.service; + + +import com.swyp.catsgotogedog.User.domain.entity.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + + +public class PrincipalDetails implements UserDetails { + + private User user; + + public PrincipalDetails(User user) { + this.user = user; + } + + @Override + public Collection getAuthorities() { + Collection collections = new ArrayList<>(); + collections.add(() -> user.getRole().name()); + return collections; + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getLoginId(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java new file mode 100644 index 0000000..d3f36e3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java @@ -0,0 +1,37 @@ +package com.swyp.catsgotogedog.common.util; + + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +public class JwtTokenUtil { + + public static String createToken(String loginId, String secretKey, long expireTimeMs) { + Claims claims = Jwts.claims(); + claims.put("loginId", loginId); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + public static String getLoginId(String token, String secretKey) { + return extractClaims(token, secretKey).get("loginId").toString(); + } + + public static boolean isExpired(String token, String secretKey) { + Date expiredDate = extractClaims(token, secretKey).getExpiration(); + return expiredDate.before(new Date()); + } + + private static Claims extractClaims(String token, String secretKey) { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); + } +} From ba63762951370f7db44758bc986adde1da610042 Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:50:54 +0900 Subject: [PATCH 002/191] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../User/controller/GoogleAuthController.java | 41 ++++++++++++++ .../User/domain/entity/User.java | 4 ++ .../domain/request/GoogleOAuthRequest.java | 51 +++++++++++++++++ .../User/service/UserService.java | 55 +++++++++++++++++++ .../security/service/PrincipalDetails.java | 20 ++++++- .../service/PrincipalOauth2UserService.java | 49 +++++++++++++++++ 7 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/User/controller/GoogleAuthController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/GoogleOAuthRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java diff --git a/build.gradle b/build.gradle index 9e23bc0..b730e73 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,8 @@ dependencies { //jwt implementation 'io.jsonwebtoken:jjwt:0.9.1' + // 구글 Oauth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/GoogleAuthController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/GoogleAuthController.java new file mode 100644 index 0000000..52ebb55 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/GoogleAuthController.java @@ -0,0 +1,41 @@ +package com.swyp.catsgotogedog.User.controller; + + +import com.swyp.catsgotogedog.User.domain.request.GoogleOAuthRequest; +import com.swyp.catsgotogedog.User.domain.response.LoginResponse; +import com.swyp.catsgotogedog.User.service.UserService; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class GoogleAuthController { + + private final UserService userService; + + @Value("${jwt.secret}") + private String secretKey; + + @PostMapping("/google") + public ResponseEntity googleLogin(@RequestBody GoogleOAuthRequest request) { +// String idToken = request.getAccount().getId_token(); + String idToken = request.getIdToken(); + + String loginId = userService.processGoogleIdToken(idToken); + + long expireTimeMs = 1000 * 60 * 60; // 예: 60분 유효 + + String jwtToken = JwtTokenUtil.createToken(loginId, secretKey, expireTimeMs); + + LoginResponse response = new LoginResponse("로그인 성공", jwtToken); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index acfc09c..d84c6b5 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -34,4 +34,8 @@ public class User { private String koreanProficiency; private String region; private String visaDescription; + + // google Oauth + private String provider; + private String providerId; } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/GoogleOAuthRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/GoogleOAuthRequest.java new file mode 100644 index 0000000..ba1d859 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/request/GoogleOAuthRequest.java @@ -0,0 +1,51 @@ +package com.swyp.catsgotogedog.User.domain.request; + + +import lombok.Data; + +@Data +public class GoogleOAuthRequest { + + private String idToken; + private Account account; + private Profile profile; + private OAuthProfile OAuthProfile; + + @Data + public static class Account { + private String provider; + private String type; + private String providerAccountId; + private String access_token; + private long expires_at; + private String scope; + private String token_type; + private String id_token; // 여기서 id_token이 실제 JWT 토큰입니다. + } + + @Data + public static class Profile { + private String id; + private String name; + private String email; + private String image; + } + + @Data + public static class OAuthProfile { + private String iss; + private String azp; + private String aud; + private String sub; + private String hd; + private String email; + private boolean email_verified; + private String at_hash; + private String name; + private String picture; + private String given_name; + private String family_name; + private long iat; + private long exp; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java index 6155266..da74819 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -2,7 +2,14 @@ +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.entity.UserRole; import com.swyp.catsgotogedog.User.domain.request.JoinRequest; import com.swyp.catsgotogedog.User.domain.request.LoginRequest; import com.swyp.catsgotogedog.User.domain.request.UserProfileUpdateRequest; @@ -14,6 +21,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.net.URL; +import java.security.interfaces.RSAPublicKey; import java.text.SimpleDateFormat; import java.util.Optional; @@ -100,5 +109,51 @@ public UserProfileResponse getUserProfile(String loginId) { user.getVisaDescription() ); } + + public String processGoogleIdToken(String idToken) { + try { + SignedJWT signedJWT = SignedJWT.parse(idToken); + + String kid = signedJWT.getHeader().getKeyID(); + if (kid == null) { + throw new Exception("토큰에 'kid' 값이 없습니다."); + } + + URL jwksUrl = new URL("https://www.googleapis.com/oauth2/v3/certs"); + JWKSet jwkSet = JWKSet.load(jwksUrl); + + JWK jwk = jwkSet.getKeyByKeyId(kid); + if (jwk == null) { + throw new Exception("토큰의 'kid'에 해당하는 공개 키를 찾을 수 없습니다: " + kid); + } + RSAPublicKey publicKey = jwk.toRSAKey().toRSAPublicKey(); + + JWSVerifier verifier = new RSASSAVerifier(publicKey); + if (!signedJWT.verify(verifier)) { + throw new Exception("토큰 서명 검증에 실패했습니다."); + } + + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + String provider = "google"; + String providerId = claims.getStringClaim("sub"); + String extractedName = claims.getStringClaim("name"); + String loginId = provider + "_" + providerId; + + Optional optionalUser = userRepository.findByLoginId(loginId); + if (optionalUser.isEmpty()) { + User newUser = User.builder() + .loginId(loginId) + .name(extractedName) + .provider(provider) + .providerId(providerId) + .role(UserRole.USER) + .build(); + userRepository.save(newUser); + } + return loginId; + } catch (Exception e) { + throw new RuntimeException("Google ID 토큰 처리 실패", e); + } + } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java index f7eee19..f98b286 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java @@ -4,19 +4,27 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; import java.util.ArrayList; import java.util.Collection; +import java.util.Map; -public class PrincipalDetails implements UserDetails { +public class PrincipalDetails implements UserDetails, OAuth2User { private User user; + private Map attributes; public PrincipalDetails(User user) { this.user = user; } + public PrincipalDetails(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + @Override public Collection getAuthorities() { Collection collections = new ArrayList<>(); @@ -53,4 +61,14 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return true; } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return user.getLoginId(); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java new file mode 100644 index 0000000..adc41c3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -0,0 +1,49 @@ +package com.swyp.catsgotogedog.common.security.service; + + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.entity.UserRole; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PrincipalOauth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + log.info("getAttributes : {}", oAuth2User.getAttributes()); + + String provider = userRequest.getClientRegistration().getRegistrationId(); + String providerId = oAuth2User.getAttribute("sub"); + String loginId = provider + "_" + providerId; + + Optional optionalUser = userRepository.findByLoginId(loginId); + User user; + if (optionalUser.isEmpty()) { + user = User.builder() + .loginId(loginId) + .name(oAuth2User.getAttribute("name")) + .provider(provider) + .providerId(providerId) + .role(UserRole.USER) + .build(); + userRepository.save(user); + } else { + user = optionalUser.get(); + } + return new PrincipalDetails(user, oAuth2User.getAttributes()); + } +} From 6a40c9cd661a81c61b3895deb41d377490d18fe7 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Thu, 10 Jul 2025 17:22:07 +0900 Subject: [PATCH 003/191] Add CI/CD files --- .github/workflows/cd.yml | 125 +++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 43 ++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..b2a5fe8 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,125 @@ +name: CD + +on: + push: + branches: [ "main", "develop" ] + +jobs: + build: + runs-on: ubuntu-22.04 # NCP Server version + environment: ${{ github.ref == 'refs/heads/main' && 'Product' || 'Develop' }} + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. + # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Generate application.properties from Secrets + run: | + mkdir -p src/main/resources + echo "${{ secrets.PROPERTIES }}" > src/main/resources/application.properties + + - name: Give execute permission to gradlew + run: chmod +x ./gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew clean build + + - name: Upload build artifact # only cd.yml + uses: actions/upload-artifact@v4 + with: + name: app-build + path: build/libs/*-SNAPSHOT.jar + retention-days: 1 + + # develop + deploy_dev: + needs: build + if: github.ref == 'refs/heads/develop' + runs-on: ubuntu-22.04 # NCP Server version + environment: Develop + steps: + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: app-build + + - name: Copy JAR to Server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.NCP_SERVER_IP }} + username: ${{ secrets.NCP_SERVER_USER }} + key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} + source: "*-SNAPSHOT.jar" + target: ${{ secrets.NCP_SERVER_PATH }} + + - name: Deploy application + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ secrets.NCP_SERVER_IP }} + username: ${{ secrets.NCP_SERVER_USER }} + key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} + script: | + cd ${{ secrets.NCP_SERVER_PATH }} + # 실행 중인 애플리케이션 중지 및 중지 실패 시 action 중단 방지 + if [ -f pid.file ]; then + kill $(cat pid.file) || true + rm pid.file + sleep 5 + fi + + # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar > dev_${TIMESTAMP}.log 2>&1 & echo $! > pid.file + echo "Development server deploy done." + + # product + deploy_prod: + needs: build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-22.04 # NCP Server version + environment: Product + steps: + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: app-build + + - name: Copy JAR to Server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.NCP_SERVER_IP }} + username: ${{ secrets.NCP_SERVER_USER }} + key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} + source: "*-SNAPSHOT.jar" + target: ${{ secrets.NCP_SERVER_PATH }} + + - name: Deploy application + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ secrets.NCP_SERVER_IP }} + username: ${{ secrets.NCP_SERVER_USER }} + key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} + script: | + cd ${{ secrets.NCP_SERVER_PATH }} + # 실행 중인 애플리케이션 중지 및 중지 실패 시 action 중단 방지 + if [ -f pid.file ]; then + kill $(cat pid.file) || true + rm pid.file + sleep 5 + fi + + # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar > prod_${TIMESTAMP}.log 2>&1 & echo $! > pid.file + echo "Production server deploy done." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f635728 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle + +name: CI + +on: + pull_request: + branches: [ "main", "develop" ] + +jobs: + build: + runs-on: ubuntu-22.04 # NCP Server version + environment: ${{ github.base_ref == 'main' && 'Product' || 'Develop' }} + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. + # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Generate application.properties from Secrets + run: | + mkdir -p src/main/resources + echo "${{ secrets.PROPERTIES }}" > src/main/resources/application.properties + + - name: Give execute permission to gradlew + run: chmod +x ./gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew clean build From cbbcf5f945ccc8efd3f18d280df2f96407ebc70a Mon Sep 17 00:00:00 2001 From: yhs99 Date: Thu, 10 Jul 2025 19:22:29 +0900 Subject: [PATCH 004/191] =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20workflow=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20/=20=EC=9D=B4=EC=8A=88=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 workflow 제거 / 이슈템플릿 작성 --- .github/ISSUE_TEMPLATE/template.md | 14 ++++++++++ .github/workflows/gradle.yml | 43 ------------------------------ 2 files changed, 14 insertions(+), 43 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/template.md delete mode 100644 .github/workflows/gradle.yml diff --git a/.github/ISSUE_TEMPLATE/template.md b/.github/ISSUE_TEMPLATE/template.md new file mode 100644 index 0000000..ea42bc1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/template.md @@ -0,0 +1,14 @@ +--- +name: 공통 이슈 템플릿 +about: 설명을 적어주세요. +title: '' +labels: '' +assignees: '' + +--- + +## 설명 +> +## 작업 상세 내용 +- [ ] +## 참고 사항 \ No newline at end of file diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml deleted file mode 100644 index b3f6ccf..0000000 --- a/.github/workflows/gradle.yml +++ /dev/null @@ -1,43 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle - -name: Backend CI - -on: - pull_request: - branches: [ "main", "develop" ] - -jobs: - build: - - runs-on: ubuntu-22.04 # NCP Server version - permissions: - contents: read - - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. - # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - - name: Setup Gradle - uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 - - - name: Generate application.properties from Secrets - run: | - mkdir -p src/main/resources - echo "${{ secrets.APP_PROPERTIES }}" > src/main/resources/application.properties - - - name: Give execute permission to gradlew - run: chmod +x ./gradlew - - - name: Build with Gradle Wrapper - run: ./gradlew clean build From ab3aee07b675bb192cbb8aa9ce7e4e45380c89ce Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Thu, 10 Jul 2025 22:28:18 +0900 Subject: [PATCH 005/191] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../User/domain/entity/User.java | 1 + .../common/config/SecurityConfig.java | 10 +++ .../handler/OAuth2LoginSuccessHandler.java | 84 +++++++++++++++++++ .../service/PrincipalOauth2UserService.java | 49 ++++++----- 4 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index d84c6b5..78ce816 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -22,6 +22,7 @@ public class User { private String name; private String loginId; private String password; + private String nickname; private Date birth; private String phoneNumber; private String email; diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index eecb691..34cceaf 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -5,6 +5,8 @@ import com.swyp.catsgotogedog.common.security.filter.JwtTokenFilter; import com.swyp.catsgotogedog.common.security.handler.MyAccessDeniedHandler; import com.swyp.catsgotogedog.common.security.handler.MyAuthenticationEntryPoint; +import com.swyp.catsgotogedog.common.security.handler.OAuth2LoginSuccessHandler; +import com.swyp.catsgotogedog.common.security.service.PrincipalOauth2UserService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -21,6 +23,8 @@ public class SecurityConfig { private final UserService userService; + private final PrincipalOauth2UserService principalOauth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; @Value("${jwt.secret}") private String jwtSecret; @@ -32,12 +36,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(authorize -> authorize .requestMatchers("/jwt-login/profile").authenticated() .requestMatchers("/jwt-login/admin/**").hasAuthority(UserRole.ADMIN.name()) + .requestMatchers("/oauth2/**", "/login/**").permitAll() .anyRequest().permitAll() ) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(exception -> exception .authenticationEntryPoint(new MyAuthenticationEntryPoint()) .accessDeniedHandler(new MyAccessDeniedHandler()) + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(principalOauth2UserService)) + .successHandler(oAuth2LoginSuccessHandler) ); http.addFilterBefore(new JwtTokenFilter(userService, jwtSecret), UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..0df07bc --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -0,0 +1,84 @@ +package com.swyp.catsgotogedog.common.security.handler; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.entity.UserRole; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + @Value("${jwt.secret}") + private String jwtSecret; + + private static final int EXPIRATION_MS = 60 * 60 * 1000; + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest req, + HttpServletResponse res, + Authentication auth) throws IOException { + + OAuth2User oAuth2 = (OAuth2User) auth.getPrincipal(); + String provider = auth.getAuthorities().stream() + .findFirst().orElseThrow().getAuthority(); + + String providerId; + String name; + String email; + + switch (provider) { + case "google" -> { + providerId = (String) oAuth2.getAttribute("sub"); + name = (String) oAuth2.getAttribute("name"); + email = (String) oAuth2.getAttribute("email"); + } + case "naver" -> { + providerId = (String) oAuth2.getAttribute("id"); + name = (String) oAuth2.getAttribute("name"); + email = (String) oAuth2.getAttribute("email"); + } + case "kakao" -> { + providerId = (String)(oAuth2.getAttribute("id")); + name = (String) oAuth2.getAttribute("nickname"); + email = (String) oAuth2.getAttribute("email"); + } + default -> throw new IllegalArgumentException("지원되지 않는 Provider: " + provider); + } + + if (providerId == null || name == null) { + throw new IllegalStateException("OAuth2 파싱 실패"); + } + + String loginId = provider + "_" + providerId; + + User user = userRepository.findByLoginId(loginId) + .orElseGet(() -> userRepository.save( + User.builder() + .loginId(loginId) + .nickname(name) + .email(email) + .provider(provider) + .providerId(providerId) + .role(UserRole.USER) + .password("") + .build())); + + String jwt = JwtTokenUtil.createToken(user.getLoginId(), jwtSecret, EXPIRATION_MS); + + res.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + jwt); + res.sendRedirect("/"); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java index adc41c3..27dc8c6 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -1,8 +1,7 @@ package com.swyp.catsgotogedog.common.security.service; -import com.swyp.catsgotogedog.User.domain.entity.User; -import com.swyp.catsgotogedog.User.domain.entity.UserRole; + import com.swyp.catsgotogedog.User.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,7 +11,6 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import java.util.Optional; @Service @RequiredArgsConstructor @@ -24,26 +22,31 @@ public class PrincipalOauth2UserService extends DefaultOAuth2UserService { @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); - log.info("getAttributes : {}", oAuth2User.getAttributes()); - - String provider = userRequest.getClientRegistration().getRegistrationId(); - String providerId = oAuth2User.getAttribute("sub"); - String loginId = provider + "_" + providerId; - - Optional optionalUser = userRepository.findByLoginId(loginId); - User user; - if (optionalUser.isEmpty()) { - user = User.builder() - .loginId(loginId) - .name(oAuth2User.getAttribute("name")) - .provider(provider) - .providerId(providerId) - .role(UserRole.USER) - .build(); - userRepository.save(user); - } else { - user = optionalUser.get(); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + String providerId; + String email = null; + String name; + + log.info("RegistrationId: {}", registrationId); + log.info("OAuth2 attributes: {}", oAuth2User.getAttributes()); + + switch (registrationId) { + case "naver": + providerId = oAuth2User.getAttribute("id"); + name = oAuth2User.getAttribute("profile_nickname"); + //email = oAuth2User.getAttribute("email"); + break; + case "kakao": + providerId = oAuth2User.getAttribute("id"); + name = oAuth2User.getAttribute("nickname"); + //email = oAuth2User.getAttribute("kakao_account_email"); + break; + case "google": + providerId = oAuth2User.getAttribute("sub"); + name = oAuth2User.getAttribute("name"); + email = oAuth2User.getAttribute("email"); } - return new PrincipalDetails(user, oAuth2User.getAttributes()); + + return super.loadUser(userRequest); } } From 2f39f2d5e6f02cdec02300a88d4aa268f84c5b70 Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Thu, 10 Jul 2025 23:06:17 +0900 Subject: [PATCH 006/191] =?UTF-8?q?feat:=20BCryptPasswordEncoder=20?= =?UTF-8?q?=EB=B9=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/PasswordEncoderConfig.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/PasswordEncoderConfig.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/PasswordEncoderConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..58e5b63 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/PasswordEncoderConfig.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} From 1c622b0bc50a2fe8c3b30c17c146c51ae78ddb69 Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:48:13 +0900 Subject: [PATCH 007/191] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 +- .../User/controller/GoogleAuthController.java | 41 ------------ .../User/controller/UserController.java | 28 ++++---- .../User/domain/entity/User.java | 7 +- .../User/repository/UserRepository.java | 1 + .../common/config/SecurityConfig.java | 27 +++----- .../common/oauth2/SocialUserInfo.java | 34 ++++++++++ .../security/filter/JwtTokenFilter.java | 54 +++++---------- .../handler/OAuth2LoginSuccessHandler.java | 67 +++---------------- .../security/service/PrincipalDetails.java | 51 ++++++-------- .../service/PrincipalOauth2UserService.java | 46 +++++-------- .../common/util/JwtTokenUtil.java | 45 ++++++++----- 12 files changed, 161 insertions(+), 246 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/controller/GoogleAuthController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java diff --git a/build.gradle b/build.gradle index b730e73..38745e2 100644 --- a/build.gradle +++ b/build.gradle @@ -40,9 +40,11 @@ dependencies { implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' //jwt - implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - // 구글 Oauth + // Oauth implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/GoogleAuthController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/GoogleAuthController.java deleted file mode 100644 index 52ebb55..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/GoogleAuthController.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.swyp.catsgotogedog.User.controller; - - -import com.swyp.catsgotogedog.User.domain.request.GoogleOAuthRequest; -import com.swyp.catsgotogedog.User.domain.response.LoginResponse; -import com.swyp.catsgotogedog.User.service.UserService; -import com.swyp.catsgotogedog.common.util.JwtTokenUtil; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/auth") -@RequiredArgsConstructor -public class GoogleAuthController { - - private final UserService userService; - - @Value("${jwt.secret}") - private String secretKey; - - @PostMapping("/google") - public ResponseEntity googleLogin(@RequestBody GoogleOAuthRequest request) { -// String idToken = request.getAccount().getId_token(); - String idToken = request.getIdToken(); - - String loginId = userService.processGoogleIdToken(idToken); - - long expireTimeMs = 1000 * 60 * 60; // 예: 60분 유효 - - String jwtToken = JwtTokenUtil.createToken(loginId, secretKey, expireTimeMs); - - LoginResponse response = new LoginResponse("로그인 성공", jwtToken); - - return ResponseEntity.ok(response); - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index 246dce1..c91a631 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -63,20 +63,20 @@ public ResponseEntity> checkDuplicate(@RequestParam("loginId return ResponseEntity.ok(response); } - @Operation(summary="로그인") - @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest loginRequest) { - User user = userService.login(loginRequest); - if(user == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body("로그인 아이디 또는 비밀번호가 틀렸습니다."); - } - - long expireTimeMs = 1000 * 60 * 60; // 60분 유효 - String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs); - LoginResponse response = new LoginResponse("로그인 성공", jwtToken); - return ResponseEntity.ok(response); - } +// @Operation(summary="로그인") +// @PostMapping("/login") +// public ResponseEntity login(@RequestBody LoginRequest loginRequest) { +// User user = userService.login(loginRequest); +// if(user == null) { +// return ResponseEntity.status(HttpStatus.UNAUTHORIZED) +// .body("로그인 아이디 또는 비밀번호가 틀렸습니다."); +// } +// +// long expireTimeMs = 1000 * 60 * 60; // 60분 유효 +// String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs); +// LoginResponse response = new LoginResponse("로그인 성공", jwtToken); +// return ResponseEntity.ok(response); +// } @Operation(summary="프로필 업데이트") @PutMapping("/update-profile") diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index 78ce816..7b2e97a 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -36,7 +36,8 @@ public class User { private String region; private String visaDescription; - // google Oauth - private String provider; - private String providerId; + // Oauth + private String provider; // google / kakao / naver + private String providerId; // 소셜 PK + } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java index 1a2c1f4..75ce640 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java @@ -8,4 +8,5 @@ public interface UserRepository extends JpaRepository { boolean existsByLoginId(String loginId); Optional findByLoginId(String loginId); + Optional findByProviderAndProviderId(String provider, String providerId); } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index 34cceaf..bf8a86c 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -7,6 +7,7 @@ import com.swyp.catsgotogedog.common.security.handler.MyAuthenticationEntryPoint; import com.swyp.catsgotogedog.common.security.handler.OAuth2LoginSuccessHandler; import com.swyp.catsgotogedog.common.security.service.PrincipalOauth2UserService; +import io.jsonwebtoken.Jwt; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -25,6 +26,7 @@ public class SecurityConfig { private final UserService userService; private final PrincipalOauth2UserService principalOauth2UserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + private final JwtTokenFilter jwtTokenFilter; @Value("${jwt.secret}") private String jwtSecret; @@ -33,24 +35,15 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/jwt-login/profile").authenticated() - .requestMatchers("/jwt-login/admin/**").hasAuthority(UserRole.ADMIN.name()) - .requestMatchers("/oauth2/**", "/login/**").permitAll() - .anyRequest().permitAll() - ) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(exception -> exception - .authenticationEntryPoint(new MyAuthenticationEntryPoint()) - .accessDeniedHandler(new MyAccessDeniedHandler()) - ) - .oauth2Login(oauth2 -> oauth2 - .userInfoEndpoint(userInfo -> userInfo - .userService(principalOauth2UserService)) - .successHandler(oAuth2LoginSuccessHandler) - ); + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/login**", "/error").permitAll() + .anyRequest().authenticated()) + .oauth2Login(oauth -> oauth + .loginPage("/login") // 커스텀 로그인 화면 (없으면 기본 템플릿) + .successHandler(oAuth2LoginSuccessHandler)) + .addFilterBefore(jwtTokenFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); - http.addFilterBefore(new JwtTokenFilter(userService, jwtSecret), UsernamePasswordAuthenticationFilter.class); return http.build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java new file mode 100644 index 0000000..939a21c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java @@ -0,0 +1,34 @@ +package com.swyp.catsgotogedog.common.oauth2; + +import java.util.Map; + +public record SocialUserInfo(String id, String email, String name) { + + public static SocialUserInfo of(String provider, Map attr) { + return switch (provider) { + case "google" -> new SocialUserInfo( + (String) attr.get("sub"), + (String) attr.get("email"), + (String) attr.get("name") + ); + case "kakao" -> { + Map acc = (Map) attr.get("kakao_account"); + Map prof = (Map) acc.get("profile"); + yield new SocialUserInfo( + String.valueOf(attr.get("id")), + (String) acc.get("email"), + (String) prof.get("nickname") + ); + } + case "naver" -> { + Map res = (Map) attr.get("response"); + yield new SocialUserInfo( + (String) res.get("id"), + (String) res.get("email"), + (String) res.get("name") + ); + } + default -> throw new IllegalArgumentException("지원하지 않는 provider"); + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java index f5f9d57..886137c 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java @@ -1,60 +1,38 @@ package com.swyp.catsgotogedog.common.security.filter; -import com.swyp.catsgotogedog.User.domain.entity.User; -import com.swyp.catsgotogedog.User.service.UserService; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; +import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.stereotype.Component; import java.io.IOException; import java.util.List; +@Component @RequiredArgsConstructor -public class JwtTokenFilter extends OncePerRequestFilter { +public class JwtTokenFilter implements Filter { - private final UserService userService; - private final String secretKey; + private final JwtTokenUtil jwt; @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { - if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { - filterChain.doFilter(request, response); - return; - } - - String token = authorizationHeader.split(" ")[1]; - - if (JwtTokenUtil.isExpired(token, secretKey)) { - filterChain.doFilter(request, response); - return; - } + HttpServletRequest request = (HttpServletRequest) req; + String bearer = request.getHeader("Authorization"); - String loginId = JwtTokenUtil.getLoginId(token, secretKey); + if (bearer != null && bearer.startsWith("Bearer ")) { + String token = bearer.substring(7); + String sub = jwt.getSubject(token); - User loginUser = userService.getLoginUserByLoginId(loginId); - if (loginUser == null) { - filterChain.doFilter(request, response); - return; + var auth = new UsernamePasswordAuthenticationToken( + sub, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(auth); } - - UsernamePasswordAuthenticationToken authenticationToken = - new UsernamePasswordAuthenticationToken(loginUser.getLoginId(), null, - List.of(new SimpleGrantedAuthority(loginUser.getRole().name()))); - authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - - SecurityContextHolder.getContext().setAuthentication(authenticationToken); - filterChain.doFilter(request, response); + chain.doFilter(req, res); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index 0df07bc..ee62a04 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -10,75 +10,30 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import java.io.IOException; +import java.util.Optional; @Component @RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { - @Value("${jwt.secret}") - private String jwtSecret; - - private static final int EXPIRATION_MS = 60 * 60 * 1000; - private final UserRepository userRepository; + private final JwtTokenUtil jwt; @Override - public void onAuthenticationSuccess(HttpServletRequest req, - HttpServletResponse res, - Authentication auth) throws IOException { - - OAuth2User oAuth2 = (OAuth2User) auth.getPrincipal(); - String provider = auth.getAuthorities().stream() - .findFirst().orElseThrow().getAuthority(); - - String providerId; - String name; - String email; - - switch (provider) { - case "google" -> { - providerId = (String) oAuth2.getAttribute("sub"); - name = (String) oAuth2.getAttribute("name"); - email = (String) oAuth2.getAttribute("email"); - } - case "naver" -> { - providerId = (String) oAuth2.getAttribute("id"); - name = (String) oAuth2.getAttribute("name"); - email = (String) oAuth2.getAttribute("email"); - } - case "kakao" -> { - providerId = (String)(oAuth2.getAttribute("id")); - name = (String) oAuth2.getAttribute("nickname"); - email = (String) oAuth2.getAttribute("email"); - } - default -> throw new IllegalArgumentException("지원되지 않는 Provider: " + provider); - } - - if (providerId == null || name == null) { - throw new IllegalStateException("OAuth2 파싱 실패"); - } - - String loginId = provider + "_" + providerId; - - User user = userRepository.findByLoginId(loginId) - .orElseGet(() -> userRepository.save( - User.builder() - .loginId(loginId) - .nickname(name) - .email(email) - .provider(provider) - .providerId(providerId) - .role(UserRole.USER) - .password("") - .build())); + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication auth) { - String jwt = JwtTokenUtil.createToken(user.getLoginId(), jwtSecret, EXPIRATION_MS); + String principal = auth.getName(); + String access = jwt.createAccessToken(principal); + String refresh = jwt.createRefreshToken(principal); - res.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + jwt); - res.sendRedirect("/"); + response.setHeader("Authorization", "Bearer " + access); + response.setHeader("X-Refresh-Token", refresh); + response.setStatus(HttpServletResponse.SC_OK); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java index f98b286..1aa73b4 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java @@ -2,18 +2,21 @@ import com.swyp.catsgotogedog.User.domain.entity.User; +import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Map; -public class PrincipalDetails implements UserDetails, OAuth2User { +public class PrincipalDetails implements OAuth2User, Authentication { - private User user; + private final User user; private Map attributes; public PrincipalDetails(User user) { @@ -25,50 +28,34 @@ public PrincipalDetails(User user, Map attributes) { this.attributes = attributes; } - @Override - public Collection getAuthorities() { - Collection collections = new ArrayList<>(); - collections.add(() -> user.getRole().name()); - return collections; - } + /* OAuth2User */ + @Override public Map getAttributes() { return attributes; } + @Override public String getName() { return user.getName(); } - @Override - public String getPassword() { - return user.getPassword(); + /* Authentication */ + @Override public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); } - - @Override - public String getUsername() { - return user.getLoginId(); - } - @Override - public boolean isAccountNonExpired() { - return true; + public Object getCredentials(){ + return null; } - @Override - public boolean isAccountNonLocked() { - return true; + public Object getDetails(){ + return null; } @Override - public boolean isCredentialsNonExpired() { - return true; + public Object getPrincipal(){ + return this; } @Override - public boolean isEnabled() { + public boolean isAuthenticated(){ return true; } @Override - public Map getAttributes() { - return attributes; - } + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {} - @Override - public String getName() { - return user.getLoginId(); - } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java index 27dc8c6..25d8dc0 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -2,7 +2,9 @@ +import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.oauth2.SocialUserInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; @@ -20,33 +22,21 @@ public class PrincipalOauth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - String providerId; - String email = null; - String name; - - log.info("RegistrationId: {}", registrationId); - log.info("OAuth2 attributes: {}", oAuth2User.getAttributes()); - - switch (registrationId) { - case "naver": - providerId = oAuth2User.getAttribute("id"); - name = oAuth2User.getAttribute("profile_nickname"); - //email = oAuth2User.getAttribute("email"); - break; - case "kakao": - providerId = oAuth2User.getAttribute("id"); - name = oAuth2User.getAttribute("nickname"); - //email = oAuth2User.getAttribute("kakao_account_email"); - break; - case "google": - providerId = oAuth2User.getAttribute("sub"); - name = oAuth2User.getAttribute("name"); - email = oAuth2User.getAttribute("email"); - } - - return super.loadUser(userRequest); + public OAuth2User loadUser(OAuth2UserRequest req) { + OAuth2User oAuth2User = super.loadUser(req); + + String provider = req.getClientRegistration().getRegistrationId(); // google/kakao/naver + SocialUserInfo info = SocialUserInfo.of(provider, oAuth2User.getAttributes()); + + User user = userRepository.findByProviderAndProviderId(provider, info.id()) + .orElseGet(() -> userRepository.save( + User.builder() + .provider(provider) + .providerId(info.id()) + .email(info.email()) + .name(info.name()) + .build())); + + return new PrincipalDetails(user, oAuth2User.getAttributes()); } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java index d3f36e3..8df34e7 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java @@ -4,34 +4,49 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.security.Key; import java.util.Date; @Component public class JwtTokenUtil { - public static String createToken(String loginId, String secretKey, long expireTimeMs) { - Claims claims = Jwts.claims(); - claims.put("loginId", loginId); + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-expire-min}") + private long accessMin; + + @Value("${jwt.refresh-expire-day}") + private long refreshDay; + + private Key key; + + public String createAccessToken(String sub) { + Date now = new Date(); return Jwts.builder() - .setClaims(claims) - .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs)) + .setSubject(sub) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + accessMin * 60_000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); } - public static String getLoginId(String token, String secretKey) { - return extractClaims(token, secretKey).get("loginId").toString(); - } - - public static boolean isExpired(String token, String secretKey) { - Date expiredDate = extractClaims(token, secretKey).getExpiration(); - return expiredDate.before(new Date()); + public String createRefreshToken(String sub) { + Date now = new Date(); + return Jwts.builder() + .setSubject(sub) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + refreshDay * 86_400_000)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); } - private static Claims extractClaims(String token, String secretKey) { - return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); + public String getSubject(String token) { + return Jwts.parserBuilder().setSigningKey(key).build() + .parseClaimsJws(token).getBody().getSubject(); } } From 9912bca9da0b47b76d64687beb9a04a2f1fa200f Mon Sep 17 00:00:00 2001 From: yhs99 Date: Fri, 11 Jul 2025 20:27:56 +0900 Subject: [PATCH 008/191] =?UTF-8?q?Milvus=20=EC=84=B8=ED=8C=85,=20Flyway?= =?UTF-8?q?=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milvus 기본 설정 및 컬렉션 로드 확인 Flyway 기본 설정 --- build.gradle | 5 +++ .../global/config/MilvusConfig.java | 39 +++++++++++++++++++ .../milvus/service/MilvusService.java | 37 ++++++++++++++++++ .../resources/db/migration/mysql/V1__init.sql | 8 ++++ 4 files changed, 89 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java create mode 100644 src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java create mode 100644 src/main/resources/db/migration/mysql/V1__init.sql diff --git a/build.gradle b/build.gradle index aa16805..ea20329 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,11 @@ dependencies { implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.9' // S3 implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' + // Milvus Java SDK + implementation group: 'io.milvus', name: 'milvus-sdk-java', version: '2.5.10' + // Flyway + implementation group: 'org.flywaydb', name: 'flyway-mysql', version: '11.10.2' + implementation 'org.flywaydb:flyway-core' } tasks.named('test') { diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java new file mode 100644 index 0000000..9ded0db --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.milvus.client.MilvusClient; +import io.milvus.client.MilvusServiceClient; +import io.milvus.param.ConnectParam; +import io.milvus.param.ConnectParam.Builder; + +@Configuration +public class MilvusConfig { + + @Value("${milvus.host}") + private String milvusHost; + + @Value("${milvus.port}") + private int milvusPort; + + @Value("${milvus.username}") + private String milvusUsername; + + @Value("${milvus.password}") + private String milvusPassword; + + @Bean + public MilvusClient milvusClient() { + Builder connectParamBuilder = ConnectParam.newBuilder() + .withHost(milvusHost) + .withPort(milvusPort); + + if (milvusUsername != null && milvusPassword != null) { + connectParamBuilder.withAuthorization(milvusUsername, milvusPassword); + } + + return new MilvusServiceClient(connectParamBuilder.build()); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java b/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java new file mode 100644 index 0000000..0133806 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java @@ -0,0 +1,37 @@ +package com.swyp.catsgotogedog.milvus.service; + +import org.springframework.stereotype.Service; + +import io.milvus.client.MilvusClient; +import io.milvus.param.collection.CreateDatabaseParam; +import io.milvus.param.collection.LoadCollectionParam; +import io.milvus.response.SearchResultsWrapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MilvusService { + + private final MilvusClient milvusClient; + private static final String COLLECTION_NAME = "catsgotogedog_collection"; + + /** + * 컬렉션 로딩 + * 스프링 시작시 milvus 컬렉션 로드 + * 메모리에 올라가는 작업 + */ + @PostConstruct + public void loadCollection() { + try { + milvusClient.loadCollection(LoadCollectionParam.newBuilder() + .withCollectionName(COLLECTION_NAME) + .build()); + log.info("Collection 로드 완료 :: {}", COLLECTION_NAME); + } catch (Exception e) { + throw new RuntimeException("Milvus user Creation Failed"); + } + } +} diff --git a/src/main/resources/db/migration/mysql/V1__init.sql b/src/main/resources/db/migration/mysql/V1__init.sql new file mode 100644 index 0000000..d0389f0 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V1__init.sql @@ -0,0 +1,8 @@ +CREATE TABLE emp_table +(emp_id INT NOT NULL, + emp_name VARCHAR(100) NOT NULL, + gender VARCHAR(10) NULL, + age INT NULL, + hire_date DATE NULL, + etc VARCHAR(300) NULL, + PRIMARY KEY (emp_id)); \ No newline at end of file From b08adbda666db209b03c5dcf5647d7718f14fd71 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Tue, 15 Jul 2025 18:37:40 +0900 Subject: [PATCH 009/191] =?UTF-8?q?milvus=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit milvus 서비스 테스트코드 작성 소셜 로그인 테스트를 위한 유저, 리프레쉬 토큰 테이블 생성 sql문 작성 --- .gitignore | 1 - .../milvus/service/MilvusService.java | 17 ++++-- .../resources/db/migration/mysql/V1__init.sql | 37 ++++++++++--- .../milvus/service/MilvusServiceTest.java | 53 +++++++++++++++++++ 4 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java diff --git a/.gitignore b/.gitignore index 235eb73..69d0cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,5 @@ out/ ### properties ### application.properties -application-local.properties application-dev.properties application-prod.properties \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java b/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java index 0133806..cb218bd 100644 --- a/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java +++ b/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java @@ -1,9 +1,15 @@ package com.swyp.catsgotogedog.milvus.service; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import io.milvus.client.MilvusClient; +import io.milvus.grpc.GetCollectionStatisticsResponse; +import io.milvus.param.R; import io.milvus.param.collection.CreateDatabaseParam; +import io.milvus.response.GetCollStatResponseWrapper; +import io.milvus.grpc.GetCollectionStatisticsResponse; +import io.milvus.param.collection.GetCollectionStatisticsParam; import io.milvus.param.collection.LoadCollectionParam; import io.milvus.response.SearchResultsWrapper; import jakarta.annotation.PostConstruct; @@ -16,7 +22,12 @@ public class MilvusService { private final MilvusClient milvusClient; - private static final String COLLECTION_NAME = "catsgotogedog_collection"; + + /** + * TODO : 개발, 운영 milvus 컬렉션을 나누어야 할 필요가 있어보임 우선 하나의 컬렉션을 사용 + */ + @Value("${milvus.collection-name}") + private String collectionName; /** * 컬렉션 로딩 @@ -27,9 +38,9 @@ public class MilvusService { public void loadCollection() { try { milvusClient.loadCollection(LoadCollectionParam.newBuilder() - .withCollectionName(COLLECTION_NAME) + .withCollectionName(collectionName) .build()); - log.info("Collection 로드 완료 :: {}", COLLECTION_NAME); + log.info("Collection 로드 완료 :: {}", collectionName); } catch (Exception e) { throw new RuntimeException("Milvus user Creation Failed"); } diff --git a/src/main/resources/db/migration/mysql/V1__init.sql b/src/main/resources/db/migration/mysql/V1__init.sql index d0389f0..36d8f8d 100644 --- a/src/main/resources/db/migration/mysql/V1__init.sql +++ b/src/main/resources/db/migration/mysql/V1__init.sql @@ -1,8 +1,29 @@ -CREATE TABLE emp_table -(emp_id INT NOT NULL, - emp_name VARCHAR(100) NOT NULL, - gender VARCHAR(10) NULL, - age INT NULL, - hire_date DATE NULL, - etc VARCHAR(300) NULL, - PRIMARY KEY (emp_id)); \ No newline at end of file +CREATE TABLE `catsgotogedog`.`user` ( + `user_id` BIGINT NOT NULL AUTO_INCREMENT, + `display_name` VARCHAR(50) NOT NULL, + `email` VARCHAR(255) NULL, + `login_provider` VARCHAR(50) NULL, + `image_filename` VARCHAR(255) NULL, + `image_url` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT NOW(), + `updated_at` DATETIME NULL DEFAULT NOW(), + `is_active` TINYINT NULL DEFAULT 1, + PRIMARY KEY (`user_id`), + UNIQUE INDEX `email_UNIQUE` (`email` ASC) VISIBLE) +COMMENT = '유저 정보 테이블'; + +CREATE TABLE `catsgotogedog`.`refresh_token` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `refresh_token` VARCHAR(511) NOT NULL, + `expires_at` DATETIME NOT NULL, + `revoked` TINYINT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE INDEX `refresh_token_UNIQUE` (`refresh_token` ASC) VISIBLE, + UNIQUE INDEX `user_id_UNIQUE` (`user_id` ASC) VISIBLE, + CONSTRAINT `refresh_token_user_user_id_fk` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION) +COMMENT = '리프레쉬 토큰 테이블'; diff --git a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java b/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java new file mode 100644 index 0000000..60597fa --- /dev/null +++ b/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java @@ -0,0 +1,53 @@ +package com.swyp.catsgotogedog.milvus.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +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.test.util.ReflectionTestUtils; + +import io.milvus.client.MilvusClient; +import io.milvus.param.collection.LoadCollectionParam; + +@ExtendWith(MockitoExtension.class) +public class MilvusServiceTest { + + @Mock + private MilvusClient milvusClient; + + @InjectMocks + private MilvusService milvusService; + + private static final String TEST_COLLECTION_NAME = "catsgotogedog_test_collection"; + + @BeforeEach + void setUp() throws Exception { + ReflectionTestUtils.setField(milvusService, "collectionName", TEST_COLLECTION_NAME); + } + + @Test + @DisplayName("컬렉션 로드 테스트 (성공)") + void loadCollection_success() { + assertDoesNotThrow(() -> milvusService.loadCollection()); + + verify(milvusClient, times(1)).loadCollection(any(LoadCollectionParam.class)); + } + + @Test + @DisplayName("컬렉션 로드 테스트 (실패)") + void loadCollection_Fail() { + doThrow(new RuntimeException("Milvus client load 실패")).when(milvusClient).loadCollection(any(LoadCollectionParam.class)); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> milvusService.loadCollection()); + + assert(thrown.getMessage().equals("Milvus user Creation Failed")); + + verify(milvusClient, times(1)).loadCollection(any(LoadCollectionParam.class)); + } +} From abf4520c247688303ba5283f4b41f751b965807e Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:27:14 +0900 Subject: [PATCH 010/191] =?UTF-8?q?feat:=20=EC=95=88=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../User/controller/UserController.java | 96 ----------- .../domain/request/GoogleOAuthRequest.java | 51 ------ .../User/domain/request/JoinRequest.java | 52 ------ .../User/domain/request/LoginRequest.java | 14 -- .../request/UserProfileUpdateRequest.java | 17 -- .../User/domain/response/LoginResponse.java | 11 -- .../domain/response/UserProfileResponse.java | 22 --- .../User/service/UserService.java | 159 ------------------ 8 files changed, 422 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/GoogleOAuthRequest.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/JoinRequest.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/LoginRequest.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/UserProfileUpdateRequest.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/response/LoginResponse.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/User/service/UserService.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java deleted file mode 100644 index c91a631..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.swyp.catsgotogedog.User.controller; - -import com.swyp.catsgotogedog.User.domain.entity.User; -import com.swyp.catsgotogedog.User.domain.request.JoinRequest; -import com.swyp.catsgotogedog.User.domain.request.LoginRequest; -import com.swyp.catsgotogedog.User.domain.request.UserProfileUpdateRequest; -import com.swyp.catsgotogedog.User.domain.response.LoginResponse; -import com.swyp.catsgotogedog.User.domain.response.UserProfileResponse; -import com.swyp.catsgotogedog.User.service.UserService; -import com.swyp.catsgotogedog.common.util.JwtTokenUtil; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.Map; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/jwt-login") -@Tag(name="로그인 및 회원가입 api") -public class UserController { - - private final UserService userService; - - @Value("${jwt.secret}") - private String secretKey; - - @Operation(summary="회원가입") - @PostMapping("/join") - public ResponseEntity join(@RequestBody JoinRequest joinRequest) { - if (userService.checkLoginIdDuplicate(joinRequest.getLoginId())) { - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body("로그인 아이디가 중복됩니다."); - } - if (!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) { - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body("비밀번호가 일치하지 않습니다."); - } - userService.join(joinRequest); - return ResponseEntity.ok("회원가입 성공"); - } - - @Operation(summary="아이디 중복체크") - @GetMapping("/check-duplicate") - public ResponseEntity> checkDuplicate(@RequestParam("loginId") String loginId) { - boolean duplicate = userService.checkLoginIdDuplicate(loginId); - Map response = new HashMap<>(); - if (duplicate) { - response.put("duplicate", true); - response.put("message", "이미 사용중인 아이디입니다."); - } else { - response.put("duplicate", false); - response.put("message", "사용 가능한 아이디입니다."); - } - return ResponseEntity.ok(response); - } - -// @Operation(summary="로그인") -// @PostMapping("/login") -// public ResponseEntity login(@RequestBody LoginRequest loginRequest) { -// User user = userService.login(loginRequest); -// if(user == null) { -// return ResponseEntity.status(HttpStatus.UNAUTHORIZED) -// .body("로그인 아이디 또는 비밀번호가 틀렸습니다."); -// } -// -// long expireTimeMs = 1000 * 60 * 60; // 60분 유효 -// String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs); -// LoginResponse response = new LoginResponse("로그인 성공", jwtToken); -// return ResponseEntity.ok(response); -// } - - @Operation(summary="프로필 업데이트") - @PutMapping("/update-profile") - public ResponseEntity updateProfile(@RequestBody UserProfileUpdateRequest req, Authentication auth) { - String loginId = auth.getName(); - userService.updateUserProfile(loginId, req); - return ResponseEntity.ok("프로필 업데이트 성공"); - } - - @Operation(summary="프로필 조회") - @GetMapping("/profile") - public ResponseEntity getProfile(Authentication authentication) { - String loginId = authentication.getName(); - UserProfileResponse userProfile = userService.getUserProfile(loginId); - return ResponseEntity.ok(userProfile); - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/GoogleOAuthRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/GoogleOAuthRequest.java deleted file mode 100644 index ba1d859..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/request/GoogleOAuthRequest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.swyp.catsgotogedog.User.domain.request; - - -import lombok.Data; - -@Data -public class GoogleOAuthRequest { - - private String idToken; - private Account account; - private Profile profile; - private OAuthProfile OAuthProfile; - - @Data - public static class Account { - private String provider; - private String type; - private String providerAccountId; - private String access_token; - private long expires_at; - private String scope; - private String token_type; - private String id_token; // 여기서 id_token이 실제 JWT 토큰입니다. - } - - @Data - public static class Profile { - private String id; - private String name; - private String email; - private String image; - } - - @Data - public static class OAuthProfile { - private String iss; - private String azp; - private String aud; - private String sub; - private String hd; - private String email; - private boolean email_verified; - private String at_hash; - private String name; - private String picture; - private String given_name; - private String family_name; - private long iat; - private long exp; - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/JoinRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/JoinRequest.java deleted file mode 100644 index 24dfcc6..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/request/JoinRequest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.swyp.catsgotogedog.User.domain.request; - -import com.fasterxml.jackson.annotation.JsonFormat; -import com.swyp.catsgotogedog.User.domain.entity.User; -import com.swyp.catsgotogedog.User.domain.entity.UserRole; -import jakarta.validation.constraints.NotBlank; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.Date; - -@Getter -@Setter -@NoArgsConstructor -public class JoinRequest { - - @NotBlank(message = "이름이 비어있습니다.") - private String name; - - @NotBlank(message = "로그인 아이디가 비어있습니다.") - private String loginId; - - @NotBlank(message = "비밀번호가 비어있습니다.") - private String password; - private String passwordCheck; - - private String address; - private String visaType; - - private String email; - private String phoneNumber; - - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "Asia/Seoul") - private Date birth; - - - public User toEntity(String encodedPassword) { - return User.builder() - .name(this.name) - .loginId(this.loginId) - .password(encodedPassword) - .birth(this.birth) - .address(this.address) - .visaType(this.visaType) - .email(this.email) - .phoneNumber(this.phoneNumber) - .role(UserRole.USER) - .build(); - } -} - diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/LoginRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/LoginRequest.java deleted file mode 100644 index b97b5e1..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/request/LoginRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.swyp.catsgotogedog.User.domain.request; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@Setter -@NoArgsConstructor -public class LoginRequest { - private String loginId; - private String password; -} - diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserProfileUpdateRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserProfileUpdateRequest.java deleted file mode 100644 index f8962d4..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserProfileUpdateRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.swyp.catsgotogedog.User.domain.request; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@Setter -@NoArgsConstructor -public class UserProfileUpdateRequest { - private Integer age; - private String education; - private String experience; - private String koreanProficiency; - private String region; - private String visaDescription; -} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/response/LoginResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/response/LoginResponse.java deleted file mode 100644 index 40647c2..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/response/LoginResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.swyp.catsgotogedog.User.domain.response; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class LoginResponse { - private String message; - private String jwtToken; -} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java deleted file mode 100644 index d99bbe1..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.swyp.catsgotogedog.User.domain.response; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class UserProfileResponse { - private String loginId; - private String name; - private String birth; - private String phoneNumber; - private String email; - private String address; - private String visaType; - private Integer age; - private String education; - private String experience; - private String koreanProficiency; - private String region; - private String visaDescription; -} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java deleted file mode 100644 index da74819..0000000 --- a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.swyp.catsgotogedog.User.service; - - - -import com.nimbusds.jose.JWSVerifier; -import com.nimbusds.jose.crypto.RSASSAVerifier; -import com.nimbusds.jose.jwk.JWK; -import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.SignedJWT; -import com.swyp.catsgotogedog.User.domain.entity.User; -import com.swyp.catsgotogedog.User.domain.entity.UserRole; -import com.swyp.catsgotogedog.User.domain.request.JoinRequest; -import com.swyp.catsgotogedog.User.domain.request.LoginRequest; -import com.swyp.catsgotogedog.User.domain.request.UserProfileUpdateRequest; -import com.swyp.catsgotogedog.User.domain.response.UserProfileResponse; -import com.swyp.catsgotogedog.User.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.net.URL; -import java.security.interfaces.RSAPublicKey; -import java.text.SimpleDateFormat; -import java.util.Optional; - -@Service -@Transactional -@RequiredArgsConstructor -public class UserService { - private final UserRepository userRepository; - - private final BCryptPasswordEncoder encoder; - - public boolean checkLoginIdDuplicate(String loginId) { - return userRepository.existsByLoginId(loginId); - } - - public void join(JoinRequest req) { - userRepository.save(req.toEntity(encoder.encode(req.getPassword()))); - } - - public User login(LoginRequest req) { - Optional optionalUser = userRepository.findByLoginId(req.getLoginId()); - - if(optionalUser.isEmpty()) { - return null; - } - - User user = optionalUser.get(); - - if (!encoder.matches(req.getPassword(), user.getPassword())) { - return null; - } - - return user; - } - - public User getLoginUserByLoginId(String loginId) { - if(loginId == null) return null; - - Optional optionalUser = userRepository.findByLoginId(loginId); - if(optionalUser.isEmpty()) return null; - - return optionalUser.get(); - } - - public void updateUserProfile(String loginId, UserProfileUpdateRequest req) { - - User user = userRepository.findByLoginId(loginId) - .orElseThrow(() -> new UsernameNotFoundException("User not found with loginId: " + loginId)); - - - user.setAge(req.getAge()); - user.setEducation(req.getEducation()); - user.setExperience(req.getExperience()); - user.setKoreanProficiency(req.getKoreanProficiency()); - user.setRegion(req.getRegion()); - user.setVisaDescription(req.getVisaDescription()); - - userRepository.save(user); - } - - public UserProfileResponse getUserProfile(String loginId) { - User user = userRepository.findByLoginId(loginId) - .orElseThrow(() -> new UsernameNotFoundException("User not found with loginId: " + loginId)); - - String formattedBirth = null; - if (user.getBirth() != null) { - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); - formattedBirth = sdf.format(user.getBirth()); - } - - return new UserProfileResponse( - user.getLoginId(), - user.getName(), - formattedBirth, - user.getPhoneNumber(), - user.getEmail(), - user.getAddress(), - user.getVisaType(), - user.getAge(), - user.getEducation(), - user.getExperience(), - user.getKoreanProficiency(), - user.getRegion(), - user.getVisaDescription() - ); - } - - public String processGoogleIdToken(String idToken) { - try { - SignedJWT signedJWT = SignedJWT.parse(idToken); - - String kid = signedJWT.getHeader().getKeyID(); - if (kid == null) { - throw new Exception("토큰에 'kid' 값이 없습니다."); - } - - URL jwksUrl = new URL("https://www.googleapis.com/oauth2/v3/certs"); - JWKSet jwkSet = JWKSet.load(jwksUrl); - - JWK jwk = jwkSet.getKeyByKeyId(kid); - if (jwk == null) { - throw new Exception("토큰의 'kid'에 해당하는 공개 키를 찾을 수 없습니다: " + kid); - } - RSAPublicKey publicKey = jwk.toRSAKey().toRSAPublicKey(); - - JWSVerifier verifier = new RSASSAVerifier(publicKey); - if (!signedJWT.verify(verifier)) { - throw new Exception("토큰 서명 검증에 실패했습니다."); - } - - JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); - String provider = "google"; - String providerId = claims.getStringClaim("sub"); - String extractedName = claims.getStringClaim("name"); - String loginId = provider + "_" + providerId; - - Optional optionalUser = userRepository.findByLoginId(loginId); - if (optionalUser.isEmpty()) { - User newUser = User.builder() - .loginId(loginId) - .name(extractedName) - .provider(provider) - .providerId(providerId) - .role(UserRole.USER) - .build(); - userRepository.save(newUser); - } - return loginId; - } catch (Exception e) { - throw new RuntimeException("Google ID 토큰 처리 실패", e); - } - } -} - From d38fb248a4483f44c35228209fc4d525ebbf4908 Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:30:21 +0900 Subject: [PATCH 011/191] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 이메일 이슈 해결 --- .../catsgotogedog/User/domain/entity/User.java | 17 +---------------- .../User/repository/UserRepository.java | 2 -- .../common/config/SecurityConfig.java | 7 ------- .../common/oauth2/KakaoUserInfo.java | 17 +++++++++++++++++ .../common/oauth2/SocialUserInfo.java | 9 --------- .../handler/OAuth2LoginSuccessHandler.java | 9 --------- .../security/service/PrincipalDetails.java | 4 ++-- .../service/PrincipalOauth2UserService.java | 12 ++++++++++-- 8 files changed, 30 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index 7b2e97a..b3c73ad 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -4,7 +4,6 @@ import jakarta.persistence.*; import lombok.*; -import java.util.Date; @Entity @Getter @@ -20,24 +19,10 @@ public class User { private Long userId; private String name; - private String loginId; - private String password; - private String nickname; - private Date birth; - private String phoneNumber; private String email; - private UserRole role; - private String address; - private String visaType; - private Integer age; - private String education; - private String experience; - private String koreanProficiency; - private String region; - private String visaDescription; // Oauth private String provider; // google / kakao / naver - private String providerId; // 소셜 PK + private String providerId; } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java index 75ce640..315a394 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java @@ -6,7 +6,5 @@ import java.util.Optional; public interface UserRepository extends JpaRepository { - boolean existsByLoginId(String loginId); - Optional findByLoginId(String loginId); Optional findByProviderAndProviderId(String provider, String providerId); } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index bf8a86c..e97aa86 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -1,13 +1,8 @@ package com.swyp.catsgotogedog.common.config; -import com.swyp.catsgotogedog.User.domain.entity.UserRole; -import com.swyp.catsgotogedog.User.service.UserService; import com.swyp.catsgotogedog.common.security.filter.JwtTokenFilter; -import com.swyp.catsgotogedog.common.security.handler.MyAccessDeniedHandler; -import com.swyp.catsgotogedog.common.security.handler.MyAuthenticationEntryPoint; import com.swyp.catsgotogedog.common.security.handler.OAuth2LoginSuccessHandler; import com.swyp.catsgotogedog.common.security.service.PrincipalOauth2UserService; -import io.jsonwebtoken.Jwt; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -16,14 +11,12 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final UserService userService; private final PrincipalOauth2UserService principalOauth2UserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final JwtTokenFilter jwtTokenFilter; diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java new file mode 100644 index 0000000..0337095 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.common.oauth2; + +import java.util.Map; + + +public record KakaoUserInfo(String id, String name) { + public static KakaoUserInfo of(Map attr) { + String id = String.valueOf(attr.get("id")); + + Map kakaoAccount = (Map) attr.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + String nickname = (String) profile.get("nickname"); + + return new KakaoUserInfo(id, nickname); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java index 939a21c..536f1c7 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java @@ -11,15 +11,6 @@ public static SocialUserInfo of(String provider, Map attr) { (String) attr.get("email"), (String) attr.get("name") ); - case "kakao" -> { - Map acc = (Map) attr.get("kakao_account"); - Map prof = (Map) acc.get("profile"); - yield new SocialUserInfo( - String.valueOf(attr.get("id")), - (String) acc.get("email"), - (String) prof.get("nickname") - ); - } case "naver" -> { Map res = (Map) attr.get("response"); yield new SocialUserInfo( diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index ee62a04..c2103bc 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -1,22 +1,13 @@ package com.swyp.catsgotogedog.common.security.handler; -import com.swyp.catsgotogedog.User.domain.entity.User; -import com.swyp.catsgotogedog.User.domain.entity.UserRole; -import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.util.Optional; @Component @RequiredArgsConstructor diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java index 1aa73b4..604ee80 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java @@ -5,10 +5,8 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; -import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -30,6 +28,7 @@ public PrincipalDetails(User user, Map attributes) { /* OAuth2User */ @Override public Map getAttributes() { return attributes; } + @Override public String getName() { return user.getName(); } /* Authentication */ @@ -40,6 +39,7 @@ public PrincipalDetails(User user, Map attributes) { public Object getCredentials(){ return null; } + @Override public Object getDetails(){ return null; diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java index 25d8dc0..73ca75e 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -4,12 +4,12 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.oauth2.KakaoUserInfo; import com.swyp.catsgotogedog.common.oauth2.SocialUserInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; @@ -26,7 +26,15 @@ public OAuth2User loadUser(OAuth2UserRequest req) { OAuth2User oAuth2User = super.loadUser(req); String provider = req.getClientRegistration().getRegistrationId(); // google/kakao/naver - SocialUserInfo info = SocialUserInfo.of(provider, oAuth2User.getAttributes()); + + SocialUserInfo info; + + if (provider.equals("kakao")) { + KakaoUserInfo kakaoInfo = KakaoUserInfo.of(oAuth2User.getAttributes()); + info = new SocialUserInfo(kakaoInfo.id(), null, kakaoInfo.name()); // email은 null 처리 + } else { + info = SocialUserInfo.of(provider, oAuth2User.getAttributes()); + } User user = userRepository.findByProviderAndProviderId(provider, info.id()) .orElseGet(() -> userRepository.save( From 4dd6c43630e874badb7f0e4470a89667f097af31 Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Tue, 15 Jul 2025 23:15:46 +0900 Subject: [PATCH 012/191] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소셜 종류별 코드 분활, 이미지 컬럼 추가 --- .../User/domain/entity/User.java | 4 +-- .../common/oauth2/GoogleUserInfo.java | 15 ++++++++++ .../common/oauth2/KakaoUserInfo.java | 5 ++-- .../common/oauth2/NaverUserInfo.java | 17 +++++++++++ .../common/oauth2/SocialUserInfo.java | 23 +-------------- .../service/PrincipalOauth2UserService.java | 28 +++++++++++++------ 6 files changed, 58 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/oauth2/GoogleUserInfo.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/oauth2/NaverUserInfo.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index b3c73ad..bcaee28 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -20,9 +20,9 @@ public class User { private String name; private String email; - - // Oauth private String provider; // google / kakao / naver private String providerId; + private String profileImage; + } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/GoogleUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/GoogleUserInfo.java new file mode 100644 index 0000000..2a680c9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/GoogleUserInfo.java @@ -0,0 +1,15 @@ +package com.swyp.catsgotogedog.common.oauth2; + +import java.util.Map; + +public record GoogleUserInfo(String id, String email, String name, String picture) { + + public static GoogleUserInfo of(Map attr) { + return new GoogleUserInfo( + (String) attr.get("sub"), + (String) attr.get("email"), + (String) attr.get("name"), + (String) attr.get("picture") + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java index 0337095..80d8c9f 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java @@ -3,7 +3,7 @@ import java.util.Map; -public record KakaoUserInfo(String id, String name) { +public record KakaoUserInfo(String id, String name, String profile_image) { public static KakaoUserInfo of(Map attr) { String id = String.valueOf(attr.get("id")); @@ -11,7 +11,8 @@ public static KakaoUserInfo of(Map attr) { Map profile = (Map) kakaoAccount.get("profile"); String nickname = (String) profile.get("nickname"); + String profile_image=(String) profile.get("profile_image"); - return new KakaoUserInfo(id, nickname); + return new KakaoUserInfo(id, nickname, profile_image); } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/NaverUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/NaverUserInfo.java new file mode 100644 index 0000000..9e3366b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/NaverUserInfo.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.common.oauth2; + +import java.util.Map; + +public record NaverUserInfo(String id, String email, String name, String profileImage) { + + public static NaverUserInfo of(Map attr) { + Map res = (Map) attr.get("response"); + + return new NaverUserInfo( + (String) res.get("id"), + (String) res.get("email"), + (String) res.get("name"), + (String) res.get("profile_image") + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java index 536f1c7..f0badfe 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java @@ -1,25 +1,4 @@ package com.swyp.catsgotogedog.common.oauth2; -import java.util.Map; +public record SocialUserInfo(String id, String email, String name, String profileImage) {} -public record SocialUserInfo(String id, String email, String name) { - - public static SocialUserInfo of(String provider, Map attr) { - return switch (provider) { - case "google" -> new SocialUserInfo( - (String) attr.get("sub"), - (String) attr.get("email"), - (String) attr.get("name") - ); - case "naver" -> { - Map res = (Map) attr.get("response"); - yield new SocialUserInfo( - (String) res.get("id"), - (String) res.get("email"), - (String) res.get("name") - ); - } - default -> throw new IllegalArgumentException("지원하지 않는 provider"); - }; - } -} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java index 73ca75e..3eae926 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -5,6 +5,8 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.common.oauth2.KakaoUserInfo; +import com.swyp.catsgotogedog.common.oauth2.GoogleUserInfo; +import com.swyp.catsgotogedog.common.oauth2.NaverUserInfo; import com.swyp.catsgotogedog.common.oauth2.SocialUserInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,16 +26,24 @@ public class PrincipalOauth2UserService extends DefaultOAuth2UserService { @Override public OAuth2User loadUser(OAuth2UserRequest req) { OAuth2User oAuth2User = super.loadUser(req); - - String provider = req.getClientRegistration().getRegistrationId(); // google/kakao/naver + String provider = req.getClientRegistration().getRegistrationId(); // google / kakao / naver SocialUserInfo info; - if (provider.equals("kakao")) { - KakaoUserInfo kakaoInfo = KakaoUserInfo.of(oAuth2User.getAttributes()); - info = new SocialUserInfo(kakaoInfo.id(), null, kakaoInfo.name()); // email은 null 처리 - } else { - info = SocialUserInfo.of(provider, oAuth2User.getAttributes()); + switch (provider) { + case "kakao" -> { + KakaoUserInfo kakao = KakaoUserInfo.of(oAuth2User.getAttributes()); + info = new SocialUserInfo(kakao.id(), null, kakao.name(), kakao.profile_image()); + } + case "naver" -> { + NaverUserInfo naver = NaverUserInfo.of(oAuth2User.getAttributes()); + info = new SocialUserInfo(naver.id(), naver.email(), naver.name(), naver.profileImage()); + } + case "google" -> { + GoogleUserInfo google = GoogleUserInfo.of(oAuth2User.getAttributes()); + info = new SocialUserInfo(google.id(), google.email(), google.name(), google.picture()); + } + default -> throw new IllegalArgumentException("지원하지 않는 소셜 로그인입니다: " + provider); } User user = userRepository.findByProviderAndProviderId(provider, info.id()) @@ -43,7 +53,9 @@ public OAuth2User loadUser(OAuth2UserRequest req) { .providerId(info.id()) .email(info.email()) .name(info.name()) - .build())); + .profileImage(info.profileImage()) + .build() + )); return new PrincipalDetails(user, oAuth2User.getAttributes()); } From f2da92fc741508a3e19ada56b3a5a51323144054 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sat, 19 Jul 2025 20:10:06 +0900 Subject: [PATCH 013/191] initialize database schema --- .../resources/db/migration/mysql/V1__init.sql | 671 +++++++++++++++++- 1 file changed, 644 insertions(+), 27 deletions(-) diff --git a/src/main/resources/db/migration/mysql/V1__init.sql b/src/main/resources/db/migration/mysql/V1__init.sql index 36d8f8d..2dc4ed7 100644 --- a/src/main/resources/db/migration/mysql/V1__init.sql +++ b/src/main/resources/db/migration/mysql/V1__init.sql @@ -1,29 +1,646 @@ -CREATE TABLE `catsgotogedog`.`user` ( - `user_id` BIGINT NOT NULL AUTO_INCREMENT, - `display_name` VARCHAR(50) NOT NULL, - `email` VARCHAR(255) NULL, - `login_provider` VARCHAR(50) NULL, - `image_filename` VARCHAR(255) NULL, - `image_url` VARCHAR(255) NULL, - `created_at` DATETIME NULL DEFAULT NOW(), - `updated_at` DATETIME NULL DEFAULT NOW(), - `is_active` TINYINT NULL DEFAULT 1, +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; + +-- ----------------------------------------------------- +-- Schema mydb +-- ----------------------------------------------------- +-- ----------------------------------------------------- +-- Schema catsgotogedog +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema catsgotogedog +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `catsgotogedog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci ; +USE `catsgotogedog` ; + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`category_code` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`category_code` ( + `category_id` INT NOT NULL AUTO_INCREMENT, + `category_name` VARCHAR(50) NOT NULL, + PRIMARY KEY (`category_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '숙박, 음식점, 관광지 등 카테고리 분류를 위한 테이블'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`region_code` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`region_code` ( + `region_id` INT NOT NULL AUTO_INCREMENT, + `region_name` VARCHAR(50) NOT NULL, + `parent_code` INT NULL DEFAULT NULL, + PRIMARY KEY (`region_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '지역코드 구분 테이블'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`content` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`content` ( + `content_id` INT NOT NULL AUTO_INCREMENT, + `category_id` INT NOT NULL, + `region_id` INT NOT NULL, + `addr1` VARCHAR(100) NULL DEFAULT NULL, + `addr2` VARCHAR(100) NULL DEFAULT NULL, + `image` VARCHAR(255) NULL DEFAULT NULL, + `thumb_image` VARCHAR(255) NULL DEFAULT NULL, + `copyright` VARCHAR(10) NULL DEFAULT NULL, + `mapx` DECIMAL(10,8) NULL DEFAULT NULL, + `mapy` DECIMAL(11,8) NULL DEFAULT NULL, + `mlevel` SMALLINT NULL DEFAULT NULL, + `modified_at` DATETIME NULL DEFAULT NULL, + `tel` VARCHAR(20) NULL DEFAULT NULL, + `title` VARCHAR(255) NULL DEFAULT NULL, + `zipcode` INT NULL DEFAULT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `content_type_id` INT NOT NULL, + PRIMARY KEY (`content_id`), + INDEX `category_code_category_code_idx` (`category_id` ASC) VISIBLE, + INDEX `region_code_region_id_idx` (`region_id` ASC) VISIBLE, + CONSTRAINT `category_code_category_id` + FOREIGN KEY (`category_id`) + REFERENCES `catsgotogedog`.`category_code` (`category_id`), + CONSTRAINT `region_code_region_id` + FOREIGN KEY (`region_id`) + REFERENCES `catsgotogedog`.`region_code` (`region_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소 목록 테이블'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`content_image` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`content_image` ( + `content_image_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `image_url` VARCHAR(255) NULL DEFAULT NULL, + `image_filename` VARCHAR(255) NULL DEFAULT NULL, + `small_image_url` VARCHAR(255) NULL DEFAULT NULL, + `small_image_filename` VARCHAR(255) NULL DEFAULT NULL, + PRIMARY KEY (`content_image_id`), + INDEX `content_content_id_content_image_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_content_image_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소별 사진 테이블'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`user` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`user` ( + `user_id` INT NOT NULL AUTO_INCREMENT, + `display_name` VARCHAR(50) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `provider` VARCHAR(50) NOT NULL, + `provider_id` VARCHAR(255) NOT NULL, + `image_filename` VARCHAR(255) NOT NULL, + `image_url` VARCHAR(255) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_active` TINYINT NULL DEFAULT '1', PRIMARY KEY (`user_id`), + UNIQUE INDEX `display_name_UNIQUE` (`display_name` ASC) VISIBLE, UNIQUE INDEX `email_UNIQUE` (`email` ASC) VISIBLE) -COMMENT = '유저 정보 테이블'; - -CREATE TABLE `catsgotogedog`.`refresh_token` ( - `id` BIGINT NOT NULL AUTO_INCREMENT, - `user_id` BIGINT NOT NULL, - `refresh_token` VARCHAR(511) NOT NULL, - `expires_at` DATETIME NOT NULL, - `revoked` TINYINT NULL DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE INDEX `refresh_token_UNIQUE` (`refresh_token` ASC) VISIBLE, - UNIQUE INDEX `user_id_UNIQUE` (`user_id` ASC) VISIBLE, - CONSTRAINT `refresh_token_user_user_id_fk` - FOREIGN KEY (`user_id`) - REFERENCES `catsgotogedog`.`user` (`user_id`) - ON DELETE CASCADE - ON UPDATE NO ACTION) -COMMENT = '리프레쉬 토큰 테이블'; + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '유저 정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`content_review` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`content_review` ( + `review_id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `content_id` INT NOT NULL, + `content` VARCHAR(6000) NOT NULL, + `score` DECIMAL(2,1) NOT NULL, + `like` INT NULL DEFAULT '0', + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`review_id`), + INDEX `review_user_id_idx` (`user_id` ASC) VISIBLE, + INDEX `review_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `review_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`), + CONSTRAINT `review_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소 리뷰'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`festival_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`festival_information` ( + `festival_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `age_limit` VARCHAR(45) NULL DEFAULT NULL, + `booking_place` VARCHAR(50) NULL DEFAULT NULL, + `discount_info` VARCHAR(100) NULL DEFAULT NULL, + `event_start_date` DATE NULL DEFAULT NULL, + `event_end_date` DATE NULL DEFAULT NULL, + `event_homepage` VARCHAR(255) NULL DEFAULT NULL, + `event_place` VARCHAR(100) NULL DEFAULT NULL, + `place_info` VARCHAR(50) NULL DEFAULT NULL, + `play_time` VARCHAR(50) NULL DEFAULT NULL, + `program` VARCHAR(100) NULL DEFAULT NULL, + `spend_time` VARCHAR(50) NULL DEFAULT NULL, + `organizer` VARCHAR(50) NULL DEFAULT NULL, + `organizer_tel` VARCHAR(50) NULL DEFAULT NULL, + `supervisor` VARCHAR(45) NULL DEFAULT NULL, + `sub_event` VARCHAR(100) NULL DEFAULT NULL, + `fee_info` VARCHAR(100) NULL DEFAULT NULL, + PRIMARY KEY (`festival_id`), + INDEX `content_content_id_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_festival_information_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '소개정보조회_행사_공연_축제'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`hashtag` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`hashtag` ( + `hashtag_id` INT NOT NULL, + `content_id` INT NOT NULL, + `content` VARCHAR(50) NOT NULL, + PRIMARY KEY (`hashtag_id`), + INDEX `hashtag_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `hashtag_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '해시태그'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`last_view_history` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`last_view_history` ( + `user_id` INT NOT NULL, + `content_id` INT NOT NULL, + `last_viewed_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `content_id`), + INDEX `last_view_history_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `last_view_history_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`), + CONSTRAINT `last_view_history_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '최근 본 장소'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`lodge_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`lodge_information` ( + `lodge_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `capacity_count` INT NULL DEFAULT NULL, + `lodge_informationcol` INT NULL DEFAULT NULL, + `benikia` TINYINT NULL DEFAULT NULL, + `check_in_time` TIME NULL DEFAULT NULL, + `check_out_time` TIME NULL DEFAULT NULL, + `cooking` VARCHAR(50) NULL DEFAULT NULL, + `foodplace` VARCHAR(50) NULL DEFAULT NULL, + `hanok` TINYINT NULL DEFAULT NULL, + `information` VARCHAR(50) NULL DEFAULT NULL, + `parking` VARCHAR(50) NULL DEFAULT NULL, + `pickup_service` TINYINT NULL DEFAULT NULL, + `room_count` INT NULL DEFAULT NULL, + `reservation_info` VARCHAR(30) NULL DEFAULT NULL, + `reservation_url` VARCHAR(50) NULL DEFAULT NULL, + `room_type` VARCHAR(30) NULL DEFAULT NULL, + `scale` VARCHAR(30) NULL DEFAULT NULL, + `sub_facility` VARCHAR(50) NULL DEFAULT NULL, + `barbecue` TINYINT NULL DEFAULT NULL, + `beauty` TINYINT NULL DEFAULT NULL, + `beverage` TINYINT NULL DEFAULT NULL, + `bicycle` TINYINT NULL DEFAULT NULL, + `campfire` TINYINT NULL DEFAULT NULL, + `fitness` TINYINT NULL DEFAULT NULL, + `karaoke` TINYINT NULL DEFAULT NULL, + `public_bath` TINYINT NULL DEFAULT NULL, + `public_pc_room` TINYINT NULL DEFAULT NULL, + `sauna` TINYINT NULL DEFAULT NULL, + `seminar` TINYINT NULL DEFAULT NULL, + `sports` TINYINT NULL DEFAULT NULL, + `refund_regulation` VARCHAR(100) NULL DEFAULT NULL, + PRIMARY KEY (`lodge_id`), + INDEX `content_content_id_lodge_information_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_lodge_information_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '소개정보조회_숙박'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`pet_size` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`pet_size` ( + `size_id` INT NOT NULL AUTO_INCREMENT, + `size` VARCHAR(10) NOT NULL, + `size_tooltip` VARCHAR(100) NOT NULL, + PRIMARY KEY (`size_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '반려동물 사이즈 정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`pet` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`pet` ( + `pet_id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `gender` CHAR(1) NOT NULL, + `birth` DATE NOT NULL, + `fierce_dog` TINYINT NOT NULL, + `size_id` INT NOT NULL, + `name` VARCHAR(20) NOT NULL, + `image_filename` VARCHAR(255) NULL DEFAULT NULL, + `image_url` VARCHAR(255) NULL DEFAULT NULL, + PRIMARY KEY (`pet_id`), + INDEX `user_id_idx` (`user_id` ASC) VISIBLE, + INDEX `size_id_idx` (`size_id` ASC) VISIBLE, + CONSTRAINT `pet_pet_size_id` + FOREIGN KEY (`size_id`) + REFERENCES `catsgotogedog`.`pet_size` (`size_id`), + CONSTRAINT `pet_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '반려동물 정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`pet_guide` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`pet_guide` ( + `pet_guide_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `accident_prep` VARCHAR(50) NULL DEFAULT NULL, + `available_facility` VARCHAR(50) NULL DEFAULT NULL, + `provided_item` VARCHAR(50) NULL DEFAULT NULL, + `etc_info` VARCHAR(255) NULL DEFAULT NULL, + `purchasable_item` VARCHAR(50) NULL DEFAULT NULL, + `allowed_pet_type` VARCHAR(50) NULL DEFAULT NULL, + `rent_item` VARCHAR(50) NULL DEFAULT NULL, + `pet_prep` VARCHAR(50) NULL DEFAULT NULL, + `with_pet` VARCHAR(50) NULL DEFAULT NULL, + PRIMARY KEY (`pet_guide_id`), + INDEX `content_content_id_pet_guide_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_pet_guide_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소별 반려동물 동반시 안내사항'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`recur_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`recur_information` ( + `recur_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `info_name` VARCHAR(45) NULL DEFAULT NULL, + `info_text` TEXT NULL DEFAULT NULL, + PRIMARY KEY (`recur_id`), + INDEX `content_content_id_recur_information_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_recur_information_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소별 반복정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`recur_information_room` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`recur_information_room` ( + `recur_room_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `room_title` VARCHAR(100) NULL DEFAULT NULL, + `room_size1` INT NULL DEFAULT NULL, + `room_count` INT NULL DEFAULT NULL, + `room_base_couint` INT NULL DEFAULT NULL, + `room_max_count` INT NULL DEFAULT NULL, + `off_season_week_min_fee` INT NULL DEFAULT NULL, + `off_season_weekend_min_fee` INT NULL DEFAULT NULL, + `peak_season_week_min_fee` INT NULL DEFAULT NULL, + `peak_season_weekend_min_fee` INT NULL DEFAULT NULL, + `room_intro` TEXT NULL DEFAULT NULL, + `room_bath_pacility` TINYINT NULL DEFAULT NULL, + `room_bath` TINYINT NULL DEFAULT NULL, + `room_home_theater` TINYINT NULL DEFAULT NULL, + `room_aircondition` TINYINT NULL DEFAULT NULL, + `room_tv` TINYINT NULL DEFAULT NULL, + `room_pc` TINYINT NULL DEFAULT NULL, + `room_cable` TINYINT NULL DEFAULT NULL, + `room_internet` TINYINT NULL DEFAULT NULL, + `room_refrigerator` TINYINT NULL DEFAULT NULL, + `room_toiletries` TINYINT NULL DEFAULT NULL, + `room_sofa` TINYINT NULL DEFAULT NULL, + `room_cook` TINYINT NULL DEFAULT NULL, + `room_table` TINYINT NULL DEFAULT NULL, + `room_hairdryer` TINYINT NULL DEFAULT NULL, + `room_size2` DECIMAL(10,2) NULL DEFAULT NULL, + PRIMARY KEY (`recur_room_id`), + INDEX `content_content_id_recur_information_room_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_recur_information_room_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '숙박 객실별 반복정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`recur_information_room_image` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`recur_information_room_image` ( + `image_id` INT NOT NULL AUTO_INCREMENT, + `recur_room_id` INT NOT NULL, + `image_url` VARCHAR(255) NULL DEFAULT NULL, + `image_filename` VARCHAR(255) NULL DEFAULT NULL, + `image_alt` VARCHAR(255) NULL DEFAULT NULL, + `image_copyright` VARCHAR(50) NULL DEFAULT NULL, + PRIMARY KEY (`image_id`), + INDEX `recur_information_room_recur_information_room_image_fk_idx` (`recur_room_id` ASC) VISIBLE, + CONSTRAINT `recur_information_room_recur_information_room_image_fk` + FOREIGN KEY (`recur_room_id`) + REFERENCES `catsgotogedog`.`recur_information_room` (`recur_room_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '반복정보 객실별 이미지'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`refresh_token` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`refresh_token` ( + `user_id` INT NOT NULL, + `refresh_token` TEXT NOT NULL, + `expires_at` DATETIME NOT NULL, + `is_revoked` TINYINT NOT NULL DEFAULT '0', + PRIMARY KEY (`user_id`), + CONSTRAINT `fk_token_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = 'JWT 리프레시 토큰 정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`report_reason` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`report_reason` ( + `reason_id` INT NOT NULL AUTO_INCREMENT, + `content` VARCHAR(255) NOT NULL, + PRIMARY KEY (`reason_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '신고 사유 목록'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`restaurant_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`restaurant_information` ( + `restaurant_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `chk_creditcard` VARCHAR(50) NULL DEFAULT NULL, + `discount_info` VARCHAR(100) NULL DEFAULT NULL, + `signature_menu` VARCHAR(100) NULL DEFAULT NULL, + `information` VARCHAR(100) NULL DEFAULT NULL, + `kids_facility` TINYINT NULL DEFAULT NULL, + `open_date` DATE NULL DEFAULT NULL, + `open_time` VARCHAR(50) NULL DEFAULT NULL, + `takeout` VARCHAR(10) NULL DEFAULT NULL, + `parking` VARCHAR(100) NULL DEFAULT NULL, + `reservation` VARCHAR(100) NULL DEFAULT NULL, + `rest_date` VARCHAR(50) NULL DEFAULT NULL, + `scale` INT NULL DEFAULT NULL, + `seat` INT NULL DEFAULT NULL, + `smoking` TINYINT NULL DEFAULT NULL, + `treat_menu` VARCHAR(100) NULL DEFAULT NULL, + PRIMARY KEY (`restaurant_id`), + INDEX `content_content_id_restaurant_information_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_restaurant_information_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '소개정보조회_음식점'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`review_image` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`review_image` ( + `image_id` INT NOT NULL AUTO_INCREMENT, + `review_id` INT NOT NULL, + `image_filename` VARCHAR(255) NOT NULL, + `image_url` VARCHAR(255) NOT NULL, + PRIMARY KEY (`image_id`), + INDEX `review_image_review_id_idx` (`review_id` ASC) VISIBLE, + CONSTRAINT `review_image_review_id` + FOREIGN KEY (`review_id`) + REFERENCES `catsgotogedog`.`content_review` (`review_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '리뷰 이미지'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`review_report` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`review_report` ( + `report_id` INT NOT NULL AUTO_INCREMENT, + `review_id` INT NOT NULL, + `reason_id` INT NOT NULL, + `user_id` INT NOT NULL, + PRIMARY KEY (`report_id`), + INDEX `review_report_review_id_idx` (`review_id` ASC) VISIBLE, + INDEX `review_report_reason_id_idx` (`reason_id` ASC) VISIBLE, + CONSTRAINT `review_report_reason_id` + FOREIGN KEY (`reason_id`) + REFERENCES `catsgotogedog`.`report_reason` (`reason_id`), + CONSTRAINT `review_report_review_id` + FOREIGN KEY (`review_id`) + REFERENCES `catsgotogedog`.`content_review` (`review_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '리뷰 신고'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`sigtes_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`sigtes_information` ( + `sights_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `content_type_id` INT NULL DEFAULT NULL, + `accom_count` INT NULL DEFAULT NULL, + `chk_creditcard` VARCHAR(50) NULL DEFAULT NULL, + `exp_age_range` VARCHAR(45) NULL DEFAULT NULL, + `exp_guide` VARCHAR(500) NULL DEFAULT NULL, + `info_center` VARCHAR(100) NULL DEFAULT NULL, + `open_date` DATE NULL DEFAULT NULL, + `parking` VARCHAR(50) NULL DEFAULT NULL, + `rest_date` VARCHAR(50) NULL DEFAULT NULL, + `use_season` VARCHAR(50) NULL DEFAULT NULL, + `use_time` VARCHAR(50) NULL DEFAULT NULL, + `heritage1` TINYINT NULL DEFAULT NULL, + `heritage2` TINYINT NULL DEFAULT NULL, + `heritage3` TINYINT NULL DEFAULT NULL, + PRIMARY KEY (`sights_id`), + INDEX `content_content_id_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '소개정보조회_관광지'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`ticket` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`ticket` ( + `ticket_id` INT NOT NULL AUTO_INCREMENT, + `email` VARCHAR(255) NOT NULL, + `content` TEXT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ticket_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '문의 내역'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`view_log` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`view_log` ( + `view_id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `content_id` INT NOT NULL, + `viewed_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`view_id`, `user_id`, `content_id`), + INDEX `view_log_user_id_idx` (`user_id` ASC) VISIBLE, + INDEX `view_log_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `view_log_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`), + CONSTRAINT `view_log_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '조회 정보 기록 저장'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`view_total` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`view_total` ( + `content_id` INT NOT NULL, + `total_view` INT NULL DEFAULT '0', + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`content_id`), + CONSTRAINT `view_total_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소별 전체 조회수'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`visit_history` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`visit_history` ( + `visit_id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `content_id` INT NOT NULL, + PRIMARY KEY (`visit_id`), + INDEX `visit_history_user_id_idx` (`user_id` ASC) VISIBLE, + INDEX `visit_history_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `visit_history_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`), + CONSTRAINT `visit_history_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '방문한 장소'; + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; From 0ad925df0af055fdd70a697260841c7fdff058cf Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Sun, 20 Jul 2025 00:35:53 +0900 Subject: [PATCH 014/191] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변수 업데이트 및 버그 수정 --- .../User/controller/UserController.java | 54 +++++++++++++++++++ .../User/domain/entity/RefreshToken.java | 25 +++++++++ .../User/domain/entity/User.java | 14 +++-- .../repository/RefreshTokenRepository.java | 11 ++++ .../User/repository/UserRepository.java | 3 +- .../User/service/RefreshTokenService.java | 41 ++++++++++++++ .../common/config/SecurityConfig.java | 3 +- .../handler/OAuth2LoginSuccessHandler.java | 22 ++++++-- .../security/service/PrincipalDetails.java | 6 ++- .../service/PrincipalOauth2UserService.java | 10 +++- .../common/util/JwtTokenUtil.java | 24 +++++++-- 11 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/entity/RefreshToken.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java new file mode 100644 index 0000000..779d6ee --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -0,0 +1,54 @@ +package com.swyp.catsgotogedog.User.controller; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.User.service.RefreshTokenService; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/user") +public class UserController { + + private final JwtTokenUtil jwt; + private final RefreshTokenService rtService; + private final UserRepository userRepo; + + @PostMapping("/reissue") + public ResponseEntity reissue( + @RequestHeader("X-Refresh-Token") String refresh) { + + if (!rtService.validate(refresh)) { + return ResponseEntity.status(401).build(); + } + + int userId = Integer.parseInt(jwt.getSubject(refresh)); + + User user = userRepo.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + + String newAccess = jwt.createAccessToken(String.valueOf(userId)); + String newRefresh = jwt.createRefreshToken(String.valueOf(userId)); + + rtService.save(user, newRefresh, jwt.getRefreshTokenExpiry()); + + return ResponseEntity.ok() + .header("Authorization", "Bearer " + newAccess) + .header("X-Refresh-Token", newRefresh) + .build(); + } + + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader("X-Refresh-Token") String refresh) { + rtService.delete(refresh); + return ResponseEntity.ok("로그아웃 완료"); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/RefreshToken.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/RefreshToken.java new file mode 100644 index 0000000..fdd18fe --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/RefreshToken.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.User.domain.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + + @Id + private int userId; + + private String refreshToken; + + private LocalDateTime expiresAt; + + private Boolean isRevoked; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index bcaee28..34b63f3 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -4,6 +4,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Entity @Getter @@ -16,13 +18,19 @@ public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name="user_id") - private Long userId; + private int userId; - private String name; + private String displayName; private String email; private String provider; // google / kakao / naver private String providerId; - private String profileImage; + private String imageFilename; + private String imageUrl; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private Boolean isActive; } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/RefreshTokenRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..f419e69 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.User.repository; + +import com.swyp.catsgotogedog.User.domain.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByRefreshToken(String token); + void deleteByUserId(int userId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java index 315a394..633a190 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderId(String provider, String providerId); + Optional findByProviderId(String providerId); } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java b/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java new file mode 100644 index 0000000..a3a65e0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java @@ -0,0 +1,41 @@ +package com.swyp.catsgotogedog.User.service; + +import com.swyp.catsgotogedog.User.domain.entity.RefreshToken; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.joda.time.DateTime; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class RefreshTokenService { + private final RefreshTokenRepository repo; + + public RefreshToken save(User user, String token, LocalDateTime expiryMs) { + repo.deleteByUserId(user.getUserId()); + RefreshToken rt = RefreshToken.builder() + .userId(user.getUserId()) + .refreshToken(token) + .expiresAt(expiryMs) + .build(); + return repo.save(rt); + } + + public boolean validate(String token) { + return repo.findByRefreshToken(token) + .filter(rt -> rt.getExpiresAt().isAfter(LocalDateTime.now())) + .isPresent(); + } + + public void delete(String token) { + repo.findByRefreshToken(token).ifPresent(repo::delete); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index e97aa86..2b13796 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -30,7 +30,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.csrf(csrf -> csrf.disable()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/login**", "/error").permitAll() + .requestMatchers("/", "/login**", "/error", + "/auth/reissue", "/auth/logout").permitAll() .anyRequest().authenticated()) .oauth2Login(oauth -> oauth .loginPage("/login") // 커스텀 로그인 화면 (없으면 기본 템플릿) diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index c2103bc..e09a14e 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -1,5 +1,9 @@ package com.swyp.catsgotogedog.common.security.handler; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.User.service.RefreshTokenService; +import com.swyp.catsgotogedog.common.security.service.PrincipalDetails; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -14,14 +18,26 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final JwtTokenUtil jwt; + private final RefreshTokenService rtService; + private final UserRepository userRepo; @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication auth) { - String principal = auth.getName(); - String access = jwt.createAccessToken(principal); - String refresh = jwt.createRefreshToken(principal); +// String principal = auth.getName(); +// User user = userRepo.findByUserId(principal).orElseThrow(); + + PrincipalDetails pd = (PrincipalDetails) auth.getPrincipal(); + String providerId = pd.getProviderId(); + + User user = userRepo.findByProviderId(providerId) + .orElseThrow(() -> new IllegalStateException("회원이 없습니다")); + + String access = jwt.createAccessToken(String.valueOf(user.getUserId())); + String refresh = jwt.createRefreshToken(String.valueOf(user.getUserId())); + + rtService.save(user, refresh, jwt.getRefreshTokenExpiry()); response.setHeader("Authorization", "Bearer " + access); response.setHeader("X-Refresh-Token", refresh); diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java index 604ee80..506963c 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java @@ -29,7 +29,7 @@ public PrincipalDetails(User user, Map attributes) { /* OAuth2User */ @Override public Map getAttributes() { return attributes; } - @Override public String getName() { return user.getName(); } + @Override public String getName() { return user.getDisplayName(); } /* Authentication */ @Override public Collection getAuthorities() { @@ -58,4 +58,8 @@ public boolean isAuthenticated(){ @Override public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {} + public String getProviderId() { + return user.getProviderId(); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java index 3eae926..6c8222e 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -15,6 +15,10 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + +import static org.joda.time.DateTime.now; + @Service @RequiredArgsConstructor @@ -52,8 +56,10 @@ public OAuth2User loadUser(OAuth2UserRequest req) { .provider(provider) .providerId(info.id()) .email(info.email()) - .name(info.name()) - .profileImage(info.profileImage()) + .displayName(info.name()) + .imageUrl(info.profileImage()) + .createdAt(LocalDateTime.now()) + .isActive(Boolean.TRUE) .build() )); diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java index 8df34e7..9dcea04 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java @@ -1,14 +1,17 @@ package com.swyp.catsgotogedog.common.util; -import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.nio.charset.StandardCharsets; import java.security.Key; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.Date; @Component @@ -21,27 +24,34 @@ public class JwtTokenUtil { private long accessMin; @Value("${jwt.refresh-expire-day}") - private long refreshDay; + private int refreshDay; private Key key; + @PostConstruct + private void init() { + key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(String sub) { Date now = new Date(); return Jwts.builder() .setSubject(sub) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + accessMin * 60_000)) - .signWith(SignatureAlgorithm.HS256, secretKey) + .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String createRefreshToken(String sub) { Date now = new Date(); + long refreshMs = Duration.ofDays(refreshDay).toMillis(); return Jwts.builder() .setSubject(sub) .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + refreshDay * 86_400_000)) - .signWith(SignatureAlgorithm.HS256, secretKey) + .setExpiration(new Date(now.getTime() + refreshMs)) + .signWith(key, SignatureAlgorithm.HS256) .compact(); } @@ -49,4 +59,8 @@ public String getSubject(String token) { return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody().getSubject(); } + + public LocalDateTime getRefreshTokenExpiry() { + return LocalDateTime.now().plusDays(refreshDay); + } } From b013021f9175b8627ff08060198392d20838e61c Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Sun, 20 Jul 2025 00:49:55 +0900 Subject: [PATCH 015/191] =?UTF-8?q?fix:=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 접근권한 수정 --- .../com/swyp/catsgotogedog/common/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index 2b13796..79a96de 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -31,7 +31,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/login**", "/error", - "/auth/reissue", "/auth/logout").permitAll() + "/user/**").permitAll() .anyRequest().authenticated()) .oauth2Login(oauth -> oauth .loginPage("/login") // 커스텀 로그인 화면 (없으면 기본 템플릿) From 3378354a6ea87e635473686951b5f151291fd001 Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Sun, 20 Jul 2025 00:50:14 +0900 Subject: [PATCH 016/191] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=88=98=EC=A7=91=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java | 5 +++-- .../common/security/service/PrincipalOauth2UserService.java | 5 +---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java index 80d8c9f..864cc61 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java @@ -3,7 +3,7 @@ import java.util.Map; -public record KakaoUserInfo(String id, String name, String profile_image) { +public record KakaoUserInfo(String id, String email, String name, String profile_image) { public static KakaoUserInfo of(Map attr) { String id = String.valueOf(attr.get("id")); @@ -12,7 +12,8 @@ public static KakaoUserInfo of(Map attr) { String nickname = (String) profile.get("nickname"); String profile_image=(String) profile.get("profile_image"); + String email=(String) profile.get("account_email"); - return new KakaoUserInfo(id, nickname, profile_image); + return new KakaoUserInfo(id, email, nickname, profile_image); } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java index 6c8222e..bea46a1 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -17,9 +17,6 @@ import java.time.LocalDateTime; -import static org.joda.time.DateTime.now; - - @Service @RequiredArgsConstructor @Slf4j @@ -37,7 +34,7 @@ public OAuth2User loadUser(OAuth2UserRequest req) { switch (provider) { case "kakao" -> { KakaoUserInfo kakao = KakaoUserInfo.of(oAuth2User.getAttributes()); - info = new SocialUserInfo(kakao.id(), null, kakao.name(), kakao.profile_image()); + info = new SocialUserInfo(kakao.id(), kakao.email(), kakao.name(), kakao.profile_image()); } case "naver" -> { NaverUserInfo naver = NaverUserInfo.of(oAuth2User.getAttributes()); From 1b743583fd052c1ed842112bdf0ea93ee5e31621 Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Sun, 20 Jul 2025 16:42:05 +0900 Subject: [PATCH 017/191] =?UTF-8?q?fix:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EA=B0=9D=EC=B2=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java index 864cc61..6c598e7 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java @@ -12,7 +12,9 @@ public static KakaoUserInfo of(Map attr) { String nickname = (String) profile.get("nickname"); String profile_image=(String) profile.get("profile_image"); - String email=(String) profile.get("account_email"); + String email = (String) kakaoAccount.get("email"); + + System.out.println("emial : "+email); return new KakaoUserInfo(id, email, nickname, profile_image); } From ec0095a00e1cea1a2df88d49f685ac27ca40a3ad Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Sun, 20 Jul 2025 17:00:27 +0900 Subject: [PATCH 018/191] =?UTF-8?q?fix:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B2=84=EC=A0=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java index 6c598e7..2ff9141 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java @@ -11,11 +11,11 @@ public static KakaoUserInfo of(Map attr) { Map profile = (Map) kakaoAccount.get("profile"); String nickname = (String) profile.get("nickname"); - String profile_image=(String) profile.get("profile_image"); + String profileImage = (String) profile.get("profile_image_url"); String email = (String) kakaoAccount.get("email"); System.out.println("emial : "+email); - return new KakaoUserInfo(id, email, nickname, profile_image); + return new KakaoUserInfo(id, email, nickname, profileImage); } } From 0355c42344ea206e771b43a6e4f13d707eb6aac8 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 21 Jul 2025 18:55:38 +0900 Subject: [PATCH 019/191] =?UTF-8?q?CORS=20=EC=84=A4=EC=A0=95,=20User=20ima?= =?UTF-8?q?ge=5Ffilename=20not=20null=20=EC=A0=9C=EC=95=BD=ED=95=B4?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../User/controller/UserController.java | 2 +- .../controller/UserControllerSwagger.java | 38 +++++++++++++++++++ .../User/service/RefreshTokenService.java | 1 + .../common/config/SecurityConfig.java | 38 ++++++++++++++++--- .../handler/OAuth2LoginSuccessHandler.java | 27 ++++++++----- .../global/config/SwaggerConfig.java | 35 +++++++++++++++++ .../V2__remove_user_imagefilename_notnull.sql | 2 + 7 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java create mode 100644 src/main/resources/db/migration/mysql/V2__remove_user_imagefilename_notnull.sql diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index 779d6ee..a3c60a4 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -16,7 +16,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/user") -public class UserController { +public class UserController implements UserControllerSwagger{ private final JwtTokenUtil jwt; private final RefreshTokenService rtService; diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java new file mode 100644 index 0000000..b420aa8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java @@ -0,0 +1,38 @@ +package com.swyp.catsgotogedog.User.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "사용자 관리", description = "사용자 관련 API") +public interface UserControllerSwagger { + + @Operation( + summary = "토큰 재발급", + description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다.\n" + + "재발급 토큰은 Header를 통해 반환됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰 또는 사용자를 찾을 수 없음") + }) + ResponseEntity reissue( + @Parameter(description = "리프레시 토큰", required = true) + String refresh + ); + + @Operation( + summary = "로그아웃", + description = "사용자 로그아웃을 처리하고 리프레시 토큰을 제거합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공") + }) + ResponseEntity logout( + @Parameter(description = "리프레시 토큰", required = true) + String refresh + ); +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java b/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java index a3a65e0..7b41d43 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java +++ b/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java @@ -25,6 +25,7 @@ public RefreshToken save(User user, String token, LocalDateTime expiryMs) { .userId(user.getUserId()) .refreshToken(token) .expiresAt(expiryMs) + .isRevoked(Boolean.FALSE) .build(); return repo.save(rt); } diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index 79a96de..a989e31 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -1,8 +1,9 @@ package com.swyp.catsgotogedog.common.config; +import java.util.List; + import com.swyp.catsgotogedog.common.security.filter.JwtTokenFilter; import com.swyp.catsgotogedog.common.security.handler.OAuth2LoginSuccessHandler; -import com.swyp.catsgotogedog.common.security.service.PrincipalOauth2UserService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -11,27 +12,39 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final PrincipalOauth2UserService principalOauth2UserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final JwtTokenFilter jwtTokenFilter; - @Value("${jwt.secret}") - private String jwtSecret; + @Value("${allowed.origins.url}") + private String allowedOriginsUrl; + + @Value("${allowed.http.methods}") + private String allowedHttpMethods; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/login**", "/error", - "/user/**").permitAll() + .requestMatchers( + "/oauth2/**", + "/login**", + "/error", + "/swagger-ui/**", + "/v3/api-docs/**", + "/user/**" + ).permitAll() .anyRequest().authenticated()) .oauth2Login(oauth -> oauth .loginPage("/login") // 커스텀 로그인 화면 (없으면 기본 템플릿) @@ -40,4 +53,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } + + private CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(List.of(allowedOriginsUrl)); + configuration.setAllowedMethods(List.of(allowedHttpMethods)); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index e09a14e..777bc2c 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -1,5 +1,7 @@ package com.swyp.catsgotogedog.common.security.handler; +import java.io.IOException; + import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.User.service.RefreshTokenService; @@ -8,25 +10,29 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; - +import org.springframework.web.util.UriComponentsBuilder; @Component @RequiredArgsConstructor -public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler + implements AuthenticationSuccessHandler { private final JwtTokenUtil jwt; private final RefreshTokenService rtService; private final UserRepository userRepo; + @Value("${frontend.base.url}") + private String frontend_base_url; + @Override public void onAuthenticationSuccess( - HttpServletRequest request, HttpServletResponse response, Authentication auth) { - -// String principal = auth.getName(); -// User user = userRepo.findByUserId(principal).orElseThrow(); + HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException { PrincipalDetails pd = (PrincipalDetails) auth.getPrincipal(); String providerId = pd.getProviderId(); @@ -39,8 +45,11 @@ public void onAuthenticationSuccess( rtService.save(user, refresh, jwt.getRefreshTokenExpiry()); - response.setHeader("Authorization", "Bearer " + access); - response.setHeader("X-Refresh-Token", refresh); - response.setStatus(HttpServletResponse.SC_OK); + String targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .queryParam("accessToken", access) + .queryParam("refreshToken", refresh) + .build() + .toUriString(); + getRedirectStrategy().sendRedirect(request, response, targetUrl); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java new file mode 100644 index 0000000..4b8b156 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java @@ -0,0 +1,35 @@ +package com.swyp.catsgotogedog.global.config; + +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components() + .addSecuritySchemes("bearer-key", securityScheme())) + .info(new Info()); + } + + private Info info() { + return new Info() + .title("어디가냥?같이가개!") + .description("어디가냥?같이가개!의 API 문서입니다.") + .version("v1.0"); + } + + private SecurityScheme securityScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + } +} diff --git a/src/main/resources/db/migration/mysql/V2__remove_user_imagefilename_notnull.sql b/src/main/resources/db/migration/mysql/V2__remove_user_imagefilename_notnull.sql new file mode 100644 index 0000000..0e27103 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V2__remove_user_imagefilename_notnull.sql @@ -0,0 +1,2 @@ +ALTER TABLE `catsgotogedog`.`user` +CHANGE COLUMN `image_filename` `image_filename` VARCHAR(255) NULL ; From 806a115d1d7aebb842e465ce12fcf3f4b2cde1c7 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Tue, 22 Jul 2025 01:53:30 +0900 Subject: [PATCH 020/191] =?UTF-8?q?=EC=9E=90=EB=8F=99=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8,=20API=20=EC=9D=91=EB=8B=B5=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=8F=20swagger=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 일관된 응답 및 Error Handling - 소셜로그인 요청시 autoLogin parameter를 받아 자동로그인 활성화 여부 적용 - UserController 응답 및 서비스 로직 수정, Swagger 문서화 --- .../User/controller/UserController.java | 46 ++++++++----------- .../controller/UserControllerSwagger.java | 30 ++++++++---- .../User/domain/AccessTokenResponse.java | 4 ++ .../User/service/UserService.java | 45 ++++++++++++++++++ .../common/config/SecurityConfig.java | 5 +- .../milvus/service/MilvusService.java | 2 +- .../filter/OAuth2AutoLoginFilter.java | 35 ++++++++++++++ .../handler/OAuth2LoginSuccessHandler.java | 40 ++++++++++++++-- .../common/util/JwtTokenUtil.java | 13 ++++-- .../global/CatsgotogedogApiResponse.java | 16 +++++++ .../exception/CatsgotogedogException.java | 14 ++++++ .../exception/ContentNotFoundException.java | 11 +++++ .../global/exception/ErrorCode.java | 28 +++++++++++ .../exception/ExpiredTokenException.java | 11 +++++ .../exception/ForbiddenAccessException.java | 11 +++++ .../exception/GlobalExceptionHandler.java | 35 ++++++++++++++ .../exception/InvalidTokenException.java | 12 +++++ .../exception/MemberNotFoundException.java | 11 +++++ .../exception/ResourceNotFoundException.java | 11 +++++ .../exception/ReviewNotFoundException.java | 11 +++++ .../UnAuthorizedAccessException.java | 14 ++++++ .../milvus/service/MilvusServiceTest.java | 2 + 22 files changed, 362 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/AccessTokenResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/service/UserService.java rename src/main/java/com/swyp/catsgotogedog/{ => common}/milvus/service/MilvusService.java (96%) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/CatsgotogedogException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ContentNotFoundException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ExpiredTokenException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ForbiddenAccessException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/InvalidTokenException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/MemberNotFoundException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ResourceNotFoundException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ReviewNotFoundException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/UnAuthorizedAccessException.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index a3c60a4..a6645e8 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -1,54 +1,44 @@ package com.swyp.catsgotogedog.User.controller; -import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.AccessTokenResponse; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.User.service.RefreshTokenService; +import com.swyp.catsgotogedog.User.service.UserService; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; + import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; @RestController @RequiredArgsConstructor -@RequestMapping("/user") +@RequestMapping("/api/user") public class UserController implements UserControllerSwagger{ private final JwtTokenUtil jwt; private final RefreshTokenService rtService; + private final UserService userService; private final UserRepository userRepo; @PostMapping("/reissue") - public ResponseEntity reissue( - @RequestHeader("X-Refresh-Token") String refresh) { - - if (!rtService.validate(refresh)) { - return ResponseEntity.status(401).build(); - } - - int userId = Integer.parseInt(jwt.getSubject(refresh)); - - User user = userRepo.findById(userId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + public ResponseEntity> reIssue( + @CookieValue("X-Refresh-Token") String refresh) { - String newAccess = jwt.createAccessToken(String.valueOf(userId)); - String newRefresh = jwt.createRefreshToken(String.valueOf(userId)); - - rtService.save(user, newRefresh, jwt.getRefreshTokenExpiry()); - - return ResponseEntity.ok() - .header("Authorization", "Bearer " + newAccess) - .header("X-Refresh-Token", newRefresh) - .build(); + return ResponseEntity.ok(CatsgotogedogApiResponse.success("재발급 성공", + new AccessTokenResponse(userService.reIssue(refresh)))); } @PostMapping("/logout") - public ResponseEntity logout(@RequestHeader("X-Refresh-Token") String refresh) { - rtService.delete(refresh); - return ResponseEntity.ok("로그아웃 완료"); + public ResponseEntity> logout( + @CookieValue("X-Refresh-Token") String refresh) { + + userService.logout(refresh); + + return ResponseEntity.ok(CatsgotogedogApiResponse.success("로그아웃 성공", null)); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java index b420aa8..9a57819 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java @@ -2,25 +2,34 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; -@Tag(name = "사용자 관리", description = "사용자 관련 API") +import com.swyp.catsgotogedog.User.domain.AccessTokenResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +@Tag(name = "User", description = "사용자 관련 API") public interface UserControllerSwagger { @Operation( - summary = "토큰 재발급", + summary = "액세스 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다.\n" - + "재발급 토큰은 Header를 통해 반환됩니다." + + "재발급된 토큰은 body를 통해 반환됩니다." ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "토큰 재발급 성공"), - @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰 또는 사용자를 찾을 수 없음") + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공" + , content = @Content(schema = @Schema(implementation = AccessTokenResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) }) - ResponseEntity reissue( - @Parameter(description = "리프레시 토큰", required = true) + ResponseEntity reIssue( + @Parameter(description = "리프레시 토큰", required = false) String refresh ); @@ -29,9 +38,12 @@ ResponseEntity reissue( description = "사용자 로그아웃을 처리하고 리프레시 토큰을 제거합니다." ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그아웃 성공") + @ApiResponse(responseCode = "200", description = "로그아웃 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) }) - ResponseEntity logout( + ResponseEntity> logout( @Parameter(description = "리프레시 토큰", required = true) String refresh ); diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/AccessTokenResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/AccessTokenResponse.java new file mode 100644 index 0000000..5b37f90 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/AccessTokenResponse.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.User.domain; + +public record AccessTokenResponse(String accessToken) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java new file mode 100644 index 0000000..17d73b4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -0,0 +1,45 @@ +package com.swyp.catsgotogedog.User.service; + +import org.springframework.stereotype.Service; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.InvalidTokenException; +import com.swyp.catsgotogedog.global.exception.UnAuthorizedAccessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserService { + + private final UserRepository userRepository; + private final RefreshTokenService rtService; + private final JwtTokenUtil jwt; + + public String reIssue(String refreshToken) { + + if(!rtService.validate(refreshToken)) { + throw new InvalidTokenException(ErrorCode.INVALID_TOKEN); + } + + int userId = Integer.parseInt(jwt.getSubject(refreshToken)); + String email = jwt.getEmail(refreshToken); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new UnAuthorizedAccessException(ErrorCode.UNAUTHORIZED_ACCESS)); + + return jwt.createAccessToken(String.valueOf(userId), email); + } + + public void logout(String refreshToken) { + if (!rtService.validate(refreshToken)) { + throw new InvalidTokenException(ErrorCode.INVALID_TOKEN); + } + rtService.delete(refreshToken); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index a989e31..8cdafac 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -3,6 +3,7 @@ import java.util.List; import com.swyp.catsgotogedog.common.security.filter.JwtTokenFilter; +import com.swyp.catsgotogedog.common.security.filter.OAuth2AutoLoginFilter; import com.swyp.catsgotogedog.common.security.handler.OAuth2LoginSuccessHandler; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -11,6 +12,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; @@ -43,9 +45,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/error", "/swagger-ui/**", "/v3/api-docs/**", - "/user/**" + "/api/**" ).permitAll() .anyRequest().authenticated()) + .addFilterBefore(new OAuth2AutoLoginFilter(), OAuth2AuthorizationRequestRedirectFilter.class) .oauth2Login(oauth -> oauth .loginPage("/login") // 커스텀 로그인 화면 (없으면 기본 템플릿) .successHandler(oAuth2LoginSuccessHandler)) diff --git a/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java b/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java similarity index 96% rename from src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java rename to src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java index cb218bd..96e91e0 100644 --- a/src/main/java/com/swyp/catsgotogedog/milvus/service/MilvusService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java @@ -1,4 +1,4 @@ -package com.swyp.catsgotogedog.milvus.service; +package com.swyp.catsgotogedog.common.milvus.service; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java new file mode 100644 index 0000000..d6a4f4d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java @@ -0,0 +1,35 @@ +package com.swyp.catsgotogedog.common.security.filter; + +import java.io.IOException; + +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +public class OAuth2AutoLoginFilter extends OncePerRequestFilter { + + public final static String AUTO_LOGIN_PARAM = "autoLogin"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if(request.getRequestURI().contains("/oauth2/authorization/")) { + String autoLoginParam = request.getParameter(AUTO_LOGIN_PARAM); + + if(autoLoginParam == null) { + autoLoginParam = "false"; + } + + boolean autoLogin = "true".equalsIgnoreCase(autoLoginParam); + + HttpSession session = request.getSession(true); + session.setAttribute(AUTO_LOGIN_PARAM, autoLogin); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index 777bc2c..b16d983 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -1,14 +1,20 @@ package com.swyp.catsgotogedog.common.security.handler; +import static com.swyp.catsgotogedog.common.security.filter.OAuth2AutoLoginFilter.*; + import java.io.IOException; +import java.time.Duration; import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.User.service.RefreshTokenService; import com.swyp.catsgotogedog.common.security.service.PrincipalDetails; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; + +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -30,6 +36,9 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan @Value("${frontend.base.url}") private String frontend_base_url; + @Value("${jwt.refresh-expire-day}") + private int refreshDay; + @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException { @@ -40,16 +49,41 @@ public void onAuthenticationSuccess( User user = userRepo.findByProviderId(providerId) .orElseThrow(() -> new IllegalStateException("회원이 없습니다")); - String access = jwt.createAccessToken(String.valueOf(user.getUserId())); - String refresh = jwt.createRefreshToken(String.valueOf(user.getUserId())); + String access = jwt.createAccessToken(String.valueOf(user.getUserId()), user.getEmail()); + String refresh = jwt.createRefreshToken(String.valueOf(user.getUserId()), user.getEmail()); rtService.save(user, refresh, jwt.getRefreshTokenExpiry()); + addRefreshTokenCookie(response, refresh, isAutoLogin(request)); + String targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) .queryParam("accessToken", access) - .queryParam("refreshToken", refresh) .build() .toUriString(); getRedirectStrategy().sendRedirect(request, response, targetUrl); } + + private Boolean isAutoLogin(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return false; + } + + var autoLoginAttribute = session.getAttribute(AUTO_LOGIN_PARAM); + session.removeAttribute(AUTO_LOGIN_PARAM); + logger.info(autoLoginAttribute.equals(true)); + return autoLoginAttribute.equals(true); + } + + private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken, Boolean isAutoLogin) { + Cookie refreshTokenCookie = new Cookie("X-Refresh-Token", refreshToken); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setSecure(true); + refreshTokenCookie.setPath("/"); + if(isAutoLogin) { + refreshTokenCookie.setMaxAge(refreshDay * 24 * 60 * 60); + } + + response.addCookie(refreshTokenCookie); + } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java index 9dcea04..d4569ef 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java @@ -34,21 +34,23 @@ private void init() { } - public String createAccessToken(String sub) { + public String createAccessToken(String sub, String email) { Date now = new Date(); return Jwts.builder() .setSubject(sub) + .claim("email", email) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + accessMin * 60_000)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } - public String createRefreshToken(String sub) { + public String createRefreshToken(String sub, String email) { Date now = new Date(); long refreshMs = Duration.ofDays(refreshDay).toMillis(); return Jwts.builder() .setSubject(sub) + .claim("email", email) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + refreshMs)) .signWith(key, SignatureAlgorithm.HS256) @@ -57,7 +59,12 @@ public String createRefreshToken(String sub) { public String getSubject(String token) { return Jwts.parserBuilder().setSigningKey(key).build() - .parseClaimsJws(token).getBody().getSubject(); + .parseClaimsJws(token).getBody().getSubject(); + } + + public String getEmail(String token) { + return Jwts.parserBuilder().setSigningKey(key).build() + .parseClaimsJws(token).getBody().get("email", String.class); } public LocalDateTime getRefreshTokenExpiry() { diff --git a/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java b/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java new file mode 100644 index 0000000..d6c327d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.global; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public record CatsgotogedogApiResponse(int status, String message, T data) { + private static final int SUCCESS = 200; + + public static CatsgotogedogApiResponse success(String message, T data) { + return new CatsgotogedogApiResponse<>(SUCCESS, message, data); + } + + public static CatsgotogedogApiResponse fail(ErrorCode errorCode) { + return new CatsgotogedogApiResponse<>(errorCode.getCode(), errorCode.getMessage(), null); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/CatsgotogedogException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/CatsgotogedogException.java new file mode 100644 index 0000000..229bb39 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/CatsgotogedogException.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class CatsgotogedogException extends RuntimeException{ + + private final ErrorCode errorCode; + + public CatsgotogedogException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ContentNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ContentNotFoundException.java new file mode 100644 index 0000000..dbb4a53 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ContentNotFoundException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ContentNotFoundException extends CatsgotogedogException { + + public ContentNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java new file mode 100644 index 0000000..076c561 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + // 401 BadRequest + INVALID_TOKEN(401, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(401, "만료된 토큰입니다."), + UNAUTHORIZED_ACCESS(401, "인증되지 않은 접근입니다."), + + // 403 Forbidden + FORBIDDEN_ACCESS(403, "접근 권한이 없습니다."), + + // 404 Notfound + MEMBER_NOT_FOUND(404, "존재하지 않는 회원입니다."), + CONTENT_NOT_FOUND(404, "존재하지 않는 컨텐츠 게시글입니다."), + REVIEW_NOT_FOUND(404, "존재하지 않는 리뷰입니다."), + RESOURCE_NOT_FOUND(404, "리소스를 찾을 수 없습니다."), + + // 500 Internal Server Error + INTERNAL_SERVER_ERROR(500, "서버 내부 오류가 발생했습니다."); + + private final int code; + private final String message; +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ExpiredTokenException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ExpiredTokenException.java new file mode 100644 index 0000000..dc67e01 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ExpiredTokenException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ExpiredTokenException extends CatsgotogedogException { + + public ExpiredTokenException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ForbiddenAccessException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ForbiddenAccessException.java new file mode 100644 index 0000000..07f8aaa --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ForbiddenAccessException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ForbiddenAccessException extends CatsgotogedogException { + + public ForbiddenAccessException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..0578938 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package com.swyp.catsgotogedog.global.exception; + +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CatsgotogedogException.class) + protected ResponseEntity> handleCatsgotogedogException(CatsgotogedogException ex) { + log.error("CatsgotogedogException : {}", ex.getMessage()); + return createErrorResponse(ex.getErrorCode()); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity> handleException(Exception ex) { + log.error("Exception : {}", ex.getMessage()); + int errorCode = ErrorCode.INTERNAL_SERVER_ERROR.getCode(); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR); + return new ResponseEntity<>(response, HttpStatusCode.valueOf(errorCode)); + } + + private ResponseEntity> createErrorResponse(ErrorCode errorCode) { + int errorCodeValue = errorCode.getCode(); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(errorCode); + return new ResponseEntity<>(response, HttpStatusCode.valueOf(errorCodeValue)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/InvalidTokenException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/InvalidTokenException.java new file mode 100644 index 0000000..3388add --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/InvalidTokenException.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +public class InvalidTokenException extends CatsgotogedogException { + + public InvalidTokenException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/MemberNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/MemberNotFoundException.java new file mode 100644 index 0000000..2fd041f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/MemberNotFoundException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class MemberNotFoundException extends CatsgotogedogException { + + public MemberNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ResourceNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..34e2a3b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ResourceNotFoundException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ResourceNotFoundException extends CatsgotogedogException { + + public ResourceNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ReviewNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ReviewNotFoundException.java new file mode 100644 index 0000000..8b7f81c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ReviewNotFoundException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ReviewNotFoundException extends CatsgotogedogException { + + public ReviewNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/UnAuthorizedAccessException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/UnAuthorizedAccessException.java new file mode 100644 index 0000000..d1ad7c8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/UnAuthorizedAccessException.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class UnAuthorizedAccessException extends RuntimeException{ + + private final ErrorCode errorCode; + + public UnAuthorizedAccessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java b/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java index 60597fa..4e43bb9 100644 --- a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java +++ b/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java @@ -12,6 +12,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import com.swyp.catsgotogedog.common.milvus.service.MilvusService; + import io.milvus.client.MilvusClient; import io.milvus.param.collection.LoadCollectionParam; From 47e32f3edb88ca559cf14eae80492e66152b0058 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Tue, 22 Jul 2025 20:35:01 +0900 Subject: [PATCH 021/191] =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=83=9D=EC=84=B1,=20Logback=20=EB=8F=84=EC=9E=85,?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=8B=9C=20RefreshToken=EB=A7=8C?= =?UTF-8?q?=20=EC=BF=A0=ED=82=A4=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 패키지 구조를 도메인 기준 생성 - 로그인시에 RefreshToken 쿠키를 반환하도록 수정 Secure, HttpOnly, SameSite None 옵션 추가 - Logback 설정을 통해 일별로 로그를 남기도록 수정 --- .github/workflows/cd.yml | 4 +- build.gradle | 3 - .../CatsgotogedogApplication.java | 2 + .../User/controller/UserController.java | 2 +- .../controller/UserControllerSwagger.java | 4 +- .../User/domain/entity/User.java | 23 +++- .../User/domain/request/.gitkeep | 0 .../{ => response}/AccessTokenResponse.java | 2 +- .../controller/CategoryController.java | 12 ++ .../controller/CategoryControllerSwagger.java | 7 ++ .../category/domain/entity/Category.java | 4 + .../category/domain/request/.gitkeep | 0 .../category/domain/response/.gitkeep | 0 .../repository/CategoryRepository.java | 4 + .../category/service/CategoryService.java | 12 ++ .../handler/OAuth2LoginSuccessHandler.java | 37 ++++-- .../service/PrincipalOauth2UserService.java | 1 - .../content/controller/ContentController.java | 13 ++ .../controller/ContentControllerSwagger.java | 7 ++ .../content/domain/entity/.gitkeep | 0 .../content/domain/request/.gitkeep | 0 .../content/domain/response/.gitkeep | 0 .../content/service/ContentService.java | 12 ++ .../catsgotogedog/global/BaseTimeEntity.java | 27 ++++ .../mypage/controller/MyPageController.java | 12 ++ .../controller/MyPageControllerSwagger.java | 7 ++ .../mypage/domain/entity/.gitkeep | 0 .../mypage/domain/request/.gitkeep | 0 .../mypage/domain/response/.gitkeep | 0 .../catsgotogedog/mypage/repository/.gitkeep | 0 .../mypage/service/MyPageService.java | 12 ++ .../pet/controller/PetController.java | 12 ++ .../pet/controller/PetControllerSwagger.java | 7 ++ .../catsgotogedog/pet/domain/entity/Pet.java | 49 ++++++++ .../pet/domain/entity/PetSize.java | 28 +++++ .../catsgotogedog/pet/domain/request/.gitkeep | 0 .../pet/domain/response/.gitkeep | 0 .../pet/repository/PetRepository.java | 8 ++ .../catsgotogedog/pet/service/PetService.java | 12 ++ .../review/controller/ReviewController.java | 12 ++ .../controller/ReviewControllerSwagger.java | 7 ++ .../review/domain/entity/Reivew.java | 4 + .../review/domain/request/.gitkeep | 0 .../review/domain/response/.gitkeep | 0 .../review/repository/ReviewRepository.java | 4 + .../review/service/ReviewService.java | 12 ++ src/main/resources/logback-spring.xml | 116 ++++++++++++++++++ 47 files changed, 449 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/.gitkeep rename src/main/java/com/swyp/catsgotogedog/User/domain/{ => response}/AccessTokenResponse.java (52%) create mode 100644 src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/category/domain/entity/Category.java create mode 100644 src/main/java/com/swyp/catsgotogedog/category/domain/request/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/category/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/request/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/BaseTimeEntity.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/request/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/repository/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetSize.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/request/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/request/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java create mode 100644 src/main/resources/logback-spring.xml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index b2a5fe8..7fdbdde 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -80,7 +80,7 @@ jobs: # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 TIMESTAMP=$(date +%Y%m%d_%H%M%S) - nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar > dev_${TIMESTAMP}.log 2>&1 & echo $! > pid.file + nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar > /dev/null 2>&1 & echo $! > pid.file echo "Development server deploy done." # product @@ -121,5 +121,5 @@ jobs: # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 TIMESTAMP=$(date +%Y%m%d_%H%M%S) - nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar > prod_${TIMESTAMP}.log 2>&1 & echo $! > pid.file + nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar > /prod/null 2>&1 & echo $! > pid.file echo "Production server deploy done." diff --git a/build.gradle b/build.gradle index 55d606a..1aa0b4d 100644 --- a/build.gradle +++ b/build.gradle @@ -44,9 +44,6 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - // Oauth - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - // Milvus Java SDK implementation group: 'io.milvus', name: 'milvus-sdk-java', version: '2.5.10' // Flyway diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index 86cdb63..33e0f84 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class CatsgotogedogApplication { diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index a6645e8..a245f57 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -1,6 +1,6 @@ package com.swyp.catsgotogedog.User.controller; -import com.swyp.catsgotogedog.User.domain.AccessTokenResponse; +import com.swyp.catsgotogedog.User.domain.response.AccessTokenResponse; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.User.service.RefreshTokenService; import com.swyp.catsgotogedog.User.service.UserService; diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java index 9a57819..7b9bb33 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java @@ -9,10 +9,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; -import com.swyp.catsgotogedog.User.domain.AccessTokenResponse; +import com.swyp.catsgotogedog.User.domain.response.AccessTokenResponse; import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; -import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; -import com.swyp.catsgotogedog.global.exception.ErrorCode; @Tag(name = "User", description = "사용자 관련 API") public interface UserControllerSwagger { diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index 34b63f3..9ee0383 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -4,8 +4,11 @@ import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import com.swyp.catsgotogedog.global.BaseTimeEntity; +import com.swyp.catsgotogedog.pet.domain.entity.Pet; @Entity @Getter @@ -13,7 +16,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class User { +public class User extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -24,13 +27,21 @@ public class User { private String email; private String provider; // google / kakao / naver private String providerId; - private String imageFilename; private String imageUrl; + private Boolean isActive; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; + @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + private List pets = new ArrayList<>(); - private Boolean isActive; + public void addPet(Pet pet) { + pets.add(pet); + pet.setUser(this); + } + + public void removePet(Pet pet) { + pets.remove(pet); + pet.setUser(null); + } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/User/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/AccessTokenResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/response/AccessTokenResponse.java similarity index 52% rename from src/main/java/com/swyp/catsgotogedog/User/domain/AccessTokenResponse.java rename to src/main/java/com/swyp/catsgotogedog/User/domain/response/AccessTokenResponse.java index 5b37f90..a7cabd5 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/AccessTokenResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/response/AccessTokenResponse.java @@ -1,4 +1,4 @@ -package com.swyp.catsgotogedog.User.domain; +package com.swyp.catsgotogedog.User.domain.response; public record AccessTokenResponse(String accessToken) { } diff --git a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java new file mode 100644 index 0000000..6d411e2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.category.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/category") +public class CategoryController { +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java new file mode 100644 index 0000000..fdce65f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.category.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Category", description = "카테고리 관련 API") +public interface CategoryControllerSwagger { +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/entity/Category.java b/src/main/java/com/swyp/catsgotogedog/category/domain/entity/Category.java new file mode 100644 index 0000000..4981ad9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/domain/entity/Category.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.category.domain.entity; + +public class Category { +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/category/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/category/domain/response/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java b/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java new file mode 100644 index 0000000..c4bc7b6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.category.repository; + +public interface CategoryRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java b/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java new file mode 100644 index 0000000..9c7656e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.category.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CategoryService { +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index b16d983..f92cdb2 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -39,6 +39,9 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan @Value("${jwt.refresh-expire-day}") private int refreshDay; + /** + * 최초 로그인 시 RefreshToken만 Cookie로 반환하도록 설정 + */ @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException { @@ -49,18 +52,18 @@ public void onAuthenticationSuccess( User user = userRepo.findByProviderId(providerId) .orElseThrow(() -> new IllegalStateException("회원이 없습니다")); - String access = jwt.createAccessToken(String.valueOf(user.getUserId()), user.getEmail()); + //String access = jwt.createAccessToken(String.valueOf(user.getUserId()), user.getEmail()); String refresh = jwt.createRefreshToken(String.valueOf(user.getUserId()), user.getEmail()); rtService.save(user, refresh, jwt.getRefreshTokenExpiry()); addRefreshTokenCookie(response, refresh, isAutoLogin(request)); - String targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) - .queryParam("accessToken", access) - .build() - .toUriString(); - getRedirectStrategy().sendRedirect(request, response, targetUrl); + // String targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + // .queryParam("accessToken", access) + // .build() + // .toUriString(); + getRedirectStrategy().sendRedirect(request, response, frontend_base_url); } private Boolean isAutoLogin(HttpServletRequest request) { @@ -76,14 +79,24 @@ private Boolean isAutoLogin(HttpServletRequest request) { } private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken, Boolean isAutoLogin) { - Cookie refreshTokenCookie = new Cookie("X-Refresh-Token", refreshToken); - refreshTokenCookie.setHttpOnly(true); - refreshTokenCookie.setSecure(true); - refreshTokenCookie.setPath("/"); + // Cookie refreshTokenCookie = new Cookie("X-Refresh-Token", refreshToken); + // refreshTokenCookie.setHttpOnly(true); + // refreshTokenCookie.setSecure(true); + // refreshTokenCookie.setPath("/"); + // if(isAutoLogin) { + // refreshTokenCookie.setMaxAge(refreshDay * 24 * 60 * 60); + // } + + StringBuilder cookieHeader = new StringBuilder(); + cookieHeader.append("X-Refresh-Token=").append(refreshToken) + .append("; HttpOnly") + .append("; Secure") + .append("; Path=/") + .append("; SameSite=None"); if(isAutoLogin) { - refreshTokenCookie.setMaxAge(refreshDay * 24 * 60 * 60); + cookieHeader.append("; Max-Age=").append(refreshDay * 24 * 60 * 60); } - response.addCookie(refreshTokenCookie); + response.addHeader("Set-Cookie", cookieHeader.toString()); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java index bea46a1..b58896a 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -55,7 +55,6 @@ public OAuth2User loadUser(OAuth2UserRequest req) { .email(info.email()) .displayName(info.name()) .imageUrl(info.profileImage()) - .createdAt(LocalDateTime.now()) .isActive(Boolean.TRUE) .build() )); diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java new file mode 100644 index 0000000..74145b6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.content.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/content") +public class ContentController implements ContentControllerSwagger{ + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java new file mode 100644 index 0000000..274dd82 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.content.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") +public interface ContentControllerSwagger { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/content/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/content/domain/response/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java new file mode 100644 index 0000000..6b1a86d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.content.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ContentService { +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/BaseTimeEntity.java b/src/main/java/com/swyp/catsgotogedog/global/BaseTimeEntity.java new file mode 100644 index 0000000..220c09e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/BaseTimeEntity.java @@ -0,0 +1,27 @@ +package com.swyp.catsgotogedog.global; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageController.java b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageController.java new file mode 100644 index 0000000..a41cae7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageController.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.mypage.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mypage") +public class MyPageController { +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageControllerSwagger.java new file mode 100644 index 0000000..4aea86b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageControllerSwagger.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.mypage.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "MyPage", description = "마이페이지 관련 API") +public interface MyPageControllerSwagger { +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/.gitkeep b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/mypage/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/repository/.gitkeep b/src/main/java/com/swyp/catsgotogedog/mypage/repository/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageService.java b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageService.java new file mode 100644 index 0000000..849aba8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageService.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.mypage.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MyPageService { +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java new file mode 100644 index 0000000..4d4158f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.pet.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/pet") +public class PetController { +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java new file mode 100644 index 0000000..19dfd53 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.pet.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Pet", description = "반려동물 관련 API") +public interface PetControllerSwagger { +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java new file mode 100644 index 0000000..55be061 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java @@ -0,0 +1,49 @@ +package com.swyp.catsgotogedog.pet.domain.entity; + +import java.time.LocalDate; + +import com.swyp.catsgotogedog.User.domain.entity.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Pet { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "pet_id") + private int petId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private char gender; + private LocalDate birth; + private boolean fierceDog; + @ManyToOne + @JoinColumn(name = "size_id", nullable = false) + private PetSize sizeId; + private String name; + private String imageFilename; + private String imageUrl; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetSize.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetSize.java new file mode 100644 index 0000000..202dc51 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetSize.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.pet.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PetSize { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "size_id") + private int sizeId; + private String size; + private String sizeTooltip; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/pet/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java new file mode 100644 index 0000000..f9dacf3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.pet.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.pet.domain.entity.Pet; + +public interface PetRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java new file mode 100644 index 0000000..c4a064f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.pet.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PetService { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java new file mode 100644 index 0000000..f6dfd0d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.review.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/review") +public class ReviewController implements ReviewControllerSwagger{ +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java new file mode 100644 index 0000000..f840df1 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.review.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Review", description = "리뷰 관련 API") +public interface ReviewControllerSwagger { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java new file mode 100644 index 0000000..8d54adc --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +public class Reivew { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/review/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/review/domain/response/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java new file mode 100644 index 0000000..803f62d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.review.repository; + +public interface ReviewRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java new file mode 100644 index 0000000..11685ef --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.review.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewService { +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..eaa1ddd --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + ${LOG_PATTERN_CONSOLE} + + + + + + + + ${INFO_LOG_DIR}info_%d{yyyy-MM-dd}.log + ${MAX_HISTORY} + + + ${LOG_PATTERN_FILE} + + + + + + + + ERROR + ACCEPT + DENY + + + ${ERROR_LOG_DIR}error_%d{yyyy-MM-dd}.log + ${MAX_HISTORY} + + + ${LOG_PATTERN_FILE} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From b1839ada4bd8750055aeb2a041a3e97c9487856e Mon Sep 17 00:00:00 2001 From: yhs99 Date: Tue, 22 Jul 2025 22:35:19 +0900 Subject: [PATCH 022/191] =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7fdbdde..b0efafb 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -80,7 +80,7 @@ jobs: # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 TIMESTAMP=$(date +%Y%m%d_%H%M%S) - nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar > /dev/null 2>&1 & echo $! > pid.file + nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar & echo $! > pid.file echo "Development server deploy done." # product @@ -120,6 +120,5 @@ jobs: fi # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 - TIMESTAMP=$(date +%Y%m%d_%H%M%S) - nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar > /prod/null 2>&1 & echo $! > pid.file + nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar & echo $! > pid.file echo "Production server deploy done." From 8008fc28b34a795ffac713ba4a7ad1c76dbb60d1 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Wed, 23 Jul 2025 00:02:25 +0900 Subject: [PATCH 023/191] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MethodArgumentTypeMismatchException, MethodArgumentNotValidException, MissingServletRequestParameterException, HttpMessageNotReadableException, HttpRequestMethodNotSupportedException 추가 --- .../User/controller/UserController.java | 2 +- .../global/CatsgotogedogApiResponse.java | 11 +++ .../global/exception/ErrorCode.java | 28 ++++--- .../exception/GlobalExceptionHandler.java | 73 ++++++++++++++++++- .../exception/NotAllowedMethodException.java | 11 +++ 5 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/NotAllowedMethodException.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index a245f57..b4028ff 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -27,7 +27,7 @@ public class UserController implements UserControllerSwagger{ @PostMapping("/reissue") public ResponseEntity> reIssue( - @CookieValue("X-Refresh-Token") String refresh) { + @CookieValue(value = "X-Refresh-Token", required = false) String refresh) { return ResponseEntity.ok(CatsgotogedogApiResponse.success("재발급 성공", new AccessTokenResponse(userService.reIssue(refresh)))); diff --git a/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java b/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java index d6c327d..9066ece 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java @@ -13,4 +13,15 @@ public static CatsgotogedogApiResponse fail(ErrorCode errorCode) { return new CatsgotogedogApiResponse<>(errorCode.getCode(), errorCode.getMessage(), null); } + public static CatsgotogedogApiResponse fail(ErrorCode errorCode, T data) { + return new CatsgotogedogApiResponse<>(errorCode.getCode(), errorCode.getMessage(), data); + } + + public static CatsgotogedogApiResponse fail(int errorCode, String message, T data) { + return new CatsgotogedogApiResponse<>(errorCode, message, data); + } + + public static CatsgotogedogApiResponse fail(int errorCode, String message) { + return new CatsgotogedogApiResponse<>(errorCode, message, null); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 076c561..7edbc72 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -1,5 +1,7 @@ package com.swyp.catsgotogedog.global.exception; +import org.springframework.http.HttpStatus; + import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -7,21 +9,29 @@ @RequiredArgsConstructor public enum ErrorCode { // 401 BadRequest - INVALID_TOKEN(401, "유효하지 않은 토큰입니다."), - EXPIRED_TOKEN(401, "만료된 토큰입니다."), - UNAUTHORIZED_ACCESS(401, "인증되지 않은 접근입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다."), + UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED.value(), "인증되지 않은 접근입니다."), // 403 Forbidden - FORBIDDEN_ACCESS(403, "접근 권한이 없습니다."), + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), // 404 Notfound - MEMBER_NOT_FOUND(404, "존재하지 않는 회원입니다."), - CONTENT_NOT_FOUND(404, "존재하지 않는 컨텐츠 게시글입니다."), - REVIEW_NOT_FOUND(404, "존재하지 않는 리뷰입니다."), - RESOURCE_NOT_FOUND(404, "리소스를 찾을 수 없습니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 회원입니다."), + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 컨텐츠 게시글입니다."), + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰입니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "리소스를 찾을 수 없습니다."), + + // 405 Method not allowed + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메소드입니다."), + + // 400 Bad Request + MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST.value(), "요청 본문 형식이 올바르지 않습니다."), + ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "유효성 검사에 실패했습니다."), + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST.value(), "필수 파라미터가 누락되었습니다."), // 500 Internal Server Error - INTERNAL_SERVER_ERROR(500, "서버 내부 오류가 발생했습니다."); + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."); private final int code; private final String message; diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java index 0578938..ee33092 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -1,9 +1,18 @@ package com.swyp.catsgotogedog.global.exception; -import org.springframework.http.HttpStatusCode; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindingResult; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; @@ -24,12 +33,70 @@ protected ResponseEntity> handleException(Excep log.error("Exception : {}", ex.getMessage()); int errorCode = ErrorCode.INTERNAL_SERVER_ERROR.getCode(); CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR); - return new ResponseEntity<>(response, HttpStatusCode.valueOf(errorCode)); + return ResponseEntity + .status(errorCode) + .body(response); } private ResponseEntity> createErrorResponse(ErrorCode errorCode) { int errorCodeValue = errorCode.getCode(); CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(errorCode); - return new ResponseEntity<>(response, HttpStatusCode.valueOf(errorCodeValue)); + return ResponseEntity + .status(errorCodeValue) + .body(response); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + protected ResponseEntity> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { + log.error("MethodArgumentTypeMismatchException: {}", e.getMessage(), e); + String errorMessage = String.format("파라미터 '%s'의 타입이 올바르지 않습니다. 요청된 타입: %s", + e.getName(), e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "알 수 없음"); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(HttpStatus.BAD_REQUEST.value(), errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("MethodArgumentNotValidException: {}", e.getMessage(), e); + BindingResult bindingResult = e.getBindingResult(); + Map errors = new HashMap<>(); + bindingResult.getFieldErrors().forEach(error -> errors.put(error.getField(), error.getDefaultMessage())); + + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.ARGUMENT_NOT_VALID, errors); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); } + + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ResponseEntity> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + log.error("MissingServletRequestParameterException: {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.MISSING_REQUEST_PARAMETER, e.getParameterName()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("HttpMessageNotReadableException: {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.MESSAGE_NOT_READABLE); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + protected ResponseEntity> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.error("HttpRequestMethodNotSupportedException: {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.METHOD_NOT_ALLOWED, e.getMethod()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + } diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/NotAllowedMethodException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/NotAllowedMethodException.java new file mode 100644 index 0000000..68ef4e8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/NotAllowedMethodException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class NotAllowedMethodException extends CatsgotogedogException { + + public NotAllowedMethodException(ErrorCode errorCode) { + super(errorCode); + } +} From 61a2e6bfa16ff1c5a10ab6ad35e17b548459337b Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Thu, 24 Jul 2025 15:45:45 +0900 Subject: [PATCH 024/191] =?UTF-8?q?NCP=20Object=20Storage=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C,=20=EC=82=AD=EC=A0=9C=20=EB=93=B1?= =?UTF-8?q?=20=ED=95=84=EC=9A=94=ED=95=9C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=ED=99=94=ED=95=98=EC=97=AC=20upload,=20delet?= =?UTF-8?q?e,=20generateUrls=EB=A1=9C=20=EC=A0=9C=EA=B3=B5=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../imagestorage/ImageStorageService.java | 163 ++++++++++++++++++ .../imagestorage/ImageUploadException.java | 9 + 3 files changed, 174 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageUploadException.java diff --git a/build.gradle b/build.gradle index 55d606a..05a6f19 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,8 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // Swagger implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.9' + // Spring Cloud AWS + implementation "io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.1" // S3 implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java new file mode 100644 index 0000000..8bbc0bd --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java @@ -0,0 +1,163 @@ +package com.swyp.catsgotogedog.common.util.imagestorage; + +import io.awspring.cloud.s3.S3Template; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.ObjectCannedACL; +import software.amazon.awssdk.services.s3.model.PutObjectAclRequest; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + + +@Service +public class ImageStorageService { + + private final S3Template s3Template; + private final S3Client s3Client; + private final String endpoint; + private final String bucketName; + + private static final int MAX_FILE_COUNT = 10; + + public ImageStorageService(S3Template s3Template, + S3Client s3Client, + @Value("${spring.cloud.aws.s3.endpoint}") String endpoint, + @Value("${spring.cloud.aws.s3.bucket}") String bucketName) { + + this.s3Template = s3Template; + this.s3Client = s3Client; + this.endpoint = endpoint; + this.bucketName = bucketName; + } + + // 다중 이미지 업로드 -> upload 메서드(path 포함)로 오버로딩 + public List upload(List files) { + return upload(files, ""); + } + + // 다중 이미지 업로드 -> 각 파일을 doUpload 메서드로 처리. 각 반환 값을 리스트로 수집 후 반환 + public List upload(List files, String path) { + validateFiles(files); + return files.stream() + .map(file -> doUpload(file, path)) + .collect(Collectors.toList()); + } + + // 단일 이미지 업로드 -> upload 메서드(path 포함)로 오버로딩 + public List upload(MultipartFile file) { + return upload(file, ""); + } + + // 단일 이미지 업로드 -> doUpload 호출 후 리스트로 래핑하여 반환 + public List upload(MultipartFile file, String path) { + validateFile(file); + return Collections.singletonList(doUpload(file, path)); + } + + // 단일 이미지 삭제 -> 리스트화 하여 처리 + public void delete(String key) { + validateKey(key); + doDelete(key); + } + + // 다중 이미지 삭제 각 이미지 키를 검증 후 삭제 + public void delete(List keys) { + validateKeyList(keys); + keys.forEach(this::doDelete); + } + + // 키를 기반으로 URL 생성 -> 단일 이미지 + public List generateUrls(String key) { + validateKey(key); + return Collections.singletonList(doGenerateUrl(key)); + } + + // 다중 이미지 URL 생성 + public List generateUrls(List keys) { + validateKeyList(keys); + return keys.stream() + .map(this::doGenerateUrl) + .collect(Collectors.toList()); + } + + private String doUpload(MultipartFile file, String path) { + String key = genKey(file, path); + + try (InputStream stream = file.getInputStream()) { + s3Template.upload(bucketName, key, stream); + setAclPublicRead(key); + } catch (IOException e) { + throw new UncheckedIOException("Failed to upload", e); + } catch (Exception e) { + // ACL 설정 실패 시 업로드된 객체 삭제 + s3Template.deleteObject(bucketName, key); + throw new ImageUploadException("Failed to set ACL for " + key, e); + } + return key; + } + + private void setAclPublicRead(String key) { + PutObjectAclRequest aclRequest = PutObjectAclRequest.builder() + .bucket(bucketName) + .key(key) + .acl(ObjectCannedACL.PUBLIC_READ) + .build(); + s3Client.putObjectAcl(aclRequest); + } + + private void doDelete(String key) { + s3Template.deleteObject(bucketName, key); + } + + private String doGenerateUrl(String key) { + return String.format("%s/%s/%s", endpoint, bucketName, key); + } + + // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 + private void validateFiles(List files) { + if (files == null || files.isEmpty()) { + throw new IllegalArgumentException("업로드할 파일이 없습니다."); + } + if (files.size() > MAX_FILE_COUNT) { + throw new IllegalArgumentException("파일은 최대 " + MAX_FILE_COUNT + "개까지만 업로드할 수 있습니다."); + } + files.forEach(this::validateFile); + } + + // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("파일이 비어있습니다."); + } + } + + private static void validateKeyList(List keys) { + if (keys == null || keys.isEmpty()) { + throw new IllegalArgumentException("키 리스트는 null 또는 비어있을 수 없습니다."); + } + // 전체 키 리스트의 유효성을 먼저 검사 + if (keys.stream().anyMatch(key -> key == null || key.isBlank())) { + throw new IllegalArgumentException("키 리스트에 null 또는 빈 문자열이 포함될 수 없습니다."); + } + } + + private static void validateKey(String key) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("키는 null 또는 빈 문자열이 될 수 없습니다."); + } + } + + private static String genKey(MultipartFile file, String path) { + String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : ""; + return path + UUID.randomUUID() + originalFilename; + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageUploadException.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageUploadException.java new file mode 100644 index 0000000..3b05d71 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageUploadException.java @@ -0,0 +1,9 @@ +package com.swyp.catsgotogedog.common.util.imagestorage; + +public class ImageUploadException extends RuntimeException { + + public ImageUploadException(String message, Throwable cause) { + super(message, cause); + } + +} \ No newline at end of file From 3881f61ecfded9fdf544bfc44c23b6327acd080f Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Thu, 24 Jul 2025 18:18:37 +0900 Subject: [PATCH 025/191] =?UTF-8?q?exception=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/imagestorage/ImageStorageService.java | 1 + .../imagestorage => global/exception}/ImageUploadException.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename src/main/java/com/swyp/catsgotogedog/{common/util/imagestorage => global/exception}/ImageUploadException.java (74%) diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java index 8bbc0bd..8bbcaa2 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java @@ -1,5 +1,6 @@ package com.swyp.catsgotogedog.common.util.imagestorage; +import com.swyp.catsgotogedog.global.exception.ImageUploadException; import io.awspring.cloud.s3.S3Template; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageUploadException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java similarity index 74% rename from src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageUploadException.java rename to src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java index 3b05d71..18d017d 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageUploadException.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java @@ -1,4 +1,4 @@ -package com.swyp.catsgotogedog.common.util.imagestorage; +package com.swyp.catsgotogedog.global.exception; public class ImageUploadException extends RuntimeException { From 26a18173e7aeebedf5c88fea2f82ea84eeb2178e Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Thu, 24 Jul 2025 20:27:14 +0900 Subject: [PATCH 026/191] change return type String to ImageInfo record refector ImageUploadException format to CatsgotogedogException --- .../imagestorage/ImageStorageService.java | 45 +++++++------------ .../util/imagestorage/dto/ImageInfo.java | 4 ++ .../global/exception/ErrorCode.java | 41 +++++++++++++++++ .../exception/ImageUploadException.java | 9 ++-- 4 files changed, 66 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/dto/ImageInfo.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java index 8bbcaa2..2e32fdb 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java @@ -1,6 +1,9 @@ package com.swyp.catsgotogedog.common.util.imagestorage; +import com.swyp.catsgotogedog.common.util.imagestorage.dto.ImageInfo; +import com.swyp.catsgotogedog.global.exception.ErrorCode; import com.swyp.catsgotogedog.global.exception.ImageUploadException; +import io.awspring.cloud.s3.S3Resource; import io.awspring.cloud.s3.S3Template; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -23,29 +26,26 @@ public class ImageStorageService { private final S3Template s3Template; private final S3Client s3Client; - private final String endpoint; private final String bucketName; private static final int MAX_FILE_COUNT = 10; public ImageStorageService(S3Template s3Template, S3Client s3Client, - @Value("${spring.cloud.aws.s3.endpoint}") String endpoint, @Value("${spring.cloud.aws.s3.bucket}") String bucketName) { this.s3Template = s3Template; this.s3Client = s3Client; - this.endpoint = endpoint; this.bucketName = bucketName; } // 다중 이미지 업로드 -> upload 메서드(path 포함)로 오버로딩 - public List upload(List files) { + public List upload(List files) { return upload(files, ""); } // 다중 이미지 업로드 -> 각 파일을 doUpload 메서드로 처리. 각 반환 값을 리스트로 수집 후 반환 - public List upload(List files, String path) { + public List upload(List files, String path) { validateFiles(files); return files.stream() .map(file -> doUpload(file, path)) @@ -53,12 +53,12 @@ public List upload(List files, String path) { } // 단일 이미지 업로드 -> upload 메서드(path 포함)로 오버로딩 - public List upload(MultipartFile file) { + public List upload(MultipartFile file) { return upload(file, ""); } // 단일 이미지 업로드 -> doUpload 호출 후 리스트로 래핑하여 반환 - public List upload(MultipartFile file, String path) { + public List upload(MultipartFile file, String path) { validateFile(file); return Collections.singletonList(doUpload(file, path)); } @@ -75,36 +75,23 @@ public void delete(List keys) { keys.forEach(this::doDelete); } - // 키를 기반으로 URL 생성 -> 단일 이미지 - public List generateUrls(String key) { - validateKey(key); - return Collections.singletonList(doGenerateUrl(key)); - } - - // 다중 이미지 URL 생성 - public List generateUrls(List keys) { - validateKeyList(keys); - return keys.stream() - .map(this::doGenerateUrl) - .collect(Collectors.toList()); - } - - private String doUpload(MultipartFile file, String path) { + private ImageInfo doUpload(MultipartFile file, String path) { String key = genKey(file, path); try (InputStream stream = file.getInputStream()) { - s3Template.upload(bucketName, key, stream); - setAclPublicRead(key); + S3Resource resource = s3Template.upload(bucketName, key, stream); + setAclPublicRead(resource.getFilename()); + return new ImageInfo(resource.getFilename(), resource.getURL().toString()); } catch (IOException e) { throw new UncheckedIOException("Failed to upload", e); } catch (Exception e) { // ACL 설정 실패 시 업로드된 객체 삭제 s3Template.deleteObject(bucketName, key); - throw new ImageUploadException("Failed to set ACL for " + key, e); + throw new ImageUploadException(ErrorCode.IMAGE_UPLOAD_FAILED); } - return key; } + // S3 객체의 ACL을 PUBLIC_READ로 설정 private void setAclPublicRead(String key) { PutObjectAclRequest aclRequest = PutObjectAclRequest.builder() .bucket(bucketName) @@ -114,14 +101,11 @@ private void setAclPublicRead(String key) { s3Client.putObjectAcl(aclRequest); } + // S3에서 객체 삭제 private void doDelete(String key) { s3Template.deleteObject(bucketName, key); } - private String doGenerateUrl(String key) { - return String.format("%s/%s/%s", endpoint, bucketName, key); - } - // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 private void validateFiles(List files) { if (files == null || files.isEmpty()) { @@ -156,6 +140,7 @@ private static void validateKey(String key) { } } + // 파일 이름과 UUID를 조합하여 고유한 키 생성 private static String genKey(MultipartFile file, String path) { String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : ""; return path + UUID.randomUUID() + originalFilename; diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/dto/ImageInfo.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/dto/ImageInfo.java new file mode 100644 index 0000000..011ef2a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/dto/ImageInfo.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.common.util.imagestorage.dto; + +public record ImageInfo(String key, String url) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java new file mode 100644 index 0000000..17b4e38 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -0,0 +1,41 @@ +package com.swyp.catsgotogedog.global.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + // 401 BadRequest + INVALID_TOKEN(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다."), + UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED.value(), "인증되지 않은 접근입니다."), + + // 403 Forbidden + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + + // 404 Notfound + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 회원입니다."), + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 컨텐츠 게시글입니다."), + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰입니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "리소스를 찾을 수 없습니다."), + + // 405 Method not allowed + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메소드입니다."), + + // 400 Bad Request + MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST.value(), "요청 본문 형식이 올바르지 않습니다."), + ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "유효성 검사에 실패했습니다."), + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST.value(), "필수 파라미터가 누락되었습니다."), + + // 500 Internal Server Error + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), + + // Image Storage Errors + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."); + + private final int code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java index 18d017d..0543d10 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java @@ -1,9 +1,12 @@ package com.swyp.catsgotogedog.global.exception; -public class ImageUploadException extends RuntimeException { +import lombok.Getter; - public ImageUploadException(String message, Throwable cause) { - super(message, cause); +@Getter +public class ImageUploadException extends CatsgotogedogException { + + public ImageUploadException(ErrorCode errorCode) { + super(errorCode); } } \ No newline at end of file From fbce23a1e0ca9ca16327ded0c5b4285c4c22d076 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Thu, 24 Jul 2025 20:37:38 +0900 Subject: [PATCH 027/191] =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=20=EC=A4=91=20=EC=9E=98=EB=AA=BB=20=EC=82=AD=EC=A0=9C=EB=90=9C?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/swyp/catsgotogedog/global/exception/ErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index a3c6ede..435a66e 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -31,6 +31,8 @@ public enum ErrorCode { MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST.value(), "필수 파라미터가 누락되었습니다."), // 500 Internal Server Error + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."); private final int code; private final String message; From 7edc7669f56f8199604347c1fdc290e4a53d715f Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:38:54 +0900 Subject: [PATCH 028/191] =?UTF-8?q?feat:=20elascit=20search=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docker-compose.yml | 35 +++++++++++++++++++ build.gradle | 3 ++ .../content/controller/ContentController.java | 2 ++ .../content/domain/entity/.gitkeep | 0 .../domain/entity/ContentDocument.java | 18 ++++++++++ .../repository/ContentElasticRepository.java | 7 ++++ .../content/service/ContentService.java | 3 ++ .../global/config/ElasticsearchConfig.java | 20 +++++++++++ .../elasticsearch/search-mapping.json | 9 +++++ .../elasticsearch/search-setting.json | 7 ++++ 10 files changed, 104 insertions(+) create mode 100644 Docker-compose.yml delete mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/config/ElasticsearchConfig.java create mode 100644 src/main/resources/elasticsearch/search-mapping.json create mode 100644 src/main/resources/elasticsearch/search-setting.json diff --git a/Docker-compose.yml b/Docker-compose.yml new file mode 100644 index 0000000..85ca12d --- /dev/null +++ b/Docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' +services: + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.6.0 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + ports: + - "9200:9200" + - "9300:9300" + volumes: + - esdata:/usr/share/elasticsearch/data + networks: + - es_network + + kibana: + image: docker.elastic.co/kibana/kibana:8.6.0 + container_name: kibana + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + ports: + - "5601:5601" + depends_on: + - elasticsearch + networks: + - es_network + +networks: + es_network: + driver: bridge + +volumes: + esdata: + driver: local \ No newline at end of file diff --git a/build.gradle b/build.gradle index 1aa0b4d..919cb0a 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,9 @@ dependencies { // Flyway implementation group: 'org.flywaydb', name: 'flyway-mysql', version: '11.10.2' implementation 'org.flywaydb:flyway-core' + + // elastic search + implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' } tasks.named('test') { diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 74145b6..f0a5e6f 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -1,5 +1,6 @@ package com.swyp.catsgotogedog.content.controller; +import com.swyp.catsgotogedog.content.service.ContentService; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -9,5 +10,6 @@ @RequiredArgsConstructor @RequestMapping("/api/content") public class ContentController implements ContentControllerSwagger{ + private final ContentService contentService; } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java new file mode 100644 index 0000000..cd99f14 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java @@ -0,0 +1,18 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Id; +import lombok.Getter; +import org.springframework.data.elasticsearch.annotations.*; + +@Getter +@Document(indexName = "content", createIndex = true) +@Setting(settingPath = "elasticsearch/search-setting.json") +@Mapping(mappingPath = "elasticsearch/search-mapping.json") +public class ContentDocument { + + @Id + @Field(type= FieldType.Integer) + private int content_id; + + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java new file mode 100644 index 0000000..b0551a9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +public interface ContentElasticRepository extends ElasticsearchRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 6b1a86d..4690942 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -1,5 +1,6 @@ package com.swyp.catsgotogedog.content.service; +import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -9,4 +10,6 @@ @RequiredArgsConstructor @Slf4j public class ContentService { + private final ContentElasticRepository contentElasticRepository; + } diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/ElasticsearchConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/ElasticsearchConfig.java new file mode 100644 index 0000000..9d7afc5 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/config/ElasticsearchConfig.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.elasticsearch.client.ClientConfiguration; +import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration; + +@Configuration +public class ElasticsearchConfig extends ElasticsearchConfiguration { + + @Value("${spring.elasticsearch.url}") + private String host; + + @Override + public ClientConfiguration clientConfiguration() { + return ClientConfiguration.builder() + .connectedTo(host) + .build(); + } +} \ No newline at end of file diff --git a/src/main/resources/elasticsearch/search-mapping.json b/src/main/resources/elasticsearch/search-mapping.json new file mode 100644 index 0000000..b400a4f --- /dev/null +++ b/src/main/resources/elasticsearch/search-mapping.json @@ -0,0 +1,9 @@ +{ + "analysis": { + "analyzer": { + "korean": { + "type": "nori" + } + } + } +} \ No newline at end of file diff --git a/src/main/resources/elasticsearch/search-setting.json b/src/main/resources/elasticsearch/search-setting.json new file mode 100644 index 0000000..edc30b2 --- /dev/null +++ b/src/main/resources/elasticsearch/search-setting.json @@ -0,0 +1,7 @@ +{ + "properties": { + "content_id": { + "type": "int" + } + } +} \ No newline at end of file From 2972870bbebbd3560ec1d970d0c634392ac375d5 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Thu, 24 Jul 2025 20:41:42 +0900 Subject: [PATCH 029/191] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/swyp/catsgotogedog/global/exception/ErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 435a66e..2ce530b 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -32,6 +32,8 @@ public enum ErrorCode { // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), + + // Image Storage Error IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."); private final int code; From 01eb10f0b0e40f6f1248bc2f32dbe48ba4ee4f24 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Thu, 24 Jul 2025 20:43:41 +0900 Subject: [PATCH 030/191] =?UTF-8?q?develop=20=EB=B8=8C=EB=9E=9C=EC=B9=98?= =?UTF-8?q?=EC=9D=98=20=ED=8F=AC=EB=A7=B7=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/ErrorCode.java | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 2ce530b..58db9c1 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -8,34 +8,34 @@ @Getter @RequiredArgsConstructor public enum ErrorCode { - // 401 BadRequest - INVALID_TOKEN(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."), - EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다."), - UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED.value(), "인증되지 않은 접근입니다."), + // 401 BadRequest + INVALID_TOKEN(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다."), + UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED.value(), "인증되지 않은 접근입니다."), - // 403 Forbidden - FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + // 403 Forbidden + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), - // 404 Notfound - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 회원입니다."), - CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 컨텐츠 게시글입니다."), - REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰입니다."), - RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "리소스를 찾을 수 없습니다."), + // 404 Notfound + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 회원입니다."), + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 컨텐츠 게시글입니다."), + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰입니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "리소스를 찾을 수 없습니다."), - // 405 Method not allowed - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메소드입니다."), + // 405 Method not allowed + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메소드입니다."), - // 400 Bad Request - MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST.value(), "요청 본문 형식이 올바르지 않습니다."), - ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "유효성 검사에 실패했습니다."), - MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST.value(), "필수 파라미터가 누락되었습니다."), + // 400 Bad Request + MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST.value(), "요청 본문 형식이 올바르지 않습니다."), + ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "유효성 검사에 실패했습니다."), + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST.value(), "필수 파라미터가 누락되었습니다."), - // 500 Internal Server Error - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), + // 500 Internal Server Error + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), - // Image Storage Error - IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."); + // Image Storage Error + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."); - private final int code; - private final String message; + private final int code; + private final String message; } \ No newline at end of file From 201c7e892c5f6bdb9a82198b154449dbc471dbcb Mon Sep 17 00:00:00 2001 From: yhs99 Date: Fri, 25 Jul 2025 00:35:26 +0900 Subject: [PATCH 031/191] =?UTF-8?q?critical)=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=8B=9C=20display=5Fname=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9B=84=20redirect=20url=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug) 유저 저장시 display_name 중복 이슈 수정 refactor) 로그인 후 redirect url 수정 refactor) playload에 displayName 값 추가 --- .../User/repository/UserRepository.java | 1 + .../User/service/UserService.java | 3 +- .../common/config/SecurityConfig.java | 4 +- .../handler/OAuth2LoginSuccessHandler.java | 8 ++-- .../security/service/PrincipalDetails.java | 4 ++ .../service/PrincipalOauth2UserService.java | 39 ++++++++++++++----- .../common/util/JwtTokenUtil.java | 11 +++++- 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java index 633a190..c5dbde8 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java @@ -8,4 +8,5 @@ public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderId(String provider, String providerId); Optional findByProviderId(String providerId); + Optional findByDisplayName(String displayName); } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java index 17d73b4..1b36cf9 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -29,11 +29,12 @@ public String reIssue(String refreshToken) { int userId = Integer.parseInt(jwt.getSubject(refreshToken)); String email = jwt.getEmail(refreshToken); + String displayName = jwt.getDisplayName(refreshToken); User user = userRepository.findById(userId) .orElseThrow(() -> new UnAuthorizedAccessException(ErrorCode.UNAUTHORIZED_ACCESS)); - return jwt.createAccessToken(String.valueOf(userId), email); + return jwt.createAccessToken(String.valueOf(userId), email, displayName); } public void logout(String refreshToken) { diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index 8cdafac..cf6b2ca 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -44,8 +44,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/login**", "/error", "/swagger-ui/**", - "/v3/api-docs/**", - "/api/**" + "/v3/api-docs/**" + // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 ).permitAll() .anyRequest().authenticated()) .addFilterBefore(new OAuth2AutoLoginFilter(), OAuth2AuthorizationRequestRedirectFilter.class) diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index f92cdb2..eca3585 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -10,6 +10,8 @@ import com.swyp.catsgotogedog.User.service.RefreshTokenService; import com.swyp.catsgotogedog.common.security.service.PrincipalDetails; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; @@ -50,10 +52,10 @@ public void onAuthenticationSuccess( String providerId = pd.getProviderId(); User user = userRepo.findByProviderId(providerId) - .orElseThrow(() -> new IllegalStateException("회원이 없습니다")); + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); //String access = jwt.createAccessToken(String.valueOf(user.getUserId()), user.getEmail()); - String refresh = jwt.createRefreshToken(String.valueOf(user.getUserId()), user.getEmail()); + String refresh = jwt.createRefreshToken(String.valueOf(user.getUserId()), user.getEmail(), user.getDisplayName()); rtService.save(user, refresh, jwt.getRefreshTokenExpiry()); @@ -63,7 +65,7 @@ public void onAuthenticationSuccess( // .queryParam("accessToken", access) // .build() // .toUriString(); - getRedirectStrategy().sendRedirect(request, response, frontend_base_url); + getRedirectStrategy().sendRedirect(request, response, frontend_base_url+"/authrediect"); } private Boolean isAutoLogin(HttpServletRequest request) { diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java index 506963c..f34cb10 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java @@ -62,4 +62,8 @@ public String getProviderId() { return user.getProviderId(); } + public String getDisplayName() { + return user.getDisplayName(); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java index b58896a..de03ad3 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -14,7 +14,9 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.security.SecureRandom; import java.time.LocalDateTime; @Service @@ -22,9 +24,12 @@ @Slf4j public class PrincipalOauth2UserService extends DefaultOAuth2UserService { + private static final int RANDDOM_NUMBER_LENGTH = 6; + private final UserRepository userRepository; @Override + @Transactional public OAuth2User loadUser(OAuth2UserRequest req) { OAuth2User oAuth2User = super.loadUser(req); String provider = req.getClientRegistration().getRegistrationId(); // google / kakao / naver @@ -48,17 +53,33 @@ public OAuth2User loadUser(OAuth2UserRequest req) { } User user = userRepository.findByProviderAndProviderId(provider, info.id()) - .orElseGet(() -> userRepository.save( + .orElseGet(() -> { + String display_name = info.name(); + while(userRepository.findByDisplayName(display_name).isPresent()) { + display_name = info.name() + generateRandomString(); + } + return userRepository.save( User.builder() - .provider(provider) - .providerId(info.id()) - .email(info.email()) - .displayName(info.name()) - .imageUrl(info.profileImage()) - .isActive(Boolean.TRUE) - .build() - )); + .provider(provider) + .providerId(info.id()) + .email(info.email()) + .displayName(display_name) + .imageUrl(info.profileImage()) + .isActive(Boolean.TRUE) + .build() + ); + }); return new PrincipalDetails(user, oAuth2User.getAttributes()); } + + // 랜덤 숫자(0~9) RANDDOM_NUMBER_LENGTH 값의 자리 만큼 반환 메서드 + private String generateRandomString() { + SecureRandom random = new SecureRandom(); + StringBuilder sb = new StringBuilder(RANDDOM_NUMBER_LENGTH); + for (int i = 0; i < RANDDOM_NUMBER_LENGTH; i++) { + sb.append(random.nextInt(10)); + } + return sb.toString(); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java index d4569ef..718234d 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java @@ -34,23 +34,25 @@ private void init() { } - public String createAccessToken(String sub, String email) { + public String createAccessToken(String sub, String email, String displayName) { Date now = new Date(); return Jwts.builder() .setSubject(sub) .claim("email", email) + .claim("displayName", displayName) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + accessMin * 60_000)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } - public String createRefreshToken(String sub, String email) { + public String createRefreshToken(String sub, String email, String displayName) { Date now = new Date(); long refreshMs = Duration.ofDays(refreshDay).toMillis(); return Jwts.builder() .setSubject(sub) .claim("email", email) + .claim("displayName", displayName) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + refreshMs)) .signWith(key, SignatureAlgorithm.HS256) @@ -67,6 +69,11 @@ public String getEmail(String token) { .parseClaimsJws(token).getBody().get("email", String.class); } + public String getDisplayName(String token) { + return Jwts.parserBuilder().setSigningKey(key).build() + .parseClaimsJws(token).getBody().get("displayName", String.class); + } + public LocalDateTime getRefreshTokenExpiry() { return LocalDateTime.now().plusDays(refreshDay); } From cf9f97bf6c08e1d18545417b46254565dd28d806 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Fri, 25 Jul 2025 20:37:50 +0900 Subject: [PATCH 032/191] =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EB=B0=9C=EC=83=9D=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../util/imagestorage/ImageStorageService.java | 17 ++++++++--------- .../global/exception/ErrorCode.java | 3 +++ .../exception/ImageKeyNotFoundException.java | 9 +++++++++ .../exception/ImageNotFoundException.java | 8 ++++++++ .../exception/TooManyImagesException.java | 9 +++++++++ 5 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ImageKeyNotFoundException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ImageNotFoundException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/TooManyImagesException.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java index 2e32fdb..4e036de 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java @@ -1,8 +1,7 @@ package com.swyp.catsgotogedog.common.util.imagestorage; import com.swyp.catsgotogedog.common.util.imagestorage.dto.ImageInfo; -import com.swyp.catsgotogedog.global.exception.ErrorCode; -import com.swyp.catsgotogedog.global.exception.ImageUploadException; +import com.swyp.catsgotogedog.global.exception.*; import io.awspring.cloud.s3.S3Resource; import io.awspring.cloud.s3.S3Template; import org.springframework.beans.factory.annotation.Value; @@ -83,7 +82,7 @@ private ImageInfo doUpload(MultipartFile file, String path) { setAclPublicRead(resource.getFilename()); return new ImageInfo(resource.getFilename(), resource.getURL().toString()); } catch (IOException e) { - throw new UncheckedIOException("Failed to upload", e); + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); } catch (Exception e) { // ACL 설정 실패 시 업로드된 객체 삭제 s3Template.deleteObject(bucketName, key); @@ -109,10 +108,10 @@ private void doDelete(String key) { // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 private void validateFiles(List files) { if (files == null || files.isEmpty()) { - throw new IllegalArgumentException("업로드할 파일이 없습니다."); + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); } if (files.size() > MAX_FILE_COUNT) { - throw new IllegalArgumentException("파일은 최대 " + MAX_FILE_COUNT + "개까지만 업로드할 수 있습니다."); + throw new TooManyImagesException(ErrorCode.TOO_MANY_IMAGES); } files.forEach(this::validateFile); } @@ -120,23 +119,23 @@ private void validateFiles(List files) { // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 private void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) { - throw new IllegalArgumentException("파일이 비어있습니다."); + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); } } private static void validateKeyList(List keys) { if (keys == null || keys.isEmpty()) { - throw new IllegalArgumentException("키 리스트는 null 또는 비어있을 수 없습니다."); + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); } // 전체 키 리스트의 유효성을 먼저 검사 if (keys.stream().anyMatch(key -> key == null || key.isBlank())) { - throw new IllegalArgumentException("키 리스트에 null 또는 빈 문자열이 포함될 수 없습니다."); + throw new ImageKeyNotFoundException(ErrorCode.IMAGE_KEY_NOT_FOUND); } } private static void validateKey(String key) { if (key == null || key.isBlank()) { - throw new IllegalArgumentException("키는 null 또는 빈 문자열이 될 수 없습니다."); + throw new ImageKeyNotFoundException(ErrorCode.IMAGE_KEY_NOT_FOUND); } } diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 58db9c1..2d3773e 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -34,6 +34,9 @@ public enum ErrorCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), // Image Storage Error + IMAGE_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 파일이 누락 되었습니다."), + IMAGE_KEY_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 키가 누락 되었습니다."), + TOO_MANY_IMAGES(HttpStatus.BAD_REQUEST.value(), "이미지 파일은 최대 10개까지 업로드 가능합니다."), IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."); private final int code; diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageKeyNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageKeyNotFoundException.java new file mode 100644 index 0000000..77aa6fe --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageKeyNotFoundException.java @@ -0,0 +1,9 @@ +package com.swyp.catsgotogedog.global.exception; + +public class ImageKeyNotFoundException extends CatsgotogedogException { + + public ImageKeyNotFoundException(ErrorCode errorCode) { + super(errorCode); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageNotFoundException.java new file mode 100644 index 0000000..a9fc3bc --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageNotFoundException.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.global.exception; + +public class ImageNotFoundException extends CatsgotogedogException { + + public ImageNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/TooManyImagesException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/TooManyImagesException.java new file mode 100644 index 0000000..9dfd7ff --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/TooManyImagesException.java @@ -0,0 +1,9 @@ +package com.swyp.catsgotogedog.global.exception; + +public class TooManyImagesException extends CatsgotogedogException { + + public TooManyImagesException(ErrorCode errorCode) { + super(errorCode); + } + +} From 5f5d79f4310be546c5b9fa078be786cfe4ac5578 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Fri, 25 Jul 2025 20:38:37 +0900 Subject: [PATCH 033/191] =?UTF-8?q?static=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/imagestorage/ImageStorageService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java index 4e036de..f8e62ed 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java @@ -27,7 +27,7 @@ public class ImageStorageService { private final S3Client s3Client; private final String bucketName; - private static final int MAX_FILE_COUNT = 10; + private final int MAX_FILE_COUNT = 10; public ImageStorageService(S3Template s3Template, S3Client s3Client, @@ -123,7 +123,7 @@ private void validateFile(MultipartFile file) { } } - private static void validateKeyList(List keys) { + private void validateKeyList(List keys) { if (keys == null || keys.isEmpty()) { throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); } @@ -133,14 +133,14 @@ private static void validateKeyList(List keys) { } } - private static void validateKey(String key) { + private void validateKey(String key) { if (key == null || key.isBlank()) { throw new ImageKeyNotFoundException(ErrorCode.IMAGE_KEY_NOT_FOUND); } } // 파일 이름과 UUID를 조합하여 고유한 키 생성 - private static String genKey(MultipartFile file, String path) { + private String genKey(MultipartFile file, String path) { String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : ""; return path + UUID.randomUUID() + originalFilename; } From 417ec91bceb6d040ec11c146fa9c0b519a158dc9 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Fri, 25 Jul 2025 20:50:36 +0900 Subject: [PATCH 034/191] =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../imagestorage/ImageStorageService.java | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java index f8e62ed..1a13349 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java @@ -38,12 +38,21 @@ public ImageStorageService(S3Template s3Template, this.bucketName = bucketName; } - // 다중 이미지 업로드 -> upload 메서드(path 포함)로 오버로딩 + /** + * 다중 이미지 업로드 + * @param files MultipartFile list + * @return List<ImageInfo> + */ public List upload(List files) { return upload(files, ""); } - // 다중 이미지 업로드 -> 각 파일을 doUpload 메서드로 처리. 각 반환 값을 리스트로 수집 후 반환 + /** + * 다중 이미지 업로드 + * @param files MultipartFile list + * @param path 업로드 경로 + * @return List<ImageInfo> + */ public List upload(List files, String path) { validateFiles(files); return files.stream() @@ -51,24 +60,39 @@ public List upload(List files, String path) { .collect(Collectors.toList()); } - // 단일 이미지 업로드 -> upload 메서드(path 포함)로 오버로딩 + /** + * 단일 이미지 업로드 + * @param file MultipartFile + * @return List<ImageInfo> + */ public List upload(MultipartFile file) { return upload(file, ""); } - // 단일 이미지 업로드 -> doUpload 호출 후 리스트로 래핑하여 반환 + /** + * 단일 이미지 업로드 + * @param file MultipartFile + * @param path 업로드 경로 + * @return List<ImageInfo> + */ public List upload(MultipartFile file, String path) { validateFile(file); return Collections.singletonList(doUpload(file, path)); } - // 단일 이미지 삭제 -> 리스트화 하여 처리 + /** + * 이미지 삭제 + * @param key image key + */ public void delete(String key) { validateKey(key); doDelete(key); } - // 다중 이미지 삭제 각 이미지 키를 검증 후 삭제 + /** + * 다중 이미지 삭제 + * @param keys list of image keys + */ public void delete(List keys) { validateKeyList(keys); keys.forEach(this::doDelete); From 5eb950f7a70895e9dceef616d2555befa4f1b59e Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Fri, 25 Jul 2025 20:51:45 +0900 Subject: [PATCH 035/191] =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/imagestorage/ImageStorageService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java index 1a13349..7115980 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java @@ -13,7 +13,6 @@ import java.io.IOException; import java.io.InputStream; -import java.io.UncheckedIOException; import java.util.Collections; import java.util.List; import java.util.UUID; From e5b2e7f624e06d4cab8c6560c25a11cfc9bf6dea Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Fri, 25 Jul 2025 22:44:25 +0900 Subject: [PATCH 036/191] =?UTF-8?q?Metadata=20=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../imagestorage/ImageStorageService.java | 31 +++++-------------- .../imagestorage/ObjectMetadataProvider.java | 16 ++++++++++ 3 files changed, 24 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ObjectMetadataProvider.java diff --git a/build.gradle b/build.gradle index 9d3d969..6acbf75 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ dependencies { // Spring Cloud AWS implementation "io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.1" // S3 - implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' +// implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' //jwt implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java index 7115980..9a6d03d 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java @@ -2,16 +2,13 @@ import com.swyp.catsgotogedog.common.util.imagestorage.dto.ImageInfo; import com.swyp.catsgotogedog.global.exception.*; +import io.awspring.cloud.s3.ObjectMetadata; import io.awspring.cloud.s3.S3Resource; import io.awspring.cloud.s3.S3Template; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.ObjectCannedACL; -import software.amazon.awssdk.services.s3.model.PutObjectAclRequest; -import java.io.IOException; import java.io.InputStream; import java.util.Collections; import java.util.List; @@ -23,17 +20,17 @@ public class ImageStorageService { private final S3Template s3Template; - private final S3Client s3Client; + private final ObjectMetadataProvider objectMetadataProvider; private final String bucketName; private final int MAX_FILE_COUNT = 10; public ImageStorageService(S3Template s3Template, - S3Client s3Client, + ObjectMetadataProvider objectMetadataProvider, @Value("${spring.cloud.aws.s3.bucket}") String bucketName) { this.s3Template = s3Template; - this.s3Client = s3Client; + this.objectMetadataProvider = objectMetadataProvider; this.bucketName = bucketName; } @@ -99,30 +96,16 @@ public void delete(List keys) { private ImageInfo doUpload(MultipartFile file, String path) { String key = genKey(file, path); + ObjectMetadata metadata = objectMetadataProvider.createPublicReadMetadata(file); try (InputStream stream = file.getInputStream()) { - S3Resource resource = s3Template.upload(bucketName, key, stream); - setAclPublicRead(resource.getFilename()); + S3Resource resource = s3Template.upload(bucketName, key, stream, metadata); return new ImageInfo(resource.getFilename(), resource.getURL().toString()); - } catch (IOException e) { - throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); - } catch (Exception e) { - // ACL 설정 실패 시 업로드된 객체 삭제 - s3Template.deleteObject(bucketName, key); + } catch (Exception e) { // IOException(InputStream), S3Exception 등 throw new ImageUploadException(ErrorCode.IMAGE_UPLOAD_FAILED); } } - // S3 객체의 ACL을 PUBLIC_READ로 설정 - private void setAclPublicRead(String key) { - PutObjectAclRequest aclRequest = PutObjectAclRequest.builder() - .bucket(bucketName) - .key(key) - .acl(ObjectCannedACL.PUBLIC_READ) - .build(); - s3Client.putObjectAcl(aclRequest); - } - // S3에서 객체 삭제 private void doDelete(String key) { s3Template.deleteObject(bucketName, key); diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ObjectMetadataProvider.java b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ObjectMetadataProvider.java new file mode 100644 index 0000000..164fff6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ObjectMetadataProvider.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.common.util.imagestorage; + +import io.awspring.cloud.s3.ObjectMetadata; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.services.s3.model.ObjectCannedACL; + +@Component +public class ObjectMetadataProvider { + + public ObjectMetadata createPublicReadMetadata(MultipartFile file) { + return new ObjectMetadata.Builder() + .acl(ObjectCannedACL.PUBLIC_READ) + .build(); + } +} \ No newline at end of file From 31b6065d16ea4fdd0f58ddd2651c89b8e6238fa3 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Fri, 25 Jul 2025 23:05:33 +0900 Subject: [PATCH 037/191] =?UTF-8?q?package=20org.joda.time=20does=20not=20?= =?UTF-8?q?exist=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6acbf75..9d3d969 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ dependencies { // Spring Cloud AWS implementation "io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.1" // S3 -// implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' + implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' //jwt implementation 'io.jsonwebtoken:jjwt-api:0.11.5' From 24679e33c5a76ad754289c84958b55d82c457cbb Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 04:56:15 +0900 Subject: [PATCH 038/191] =?UTF-8?q?Authorization=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=A7=81=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정상적인 401 UnAuthroizedAccessException을 반환하도록 수정 --- .../User/controller/UserController.java | 17 +++++++-- .../controller/UserControllerSwagger.java | 9 +++-- ...CatsgotogedogAuthenticationEntryPoint.java | 37 +++++++++++++++++++ .../common/config/SecurityConfig.java | 9 ++++- .../global/config/SwaggerConfig.java | 4 +- .../exception/GlobalExceptionHandler.java | 13 ++++++- 6 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/CatsgotogedogAuthenticationEntryPoint.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index b4028ff..0b62d5f 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -8,7 +8,10 @@ import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; @@ -18,6 +21,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/user") +@Slf4j public class UserController implements UserControllerSwagger{ private final JwtTokenUtil jwt; @@ -28,7 +32,6 @@ public class UserController implements UserControllerSwagger{ @PostMapping("/reissue") public ResponseEntity> reIssue( @CookieValue(value = "X-Refresh-Token", required = false) String refresh) { - return ResponseEntity.ok(CatsgotogedogApiResponse.success("재발급 성공", new AccessTokenResponse(userService.reIssue(refresh)))); } @@ -38,7 +41,15 @@ public ResponseEntity> logout( @CookieValue("X-Refresh-Token") String refresh) { userService.logout(refresh); - - return ResponseEntity.ok(CatsgotogedogApiResponse.success("로그아웃 성공", null)); + ResponseCookie cookie = ResponseCookie.from(("X-Refresh-Token"), refresh) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(-1) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(CatsgotogedogApiResponse.success("로그아웃 성공", null)); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java index 7b9bb33..667847f 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; @@ -19,6 +20,7 @@ public interface UserControllerSwagger { summary = "액세스 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다.\n" + "재발급된 토큰은 body를 통해 반환됩니다." + + " Cookie를 통해 Refresh-Token값을 읽어 재발급을 진행합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "토큰 재발급 성공" @@ -27,14 +29,15 @@ public interface UserControllerSwagger { , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) }) ResponseEntity reIssue( - @Parameter(description = "리프레시 토큰", required = false) + @Parameter(description = "리프레시 토큰", hidden = true) String refresh ); @Operation( summary = "로그아웃", - description = "사용자 로그아웃을 처리하고 리프레시 토큰을 제거합니다." + description = "사용자 로그아웃을 처리하고 리프레시 토큰을 제거합니다. Cookie를 통해 Refresh-Token값을 읽어 로그아웃 처리를 진행합니다." ) + @SecurityRequirement(name = "bearer-key") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그아웃 성공" , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), @@ -42,7 +45,7 @@ ResponseEntity reIssue( , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) }) ResponseEntity> logout( - @Parameter(description = "리프레시 토큰", required = true) + @Parameter(description = "리프레시 토큰", hidden = true) String refresh ); } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/CatsgotogedogAuthenticationEntryPoint.java b/src/main/java/com/swyp/catsgotogedog/common/config/CatsgotogedogAuthenticationEntryPoint.java new file mode 100644 index 0000000..e3409e9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/CatsgotogedogAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package com.swyp.catsgotogedog.common.config; + +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 com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CatsgotogedogAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + CatsgotogedogApiResponse apiResponse = CatsgotogedogApiResponse.fail(ErrorCode.UNAUTHORIZED_ACCESS); + + log.info(authException.getMessage(), ErrorCode.UNAUTHORIZED_ACCESS.getMessage()); + objectMapper.writeValue(response.getWriter(), apiResponse); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index cf6b2ca..6cc0ff4 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Configuration; 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.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.web.SecurityFilterChain; @@ -25,6 +26,7 @@ public class SecurityConfig { private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final JwtTokenFilter jwtTokenFilter; + private final CatsgotogedogAuthenticationEntryPoint catsgotogedogAuthenticationEntryPoint; @Value("${allowed.origins.url}") private String allowedOriginsUrl; @@ -44,13 +46,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/login**", "/error", "/swagger-ui/**", - "/v3/api-docs/**" + "/v3/api-docs/**", + "/api/user/reissue" // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 ).permitAll() .anyRequest().authenticated()) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .exceptionHandling(eh -> eh.authenticationEntryPoint(catsgotogedogAuthenticationEntryPoint)) .addFilterBefore(new OAuth2AutoLoginFilter(), OAuth2AuthorizationRequestRedirectFilter.class) .oauth2Login(oauth -> oauth - .loginPage("/login") // 커스텀 로그인 화면 (없으면 기본 템플릿) .successHandler(oAuth2LoginSuccessHandler)) .addFilterBefore(jwtTokenFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java index 4b8b156..667251f 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java @@ -4,6 +4,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; @@ -16,7 +18,7 @@ public OpenAPI openAPI() { return new OpenAPI() .components(new Components() .addSecuritySchemes("bearer-key", securityScheme())) - .info(new Info()); + .info(info()); } private Info info() { diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java index ee33092..7ea1770 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -24,13 +24,13 @@ public class GlobalExceptionHandler { @ExceptionHandler(CatsgotogedogException.class) protected ResponseEntity> handleCatsgotogedogException(CatsgotogedogException ex) { - log.error("CatsgotogedogException : {}", ex.getMessage()); + log.error("CatsgotogedogException : {}", ex.getMessage(), ex); return createErrorResponse(ex.getErrorCode()); } @ExceptionHandler(Exception.class) protected ResponseEntity> handleException(Exception ex) { - log.error("Exception : {}", ex.getMessage()); + log.error("Exception : {}", ex.getMessage(), ex); int errorCode = ErrorCode.INTERNAL_SERVER_ERROR.getCode(); CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR); return ResponseEntity @@ -98,5 +98,14 @@ protected ResponseEntity> handleHttpRequestMeth .body(response); } + @ExceptionHandler(UnAuthorizedAccessException.class) + protected ResponseEntity> handleUnAuthorizedAccessException(UnAuthorizedAccessException e) { + log.error("UnAuthorizedAccessException: {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.UNAUTHORIZED_ACCESS); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(response); + } + } From cb0441514012a27627fa16c46c1f79ac26fc069a Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 05:15:15 +0900 Subject: [PATCH 039/191] =?UTF-8?q?logback=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=EC=8B=9C=20refresh=20token=20=EB=A7=8C=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logback 권한 추가 로그아웃시 refresh token 만료 되도록 수정 --- .github/workflows/cd.yml | 2 ++ .../com/swyp/catsgotogedog/User/controller/UserController.java | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index b0efafb..741b81f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -71,6 +71,7 @@ jobs: key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} script: | cd ${{ secrets.NCP_SERVER_PATH }} + chmod -R 755 log # 실행 중인 애플리케이션 중지 및 중지 실패 시 action 중단 방지 if [ -f pid.file ]; then kill $(cat pid.file) || true @@ -112,6 +113,7 @@ jobs: key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} script: | cd ${{ secrets.NCP_SERVER_PATH }} + chmod -R 755 log # 실행 중인 애플리케이션 중지 및 중지 실패 시 action 중단 방지 if [ -f pid.file ]; then kill $(cat pid.file) || true diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index 0b62d5f..c78731a 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -45,7 +45,8 @@ public ResponseEntity> logout( .httpOnly(true) .secure(true) .path("/") - .maxAge(-1) + .maxAge(0) + .sameSite("None") .build(); return ResponseEntity.ok() From 83154f009f0a23f3a3280f005f066fa2cb1e2a93 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 05:15:15 +0900 Subject: [PATCH 040/191] =?UTF-8?q?logback=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=EC=8B=9C=20refresh=20token=20=EB=A7=8C=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logback 권한 추가 로그아웃시 refresh token 만료 되도록 수정 --- .github/workflows/cd.yml | 2 ++ .../com/swyp/catsgotogedog/User/controller/UserController.java | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index b0efafb..741b81f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -71,6 +71,7 @@ jobs: key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} script: | cd ${{ secrets.NCP_SERVER_PATH }} + chmod -R 755 log # 실행 중인 애플리케이션 중지 및 중지 실패 시 action 중단 방지 if [ -f pid.file ]; then kill $(cat pid.file) || true @@ -112,6 +113,7 @@ jobs: key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} script: | cd ${{ secrets.NCP_SERVER_PATH }} + chmod -R 755 log # 실행 중인 애플리케이션 중지 및 중지 실패 시 action 중단 방지 if [ -f pid.file ]; then kill $(cat pid.file) || true diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index 0b62d5f..c78731a 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -45,7 +45,8 @@ public ResponseEntity> logout( .httpOnly(true) .secure(true) .path("/") - .maxAge(-1) + .maxAge(0) + .sameSite("None") .build(); return ResponseEntity.ok() From 9442a3967ddc02d426a6340bc3af6d20b4107743 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 05:54:38 +0900 Subject: [PATCH 041/191] =?UTF-8?q?CORS=20=EC=A0=95=EC=B1=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/WebConfig.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java new file mode 100644 index 0000000..1b2476d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${allowed.origins.url}") + private String allowedOriginsUrl; + + @Value("${allowed.http.methods}") + private String allowedHttpMethods; + + @Override + public void addCorsMappings(CorsRegistry registry) { + String[] origins = allowedOriginsUrl.split(","); + String[] methods = allowedHttpMethods.split(","); + registry.addMapping("/**") + .allowedOrigins(origins) + .allowedMethods(methods) + .allowedMethods("*") + .allowCredentials(true) + .maxAge(3600); + } +} From b3e1e37795190347381d8f308199b6b8520e5e18 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 05:54:38 +0900 Subject: [PATCH 042/191] =?UTF-8?q?CORS=20=EC=A0=95=EC=B1=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/WebConfig.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java new file mode 100644 index 0000000..1b2476d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${allowed.origins.url}") + private String allowedOriginsUrl; + + @Value("${allowed.http.methods}") + private String allowedHttpMethods; + + @Override + public void addCorsMappings(CorsRegistry registry) { + String[] origins = allowedOriginsUrl.split(","); + String[] methods = allowedHttpMethods.split(","); + registry.addMapping("/**") + .allowedOrigins(origins) + .allowedMethods(methods) + .allowedMethods("*") + .allowCredentials(true) + .maxAge(3600); + } +} From b031b038da360942faee6ec84f6bb3ed521fb3d6 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 06:25:34 +0900 Subject: [PATCH 043/191] =?UTF-8?q?CORS=20=EC=A0=95=EC=B1=85=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORS 정책이 적용되지 않던 현상 수정.. --- .../common/config/SecurityConfig.java | 12 ++------ .../common/config/WebConfig.java | 28 ------------------- 2 files changed, 3 insertions(+), 37 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index 6cc0ff4..5219200 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -28,16 +28,10 @@ public class SecurityConfig { private final JwtTokenFilter jwtTokenFilter; private final CatsgotogedogAuthenticationEntryPoint catsgotogedogAuthenticationEntryPoint; - @Value("${allowed.origins.url}") - private String allowedOriginsUrl; - - @Value("${allowed.http.methods}") - private String allowedHttpMethods; - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.csrf(csrf -> csrf.disable()) + http.csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth @@ -65,8 +59,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti private CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(allowedOriginsUrl)); - configuration.setAllowedMethods(List.of(allowedHttpMethods)); + configuration.setAllowedOrigins(List.of("https://localhost:5173", "https://frontend-dev-bukp.onrender.com")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java deleted file mode 100644 index 1b2476d..0000000 --- a/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.swyp.catsgotogedog.common.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - - @Value("${allowed.origins.url}") - private String allowedOriginsUrl; - - @Value("${allowed.http.methods}") - private String allowedHttpMethods; - - @Override - public void addCorsMappings(CorsRegistry registry) { - String[] origins = allowedOriginsUrl.split(","); - String[] methods = allowedHttpMethods.split(","); - registry.addMapping("/**") - .allowedOrigins(origins) - .allowedMethods(methods) - .allowedMethods("*") - .allowCredentials(true) - .maxAge(3600); - } -} From abfffd1197bdbc95dc48b734340420641d6e0240 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 06:38:25 +0900 Subject: [PATCH 044/191] =?UTF-8?q?AllowOrigin,=20methods=EB=A5=BC=20List?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=ED=99=98=ED=95=98=EC=97=AC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AllowOrigin, methods를 List로 변환하여 적용 --- .../catsgotogedog/common/config/SecurityConfig.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index 5219200..e40b9c0 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -28,6 +28,12 @@ public class SecurityConfig { private final JwtTokenFilter jwtTokenFilter; private final CatsgotogedogAuthenticationEntryPoint catsgotogedogAuthenticationEntryPoint; + @Value("${allowed.origins.url}") + private String allowedOriginsUrl; + + @Value("${allowed.http.methods}") + private String allowedHttpMethods; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -58,9 +64,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti private CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - - configuration.setAllowedOrigins(List.of("https://localhost:5173", "https://frontend-dev-bukp.onrender.com")); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH")); + List origins = List.of(allowedOriginsUrl.split(",")); + List methods = List.of(allowedHttpMethods.split(",")); + configuration.setAllowedOrigins(origins); + configuration.setAllowedMethods(methods); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); From cea6c794823709225037d2275085f9508a2c5308 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 07:21:10 +0900 Subject: [PATCH 045/191] =?UTF-8?q?bug)=20deploy=20actions=EC=9D=B4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20job=EC=9C=BC=EB=A1=9C=20=EC=88=98=ED=96=89?= =?UTF-8?q?=EB=90=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=EB=81=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 741b81f..1336f77 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -81,7 +81,7 @@ jobs: # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 TIMESTAMP=$(date +%Y%m%d_%H%M%S) - nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar & echo $! > pid.file + nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar > /dev/null 2>&1 & echo $! > pid.file echo "Development server deploy done." # product @@ -122,5 +122,5 @@ jobs: fi # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 - nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar & echo $! > pid.file + nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar > /prod/null 2>&1 & echo $! > pid.file echo "Production server deploy done." From 3fc199b53f05c1acf4fab0084c6c5d736e35868d Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 15:12:23 +0900 Subject: [PATCH 046/191] =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=9A=94=EC=B2=AD=EC=82=AC=ED=95=AD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요청 URL에 맞춰 callback 할 수 있도록 수정 --- .../common/config/WebConfig.java | 28 ----------- .../handler/OAuth2LoginSuccessHandler.java | 48 ++++++++++++++++--- 2 files changed, 41 insertions(+), 35 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java deleted file mode 100644 index 1b2476d..0000000 --- a/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.swyp.catsgotogedog.common.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - - @Value("${allowed.origins.url}") - private String allowedOriginsUrl; - - @Value("${allowed.http.methods}") - private String allowedHttpMethods; - - @Override - public void addCorsMappings(CorsRegistry registry) { - String[] origins = allowedOriginsUrl.split(","); - String[] methods = allowedHttpMethods.split(","); - registry.addMapping("/**") - .allowedOrigins(origins) - .allowedMethods(methods) - .allowedMethods("*") - .allowCredentials(true) - .maxAge(3600); - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index eca3585..8bf4345 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -3,7 +3,8 @@ import static com.swyp.catsgotogedog.common.security.filter.OAuth2AutoLoginFilter.*; import java.io.IOException; -import java.time.Duration; +import java.net.MalformedURLException; +import java.net.URL; import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; @@ -13,11 +14,11 @@ import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; import com.swyp.catsgotogedog.global.exception.ErrorCode; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; @@ -28,6 +29,7 @@ @Component @RequiredArgsConstructor +@Slf4j public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @@ -61,11 +63,43 @@ public void onAuthenticationSuccess( addRefreshTokenCookie(response, refresh, isAutoLogin(request)); - // String targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) - // .queryParam("accessToken", access) - // .build() - // .toUriString(); - getRedirectStrategy().sendRedirect(request, response, frontend_base_url+"/authrediect"); + String targetUrl; + String requestURLString = request.getRequestURL().toString(); + + try { + URL requestURL = new URL(requestURLString); + String host = requestURL.getHost(); + int port = requestURL.getPort(); + String scheme = requestURL.getProtocol(); + + // 개발, 배포 서버 요청 scheme에 맞춰 유연한 callback 응답 + if (host != null && (host.equals("localhost") || host.equals("127.0.0.1"))) { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(scheme) + .host(host); + if (port != -1) { + builder.port(port); + } + targetUrl = builder.path("/authrediect").build().toUriString(); + log.info("개발 서버 요청 감지 URL :: {}", targetUrl); + } else { + targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .path("/authrediect") + .build() + .toUriString(); + log.info("배포 서버 요청 감지 URL :: {}", targetUrl); + } + } catch (MalformedURLException e) { + log.error("Invalid Request URL: {}", requestURLString, e); + targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .path("/authrediect") + .build() + .toUriString(); + log.info("잘못된 요청 URL :: {}", targetUrl); + } + + + getRedirectStrategy().sendRedirect(request, response, targetUrl); } private Boolean isAutoLogin(HttpServletRequest request) { From 995fb09f3d75b083360e79838c0f5de86b405dca Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 15:12:23 +0900 Subject: [PATCH 047/191] =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=9A=94=EC=B2=AD=EC=82=AC=ED=95=AD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요청 URL에 맞춰 callback 할 수 있도록 수정 --- .../common/config/WebConfig.java | 28 ----------- .../handler/OAuth2LoginSuccessHandler.java | 48 ++++++++++++++++--- 2 files changed, 41 insertions(+), 35 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java deleted file mode 100644 index 1b2476d..0000000 --- a/src/main/java/com/swyp/catsgotogedog/common/config/WebConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.swyp.catsgotogedog.common.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - - @Value("${allowed.origins.url}") - private String allowedOriginsUrl; - - @Value("${allowed.http.methods}") - private String allowedHttpMethods; - - @Override - public void addCorsMappings(CorsRegistry registry) { - String[] origins = allowedOriginsUrl.split(","); - String[] methods = allowedHttpMethods.split(","); - registry.addMapping("/**") - .allowedOrigins(origins) - .allowedMethods(methods) - .allowedMethods("*") - .allowCredentials(true) - .maxAge(3600); - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index eca3585..8bf4345 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -3,7 +3,8 @@ import static com.swyp.catsgotogedog.common.security.filter.OAuth2AutoLoginFilter.*; import java.io.IOException; -import java.time.Duration; +import java.net.MalformedURLException; +import java.net.URL; import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; @@ -13,11 +14,11 @@ import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; import com.swyp.catsgotogedog.global.exception.ErrorCode; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; @@ -28,6 +29,7 @@ @Component @RequiredArgsConstructor +@Slf4j public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @@ -61,11 +63,43 @@ public void onAuthenticationSuccess( addRefreshTokenCookie(response, refresh, isAutoLogin(request)); - // String targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) - // .queryParam("accessToken", access) - // .build() - // .toUriString(); - getRedirectStrategy().sendRedirect(request, response, frontend_base_url+"/authrediect"); + String targetUrl; + String requestURLString = request.getRequestURL().toString(); + + try { + URL requestURL = new URL(requestURLString); + String host = requestURL.getHost(); + int port = requestURL.getPort(); + String scheme = requestURL.getProtocol(); + + // 개발, 배포 서버 요청 scheme에 맞춰 유연한 callback 응답 + if (host != null && (host.equals("localhost") || host.equals("127.0.0.1"))) { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(scheme) + .host(host); + if (port != -1) { + builder.port(port); + } + targetUrl = builder.path("/authrediect").build().toUriString(); + log.info("개발 서버 요청 감지 URL :: {}", targetUrl); + } else { + targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .path("/authrediect") + .build() + .toUriString(); + log.info("배포 서버 요청 감지 URL :: {}", targetUrl); + } + } catch (MalformedURLException e) { + log.error("Invalid Request URL: {}", requestURLString, e); + targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .path("/authrediect") + .build() + .toUriString(); + log.info("잘못된 요청 URL :: {}", targetUrl); + } + + + getRedirectStrategy().sendRedirect(request, response, targetUrl); } private Boolean isAutoLogin(HttpServletRequest request) { From 17e8ef577b57bcc08bd9c8ce7b877893d088ed58 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 16:04:43 +0900 Subject: [PATCH 048/191] =?UTF-8?q?callback=EC=8B=9C=20Referer=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EC=B0=B8=EC=A1=B0=ED=95=98=EC=97=AC=20red?= =?UTF-8?q?irect=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/OAuth2LoginSuccessHandler.java | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index 8bf4345..5ef4d94 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -64,42 +64,62 @@ public void onAuthenticationSuccess( addRefreshTokenCookie(response, refresh, isAutoLogin(request)); String targetUrl; - String requestURLString = request.getRequestURL().toString(); + String refererHeader = request.getHeader("Referer"); + log.info("refererHeader :: {} ", refererHeader); + URL parsedUrl = null; + + // Referer 헤더를 통해 Redirect + if (refererHeader != null && !refererHeader.isEmpty()) { + try { + parsedUrl = new URL(refererHeader); + if (parsedUrl.getHost() != null && !parsedUrl.getHost().isEmpty()) { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(parsedUrl.getProtocol()) + .host(parsedUrl.getHost()); + if (parsedUrl.getPort() != -1) { + builder.port(parsedUrl.getPort()); + } + targetUrl = builder.path("/authredirect").build().toUriString(); + log.info("Referer 헤더를 통한 Redirect :: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + return; + } + } catch (MalformedURLException e) { + log.warn("잘못된 형식의 URL :: {}", refererHeader, e); + } + } + String requestURLString = request.getRequestURL().toString(); try { URL requestURL = new URL(requestURLString); String host = requestURL.getHost(); - int port = requestURL.getPort(); String scheme = requestURL.getProtocol(); + int port = requestURL.getPort(); - // 개발, 배포 서버 요청 scheme에 맞춰 유연한 callback 응답 if (host != null && (host.equals("localhost") || host.equals("127.0.0.1"))) { - UriComponentsBuilder builder = UriComponentsBuilder.newInstance() - .scheme(scheme) - .host(host); - if (port != -1) { - builder.port(port); + if (frontend_base_url != null && !frontend_base_url.isEmpty()) { + // Use the specifically configured localhost frontend URL + targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .path("/authredirect") + .build() + .toUriString(); + log.info("Referer Header가 존재하지 않아 frontend_base_url로 redirect :: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } else { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(scheme) + .host(host); + if (port != -1) { + builder.port(port); + } + targetUrl = builder.path("/authredirect").build().toUriString(); + log.info("frontend_base_url이 존재하지 않아 request url로 강제 redirect :: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); } - targetUrl = builder.path("/authrediect").build().toUriString(); - log.info("개발 서버 요청 감지 URL :: {}", targetUrl); - } else { - targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) - .path("/authrediect") - .build() - .toUriString(); - log.info("배포 서버 요청 감지 URL :: {}", targetUrl); } } catch (MalformedURLException e) { - log.error("Invalid Request URL: {}", requestURLString, e); - targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) - .path("/authrediect") - .build() - .toUriString(); - log.info("잘못된 요청 URL :: {}", targetUrl); + log.warn("잘못된 형식의 비 Referer URL :: {}", requestURLString, e); } - - - getRedirectStrategy().sendRedirect(request, response, targetUrl); } private Boolean isAutoLogin(HttpServletRequest request) { From 75a09b3fe2f3fdc9792beeab702655de8ce17244 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 16:04:43 +0900 Subject: [PATCH 049/191] =?UTF-8?q?callback=EC=8B=9C=20Referer=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EC=B0=B8=EC=A1=B0=ED=95=98=EC=97=AC=20red?= =?UTF-8?q?irect=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/OAuth2LoginSuccessHandler.java | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index 8bf4345..5ef4d94 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -64,42 +64,62 @@ public void onAuthenticationSuccess( addRefreshTokenCookie(response, refresh, isAutoLogin(request)); String targetUrl; - String requestURLString = request.getRequestURL().toString(); + String refererHeader = request.getHeader("Referer"); + log.info("refererHeader :: {} ", refererHeader); + URL parsedUrl = null; + + // Referer 헤더를 통해 Redirect + if (refererHeader != null && !refererHeader.isEmpty()) { + try { + parsedUrl = new URL(refererHeader); + if (parsedUrl.getHost() != null && !parsedUrl.getHost().isEmpty()) { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(parsedUrl.getProtocol()) + .host(parsedUrl.getHost()); + if (parsedUrl.getPort() != -1) { + builder.port(parsedUrl.getPort()); + } + targetUrl = builder.path("/authredirect").build().toUriString(); + log.info("Referer 헤더를 통한 Redirect :: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + return; + } + } catch (MalformedURLException e) { + log.warn("잘못된 형식의 URL :: {}", refererHeader, e); + } + } + String requestURLString = request.getRequestURL().toString(); try { URL requestURL = new URL(requestURLString); String host = requestURL.getHost(); - int port = requestURL.getPort(); String scheme = requestURL.getProtocol(); + int port = requestURL.getPort(); - // 개발, 배포 서버 요청 scheme에 맞춰 유연한 callback 응답 if (host != null && (host.equals("localhost") || host.equals("127.0.0.1"))) { - UriComponentsBuilder builder = UriComponentsBuilder.newInstance() - .scheme(scheme) - .host(host); - if (port != -1) { - builder.port(port); + if (frontend_base_url != null && !frontend_base_url.isEmpty()) { + // Use the specifically configured localhost frontend URL + targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .path("/authredirect") + .build() + .toUriString(); + log.info("Referer Header가 존재하지 않아 frontend_base_url로 redirect :: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } else { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(scheme) + .host(host); + if (port != -1) { + builder.port(port); + } + targetUrl = builder.path("/authredirect").build().toUriString(); + log.info("frontend_base_url이 존재하지 않아 request url로 강제 redirect :: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); } - targetUrl = builder.path("/authrediect").build().toUriString(); - log.info("개발 서버 요청 감지 URL :: {}", targetUrl); - } else { - targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) - .path("/authrediect") - .build() - .toUriString(); - log.info("배포 서버 요청 감지 URL :: {}", targetUrl); } } catch (MalformedURLException e) { - log.error("Invalid Request URL: {}", requestURLString, e); - targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) - .path("/authrediect") - .build() - .toUriString(); - log.info("잘못된 요청 URL :: {}", targetUrl); + log.warn("잘못된 형식의 비 Referer URL :: {}", requestURLString, e); } - - - getRedirectStrategy().sendRedirect(request, response, targetUrl); } private Boolean isAutoLogin(HttpServletRequest request) { From b42613afa3bd2c7602778c2a43a64c955c437c10 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 26 Jul 2025 16:42:43 +0900 Subject: [PATCH 050/191] =?UTF-8?q?Referer=20=EC=BA=90=EC=B9=98=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Referer 캐치하는 시점을 OncePerRequestFilter를 통해 세션에 저장하도록 수정 --- .../common/config/SecurityConfig.java | 3 +- .../filter/OAuth2AutoLoginFilter.java | 70 ++++++++++++++++++ .../handler/OAuth2LoginSuccessHandler.java | 73 ++----------------- 3 files changed, 80 insertions(+), 66 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index e40b9c0..f44dbb5 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -27,6 +27,7 @@ public class SecurityConfig { private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final JwtTokenFilter jwtTokenFilter; private final CatsgotogedogAuthenticationEntryPoint catsgotogedogAuthenticationEntryPoint; + private final OAuth2AutoLoginFilter oAuth2AutoLoginFilter; @Value("${allowed.origins.url}") private String allowedOriginsUrl; @@ -54,7 +55,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .exceptionHandling(eh -> eh.authenticationEntryPoint(catsgotogedogAuthenticationEntryPoint)) - .addFilterBefore(new OAuth2AutoLoginFilter(), OAuth2AuthorizationRequestRedirectFilter.class) + .addFilterBefore(oAuth2AutoLoginFilter, OAuth2AuthorizationRequestRedirectFilter.class) .oauth2Login(oauth -> oauth .successHandler(oAuth2LoginSuccessHandler)) .addFilterBefore(jwtTokenFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java index d6a4f4d..88e321a 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java @@ -1,33 +1,103 @@ package com.swyp.catsgotogedog.common.security.filter; import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +@Slf4j +@Component public class OAuth2AutoLoginFilter extends OncePerRequestFilter { public final static String AUTO_LOGIN_PARAM = "autoLogin"; + @Value("${frontend.base.url}") + private String frontend_base_url; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + log.info("frontend_base_url :: {}", frontend_base_url); + if(request.getRequestURI().contains("/oauth2/authorization/")) { String autoLoginParam = request.getParameter(AUTO_LOGIN_PARAM); + String targetUrl = ""; + String refererHeader = request.getHeader("Referer"); + log.info("refererHeader :: {} ", refererHeader); + URL parsedUrl = null; + + if(autoLoginParam == null) { autoLoginParam = "false"; } boolean autoLogin = "true".equalsIgnoreCase(autoLoginParam); + // Referer 헤더를 통해 Redirect + if (refererHeader != null && !refererHeader.isEmpty()) { + try { + parsedUrl = new URL(refererHeader); + if (parsedUrl.getHost() != null && !parsedUrl.getHost().isEmpty()) { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(parsedUrl.getProtocol()) + .host(parsedUrl.getHost()); + if (parsedUrl.getPort() != -1) { + builder.port(parsedUrl.getPort()); + } + targetUrl = builder.path("/authredirect").build().toUriString(); + log.info("Referer 헤더를 통한 Redirect :: {}", targetUrl); + } + } catch (MalformedURLException e) { + log.warn("잘못된 형식의 URL :: {}", refererHeader, e); + } + } + + String requestURLString = request.getRequestURL().toString(); + + try { + URL requestURL = new URL(requestURLString); + String host = requestURL.getHost(); + String scheme = requestURL.getProtocol(); + int port = requestURL.getPort(); + + if (host != null && (host.equals("localhost") || host.equals("127.0.0.1"))) { + if (frontend_base_url != null && !frontend_base_url.isEmpty()) { + // Use the specifically configured localhost frontend URL + targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .path("/authredirect") + .build() + .toUriString(); + log.info("Referer Header가 존재하지 않아 frontend_base_url로 redirect :: {}", targetUrl); + } else { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(scheme) + .host(host); + if (port != -1) { + builder.port(port); + } + targetUrl = builder.path("/authredirect").build().toUriString(); + log.info("frontend_base_url이 존재하지 않아 request url로 강제 redirect :: {}", targetUrl); + } + } + } catch (MalformedURLException e) { + log.warn("잘못된 형식의 비 Referer URL :: {}", requestURLString, e); + } + HttpSession session = request.getSession(true); session.setAttribute(AUTO_LOGIN_PARAM, autoLogin); + session.setAttribute("targetUrl", targetUrl); } filterChain.doFilter(request, response); diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java index 5ef4d94..c3d0991 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -3,8 +3,6 @@ import static com.swyp.catsgotogedog.common.security.filter.OAuth2AutoLoginFilter.*; import java.io.IOException; -import java.net.MalformedURLException; -import java.net.URL; import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; @@ -25,7 +23,6 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; @Component @RequiredArgsConstructor @@ -37,9 +34,6 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan private final RefreshTokenService rtService; private final UserRepository userRepo; - @Value("${frontend.base.url}") - private String frontend_base_url; - @Value("${jwt.refresh-expire-day}") private int refreshDay; @@ -61,69 +55,18 @@ public void onAuthenticationSuccess( rtService.save(user, refresh, jwt.getRefreshTokenExpiry()); - addRefreshTokenCookie(response, refresh, isAutoLogin(request)); - - String targetUrl; - String refererHeader = request.getHeader("Referer"); - log.info("refererHeader :: {} ", refererHeader); - URL parsedUrl = null; - - // Referer 헤더를 통해 Redirect - if (refererHeader != null && !refererHeader.isEmpty()) { - try { - parsedUrl = new URL(refererHeader); - if (parsedUrl.getHost() != null && !parsedUrl.getHost().isEmpty()) { - UriComponentsBuilder builder = UriComponentsBuilder.newInstance() - .scheme(parsedUrl.getProtocol()) - .host(parsedUrl.getHost()); - if (parsedUrl.getPort() != -1) { - builder.port(parsedUrl.getPort()); - } - targetUrl = builder.path("/authredirect").build().toUriString(); - log.info("Referer 헤더를 통한 Redirect :: {}", targetUrl); - getRedirectStrategy().sendRedirect(request, response, targetUrl); - return; - } - } catch (MalformedURLException e) { - log.warn("잘못된 형식의 URL :: {}", refererHeader, e); - } - } + HttpSession session = request.getSession(false); + addRefreshTokenCookie(response, refresh, isAutoLogin(session)); - String requestURLString = request.getRequestURL().toString(); - try { - URL requestURL = new URL(requestURLString); - String host = requestURL.getHost(); - String scheme = requestURL.getProtocol(); - int port = requestURL.getPort(); - - if (host != null && (host.equals("localhost") || host.equals("127.0.0.1"))) { - if (frontend_base_url != null && !frontend_base_url.isEmpty()) { - // Use the specifically configured localhost frontend URL - targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) - .path("/authredirect") - .build() - .toUriString(); - log.info("Referer Header가 존재하지 않아 frontend_base_url로 redirect :: {}", targetUrl); - getRedirectStrategy().sendRedirect(request, response, targetUrl); - } else { - UriComponentsBuilder builder = UriComponentsBuilder.newInstance() - .scheme(scheme) - .host(host); - if (port != -1) { - builder.port(port); - } - targetUrl = builder.path("/authredirect").build().toUriString(); - log.info("frontend_base_url이 존재하지 않아 request url로 강제 redirect :: {}", targetUrl); - getRedirectStrategy().sendRedirect(request, response, targetUrl); - } - } - } catch (MalformedURLException e) { - log.warn("잘못된 형식의 비 Referer URL :: {}", requestURLString, e); + if (session != null) { + log.info("targetUrl :: {}", session.getAttribute("targetUrl").toString()); + String targetUrl = session.getAttribute("targetUrl").toString(); + session.removeAttribute("targetUrl"); + getRedirectStrategy().sendRedirect(request, response, targetUrl); } } - private Boolean isAutoLogin(HttpServletRequest request) { - HttpSession session = request.getSession(false); + private Boolean isAutoLogin(HttpSession session) { if (session == null) { return false; } From 511d7c72bdc34b52b95fc213928d0da16f810a6a Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sun, 27 Jul 2025 23:11:52 +0900 Subject: [PATCH 051/191] =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../util/{imagestorage => image/storage}/ImageStorageService.java | 0 .../{imagestorage => image/storage}/ObjectMetadataProvider.java | 0 .../util/{imagestorage => image/storage}/dto/ImageInfo.java | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/main/java/com/swyp/catsgotogedog/common/util/{imagestorage => image/storage}/ImageStorageService.java (100%) rename src/main/java/com/swyp/catsgotogedog/common/util/{imagestorage => image/storage}/ObjectMetadataProvider.java (100%) rename src/main/java/com/swyp/catsgotogedog/common/util/{imagestorage => image/storage}/dto/ImageInfo.java (100%) diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java similarity index 100% rename from src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ImageStorageService.java rename to src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ObjectMetadataProvider.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java similarity index 100% rename from src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/ObjectMetadataProvider.java rename to src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/dto/ImageInfo.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java similarity index 100% rename from src/main/java/com/swyp/catsgotogedog/common/util/imagestorage/dto/ImageInfo.java rename to src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java From ff4e5fe66f639a8c8987cc1d7f76c3c7d616e88d Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sun, 27 Jul 2025 23:15:16 +0900 Subject: [PATCH 052/191] =?UTF-8?q?=EC=8A=A4=ED=94=84=EB=A7=81(=ED=86=B0?= =?UTF-8?q?=EC=BA=A3)=EC=97=90=EC=84=9C=20=EB=B0=9C=EC=83=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/GlobalExceptionHandler.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java index 7ea1770..66f8881 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; @@ -28,6 +29,16 @@ protected ResponseEntity> handleCatsgotogedogEx return createErrorResponse(ex.getErrorCode()); } + @ExceptionHandler(MaxUploadSizeExceededException.class) + protected ResponseEntity> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { + log.error("CatsgotogedogException: {}", e.getMessage(), e); + int errorCode = ErrorCode.FILE_SIZE_EXCEEDED.getCode(); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.FILE_SIZE_EXCEEDED); + return ResponseEntity + .status(errorCode) + .body(response); + } + @ExceptionHandler(Exception.class) protected ResponseEntity> handleException(Exception ex) { log.error("Exception : {}", ex.getMessage(), ex); From b7404735f9e4a7e94bd6004117c1a0d2cdf77ea4 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sun, 27 Jul 2025 23:16:35 +0900 Subject: [PATCH 053/191] =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/image/storage/ImageStorageService.java | 4 ++-- .../common/util/image/storage/ObjectMetadataProvider.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java index 9a6d03d..213ff06 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java @@ -1,6 +1,6 @@ -package com.swyp.catsgotogedog.common.util.imagestorage; +package com.swyp.catsgotogedog.common.util.image.storage; -import com.swyp.catsgotogedog.common.util.imagestorage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; import com.swyp.catsgotogedog.global.exception.*; import io.awspring.cloud.s3.ObjectMetadata; import io.awspring.cloud.s3.S3Resource; diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java index 164fff6..1131fc7 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java @@ -1,4 +1,4 @@ -package com.swyp.catsgotogedog.common.util.imagestorage; +package com.swyp.catsgotogedog.common.util.image.storage; import io.awspring.cloud.s3.ObjectMetadata; import org.springframework.stereotype.Component; From 5e9c620563a2f05ef5c74dda218e583cb45f80a3 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Mon, 28 Jul 2025 00:13:13 +0900 Subject: [PATCH 054/191] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../validator/DefaultImageValidator.java | 119 ++++++++++++++++++ .../util/image/validator/ImageUploadType.java | 17 +++ .../util/image/validator/ImageValidator.java | 11 ++ .../global/exception/ErrorCode.java | 16 ++- .../exception/GlobalExceptionHandler.java | 4 +- .../exception/ImageNotFoundException.java | 8 -- .../exception/ImageUploadException.java | 12 -- .../exception/TooManyImagesException.java | 9 -- .../images/ImageLimitExceededException.java | 11 ++ .../images/ImageNotFoundException.java | 10 ++ .../images/ImageUploadException.java | 12 ++ .../images/ImageValidatorException.java | 11 ++ .../images/InvalidImageException.java | 10 ++ 14 files changed, 219 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageValidator.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ImageNotFoundException.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/TooManyImagesException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageNotFoundException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageUploadException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageValidatorException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/images/InvalidImageException.java diff --git a/build.gradle b/build.gradle index 9d3d969..1a25e84 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,9 @@ dependencies { // Flyway implementation group: 'org.flywaydb', name: 'flyway-mysql', version: '11.10.2' implementation 'org.flywaydb:flyway-core' + + // Tika + implementation 'org.apache.tika:tika-core:3.1.0' } tasks.named('test') { diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java new file mode 100644 index 0000000..879a320 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java @@ -0,0 +1,119 @@ +package com.swyp.catsgotogedog.common.util.image.validator; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.images.ImageNotFoundException; +import com.swyp.catsgotogedog.global.exception.images.InvalidImageException; +import com.swyp.catsgotogedog.global.exception.images.ImageLimitExceededException; +import org.apache.tika.Tika; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +@Component +public class DefaultImageValidator implements ImageValidator { + + private final Tika tika = new Tika(); + private final Set allowedExtensions = Set.of("jpg", "jpeg", "png"); + private final Set allowedMimeTypes = Set.of("image/jpeg", "image/png"); + + /** + * 단일 이미지 파일 검증 + * @param file 검증할 이미지 파일 + * @param uploadType 업로드 타입 (최대 파일 수 제한용) + */ + @Override + public void validate(MultipartFile file, ImageUploadType uploadType) { + validateFileNotNull(file); + validateFileNameAndExtension(file); + validateImageFormat(file); + } + + /** + * 다중 이미지 파일 검증 + * @param files 검증할 이미지 파일 리스트 + * @param uploadType 업로드 타입 (최대 파일 수 제한용) + */ + @Override + public void validate(List files, ImageUploadType uploadType) { + validateFilesNotNull(files); + validateFileCount(files, uploadType); + + // 각 파일에 대해 개별 검증 수행 (하나라도 실패하면 전체 실패) + for (MultipartFile file : files) { + validate(file, uploadType); + } + } + + private void validateFileNotNull(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); + } + } + + private void validateFileNameAndExtension(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isBlank()) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_NAME); + } + + String extension = getFileExtension(originalFilename).toLowerCase(); + if (!allowedExtensions.contains(extension)) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_EXTENSION); + } + } + + private void validateImageFormat(MultipartFile file) { + try { + // Apache Tika를 사용하여 실제 파일 내용의 MIME 타입 검증 + String detectedMimeType = tika.detect(file.getInputStream()); + + if (!allowedMimeTypes.contains(detectedMimeType)) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_FORMAT); + } + + // 추가 검증: 파일 확장자와 실제 MIME 타입 일치 여부 확인 + validateExtensionMimeTypeConsistency(file, detectedMimeType); + + } catch (IOException e) { + throw new InvalidImageException(ErrorCode.STREAM_IO_EXCEPTION); + } + } + + private void validateFilesNotNull(List files) { + if (files == null || files.isEmpty()) { + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); + } + } + + private void validateFileCount(List files, ImageUploadType uploadType) { + if (files.size() > uploadType.getMaxFiles()) { + throw new ImageLimitExceededException(ErrorCode.IMAGE_LIMIT_EXCEEDED); + } + } + + private void validateExtensionMimeTypeConsistency(MultipartFile file, String detectedMimeType) { + String originalFilename = file.getOriginalFilename(); + String extension = getFileExtension(originalFilename).toLowerCase(); + + boolean isConsistent = switch (extension) { + case "jpg", "jpeg" -> "image/jpeg".equals(detectedMimeType); + case "png" -> "image/png".equals(detectedMimeType); + default -> false; + }; + + if (!isConsistent) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_FORMAT); + } + } + + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { + return ""; + } + return filename.substring(lastDotIndex + 1); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java new file mode 100644 index 0000000..b393bed --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.common.util.image.validator; + +import lombok.Getter; + +@Getter +public enum ImageUploadType { + PROFILE(1), + REVIEW(5), + GENERAL(10); + + private final int maxFiles; + + ImageUploadType(int maxFiles) { + this.maxFiles = maxFiles; + } + +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageValidator.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageValidator.java new file mode 100644 index 0000000..2f14bc9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageValidator.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.common.util.image.validator; + +import org.springframework.web.multipart.MultipartFile; +import java.util.List; + +public interface ImageValidator { + + void validate(MultipartFile file, ImageUploadType uploadType); + + void validate(List files, ImageUploadType uploadType); +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 2d3773e..623921f 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -33,11 +33,21 @@ public enum ErrorCode { // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), - // Image Storage Error + // Image Validator Error + INVALID_IMAGE_NAME(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 이미지 이름입니다."), + INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 이미지 확장자입니다."), + INVALID_IMAGE_FORMAT(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 이미지 형식입니다."), IMAGE_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 파일이 누락 되었습니다."), + IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "이미지 크기가 허용 범위를 초과했습니다."), + IMAGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "이미지 파일은 최대 10개까지 업로드 가능합니다."), + IMAGE_VALIDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 유효성 검사에 실패했습니다."), + + // Image Storage Error IMAGE_KEY_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 키가 누락 되었습니다."), - TOO_MANY_IMAGES(HttpStatus.BAD_REQUEST.value(), "이미지 파일은 최대 10개까지 업로드 가능합니다."), - IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."); + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."), + + // Stream IO Exception + STREAM_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR.value(), "스트림 처리 중 오류가 발생했습니다."); private final int code; private final String message; diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java index 66f8881..6240891 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -32,8 +32,8 @@ protected ResponseEntity> handleCatsgotogedogEx @ExceptionHandler(MaxUploadSizeExceededException.class) protected ResponseEntity> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { log.error("CatsgotogedogException: {}", e.getMessage(), e); - int errorCode = ErrorCode.FILE_SIZE_EXCEEDED.getCode(); - CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.FILE_SIZE_EXCEEDED); + int errorCode = ErrorCode.IMAGE_SIZE_EXCEEDED.getCode(); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.IMAGE_SIZE_EXCEEDED); return ResponseEntity .status(errorCode) .body(response); diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageNotFoundException.java deleted file mode 100644 index a9fc3bc..0000000 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageNotFoundException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.swyp.catsgotogedog.global.exception; - -public class ImageNotFoundException extends CatsgotogedogException { - - public ImageNotFoundException(ErrorCode errorCode) { - super(errorCode); - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java deleted file mode 100644 index 0543d10..0000000 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageUploadException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.swyp.catsgotogedog.global.exception; - -import lombok.Getter; - -@Getter -public class ImageUploadException extends CatsgotogedogException { - - public ImageUploadException(ErrorCode errorCode) { - super(errorCode); - } - -} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/TooManyImagesException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/TooManyImagesException.java deleted file mode 100644 index 9dfd7ff..0000000 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/TooManyImagesException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.swyp.catsgotogedog.global.exception; - -public class TooManyImagesException extends CatsgotogedogException { - - public TooManyImagesException(ErrorCode errorCode) { - super(errorCode); - } - -} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java new file mode 100644 index 0000000..10bd557 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class ImageLimitExceededException extends ImageValidatorException { + + public ImageLimitExceededException(ErrorCode errorCode) { + super(errorCode); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageNotFoundException.java new file mode 100644 index 0000000..a142583 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageNotFoundException.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class ImageNotFoundException extends ImageValidatorException { + + public ImageNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageUploadException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageUploadException.java new file mode 100644 index 0000000..f09e613 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageUploadException.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class ImageUploadException extends CatsgotogedogException { + + public ImageUploadException(ErrorCode errorCode) { + super(errorCode); + } + +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageValidatorException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageValidatorException.java new file mode 100644 index 0000000..b9b9884 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageValidatorException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class ImageValidatorException extends CatsgotogedogException { + + public ImageValidatorException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/InvalidImageException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/InvalidImageException.java new file mode 100644 index 0000000..febd67a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/InvalidImageException.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class InvalidImageException extends ImageValidatorException { + + public InvalidImageException(ErrorCode errorCode) { + super(errorCode); + } +} From deef95409596674a41dddd83b5b42311b14a9ae6 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Mon, 28 Jul 2025 01:11:19 +0900 Subject: [PATCH 055/191] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../image/storage/ImageStorageService.java | 62 ++++++++++++------- .../util/image/storage/dto/ImageInfo.java | 2 +- .../validator/DefaultImageValidator.java | 38 ++++++++---- .../global/exception/ErrorCode.java | 2 +- .../images/ImageLimitExceededException.java | 1 - 5 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java index 213ff06..75c2a96 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java @@ -1,7 +1,12 @@ package com.swyp.catsgotogedog.common.util.image.storage; import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.common.util.image.validator.ImageValidator; import com.swyp.catsgotogedog.global.exception.*; +import com.swyp.catsgotogedog.global.exception.images.ImageNotFoundException; +import com.swyp.catsgotogedog.global.exception.images.ImageUploadException; +import com.swyp.catsgotogedog.global.exception.images.ImageLimitExceededException; import io.awspring.cloud.s3.ObjectMetadata; import io.awspring.cloud.s3.S3Resource; import io.awspring.cloud.s3.S3Template; @@ -9,6 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.io.InputStream; import java.util.Collections; import java.util.List; @@ -21,36 +27,41 @@ public class ImageStorageService { private final S3Template s3Template; private final ObjectMetadataProvider objectMetadataProvider; + private final ImageValidator imageValidator; private final String bucketName; private final int MAX_FILE_COUNT = 10; public ImageStorageService(S3Template s3Template, ObjectMetadataProvider objectMetadataProvider, + ImageValidator imageValidator, @Value("${spring.cloud.aws.s3.bucket}") String bucketName) { this.s3Template = s3Template; this.objectMetadataProvider = objectMetadataProvider; + this.imageValidator = imageValidator; this.bucketName = bucketName; } /** * 다중 이미지 업로드 * @param files MultipartFile list + * @param uploadType 업로드 타입 (프로필 업로드, 리뷰 업로드 등 개수 제한용) * @return List<ImageInfo> */ - public List upload(List files) { - return upload(files, ""); + public List upload(List files, ImageUploadType uploadType) { + return upload(files, "", uploadType); } /** * 다중 이미지 업로드 * @param files MultipartFile list * @param path 업로드 경로 + * @param uploadType 업로드 타입 (프로필 업로드, 리뷰 업로드 등 개수 제한용) * @return List<ImageInfo> */ - public List upload(List files, String path) { - validateFiles(files); + public List upload(List files, String path, ImageUploadType uploadType) { + validateFiles(files,uploadType); return files.stream() .map(file -> doUpload(file, path)) .collect(Collectors.toList()); @@ -59,20 +70,22 @@ public List upload(List files, String path) { /** * 단일 이미지 업로드 * @param file MultipartFile + * @param uploadType 업로드 타입 (프로필 업로드, 리뷰 업로드 등 개수 제한용) * @return List<ImageInfo> */ - public List upload(MultipartFile file) { - return upload(file, ""); + public List upload(MultipartFile file, ImageUploadType uploadType) { + return upload(file, "", uploadType); } /** * 단일 이미지 업로드 * @param file MultipartFile * @param path 업로드 경로 + * @param uploadType 업로드 타입 (프로필 업로드, 리뷰 업로드 등 개수 제한용) * @return List<ImageInfo> */ - public List upload(MultipartFile file, String path) { - validateFile(file); + public List upload(MultipartFile file, String path, ImageUploadType uploadType) { + validateFile(file, uploadType); return Collections.singletonList(doUpload(file, path)); } @@ -101,7 +114,9 @@ private ImageInfo doUpload(MultipartFile file, String path) { try (InputStream stream = file.getInputStream()) { S3Resource resource = s3Template.upload(bucketName, key, stream, metadata); return new ImageInfo(resource.getFilename(), resource.getURL().toString()); - } catch (Exception e) { // IOException(InputStream), S3Exception 등 + } catch (IOException e) { // Stream Exception + throw new ImageUploadException(ErrorCode.STREAM_IO_EXCEPTION); + } catch (Exception e) { // S3Exception 등 throw new ImageUploadException(ErrorCode.IMAGE_UPLOAD_FAILED); } } @@ -112,26 +127,18 @@ private void doDelete(String key) { } // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 - private void validateFiles(List files) { - if (files == null || files.isEmpty()) { - throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); - } - if (files.size() > MAX_FILE_COUNT) { - throw new TooManyImagesException(ErrorCode.TOO_MANY_IMAGES); - } - files.forEach(this::validateFile); + private void validateFiles(List files, ImageUploadType uploadType) { + imageValidator.validate(files, uploadType); } // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 - private void validateFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); - } + private void validateFile(MultipartFile file, ImageUploadType uploadType) { + imageValidator.validate(file, uploadType); } private void validateKeyList(List keys) { if (keys == null || keys.isEmpty()) { - throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); + throw new ImageKeyNotFoundException(ErrorCode.IMAGE_KEY_NOT_FOUND); } // 전체 키 리스트의 유효성을 먼저 검사 if (keys.stream().anyMatch(key -> key == null || key.isBlank())) { @@ -147,8 +154,15 @@ private void validateKey(String key) { // 파일 이름과 UUID를 조합하여 고유한 키 생성 private String genKey(MultipartFile file, String path) { - String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : ""; - return path + UUID.randomUUID() + originalFilename; + return path + UUID.randomUUID() + getFileExtension(file.getOriginalFilename()); + } + + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { + return ""; + } + return "." + filename.substring(lastDotIndex + 1).toLowerCase(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java index 011ef2a..884e779 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java @@ -1,4 +1,4 @@ -package com.swyp.catsgotogedog.common.util.imagestorage.dto; +package com.swyp.catsgotogedog.common.util.image.storage.dto; public record ImageInfo(String key, String url) { } diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java index 879a320..6460c72 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java @@ -59,6 +59,11 @@ private void validateFileNameAndExtension(MultipartFile file) { throw new InvalidImageException(ErrorCode.INVALID_IMAGE_NAME); } + String baseFilename = getBaseFilename(originalFilename); + if (baseFilename == null || baseFilename.isBlank()) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_NAME); + } + String extension = getFileExtension(originalFilename).toLowerCase(); if (!allowedExtensions.contains(extension)) { throw new InvalidImageException(ErrorCode.INVALID_IMAGE_EXTENSION); @@ -69,7 +74,6 @@ private void validateImageFormat(MultipartFile file) { try { // Apache Tika를 사용하여 실제 파일 내용의 MIME 타입 검증 String detectedMimeType = tika.detect(file.getInputStream()); - if (!allowedMimeTypes.contains(detectedMimeType)) { throw new InvalidImageException(ErrorCode.INVALID_IMAGE_FORMAT); } @@ -82,18 +86,6 @@ private void validateImageFormat(MultipartFile file) { } } - private void validateFilesNotNull(List files) { - if (files == null || files.isEmpty()) { - throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); - } - } - - private void validateFileCount(List files, ImageUploadType uploadType) { - if (files.size() > uploadType.getMaxFiles()) { - throw new ImageLimitExceededException(ErrorCode.IMAGE_LIMIT_EXCEEDED); - } - } - private void validateExtensionMimeTypeConsistency(MultipartFile file, String detectedMimeType) { String originalFilename = file.getOriginalFilename(); String extension = getFileExtension(originalFilename).toLowerCase(); @@ -109,6 +101,26 @@ private void validateExtensionMimeTypeConsistency(MultipartFile file, String det } } + private void validateFilesNotNull(List files) { + if (files == null || files.isEmpty()) { + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); + } + } + + private void validateFileCount(List files, ImageUploadType uploadType) { + if (files.size() > uploadType.getMaxFiles()) { + throw new ImageLimitExceededException(ErrorCode.IMAGE_LIMIT_EXCEEDED); + } + } + + private String getBaseFilename(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { + return ""; + } + return filename.substring(0, lastDotIndex); + } + private String getFileExtension(String filename) { int lastDotIndex = filename.lastIndexOf('.'); if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 623921f..46b6052 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -39,7 +39,7 @@ public enum ErrorCode { INVALID_IMAGE_FORMAT(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 이미지 형식입니다."), IMAGE_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 파일이 누락 되었습니다."), IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "이미지 크기가 허용 범위를 초과했습니다."), - IMAGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "이미지 파일은 최대 10개까지 업로드 가능합니다."), + IMAGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "최대 이미지 업로드 개수를 초과했습니다."), IMAGE_VALIDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 유효성 검사에 실패했습니다."), // Image Storage Error diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java index 10bd557..663d1cd 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java @@ -7,5 +7,4 @@ public class ImageLimitExceededException extends ImageValidatorException { public ImageLimitExceededException(ErrorCode errorCode) { super(errorCode); } - } From 2f769382c98885194e3846870bcb754aee862870 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 28 Jul 2025 21:28:30 +0900 Subject: [PATCH 056/191] refactor/alter tables category content region_code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit category_code 테이블 category_id 컬럼 타입 int > varchar(30) 따라서 content 테이블의 category_id 도 varchar(30)로 수정 카테고리의 소분류 코드를 그대로 마이그레이션 하기 위함 category_code 테이블 content_type_id 컬럼 추가 카테고리를 통한 필터링 유연성 확보 가능 region_code 테이블 기존의 parent_code 형태에서 공공데이터 구조와 유사한 구조로 변경 sigungu_code int 컬럼 추가 region_level int 컬럼 추가 sido_code int 컬럼 추가 content 테이블에도 따라서 sido_code, sigungu_code 컬럼 추가 content 테이블 region_code 컬럼, 외래키 제약 삭제 region_code 기본 지역코드 데이터 생성 쿼리문 추가 --- .../mysql/V3__alter_categaory_content.sql | 17 ++ .../mysql/V4__alter_region_code_content.sql | 22 ++ .../migration/mysql/V5__init_region_data.sql | 248 ++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql create mode 100644 src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql create mode 100644 src/main/resources/db/migration/mysql/V5__init_region_data.sql diff --git a/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql b/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql new file mode 100644 index 0000000..57e41c9 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql @@ -0,0 +1,17 @@ +ALTER TABLE `catsgotogedog`.`content` +DROP FOREIGN KEY `category_code_category_id`; + +ALTER TABLE `catsgotogedog`.`category_code` + CHANGE COLUMN `category_id` `category_id` VARCHAR(30) NOT NULL ; + +ALTER TABLE `catsgotogedog`.`content` + CHANGE COLUMN `category_id` `category_id` VARCHAR(30) NOT NULL ; + +ALTER TABLE `catsgotogedog`.`content` + ADD CONSTRAINT `category_code_category_id` + FOREIGN KEY (`category_id`) + REFERENCES `catsgotogedog`.`category_code` (`category_id`); + +ALTER TABLE `catsgotogedog`.`content` + CHANGE COLUMN `mapx` `mapx` DECIMAL(13,10) NULL DEFAULT NULL , + CHANGE COLUMN `mapy` `mapy` DECIMAL(13,10) NULL DEFAULT NULL ; diff --git a/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql b/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql new file mode 100644 index 0000000..8c6f04d --- /dev/null +++ b/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql @@ -0,0 +1,22 @@ +ALTER TABLE `catsgotogedog`.`content` +DROP FOREIGN KEY `region_code_region_id`; + +ALTER TABLE `catsgotogedog`.`category_code` + ADD COLUMN `content_type_id` INT NULL AFTER `category_name`; + +ALTER TABLE `catsgotogedog`.`content` +DROP COLUMN `region_id`, +ADD COLUMN `contentcol` VARCHAR(45) NULL AFTER `content_type_id`, +ADD COLUMN `sido_code` INT NULL AFTER `contentcol`, +ADD COLUMN `sigungu_code` INT NULL AFTER `sido_code`, +DROP INDEX `region_code_region_id_idx`; + +ALTER TABLE `catsgotogedog`.`region_code` + ADD COLUMN `sido_code` INT NULL AFTER `region_name`, +ADD COLUMN `sigungu_code` INT NULL AFTER `sido_code`, +ADD UNIQUE INDEX `sido_sigungu_code_UNIQUE` (`sido_code` ASC, `sigungu_code` ASC) VISIBLE; + +ALTER TABLE `catsgotogedog`.`region_code` + ADD COLUMN `region_level` INT NULL AFTER `parent_code`; + +ALTER TABLE content AUTO_INCREMENT = 100000; \ No newline at end of file diff --git a/src/main/resources/db/migration/mysql/V5__init_region_data.sql b/src/main/resources/db/migration/mysql/V5__init_region_data.sql new file mode 100644 index 0000000..97b7dbb --- /dev/null +++ b/src/main/resources/db/migration/mysql/V5__init_region_data.sql @@ -0,0 +1,248 @@ +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('1', '서울', '1', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('2', '인천', '2', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('3', '대전', '3', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('4', '대구', '4', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('5', '광주', '5', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('6', '부산', '6', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('7', '울산', '7', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('8', '세종특별자치시', '8', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('9', '경기도', '31', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('10', '강원특별자치도', '32', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('11', '충청북도', '33', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('12', '충청남도', '34', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('13', '경상북도', '35', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('14', '경상남도', '36', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('15', '전북특별자치도', '37', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('16', '전라남도', '38', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('17', '제주도', '39', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('18', '강남구', '1', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('19', '강동구', '2', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('20', '강북구', '3', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('21', '강서구', '4', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('22', '관악구', '5', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('23', '광진구', '6', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('24', '구로구', '7', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('25', '금천구', '8', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('26', '노원구', '9', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('27', '도봉구', '10', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('28', '동대문구', '11', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('29', '동작구', '12', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('30', '마포구', '13', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('31', '서대문구', '14', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('32', '서초구', '15', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('33', '성동구', '16', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('34', '성북구', '17', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('35', '송파구', '18', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('36', '양천구', '19', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('37', '영등포구', '20', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('38', '용산구', '21', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('39', '은평구', '22', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('40', '종로구', '23', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('41', '중구', '24', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('42', '중랑구', '25', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('43', '강화군', '1', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('44', '계양구', '2', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('45', '미추홀구', '3', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('46', '남동구', '4', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('47', '동구', '5', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('48', '부평구', '6', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('49', '서구', '7', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('50', '연수구', '8', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('51', '옹진군', '9', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('52', '중구', '10', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('53', '대덕구', '1', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('54', '동구', '2', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('55', '서구', '3', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('56', '유성구', '4', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('57', '중구', '5', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('58', '남구', '1', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('59', '달서구', '2', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('60', '달성군', '3', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('61', '동구', '4', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('62', '북구', '5', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('63', '서구', '6', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('64', '수성구', '7', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('65', '중구', '8', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('66', '군위군', '9', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('67', '광산구', '1', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('68', '남구', '2', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('69', '동구', '3', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('70', '북구', '4', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('71', '서구', '5', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('72', '강서구', '1', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('73', '금정구', '2', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('74', '기장군', '3', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('75', '남구', '4', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('76', '동구', '5', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('77', '동래구', '6', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('78', '부산진구', '7', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('79', '북구', '8', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('80', '사상구', '9', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('81', '사하구', '10', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('82', '서구', '11', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('83', '수영구', '12', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('84', '연제구', '13', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('85', '영도구', '14', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('86', '중구', '15', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('87', '해운대구', '16', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('88', '중구', '1', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('89', '남구', '2', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('90', '동구', '3', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('91', '북구', '4', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('92', '울주군', '5', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('93', '세종특별자치시', '1', '8', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('94', '가평군', '1', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('95', '고양시', '2', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('96', '과천시', '3', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('97', '광명시', '4', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('98', '광주시', '5', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('99', '구리시', '6', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('100', '군포시', '7', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('101', '김포시', '8', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('102', '남양주시', '9', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('103', '동두천시', '10', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('104', '부천시', '11', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('105', '성남시', '12', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('106', '수원시', '13', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('107', '시흥시', '14', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('108', '안산시', '15', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('109', '안성시', '16', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('110', '안양시', '17', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('111', '양주시', '18', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('112', '양평군', '19', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('113', '여주시', '20', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('114', '연천군', '21', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('115', '오산시', '22', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('116', '용인시', '23', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('117', '의왕시', '24', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('118', '의정부시', '25', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('119', '이천시', '26', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('120', '파주시', '27', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('121', '평택시', '28', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('122', '포천시', '29', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('123', '하남시', '30', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('124', '화성시', '31', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('125', '강릉시', '1', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('126', '고성군', '2', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('127', '동해시', '3', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('128', '삼척시', '4', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('129', '속초시', '5', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('130', '양구군', '6', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('131', '양양군', '7', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('132', '영월군', '8', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('133', '원주시', '9', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('134', '인제군', '10', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('135', '정선군', '11', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('136', '철원군', '12', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('137', '춘천시', '13', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('138', '태백시', '14', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('139', '평창군', '15', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('140', '홍천군', '16', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('141', '화천군', '17', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('142', '횡성군', '18', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('143', '괴산군', '1', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('144', '단양군', '2', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('145', '보은군', '3', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('146', '영동군', '4', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('147', '옥천군', '5', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('148', '음성군', '6', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('149', '제천시', '7', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('150', '진천군', '8', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('151', '청주시', '10', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('152', '충주시', '11', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('153', '증평군', '12', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('154', '공주시', '1', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('155', '금산군', '2', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('156', '논산시', '3', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('157', '당진시', '4', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('158', '보령시', '5', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('159', '부여군', '6', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('160', '서산시', '7', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('161', '서천군', '8', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('162', '아산시', '9', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('163', '예산군', '11', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('164', '천안시', '12', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('165', '청양군', '13', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('166', '태안군', '14', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('167', '홍성군', '15', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('168', '계룡시', '16', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('169', '경산시', '1', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('170', '경주시', '2', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('171', '고령군', '3', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('172', '구미시', '4', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('173', '김천시', '6', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('174', '문경시', '7', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('175', '봉화군', '8', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('176', '상주시', '9', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('177', '성주군', '10', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('178', '안동시', '11', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('179', '영덕군', '12', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('180', '영양군', '13', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('181', '영주시', '14', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('182', '영천시', '15', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('183', '예천군', '16', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('184', '울릉군', '17', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('185', '울진군', '18', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('186', '의성군', '19', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('187', '청도군', '20', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('188', '청송군', '21', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('189', '칠곡군', '22', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('190', '포항시', '23', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('191', '거제시', '1', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('192', '거창군', '2', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('193', '고성군', '3', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('194', '김해시', '4', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('195', '남해군', '5', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('196', '밀양시', '7', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('197', '사천시', '8', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('198', '산청군', '9', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('199', '양산시', '10', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('200', '의령군', '12', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('201', '진주시', '13', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('202', '창녕군', '15', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('203', '창원시', '16', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('204', '통영시', '17', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('205', '하동군', '18', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('206', '함안군', '19', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('207', '함양군', '20', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('208', '합천군', '21', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('209', '고창군', '1', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('210', '군산시', '2', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('211', '김제시', '3', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('212', '남원시', '4', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('213', '무주군', '5', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('214', '부안군', '6', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('215', '순창군', '7', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('216', '완주군', '8', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('217', '익산시', '9', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('218', '임실군', '10', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('219', '장수군', '11', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('220', '전주시', '12', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('221', '정읍시', '13', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('222', '진안군', '14', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('223', '강진군', '1', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('224', '고흥군', '2', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('225', '곡성군', '3', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('226', '광양시', '4', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('227', '구례군', '5', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('228', '나주시', '6', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('229', '담양군', '7', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('230', '목포시', '8', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('231', '무안군', '9', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('232', '보성군', '10', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('233', '순천시', '11', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('234', '신안군', '12', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('235', '여수시', '13', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('236', '영광군', '16', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('237', '영암군', '17', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('238', '완도군', '18', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('239', '장성군', '19', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('240', '장흥군', '20', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('241', '진도군', '21', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('242', '함평군', '22', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('243', '해남군', '23', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('244', '화순군', '24', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('245', '남제주군', '1', '39', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('246', '북제주군', '2', '39', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('247', '서귀포시', '3', '39', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('248', '제주시', '4', '39', '2'); \ No newline at end of file From 82b11729f4486fa0f7bb2ac24b4cad0cfd944231 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 28 Jul 2025 21:40:09 +0900 Subject: [PATCH 057/191] =?UTF-8?q?bug/Builder=20null=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빌더 사용시 기본값을 사용하도록 지정 --- .../catsgotogedog/User/domain/entity/User.java | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index 9ee0383..4d2e2fb 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -32,16 +32,6 @@ public class User extends BaseTimeEntity { private Boolean isActive; @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) - private List pets = new ArrayList<>(); - - public void addPet(Pet pet) { - pets.add(pet); - pet.setUser(this); - } - - public void removePet(Pet pet) { - pets.remove(pet); - pet.setUser(null); - } - + @Builder.Default + private final List pets = new ArrayList<>(); } \ No newline at end of file From 7b0dbce2490f7538457e1c2b0b31a512e589e17a Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 28 Jul 2025 22:43:01 +0900 Subject: [PATCH 058/191] rename/sigtest_information to sights_information --- src/main/resources/db/migration/mysql/V5__init_region_data.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/resources/db/migration/mysql/V5__init_region_data.sql b/src/main/resources/db/migration/mysql/V5__init_region_data.sql index 97b7dbb..c9b80e5 100644 --- a/src/main/resources/db/migration/mysql/V5__init_region_data.sql +++ b/src/main/resources/db/migration/mysql/V5__init_region_data.sql @@ -1,3 +1,5 @@ +ALTER TABLE `catsgotogedog`.`sigtes_information` + RENAME TO `catsgotogedog`.`sights_information` ; INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('1', '서울', '1', '1'); INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('2', '인천', '2', '1'); INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('3', '대전', '3', '1'); From d3344cc5fc3e3247ef5cac093e47a0bedf94814d Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+spacedivver@users.noreply.github.com> Date: Tue, 29 Jul 2025 02:02:17 +0900 Subject: [PATCH 059/191] =?UTF-8?q?feat:=20elasticsearch=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20#32=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색, 데이터 저장 로직, 필드타입, analyzer 설정 --- .../content/controller/ContentController.java | 20 ++++++- .../content/domain/entity/Content.java | 53 +++++++++++++++++++ .../domain/entity/ContentDocument.java | 29 ++++++++-- .../content/domain/request/.gitkeep | 0 .../domain/request/ContentRequest.java | 24 +++++++++ .../repository/ContentElasticRepository.java | 3 ++ .../content/repository/ContentRepository.java | 7 +++ .../content/service/ContentSearchService.java | 21 ++++++++ .../content/service/ContentService.java | 25 +++++++++ .../global/config/ElasticsearchConfig.java | 20 ------- .../elasticsearch/search-mapping.json | 52 +++++++++++++++--- .../elasticsearch/search-setting.json | 8 +-- 12 files changed, 228 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/request/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/global/config/ElasticsearchConfig.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index f0a5e6f..67c1979 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -1,15 +1,31 @@ package com.swyp.catsgotogedog.content.controller; +import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; +import com.swyp.catsgotogedog.content.domain.request.ContentRequest; +import com.swyp.catsgotogedog.content.service.ContentSearchService; import com.swyp.catsgotogedog.content.service.ContentService; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; import lombok.RequiredArgsConstructor; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/content") public class ContentController implements ContentControllerSwagger{ private final ContentService contentService; + private final ContentSearchService contentSearchService; + + @GetMapping("/search") + public ResponseEntity> contentSearch(@RequestParam("keyword") String keyword){ + return ResponseEntity.ok(contentSearchService.searchByKeyword(keyword)); + } + @PostMapping("/save") + ResponseEntity saveContent(@RequestBody ContentRequest request) { + contentService.saveContent(request); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java new file mode 100644 index 0000000..401c1cb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java @@ -0,0 +1,53 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import com.swyp.catsgotogedog.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; + +@Entity +@Getter +@Builder +public class Content extends BaseTimeEntity { + + @Id + @Column(nullable = false) + private int contentId; + + @Column(nullable = false) + private int categoryId; + + @Column(nullable = false) + private int regionId; + + private String addr1; + + private String addr2; + + private String image; + + private String thumbImage; + + private String copyright; + + @Column(precision = 10, scale = 8) + private BigDecimal mapx; + + @Column(precision = 11, scale = 8) + private BigDecimal mapy; + + private int mlevel; + + private String tel; + + private String title; + + private int zipcode; + + private int contentTypeId; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java index cd99f14..df084f2 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java @@ -1,18 +1,41 @@ package com.swyp.catsgotogedog.content.domain.entity; -import jakarta.persistence.Id; -import lombok.Getter; +import org.springframework.data.annotation.Id; +import lombok.*; import org.springframework.data.elasticsearch.annotations.*; @Getter @Document(indexName = "content", createIndex = true) @Setting(settingPath = "elasticsearch/search-setting.json") @Mapping(mappingPath = "elasticsearch/search-mapping.json") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class ContentDocument { @Id @Field(type= FieldType.Integer) - private int content_id; + private int contentId; + private int categoryId; + + private String addr1; + + private String addr2; + + private String title; + + private int contentTypeId; + + public static ContentDocument from(Content content){ + return ContentDocument.builder() + .contentId(content.getContentId()) + .categoryId(content.getCategoryId()) + .addr1(content.getAddr1()) + .addr2(content.getAddr2()) + .title(content.getTitle()) + .contentTypeId(content.getContentTypeId()) + .build(); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/content/domain/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java new file mode 100644 index 0000000..82c81f2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java @@ -0,0 +1,24 @@ +package com.swyp.catsgotogedog.content.domain.request; + +import lombok.Getter; + +import java.math.BigDecimal; + +@Getter +public class ContentRequest { + private int contentId; + private int categoryId; + private int regionId; + private String addr1; + private String addr2; + private String image; + private String thumbImage; + private String copyright; + private BigDecimal mapx; + private BigDecimal mapy; + private int mlevel; + private String tel; + private String title; + private int zipcode; + private int contentTypeId; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java index b0551a9..eaa10d4 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java @@ -3,5 +3,8 @@ import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; +import java.util.List; + public interface ContentElasticRepository extends ElasticsearchRepository { + List findByTitleContaining(String title); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java new file mode 100644 index 0000000..5d6ba96 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContentRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java new file mode 100644 index 0000000..7dddc24 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -0,0 +1,21 @@ +package com.swyp.catsgotogedog.content.service; + +import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; +import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContentSearchService { + private final ContentElasticRepository contentElasticRepository; + + public List searchByKeyword(String keyword){ + return contentElasticRepository.findByTitleContaining(keyword); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 4690942..3f5f6bd 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -1,6 +1,10 @@ package com.swyp.catsgotogedog.content.service; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; +import com.swyp.catsgotogedog.content.domain.request.ContentRequest; import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; +import com.swyp.catsgotogedog.content.repository.ContentRepository; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -10,6 +14,27 @@ @RequiredArgsConstructor @Slf4j public class ContentService { + private final ContentRepository contentRepository; private final ContentElasticRepository contentElasticRepository; + public void saveContent(ContentRequest request){ + Content content = Content.builder() + .categoryId(request.getCategoryId()) + .regionId(request.getRegionId()) + .addr1(request.getAddr1()) + .addr2(request.getAddr2()) + .image(request.getImage()) + .thumbImage(request.getThumbImage()) + .copyright(request.getCopyright()) + .mapx(request.getMapx()) + .mapy(request.getMapy()) + .mlevel(request.getMlevel()) + .tel(request.getTel()) + .title(request.getTitle()) + .zipcode(request.getZipcode()) + .contentTypeId(request.getContentTypeId()) + .build(); + contentRepository.save(content); + contentElasticRepository.save(ContentDocument.from(content)); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/ElasticsearchConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/ElasticsearchConfig.java deleted file mode 100644 index 9d7afc5..0000000 --- a/src/main/java/com/swyp/catsgotogedog/global/config/ElasticsearchConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.swyp.catsgotogedog.global.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.elasticsearch.client.ClientConfiguration; -import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration; - -@Configuration -public class ElasticsearchConfig extends ElasticsearchConfiguration { - - @Value("${spring.elasticsearch.url}") - private String host; - - @Override - public ClientConfiguration clientConfiguration() { - return ClientConfiguration.builder() - .connectedTo(host) - .build(); - } -} \ No newline at end of file diff --git a/src/main/resources/elasticsearch/search-mapping.json b/src/main/resources/elasticsearch/search-mapping.json index b400a4f..8593597 100644 --- a/src/main/resources/elasticsearch/search-mapping.json +++ b/src/main/resources/elasticsearch/search-mapping.json @@ -1,9 +1,49 @@ { - "analysis": { - "analyzer": { - "korean": { - "type": "nori" - } + "properties": { + "contentId": { + "type": "integer" + }, + "categoryId": { + "type": "integer" + }, + "regionId": { + "type": "integer" + }, + "addr1": { + "type": "text" + }, + "addr2": { + "type": "text" + }, + "image": { + "type": "keyword" + }, + "thumbImage": { + "type": "keyword" + }, + "copyright": { + "type": "text" + }, + "mapx": { + "type": "double" + }, + "mapy": { + "type": "double" + }, + "mlevel": { + "type": "integer" + }, + "tel": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "zipcode": { + "type": "integer" + }, + "contentTypeId": { + "type": "integer" } } -} \ No newline at end of file +} diff --git a/src/main/resources/elasticsearch/search-setting.json b/src/main/resources/elasticsearch/search-setting.json index edc30b2..b400a4f 100644 --- a/src/main/resources/elasticsearch/search-setting.json +++ b/src/main/resources/elasticsearch/search-setting.json @@ -1,7 +1,9 @@ { - "properties": { - "content_id": { - "type": "int" + "analysis": { + "analyzer": { + "korean": { + "type": "nori" + } } } } \ No newline at end of file From 86f9c4214e1951be62847f804f73990e854dec08 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Tue, 29 Jul 2025 22:09:00 +0900 Subject: [PATCH 060/191] feat/batch content, content_image, content overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스프링 배치를 통한 content 테이블 데이터, content_image 데이터, content overview 컬럼 값을 받아오도록 함 --- build.gradle | 6 + .../com/batch/client/MigrationClient.java | 205 ++++++++++++++++++ .../java/com/batch/config/BatchConfig.java | 149 +++++++++++++ .../com/batch/config/ItemsDeserializer.java | 47 ++++ .../com/batch/dto/AreaBasedListResponse.java | 53 +++++ .../java/com/batch/dto/AreaCodeResponse.java | 43 ++++ .../com/batch/dto/CategoryCodeResponse.java | 46 ++++ .../java/com/batch/dto/ContentTypeId.java | 18 ++ .../com/batch/dto/DetailCommonResponse.java | 72 ++++++ .../com/batch/dto/DetailImageResponse.java | 78 +++++++ .../dto/DetailInfoAccommodationResponse.java | 66 ++++++ .../com/batch/dto/DetailInfoResponse.java | 46 ++++ .../listener/CustomJobExecutionListener.java | 29 +++ .../listener/CustomStepExecutionListener.java | 37 ++++ .../processor/AreaBasedListItemProcessor.java | 83 +++++++ .../processor/DetailCommonProcessor.java | 63 ++++++ .../batch/processor/DetailImageProcessor.java | 71 ++++++ .../batch/reader/AreaBasedListApiReader.java | 132 +++++++++++ .../com/batch/reader/DetailCommonReader.java | 30 +++ .../com/batch/reader/DetailImageReader.java | 28 +++ .../batch/tasklet/CategoryFetchTasklet.java | 82 +++++++ .../com/batch/writer/ContentImageWriter.java | 33 +++ .../com/batch/writer/DetailCommonWriter.java | 20 ++ .../com/batch/writer/ItemWriterConfig.java | 23 ++ .../CatsgotogedogApplication.java | 55 ++++- .../category/domain/entity/Category.java | 4 - .../category/domain/entity/CategoryCode.java | 29 +++ .../repository/CategoryRepository.java | 6 +- .../content/domain/entity/Content.java | 89 ++++++++ .../content/domain/entity/ContentImage.java | 46 ++++ .../content/domain/entity/RegionCode.java | 41 ++++ .../repository/ContentImageRepository.java | 8 + .../content/repository/ContentRepository.java | 8 + .../repository/RegionCodeRepository.java | 16 ++ .../mysql/V3__alter_categaory_content.sql | 3 + .../mysql/V4__alter_region_code_content.sql | 4 +- 36 files changed, 1759 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/batch/client/MigrationClient.java create mode 100644 src/main/java/com/batch/config/BatchConfig.java create mode 100644 src/main/java/com/batch/config/ItemsDeserializer.java create mode 100644 src/main/java/com/batch/dto/AreaBasedListResponse.java create mode 100644 src/main/java/com/batch/dto/AreaCodeResponse.java create mode 100644 src/main/java/com/batch/dto/CategoryCodeResponse.java create mode 100644 src/main/java/com/batch/dto/ContentTypeId.java create mode 100644 src/main/java/com/batch/dto/DetailCommonResponse.java create mode 100644 src/main/java/com/batch/dto/DetailImageResponse.java create mode 100644 src/main/java/com/batch/dto/DetailInfoAccommodationResponse.java create mode 100644 src/main/java/com/batch/dto/DetailInfoResponse.java create mode 100644 src/main/java/com/batch/listener/CustomJobExecutionListener.java create mode 100644 src/main/java/com/batch/listener/CustomStepExecutionListener.java create mode 100644 src/main/java/com/batch/processor/AreaBasedListItemProcessor.java create mode 100644 src/main/java/com/batch/processor/DetailCommonProcessor.java create mode 100644 src/main/java/com/batch/processor/DetailImageProcessor.java create mode 100644 src/main/java/com/batch/reader/AreaBasedListApiReader.java create mode 100644 src/main/java/com/batch/reader/DetailCommonReader.java create mode 100644 src/main/java/com/batch/reader/DetailImageReader.java create mode 100644 src/main/java/com/batch/tasklet/CategoryFetchTasklet.java create mode 100644 src/main/java/com/batch/writer/ContentImageWriter.java create mode 100644 src/main/java/com/batch/writer/DetailCommonWriter.java create mode 100644 src/main/java/com/batch/writer/ItemWriterConfig.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/category/domain/entity/Category.java create mode 100644 src/main/java/com/swyp/catsgotogedog/category/domain/entity/CategoryCode.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java diff --git a/build.gradle b/build.gradle index 9d3d969..f7bc163 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,12 @@ dependencies { // Flyway implementation group: 'org.flywaydb', name: 'flyway-mysql', version: '11.10.2' implementation 'org.flywaydb:flyway-core' + + //Spring Batch + implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'org.springframework.batch:spring-batch-core' + testImplementation 'org.springframework.batch:spring-batch-test' + implementation 'jakarta.persistence:jakarta.persistence-api' } tasks.named('test') { diff --git a/src/main/java/com/batch/client/MigrationClient.java b/src/main/java/com/batch/client/MigrationClient.java new file mode 100644 index 0000000..b9e1658 --- /dev/null +++ b/src/main/java/com/batch/client/MigrationClient.java @@ -0,0 +1,205 @@ +package com.batch.client; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import com.batch.dto.AreaBasedListResponse; +import com.batch.dto.CategoryCodeResponse; +import com.batch.dto.ContentTypeId; +import com.batch.dto.DetailImageResponse; +import com.batch.dto.DetailInfoResponse; + +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class MigrationClient { + + @Value("${tour.api.base-url}") + private String baseUrl; + + @Value("${tour.api.service-key}") + private String serviceKey; + + private RestClient restClient; + + public MigrationClient(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.build(); + } + + private static void handle(HttpRequest request, ClientHttpResponse response) throws IOException { + // 5xx 서버 에러 처리 + String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); + log.error("에러 응답 areaBasedList API ({} {}): Status {}, Body: {}", + request.getMethod(), request.getURI(), response.getStatusCode(), errorBody); + throw new RuntimeException("AreaBasedList API 호출 오류 : " + errorBody); + } + + // areaBasedList + public AreaBasedListResponse getAreaBasedLists(int pageNo, int numOfRows, String contentTypeId, String modifiedTime) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUrl + "/areaBasedList") + .queryParam("serviceKey", serviceKey) + .queryParam("pageNo", pageNo) + .queryParam("numOfRows", numOfRows) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "CatsGoToGedog") + .queryParam("contentTypeId", contentTypeId) // 관광타입(12:관광지, 14:문화시설, 15:축제공연행사, 28:레포츠, 32:숙박, 38:쇼핑, 39:음식점) ID + .queryParam("_type", "json"); + + if(modifiedTime != null && !modifiedTime.isEmpty()) { + uri.queryParam("modifiedtime", modifiedTime); + } + URI fullUri = uri.encode(StandardCharsets.UTF_8).build().toUri(); + + log.info("API Request URL (areaBasedList) : {}", uri.toUriString()); + + try { + return restClient.get() + .uri(fullUri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatus.BAD_REQUEST::equals, (request, response) -> { + String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); + log.error("에러 응답 areaBasedList API ({} {}): Status {}, Body {}",request.getMethod(), request.getURI(), response.getStatusCode(), errorBody); + }) + .onStatus(HttpStatusCode::is4xxClientError, MigrationClient::handle) + .onStatus(HttpStatusCode::is5xxServerError, MigrationClient::handle) + .body(AreaBasedListResponse.class); + }catch (RuntimeException e) { + log.error("areaBasedList API 요청중 오류 발생 : {}", e.getMessage(), e); + throw e; // 배치 스탭에서 재시도/스킵 위해 재throw + }catch (Exception e) {// 기타 exception + log.error("areaBasedList API 요청중 예상치 못한 오류 발생: {}", e.getMessage(), e); + throw new RuntimeException("areaBasedList API 요청중 예상치 못한 오류 발생", e); + } + } + + public AreaBasedListResponse getAreaBasedLists(int pageNo, int numOfRows, String contentTypeId) { + return getAreaBasedLists(pageNo, numOfRows, contentTypeId, null); + } + + // categoryCode + public List getCategoryCode(String contentTypeId, String cat1, String cat2) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUrl + "/categoryCode") + .queryParam("serviceKey", serviceKey) + .queryParam("pageNo", 1) + .queryParam("numOfRows", 100) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "CatsGoToGedog") + .queryParam("contentTypeId", contentTypeId) // 관광타입(12:관광지, 14:문화시설, 15:축제공연행사, 28:레포츠, 32:숙박, 38:쇼핑, 39:음식점) ID + .queryParam("_type", "json"); + if(cat1 != null && !cat1.isEmpty()) { + uri.queryParam("cat1", cat1); + } + if(cat2 != null && !cat2.isEmpty()) { + uri.queryParam("cat2", cat2); + } + URI fullUri = uri.encode(StandardCharsets.UTF_8).build().toUri(); + + try { + CategoryCodeResponse response = restClient.get() + .uri(fullUri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, MigrationClient::handle) + .body(CategoryCodeResponse.class); + return Optional.ofNullable(response) + .map(CategoryCodeResponse::getResponse) + .map(CategoryCodeResponse.Response::getBody) + .map(CategoryCodeResponse.Body::getItems) + .map(CategoryCodeResponse.Items::getItem) + .orElse(Collections.emptyList()); + } catch (RuntimeException e) { + log.error("categoryCode API 요청 중 오류 발생 (contentTypeId: {}, cat1: {}, cat2: {}): {}", + contentTypeId, cat1, cat2, e.getMessage(), e); + return Collections.emptyList(); + } catch (Exception e) { + log.error("categoryCode API 요청 중 예상치 못한 오류 발생 (contentTypeId: {}, cat1: {}, cat2: {}): {}", + contentTypeId, cat1, cat2, e.getMessage(), e); + return Collections.emptyList(); + } + } + + + + // detailInfo + public DetailInfoResponse getDetailInfoAccom(String contentId, String contentTypeId) { + URI uri = UriComponentsBuilder.fromUriString(baseUrl + "/detailInfo") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "CatsGoToGedog") + .queryParam("contentTypeId", ContentTypeId.숙박.getContentTypeId()) + .queryParam("_type", "json") + .encode(StandardCharsets.UTF_8) + .build() + .toUri(); + log.info("API Request URL (detailInfo) : {}", uri); + + try { + return restClient.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); + log.error("detailInfo API 요청중 오류 발생(컨텐츠 ID: {}) ({} {}) : Status {}, Body {}", contentId, request.getMethod(), request.getURI(), response.getStatusCode(), errorBody); + }) + .body(DetailInfoResponse.class); + }catch (Exception e) { + log.error("detailInfo API 요청중 예상치 못한 오류 발생(컨텐츠 ID: {}) : {}", contentId, e.getMessage(), e); + return null; + } + } + + // Content Image + public List getDetailImageList(int contentId) { + URI uri = UriComponentsBuilder.fromUriString(baseUrl + "/detailImage") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "CatsGoToGedog") + .queryParam("contentId ", contentId) + .queryParam("imageYN", "Y") + .queryParam("_type", "json") + .encode(StandardCharsets.UTF_8) + .build() + .toUri(); + + log.info("API Request URL (detailImage) : {}", uri); + + try { + DetailImageResponse response = restClient.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, MigrationClient::handle) + .body(DetailImageResponse.class); + + return Optional.ofNullable(response) + .map(DetailImageResponse::response) + .map(DetailImageResponse.Response::body) + .map(DetailImageResponse.Body::items) + .map(DetailImageResponse.Items::item) + .orElse(Collections.emptyList()); + } catch (RuntimeException e) { + log.error("detailImage API 요청 중 오류 발생 (contentId: {}): {}", contentId, e.getMessage(), e); + return Collections.emptyList(); + } catch (Exception e) { + log.error("detailImage API 요청 중 예상치 못한 오류 발생 (contentId: {}): {}", contentId, e.getMessage(), e); + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/com/batch/config/BatchConfig.java b/src/main/java/com/batch/config/BatchConfig.java new file mode 100644 index 0000000..bbab9ae --- /dev/null +++ b/src/main/java/com/batch/config/BatchConfig.java @@ -0,0 +1,149 @@ +package com.batch.config; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.client.ResourceAccessException; + +import com.batch.dto.AreaBasedListResponse; +import com.batch.listener.CustomJobExecutionListener; +import com.batch.listener.CustomStepExecutionListener; +import com.batch.processor.AreaBasedListItemProcessor; +import com.batch.processor.DetailCommonProcessor; +import com.batch.processor.DetailImageProcessor; +import com.batch.reader.AreaBasedListApiReader; +import com.batch.reader.DetailImageReader; +import com.batch.tasklet.CategoryFetchTasklet; +import com.batch.writer.ContentImageWriter; +import com.batch.writer.DetailCommonWriter; +import com.batch.writer.ItemWriterConfig; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; + +import jakarta.persistence.EntityManagerFactory; + +@Configuration +@Slf4j +@RequiredArgsConstructor +public class BatchConfig { + + private final EntityManagerFactory entityManagerFactory; + private final PlatformTransactionManager transactionManager; + private final CustomJobExecutionListener customJobExecutionListener; + private final CustomStepExecutionListener customStepExecutionListener; + private final JobRepository jobRepository; + private final CategoryFetchTasklet categoryFetchTasklet; + + // Reader + private final JpaPagingItemReader detailImageContentReader; + private final AreaBasedListApiReader contentReader; + private final JpaPagingItemReader detailCommonItemReader; + + // Writer + private final ItemWriterConfig itemWriterConfig; + private final ContentImageWriter contentImageWriter; + private final DetailCommonWriter detailCommonWriter; + + // Processor + private final DetailImageProcessor detailImageProcessor; + private final AreaBasedListItemProcessor contentProcessor; + private final DetailCommonProcessor detailCommonProcessor; + + private final int CHUNK_SIZE = 100; + + + // 메인 JOB 컨텐츠 > 이미지 > 디테일 + @Bean + public Job contentBatchJob() { + log.info("Configuring contentBatchJob..."); + return new JobBuilder("contentBatchJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(customJobExecutionListener) + .start(contentDataFetchStep()) + .next(detailCommonFetchStep()) + //.next(detailImageFetchStep()) + .build(); + } + + // content step + @Bean + public Step contentDataFetchStep() { + log.info("Configuring contentDataFetchStep..."); + return new StepBuilder("contentDataFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(contentReader) + .processor(contentProcessor) + .writer(itemWriterConfig.step1ContentWriter(entityManagerFactory)) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retryLimit(3) + .retry(ResourceAccessException.class) + .listener(customStepExecutionListener) + .build(); + } + + // category Step + @Bean + public Step categoryCodeFetchStep() { + log.info("Configuring categoryCodeFetchStep..."); + return new StepBuilder("categoryCodeFetchStep", jobRepository) + .tasklet(categoryFetchTasklet, transactionManager) + .listener(customStepExecutionListener) + .build(); + } + + // category Job + @Bean + public Job categoryCodeBatchJob() { + log.info("Configuring categoryCodeBatchJob..."); + return new JobBuilder("categoryCodeBatchJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(customJobExecutionListener) + .start(categoryCodeFetchStep()) + .build(); + } + + // 이미지 스텝 + @Bean + public Step detailImageFetchStep() { + return new StepBuilder("detailImageFetchStep", jobRepository) + .>chunk(100, transactionManager) + .reader(detailImageContentReader) + .processor(detailImageProcessor) + .writer(contentImageWriter) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customStepExecutionListener) + .build(); + } + + // content overview 스텝 + @Bean + public Step detailCommonFetchStep() { + return new StepBuilder("detailCommonFetchStep", jobRepository) + .chunk(100, transactionManager) + .reader(detailCommonItemReader) + .processor(detailCommonProcessor) + .writer(detailCommonWriter) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customStepExecutionListener) + .build(); + } +} diff --git a/src/main/java/com/batch/config/ItemsDeserializer.java b/src/main/java/com/batch/config/ItemsDeserializer.java new file mode 100644 index 0000000..d4d716a --- /dev/null +++ b/src/main/java/com/batch/config/ItemsDeserializer.java @@ -0,0 +1,47 @@ +package com.batch.config; + +import java.io.IOException; + +import com.batch.dto.AreaBasedListResponse; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; + +public class ItemsDeserializer extends JsonDeserializer implements ContextualDeserializer { + + private JavaType targetType; + + public ItemsDeserializer() { + } + + public ItemsDeserializer(JavaType targetType) { + this.targetType = targetType; + } + + @Override + public AreaBasedListResponse.Items deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.readValueAsTree(); + + if(node.isTextual() && node.textValue().isEmpty()) { + return null; + } + + ObjectMapper mapper = (ObjectMapper) p.getCodec(); + return mapper.treeToValue(node, targetType); + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException { + if(property != null) { + JavaType type = property.getType(); + return new ItemsDeserializer(type); + } + return this; + } +} diff --git a/src/main/java/com/batch/dto/AreaBasedListResponse.java b/src/main/java/com/batch/dto/AreaBasedListResponse.java new file mode 100644 index 0000000..5a01d9b --- /dev/null +++ b/src/main/java/com/batch/dto/AreaBasedListResponse.java @@ -0,0 +1,53 @@ +package com.batch.dto; + +import java.util.List; + +import com.batch.config.ItemsDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/areaBasedList) +public record AreaBasedListResponse (Response response) { + + public record Response( + Header header, + Body body + ) {} + + public record Header( + String resultCode, + String resultMsg + ) {} + + public record Body( + @JsonDeserialize(using = ItemsDeserializer.class) + Items items, + int numOfRows, + int pageNo, + int totalCount + ) {} + + public record Items(List item) {} + + public record Item( + String addr1, + String addr2, + String areacode, + String cat1, + String cat2, + String cat3, + String contentid, + String contenttypeid, + String createdtime, + String firstimage, + String firstimage2, + String cpyrhtDivCd, // copyright + String mapx, + String mapy, + String mlevel, + String modifiedtime, + String sigungucode, + String tel, + String title, + String zipcode + ) {} +} diff --git a/src/main/java/com/batch/dto/AreaCodeResponse.java b/src/main/java/com/batch/dto/AreaCodeResponse.java new file mode 100644 index 0000000..52cad56 --- /dev/null +++ b/src/main/java/com/batch/dto/AreaCodeResponse.java @@ -0,0 +1,43 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import lombok.Data; + +@Data +public class AreaCodeResponse { + private Response response; + + @Data + public static class Response { + private Header header; + private Body body; + } + + @Data + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Data + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + } + + @Data + public static class Items { + private List item = Collections.emptyList(); + } + + @Data + public static class Item { + private String code; // 지역 코드 (areaCode) + private String name; // 지역 한글명 + private String rnum; + } +} diff --git a/src/main/java/com/batch/dto/CategoryCodeResponse.java b/src/main/java/com/batch/dto/CategoryCodeResponse.java new file mode 100644 index 0000000..5e47f50 --- /dev/null +++ b/src/main/java/com/batch/dto/CategoryCodeResponse.java @@ -0,0 +1,46 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import lombok.Data; + +@Data +public class CategoryCodeResponse { + private Response response; + + @Data + public static class Response { + private Header header; + private Body body; + } + + @Data + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Data + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + } + + @Data + public static class Items { + private List item = Collections.emptyList(); + } + + @Data + public static class Item { + private String name; + private String rnum; + private String code; + private String cat1; + private String cat2; + private String cat3; + } +} diff --git a/src/main/java/com/batch/dto/ContentTypeId.java b/src/main/java/com/batch/dto/ContentTypeId.java new file mode 100644 index 0000000..b8cc599 --- /dev/null +++ b/src/main/java/com/batch/dto/ContentTypeId.java @@ -0,0 +1,18 @@ +package com.batch.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ContentTypeId { + 관광지(12), + 문화시설(14), + 축제공연행사(15), + 레포츠(28), + 숙박(32), + 쇼핑(38), + 음식점(39); + + private final int contentTypeId; +} diff --git a/src/main/java/com/batch/dto/DetailCommonResponse.java b/src/main/java/com/batch/dto/DetailCommonResponse.java new file mode 100644 index 0000000..fd25470 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailCommonResponse.java @@ -0,0 +1,72 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.io.IOException; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailCommon) +public record DetailCommonResponse(Response response){ + + public record Response( + Header header, + Body body + ) {} + + public record Header( + String resultCode, + String resultMsg + ) {} + + public record Body( + Items items, + int numOfRows, + int pageNo, + int totalCount + ) {} + + public record Items(List item) { + + @JsonCreator + public static Items from(JsonNode node) throws IOException { + + // ObjectMapper는 한 번만 생성하는 것이 효율적입니다. + final ObjectMapper mapper = new ObjectMapper(); + + // 1. items 필드가 비어있는 문자열("")일 경우 + if (node.isTextual() && node.asText().isEmpty()) { + return new Items(Collections.emptyList()); + } + + // 2. 정상적인 JSON 객체일 경우 + if (node.isObject()) { + // "item"이라는 이름의 하위 노드를 찾습니다. + JsonNode itemNode = node.get("item"); + + // 하위 노드가 없거나 배열이 아니면 빈 리스트로 처리합니다. + if (itemNode == null || !itemNode.isArray()) { + return new Items(Collections.emptyList()); + } + + // ✨ itemNode(배열)를 List 타입으로 직접 변환합니다. + List itemList = mapper.convertValue(itemNode, new TypeReference<>() {}); + return new Items(itemList); + } + + // 3. 그 외의 모든 경우 (null 등) + return new Items(Collections.emptyList()); + } + + } + + public record Item( + String contentid, + String contenttypeid, + String overview + ){} +} diff --git a/src/main/java/com/batch/dto/DetailImageResponse.java b/src/main/java/com/batch/dto/DetailImageResponse.java new file mode 100644 index 0000000..5ff0fa1 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailImageResponse.java @@ -0,0 +1,78 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import com.batch.config.ItemsDeserializer; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import io.jsonwebtoken.io.IOException; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailImage) +public record DetailImageResponse (Response response){ + + public record Response( + Header header, + Body body + ) {} + + public record Header( + String resultCode, + String resultMsg + ) {} + + public record Body( + Items items, + int numOfRows, + int pageNo, + int totalCount + ) {} + + public record Items(List item) { + + @JsonCreator + public static Items from(JsonNode node) throws IOException { + + // ObjectMapper는 한 번만 생성하는 것이 효율적입니다. + final ObjectMapper mapper = new ObjectMapper(); + + // 1. items 필드가 비어있는 문자열("")일 경우 + if (node.isTextual() && node.asText().isEmpty()) { + return new Items(Collections.emptyList()); + } + + // 2. 정상적인 JSON 객체일 경우 + if (node.isObject()) { + // "item"이라는 이름의 하위 노드를 찾습니다. + JsonNode itemNode = node.get("item"); + + // 하위 노드가 없거나 배열이 아니면 빈 리스트로 처리합니다. + if (itemNode == null || !itemNode.isArray()) { + return new Items(Collections.emptyList()); + } + + // ✨ itemNode(배열)를 List 타입으로 직접 변환합니다. + List itemList = mapper.convertValue(itemNode, new TypeReference<>() {}); + return new Items(itemList); + } + + // 3. 그 외의 모든 경우 (null 등) + return new Items(Collections.emptyList()); + } + + } + + public record Item( + String cpyrhtDivCd, + String contentid, + String imgname, + String originimgurl, + String serialnum, + String smallimageurl + ){} +} diff --git a/src/main/java/com/batch/dto/DetailInfoAccommodationResponse.java b/src/main/java/com/batch/dto/DetailInfoAccommodationResponse.java new file mode 100644 index 0000000..b18e676 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailInfoAccommodationResponse.java @@ -0,0 +1,66 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import lombok.Data; + +// 반복정보검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailInfo) +@Data +public class DetailInfoAccommodationResponse { + private Response response; + + @Data + public static class Response { + private Header header; + private Body body; + } + + @Data + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Data + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + } + + @Data + public static class Items { + private List item = Collections.emptyList(); + } + + @Data + public static class Item { + private String roominfono; + private String roomtitle; + private String roomsize1; + private String roomcount; + private String roombasecount; + private String roommaxcount; + private String roomoffseasonminfee1; + private String roomoffseasonminfee2; + private String roompeakseasonminfee1; + private String roompeakseasonminfee2; + private String roomintro; + private String roombath; + private String facility; + private String roomhometheater; + private String roomaircondition; + private String roomtv; + private String roompc; + private String roomcable; + private String roominternet; + private String roomrefrigerator; + private String roomtoiletries; + private String roomsofa; + private String roomcook; + private String roomhairdryer; + private String roomtable; + } +} diff --git a/src/main/java/com/batch/dto/DetailInfoResponse.java b/src/main/java/com/batch/dto/DetailInfoResponse.java new file mode 100644 index 0000000..77e909b --- /dev/null +++ b/src/main/java/com/batch/dto/DetailInfoResponse.java @@ -0,0 +1,46 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import lombok.Data; + +@Data +public class DetailInfoResponse { + private Response response; + + @Data + public static class Response { + private Header header; + private Body body; + } + + @Data + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Data + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + } + + @Data + public static class Items { + private List item = Collections.emptyList(); + } + + @Data + public static class Item { + private String contentid; + private String contenttypeid; + private String fldgubun; + private String infoname; + private String infotext; + private String serialnum; + } +} diff --git a/src/main/java/com/batch/listener/CustomJobExecutionListener.java b/src/main/java/com/batch/listener/CustomJobExecutionListener.java new file mode 100644 index 0000000..fd0ce97 --- /dev/null +++ b/src/main/java/com/batch/listener/CustomJobExecutionListener.java @@ -0,0 +1,29 @@ +package com.batch.listener; + +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomJobExecutionListener implements JobExecutionListener { + @Override + public void beforeJob(JobExecution jobExecution) { + log.info("[배치 Job 시작] Job 이름 : {}, Job Id : {}", + jobExecution.getJobInstance().getJobName(), jobExecution.getId()); + log.info("Job Parameter : {}", jobExecution.getJobParameters()); + } + + @Override + public void afterJob(JobExecution jobExecution) { + log.info("[배치 JOB 종료] Job 이름: {}, Job Id : {}, 상태 : {}", + jobExecution.getJobInstance().getJobName(), jobExecution.getJobId(), jobExecution.getStatus()); + + if (jobExecution.getStatus().isUnsuccessful()) { + log.error("Job {} 이 상태 {} 로 완료 되었습니다. 실패 : {}", + jobExecution.getJobInstance().getJobName(), jobExecution.getStatus(), jobExecution.getAllFailureExceptions()); + } + } +} diff --git a/src/main/java/com/batch/listener/CustomStepExecutionListener.java b/src/main/java/com/batch/listener/CustomStepExecutionListener.java new file mode 100644 index 0000000..5a97eb4 --- /dev/null +++ b/src/main/java/com/batch/listener/CustomStepExecutionListener.java @@ -0,0 +1,37 @@ +package com.batch.listener; + +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomStepExecutionListener implements StepExecutionListener { + + @Override + public void beforeStep(StepExecution stepExecution) { + log.info("[배치 스탭 시작] 스탭 이름 : {}, Job 이름 : {}", + stepExecution.getStepName(), stepExecution.getJobExecution().getJobInstance().getJobName()); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + log.info("[배치 스탭 종료] 스탭 이름 : {}, 상태 : {}, 읽기 : {}, 쓰기 : {}, 스킵 : {}, 실패 : {}", + stepExecution.getStepName(), + stepExecution.getStatus(), + stepExecution.getReadCount(), + stepExecution.getWriteCount(), + stepExecution.getSkipCount(), + stepExecution.getFailureExceptions().size()); + if (stepExecution.getStatus().isUnsuccessful()) { + log.error("{} 스텝이 {} 상태로 완료되었습니다. 이유 : {}", + stepExecution.getStepName(), + stepExecution.getStatus(), + stepExecution.getFailureExceptions()); + } + return stepExecution.getExitStatus(); + } +} diff --git a/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java b/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java new file mode 100644 index 0000000..d68e1c0 --- /dev/null +++ b/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java @@ -0,0 +1,83 @@ +package com.batch.processor; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import com.batch.dto.AreaBasedListResponse.Item; +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class AreaBasedListItemProcessor implements ItemProcessor { + + @Override + public Content process(Item item) throws Exception { + if (item == null) { + log.warn("Null item received in AreaBasedListItemProcessor."); + return null; + } + + if(item.contentid() == null || item.contentid().isEmpty()) { + log.warn("Content ID가 존재하지 않아 아이템 스킵 ContentId : {}", item.contentid()); + return null; + } + try { + Content content = Content.builder() + .contentId(Integer.parseInt(item.contentid())) + .contentTypeId(Integer.parseInt(item.contenttypeid())) + .categoryId(item.cat3()) + .title(item.title()) + .addr1(item.addr1()) + .addr2(item.addr2()) + .imageUrl(item.firstimage()) + .thumbImageUrl(item.firstimage2()) + .mapX(Double.parseDouble(item.mapx())) + .mapy(Double.parseDouble(item.mapy())) + .mLevel(Integer.parseInt(item.mlevel().isEmpty() ? "0" : item.mlevel())) + .tel(item.tel()) + .zipCode(encodeZipCode(item.zipcode())) + .sigunguCode(Integer.parseInt(item.sigungucode().isEmpty() ? "0" : item.sigungucode())) + .sidoCode(Integer.parseInt(item.areacode().isEmpty() ? "0" : item.areacode())) + .copyright(item.cpyrhtDivCd()) + .createdAt(LocalDateTime.now()) + .modifiedAt(parseLocalDateTime(item.modifiedtime())) + .build(); + return content; + }catch (Exception e) { + log.error("AreaBasedList item 처리중 오류가 발생했습니다 ({}) : {}", item.contentid(), e.getMessage(), e); + return null; + } + } + + private LocalDateTime parseLocalDateTime(String dateStr) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + try { + LocalDateTime localDateTime = LocalDateTime.parse(dateStr, formatter); + return localDateTime; + } catch(DateTimeParseException e) { + log.warn("modified 데이터 파싱 실패 : {} - {} ", dateStr, e.getMessage()); + return LocalDateTime.now(); + } + } + + private int encodeZipCode(String zipCode) { + try { + if(zipCode == null || zipCode.isEmpty()) { + return 0; + } + if(zipCode.contains(",")) { + return Integer.parseInt(zipCode.split(",")[0].replaceAll("^[0-9]", "")); + } else { + return Integer.parseInt(zipCode); + } + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/main/java/com/batch/processor/DetailCommonProcessor.java b/src/main/java/com/batch/processor/DetailCommonProcessor.java new file mode 100644 index 0000000..5b6fc35 --- /dev/null +++ b/src/main/java/com/batch/processor/DetailCommonProcessor.java @@ -0,0 +1,63 @@ +package com.batch.processor; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailCommonResponse; +import com.batch.dto.DetailImageResponse; +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DetailCommonProcessor implements ItemProcessor { + + private final RestClient restClient; + private final String serviceKey; + + public DetailCommonProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public Content process(Content content) throws Exception { + log.info("ContentId : ({}) 설명 정보 수집 중", content.getContentId()); + + DetailCommonResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailCommon") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("_type", "json") + .queryParam("contentId", content.getContentId()) + .queryParam("overviewYN", "Y") + .build()) + .retrieve() + .body(DetailCommonResponse.class); + + DetailCommonResponse.Response bodyResponse = (response != null) ? response.response() : null; + DetailCommonResponse.Body body = (bodyResponse != null) ? bodyResponse.body() : null; + DetailCommonResponse.Items items = (body != null) ? body.items() : null; + + if (response != null && response.response().body().items() != null && !response.response().body().items().item().isEmpty()) { + String overview = response.response().body().items().item().get(0).overview(); + content.setOverview(overview); + } + return content; + } +} diff --git a/src/main/java/com/batch/processor/DetailImageProcessor.java b/src/main/java/com/batch/processor/DetailImageProcessor.java new file mode 100644 index 0000000..eaf1090 --- /dev/null +++ b/src/main/java/com/batch/processor/DetailImageProcessor.java @@ -0,0 +1,71 @@ +package com.batch.processor; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailImageResponse; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class DetailImageProcessor implements ItemProcessor> { + + private final RestClient restClient; + private final String serviceKey; + + public DetailImageProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public List process(Content content) throws Exception { + log.info("ContentId : ({}) 이미지 수집 중", content.getContentId()); + + DetailImageResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailImage") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("_type", "json") + .queryParam("contentId", content.getContentId()) + .build() + ) + .retrieve() + .body(DetailImageResponse.class); + + DetailImageResponse.Response bodyResponse = (response != null) ? response.response() : null; + DetailImageResponse.Body body = (bodyResponse != null) ? bodyResponse.body() : null; + DetailImageResponse.Items items = (body != null) ? body.items() : null; + + // items 객체가 null이거나, 그 안의 item 리스트가 비어있는 경우를 안전하게 확인 + if (items == null || items.item() == null || items.item().isEmpty()) { + return Collections.emptyList(); // 이미지가 없으면 빈 리스트 반환 + } + + return response.response().body().items().item().stream() + .map(item -> { + return ContentImage.builder() + .contentId(content) + .imageUrl(item.originimgurl()) + .smallImageUrl(item.smallimageurl()) + .build(); + }).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/batch/reader/AreaBasedListApiReader.java b/src/main/java/com/batch/reader/AreaBasedListApiReader.java new file mode 100644 index 0000000..488ae21 --- /dev/null +++ b/src/main/java/com/batch/reader/AreaBasedListApiReader.java @@ -0,0 +1,132 @@ +package com.batch.reader; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Iterator; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.NonTransientResourceException; +import org.springframework.batch.item.ParseException; +import org.springframework.batch.item.UnexpectedInputException; +import org.springframework.stereotype.Component; + +import com.batch.client.MigrationClient; +import com.batch.dto.AreaBasedListResponse; +import com.batch.dto.AreaBasedListResponse.Item; +import com.batch.dto.ContentTypeId; +import com.swyp.catsgotogedog.content.repository.ContentRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AreaBasedListApiReader implements ItemReader { + + private final MigrationClient migrationClient; + private final ContentRepository contentRepository; + private final Queue itemQueue = new ConcurrentLinkedQueue<>(); + + private int currentPageNo = 1; + private final int numOfRows = 100; + private int currentContentTypeIndex = 0; + private int totalPages = 1; + private boolean isInitialLoad = false; + private String modifiedTimeParam = null; + + private Iterator itemIterator; + private List contentTypeIds = List.of( + String.valueOf(ContentTypeId.관광지.getContentTypeId()), + String.valueOf(ContentTypeId.축제공연행사.getContentTypeId()), + String.valueOf(ContentTypeId.숙박.getContentTypeId()), + String.valueOf(ContentTypeId.음식점.getContentTypeId()) + ); + + @Override + public Item read() throws + Exception, + UnexpectedInputException, + ParseException, + NonTransientResourceException { + + if (!itemQueue.isEmpty()) { + return itemQueue.poll(); + } + + boolean hasMore = fetchNextPage(); + if (!hasMore || itemQueue.isEmpty()) { + return null; + } + + return itemQueue.poll(); + } + + private boolean fetchNextPage() { + while(currentContentTypeIndex < contentTypeIds.size()) { + + if(currentPageNo == 1 && currentContentTypeIndex == 0) { + isInitialLoad = contentRepository.count() == 0; + modifiedTimeParam = isInitialLoad + ? null + : LocalDate.now().minusDays(3).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + log.info("Content {} 데이터 처리 시작 :: {}", + isInitialLoad ? "초기" : "갱신", modifiedTimeParam); + } + + var currentContentTypeId = contentTypeIds.get(currentContentTypeIndex); + log.info("처리 중인 ContentType ID: {}, 페이지: {}/{}", currentContentTypeId, currentPageNo, totalPages); + + AreaBasedListResponse response; + try { + response = migrationClient.getAreaBasedLists(currentPageNo, numOfRows, currentContentTypeId, modifiedTimeParam); + } catch(Exception e) { + log.error("API 호출 중 오류 발생 (contentTypeId : {}, pageNo : {})", currentContentTypeId, currentPageNo, e); + moveToNextContentType(); + continue; + } + + if (response == null || response.response() == null || response.response().body() == null) { + log.warn("응답 없음 - contentTypeId: {}, pageNo: {}", currentContentTypeId, currentPageNo); + moveToNextContentType(); + continue; + } + var body = response.response().body(); + var items = (body.items() == null) ? null : body.items().item(); + + if (items == null || items.isEmpty()) { + log.info("더 이상 데이터 없음 - contentTypeId: {}, pageNo: {}", currentContentTypeId, currentPageNo); + moveToNextContentType(); + continue; + } + + itemQueue.addAll(items); + + if (currentPageNo == 1) { + totalPages = (int) Math.ceil((double) body.totalCount() / numOfRows); + log.info("총 건수: {}, 총 페이지: {}", body.totalCount(), totalPages); + } + + currentPageNo++; + + if (currentPageNo > totalPages) { + log.info("ContentType 완료 - {}", currentContentTypeId); + moveToNextContentType(); + } + + return true; + } + log.info("모든 ContentType 처리 완료"); + return false; + } + + private void moveToNextContentType() { + currentContentTypeIndex++; + currentPageNo = 1; + totalPages = 1; + } +} diff --git a/src/main/java/com/batch/reader/DetailCommonReader.java b/src/main/java/com/batch/reader/DetailCommonReader.java new file mode 100644 index 0000000..f0c34d5 --- /dev/null +++ b/src/main/java/com/batch/reader/DetailCommonReader.java @@ -0,0 +1,30 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailCommonReader { + + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader detailCommonItemReader() { + String jpqlQuery = "SELECT c FROM Content c WHERE c.overview IS NULL OR c.overview = '' ORDER BY c.contentId ASC"; + + return new JpaPagingItemReaderBuilder() + .name("detailCommonItemReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(100) + .queryString(jpqlQuery) + .build(); + } +} diff --git a/src/main/java/com/batch/reader/DetailImageReader.java b/src/main/java/com/batch/reader/DetailImageReader.java new file mode 100644 index 0000000..ae84dde --- /dev/null +++ b/src/main/java/com/batch/reader/DetailImageReader.java @@ -0,0 +1,28 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailImageReader { + + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader detailImageContentReader() { + return new JpaPagingItemReaderBuilder() + .name("contentReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(100) + .queryString("SELECT c FROM Content c ORDER BY c.contentId ASC") + .build(); + } +} diff --git a/src/main/java/com/batch/tasklet/CategoryFetchTasklet.java b/src/main/java/com/batch/tasklet/CategoryFetchTasklet.java new file mode 100644 index 0000000..2f605bb --- /dev/null +++ b/src/main/java/com/batch/tasklet/CategoryFetchTasklet.java @@ -0,0 +1,82 @@ +package com.batch.tasklet; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +import com.batch.client.MigrationClient; +import com.batch.dto.CategoryCodeResponse; +import com.swyp.catsgotogedog.category.domain.entity.CategoryCode; +import com.swyp.catsgotogedog.category.repository.CategoryRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CategoryFetchTasklet implements Tasklet { + + private final MigrationClient migrationClient; + private final CategoryRepository categoryRepository; + + private static final List CATEGORY_LIST = Arrays.asList( + "12", // 관광지 + "14", // 문화시설 + "15", // 축제공연행사 + "25", // 여행코스 + "28", // 레포츠 + "32", // 숙박 + "38", // 쇼핑 + "39" // 음식점 + ); + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if(categoryRepository.count() > 0) { + log.info("카테고리 데이터가 존재하여 스킵"); + return RepeatStatus.FINISHED; + }; + + log.info("Category Fetch Tasklet Start"); + List categoryList = new ArrayList<>(); + + for(String category : CATEGORY_LIST) { + log.info("Fetching Category : {}", category); + + // cat1 대분류 조회 + List cat1Items = migrationClient.getCategoryCode(category, null, null); + for(CategoryCodeResponse.Item cat1Item : cat1Items) { + log.debug("cat1Item : {} ({})", cat1Item, cat1Item.getCode()); + + List cat2Items = migrationClient.getCategoryCode(category, cat1Item.getCode(), null); + + for(CategoryCodeResponse.Item cat2Item : cat2Items) { + log.debug("cat2Item : {} ({})", cat2Item, cat2Item.getCode()); + + List cat3Items = migrationClient.getCategoryCode(category, cat1Item.getCode(), cat2Item.getCode()); + + for(CategoryCodeResponse.Item cat3Item : cat3Items) { + + log.debug("cat3Item : {} ({})", cat3Item, cat3Item.getCode()); + + categoryList.add(CategoryCode.builder() + .categoryId(cat3Item.getCode()) + .categoryName(cat3Item.getName()) + .contentTypeId(Integer.parseInt(category)) + .build()); + } + } + } + } + categoryRepository.saveAll(categoryList); + log.info("Category Fetch Tasklet End"); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/batch/writer/ContentImageWriter.java b/src/main/java/com/batch/writer/ContentImageWriter.java new file mode 100644 index 0000000..c00e6e8 --- /dev/null +++ b/src/main/java/com/batch/writer/ContentImageWriter.java @@ -0,0 +1,33 @@ +package com.batch.writer; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import com.swyp.catsgotogedog.content.repository.ContentImageRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ContentImageWriter implements ItemWriter> { + + private final ContentImageRepository contentImageRepository; + + @Override + @Transactional + public void write(Chunk> chunk) throws Exception { + List flatList = chunk.getItems().stream() + .flatMap(List::stream) + .toList(); + log.info("{}개 이미지를 DB 삽입중", flatList.size()); + contentImageRepository.saveAll(flatList); + } +} diff --git a/src/main/java/com/batch/writer/DetailCommonWriter.java b/src/main/java/com/batch/writer/DetailCommonWriter.java new file mode 100644 index 0000000..90a1153 --- /dev/null +++ b/src/main/java/com/batch/writer/DetailCommonWriter.java @@ -0,0 +1,20 @@ +package com.batch.writer; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DetailCommonWriter implements ItemWriter { + + + @Override + public void write(Chunk chunk) throws Exception { + } +} diff --git a/src/main/java/com/batch/writer/ItemWriterConfig.java b/src/main/java/com/batch/writer/ItemWriterConfig.java new file mode 100644 index 0000000..628835c --- /dev/null +++ b/src/main/java/com/batch/writer/ItemWriterConfig.java @@ -0,0 +1,23 @@ +package com.batch.writer; + +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +public class ItemWriterConfig { + + @Bean + public JpaItemWriter step1ContentWriter(EntityManagerFactory entityManagerFactory) { + JpaItemWriter writer = new JpaItemWriter<>(); + writer.setEntityManagerFactory(entityManagerFactory); + writer.setUsePersist(false); + return writer; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index 33e0f84..0e10879 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -1,15 +1,66 @@ package com.swyp.catsgotogedog; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationContext; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @EnableJpaAuditing -@SpringBootApplication -public class CatsgotogedogApplication { +@SpringBootApplication(scanBasePackages = { "com.swyp", "com.batch" }) +@EnableScheduling +@RequiredArgsConstructor +@Slf4j +public class CatsgotogedogApplication implements CommandLineRunner { + + private final JobLauncher jobLauncher; + private final ApplicationContext applicationContext; + // private final Job contentDataFetchStep; + // private final Job categoryCodeFetchStep; public static void main(String[] args) { SpringApplication.run(CatsgotogedogApplication.class, args); } + @Scheduled(cron = "0 0 1 * * ?") + public void runBatch() throws Exception { + Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); + + // category job (서버 최초 실행시에만 실행) + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + + //content job + jobLauncher.run(contentBatchJob, jobParameters); + log.info(">> CommandLineRunner: content 배치 수동 실행 완료"); + + } + + @Override + public void run(String... args) throws Exception { + Job categoryCodeBatchJob = (Job) applicationContext.getBean("categoryCodeBatchJob"); + Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); + + // category job (서버 최초 실행시에만 실행) + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + jobLauncher.run(categoryCodeBatchJob, jobParameters); + log.info(">> CommandLineRunner: categoryCode 배치 수동 실행 완료"); + + //content job + jobLauncher.run(contentBatchJob, jobParameters); + log.info(">> CommandLineRunner: content 배치 수동 실행 완료"); + + } } diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/entity/Category.java b/src/main/java/com/swyp/catsgotogedog/category/domain/entity/Category.java deleted file mode 100644 index 4981ad9..0000000 --- a/src/main/java/com/swyp/catsgotogedog/category/domain/entity/Category.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.swyp.catsgotogedog.category.domain.entity; - -public class Category { -} diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/entity/CategoryCode.java b/src/main/java/com/swyp/catsgotogedog/category/domain/entity/CategoryCode.java new file mode 100644 index 0000000..7017183 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/domain/entity/CategoryCode.java @@ -0,0 +1,29 @@ +package com.swyp.catsgotogedog.category.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "category_code") +@Builder +public class CategoryCode { + + @Id + @Column(name = "category_id", length = 30) + private String categoryId; + + @Column(name = "category_name", length = 50, nullable = false) + private String categoryName; + + @Column(name = "content_type_id", nullable = false) + private int contentTypeId; +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java b/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java index c4bc7b6..fe4341a 100644 --- a/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java @@ -1,4 +1,8 @@ package com.swyp.catsgotogedog.category.repository; -public interface CategoryRepository { +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.category.domain.entity.CategoryCode; + +public interface CategoryRepository extends JpaRepository { } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java new file mode 100644 index 0000000..0d47709 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java @@ -0,0 +1,89 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "content") +@ToString +@Builder +public class Content { + @Id + @Column(name = "content_id", updatable = false) + private int contentId; + + @Column(name = "content_type_id", nullable = false) + private int contentTypeId; + + @Column(name = "category_id") + private String categoryId; + + @Column(name = "sido_code") + private int sidoCode; + + @Column(name = "sigungu_code") + private int sigunguCode; + + @Column(name = "addr1") + private String addr1; + + @Column(name = "addr2") + private String addr2; + + @Column(name = "image") + private String imageUrl; + + @Column(name = "thumb_image") + private String thumbImageUrl; + + @Column(name = "copyright") + private String copyright; + + @Column(name = "mapx") + private double mapX; + + @Column(name = "mapy") + private double mapy; + + @Column(name = "mlevel") + private int mLevel; + + @Column(name = "tel") + private String tel; + + @Column(name = "title") + private String title; + + @Column(name = "zipcode") + private int zipCode; + + @Lob + @Column(name = "overview") + private String overview; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java new file mode 100644 index 0000000..3d87ad8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java @@ -0,0 +1,46 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "content_image") +@Builder +public class ContentImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "content_image_id", updatable = false) + private int contentImageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content contentId; + + @Column(name = "image_url") + private String imageUrl; + + @Column(name = "image_filename") + private String imageFilename; + + @Column(name = "small_image_url") + private String smallImageUrl; + + @Column(name = "small_image_filename") + private String smallImageFilename; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java new file mode 100644 index 0000000..c230ae3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java @@ -0,0 +1,41 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "region_code") +@Builder +public class RegionCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "region_id") + private int regionId; + + @Column(name = "region_name", nullable = false) + private String regionName; + + @Column(name = "sido_code") + private int sidoCode; + + @Column(name = "sigungu_code") + private int sigunguCode; + + @Column(name = "parent_code") + private int parentCode; + + @Column(name = "region_level") + private int regionLevel; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java new file mode 100644 index 0000000..69061bf --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; + +public interface ContentImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java new file mode 100644 index 0000000..3e0ed42 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +public interface ContentRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java new file mode 100644 index 0000000..846e509 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.content.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; + +public interface RegionCodeRepository extends JpaRepository { + Optional findBySidoCodeAndSigunguCode(int sidoCode, int sigunguCode); + Optional findBySidoCodeAndSigunguCodeIsNull(int sidoCode); + List findBySidoCode(int sidoCode); +} diff --git a/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql b/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql index 57e41c9..02c05df 100644 --- a/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql +++ b/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql @@ -15,3 +15,6 @@ ALTER TABLE `catsgotogedog`.`content` ALTER TABLE `catsgotogedog`.`content` CHANGE COLUMN `mapx` `mapx` DECIMAL(13,10) NULL DEFAULT NULL , CHANGE COLUMN `mapy` `mapy` DECIMAL(13,10) NULL DEFAULT NULL ; + +ALTER TABLE `catsgotogedog`.`content` + ADD COLUMN `overview` TEXT NULL; diff --git a/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql b/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql index 8c6f04d..aeee765 100644 --- a/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql +++ b/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql @@ -17,6 +17,4 @@ ADD COLUMN `sigungu_code` INT NULL AFTER `sido_code`, ADD UNIQUE INDEX `sido_sigungu_code_UNIQUE` (`sido_code` ASC, `sigungu_code` ASC) VISIBLE; ALTER TABLE `catsgotogedog`.`region_code` - ADD COLUMN `region_level` INT NULL AFTER `parent_code`; - -ALTER TABLE content AUTO_INCREMENT = 100000; \ No newline at end of file + ADD COLUMN `region_level` INT NULL AFTER `parent_code`; \ No newline at end of file From 8aa29b192d4b7400f7fd7768b5651a631977be1e Mon Sep 17 00:00:00 2001 From: spacedivver <142153611+wooodev@users.noreply.github.com> Date: Wed, 30 Jul 2025 02:24:28 +0900 Subject: [PATCH 061/191] =?UTF-8?q?feat:=20elasticsearch=EC=97=90=EC=84=9C?= =?UTF-8?q?=20id=EC=A1=B0=ED=9A=8C=20=ED=9B=84=20mysql=20=EA=B0=92=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=EC=A0=81=EC=9A=A9=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit title 입력값을 바탕으로 데이터 조회 --- .../content/controller/ContentController.java | 6 +-- .../content/domain/entity/Content.java | 10 ++-- .../content/domain/response/.gitkeep | 0 .../domain/response/ContentResponse.java | 47 +++++++++++++++++++ .../content/service/ContentSearchService.java | 28 +++++++++++ .../content/service/ContentService.java | 1 + 6 files changed, 84 insertions(+), 8 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 67c1979..05cbbf1 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -1,7 +1,7 @@ package com.swyp.catsgotogedog.content.controller; -import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; +import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.service.ContentSearchService; import com.swyp.catsgotogedog.content.service.ContentService; import org.springframework.http.ResponseEntity; @@ -19,8 +19,8 @@ public class ContentController implements ContentControllerSwagger{ private final ContentSearchService contentSearchService; @GetMapping("/search") - public ResponseEntity> contentSearch(@RequestParam("keyword") String keyword){ - return ResponseEntity.ok(contentSearchService.searchByKeyword(keyword)); + public ResponseEntity> search(@RequestParam String keyword){ + return ResponseEntity.ok(contentSearchService.searchByTitle(keyword)); } @PostMapping("/save") diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java index 401c1cb..494e7be 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java @@ -1,20 +1,20 @@ package com.swyp.catsgotogedog.content.domain.entity; import com.swyp.catsgotogedog.global.BaseTimeEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import lombok.Builder; -import lombok.Getter; +import jakarta.persistence.*; +import lombok.*; import java.math.BigDecimal; @Entity @Getter @Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Content extends BaseTimeEntity { @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(nullable = false) private int contentId; diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/content/domain/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java new file mode 100644 index 0000000..acdf6bc --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -0,0 +1,47 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; + +@Getter +@Builder +public class ContentResponse { + private int contentId; + private String title; + private String addr1; + private String addr2; + private String image; + private String thumbImage; + private int categoryId; + private int regionId; + private int contentTypeId; + private String copyright; + private BigDecimal mapx; + private BigDecimal mapy; + private int mlevel; + private String tel; + private int zipcode; + + public static ContentResponse from(Content c){ + return ContentResponse.builder() + .contentId(c.getContentId()) + .title(c.getTitle()) + .addr1(c.getAddr1()) + .addr2(c.getAddr2()) + .image(c.getImage()) + .thumbImage(c.getThumbImage()) + .categoryId(c.getCategoryId()) + .regionId(c.getRegionId()) + .contentTypeId(c.getContentTypeId()) + .copyright(c.getCopyright()) + .mapx(c.getMapx()) + .mapy(c.getMapy()) + .mlevel(c.getMlevel()) + .tel(c.getTel()) + .zipcode(c.getZipcode()) + .build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 7dddc24..d665922 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -1,21 +1,49 @@ package com.swyp.catsgotogedog.content.service; +import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; +import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; +import com.swyp.catsgotogedog.content.repository.ContentRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ContentSearchService { + private final ContentRepository contentRepository; private final ContentElasticRepository contentElasticRepository; public List searchByKeyword(String keyword){ return contentElasticRepository.findByTitleContaining(keyword); } + public List searchByTitle(String keyword) { + + List ids = contentElasticRepository.findByTitleContaining(keyword) + .stream() + .map(ContentDocument::getContentId) + .toList(); + + if (ids.isEmpty()) return List.of(); + + List contents = contentRepository.findAllById(ids); + + Map map = contents.stream() + .collect(Collectors.toMap(Content::getContentId, c -> c)); + + return ids.stream() + .map(map::get) + .filter(Objects::nonNull) + .map(ContentResponse::from) + .toList(); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 3f5f6bd..534862b 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -37,4 +37,5 @@ public void saveContent(ContentRequest request){ contentRepository.save(content); contentElasticRepository.save(ContentDocument.from(content)); } + } From 201ca8f7255fa8f0958b641eb938a7545ff131a7 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 31 Jul 2025 01:34:17 +0900 Subject: [PATCH 062/191] =?UTF-8?q?feat:=20title,=20addr1,=20addr2,=20cont?= =?UTF-8?q?entTypeId=20=EC=A1=B0=EA=B1=B4=20=EA=B2=80=EC=83=89=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아직 구체화되지 않은 반려동물 조건 미반영 --- .../content/controller/ContentController.java | 13 +++- .../content/service/ContentSearchService.java | 64 +++++++++++++++++-- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 05cbbf1..bcc581d 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -19,8 +19,17 @@ public class ContentController implements ContentControllerSwagger{ private final ContentSearchService contentSearchService; @GetMapping("/search") - public ResponseEntity> search(@RequestParam String keyword){ - return ResponseEntity.ok(contentSearchService.searchByTitle(keyword)); + public ResponseEntity> search( + @RequestParam(required = false) String title, + @RequestParam(required = false) String addr1, + @RequestParam(required = false) String addr2, + @RequestParam(required = false) Integer contentTypeId) { + + List list = contentSearchService.search(title, addr1, addr2, contentTypeId); + + return list.isEmpty() + ? ResponseEntity.noContent().build() // 204 + : ResponseEntity.ok(list); // 200 } @PostMapping("/save") diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index d665922..758cb8d 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -1,11 +1,17 @@ package com.swyp.catsgotogedog.content.service; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; import com.swyp.catsgotogedog.content.repository.ContentRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,23 +26,69 @@ public class ContentSearchService { private final ContentRepository contentRepository; private final ContentElasticRepository contentElasticRepository; + private final ElasticsearchOperations elasticsearchOperations; public List searchByKeyword(String keyword){ return contentElasticRepository.findByTitleContaining(keyword); } - public List searchByTitle(String keyword) { - List ids = contentElasticRepository.findByTitleContaining(keyword) - .stream() + public List search(String title, + String addr1, + String addr2, + Integer contentTypeId) { + + boolean noTitle = (title == null || title.isBlank()); + boolean noAddr1 = (addr1 == null || addr1.isBlank()); + boolean noAddr2 = (addr2 == null || addr2.isBlank()); + boolean noTypeId = (contentTypeId == null || contentTypeId <= 0); + + BoolQuery.Builder boolBuilder = new BoolQuery.Builder(); + + if (noTitle && noAddr1 && noAddr2 && noTypeId) { + boolBuilder.must(m -> m.matchAll(ma -> ma)); + } else { + if (!noTitle) { + boolBuilder.must(m -> m.matchPhrasePrefix(mp -> mp + .field("title") + .query(title))); + } else { + boolBuilder.must(m -> m.matchAll(ma -> ma)); + } + + if (!noAddr1) { + boolBuilder.filter(f -> f.term(t -> t.field("addr1") + .value(addr1))); + } + + if (!noAddr2) { + boolBuilder.filter(f -> f.term(t -> t.field("addr2") + .value(addr2))); + } + + if (!noTypeId) { + boolBuilder.filter(f -> f.term(t -> t.field("contentTypeId") + .value(contentTypeId))); + } + } + + Query esQuery = new Query.Builder() + .bool(boolBuilder.build()) + .build(); + + NativeQuery nativeQuery = NativeQuery.builder() + .withQuery(esQuery) + .withPageable(PageRequest.of(0, 20)) + .build(); + + List ids = elasticsearchOperations.search(nativeQuery, ContentDocument.class).stream() + .map(SearchHit::getContent) .map(ContentDocument::getContentId) .toList(); if (ids.isEmpty()) return List.of(); - List contents = contentRepository.findAllById(ids); - - Map map = contents.stream() + Map map = contentRepository.findAllById(ids).stream() .collect(Collectors.toMap(Content::getContentId, c -> c)); return ids.stream() From 8f821e3f6a637d6816104b1479d8037eed2e70ab Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 31 Jul 2025 02:11:43 +0900 Subject: [PATCH 063/191] =?UTF-8?q?feat:=20=EC=97=AC=EB=9F=AC=EA=B1=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20controller=20=EC=B6=94=EA=B0=80=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 용 데이터 여러건 저장 api --- .../content/controller/ContentController.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index bcc581d..1487370 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -37,4 +37,11 @@ ResponseEntity saveContent(@RequestBody ContentRequest request) { contentService.saveContent(request); return ResponseEntity.ok().build(); } + + @PostMapping("/savelist") + public ResponseEntity saveList(@RequestBody List requests) { + requests.forEach(contentService::saveContent); + return ResponseEntity.ok().build(); + } + } From 99d7c1cb45eaf03d058ebb7e6d66e702396db54b Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Fri, 1 Aug 2025 18:01:33 +0900 Subject: [PATCH 064/191] =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../User/controller/UserController.java | 37 ++++++++-- .../controller/UserControllerSwagger.java | 60 +++++++++++++++ .../User/domain/entity/User.java | 3 + .../domain/request/UserUpdateRequest.java | 20 +++++ .../domain/response/UserProfileResponse.java | 42 +++++++++++ .../User/repository/UserRepository.java | 1 + .../User/service/UserService.java | 74 ++++++++++++++++++- .../global/exception/ErrorCode.java | 15 ++++ .../V3__add_name_update_at_to_user_table.sql | 2 + 9 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/request/UserUpdateRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java create mode 100644 src/main/resources/db/migration/mysql/V3__add_name_update_at_to_user_table.sql diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index c78731a..16428ac 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -1,28 +1,30 @@ package com.swyp.catsgotogedog.User.controller; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.request.UserUpdateRequest; import com.swyp.catsgotogedog.User.domain.response.AccessTokenResponse; +import com.swyp.catsgotogedog.User.domain.response.UserProfileResponse; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.User.service.RefreshTokenService; import com.swyp.catsgotogedog.User.service.UserService; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.CookieValue; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @RequestMapping("/api/user") @Slf4j -public class UserController implements UserControllerSwagger{ +public class UserController implements UserControllerSwagger { private final JwtTokenUtil jwt; private final RefreshTokenService rtService; @@ -53,4 +55,29 @@ public ResponseEntity> logout( .header(HttpHeaders.SET_COOKIE, cookie.toString()) .body(CatsgotogedogApiResponse.success("로그아웃 성공", null)); } + + @GetMapping("/profile") + public ResponseEntity> profile( + @AuthenticationPrincipal String userId) { + User user = userService.profile(userId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("사용자 프로필 조회 성공", UserProfileResponse.from(user))); + } + + @PatchMapping("/profile") + public ResponseEntity> updateProfile( + @AuthenticationPrincipal String userId, + @Valid @ModelAttribute UserUpdateRequest request) { + User updatedUser = userService.update(userId, request); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("프로필 수정 성공", null)); + } + + @DeleteMapping("/profile/image") + public ResponseEntity> deleteProfileImage( + @AuthenticationPrincipal String userId) { + userService.deleteProfileImage(userId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("프로필 이미지 삭제 성공", null)); + } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java index 667847f..12fe174 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java @@ -1,5 +1,7 @@ package com.swyp.catsgotogedog.User.controller; +import com.swyp.catsgotogedog.User.domain.request.UserUpdateRequest; +import com.swyp.catsgotogedog.User.domain.response.UserProfileResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -48,4 +50,62 @@ ResponseEntity> logout( @Parameter(description = "리프레시 토큰", hidden = true) String refresh ); + + @Operation( + summary = "사용자 프로필 조회", + description = "인증된 사용자의 프로필 정보를 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "프로필 조회 성공", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> profile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId + ); + + @Operation( + summary = "사용자 정보 수정", + description = "인증된 사용자의 정보를 수정합니다. 사용자 ID는 인증된 사용자로부터 가져옵니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용자 정보 수정 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> updateProfile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "수정할 사용자 정보", required = true) + UserUpdateRequest request + ); + + @Operation( + summary = "사용자 프로필 이미지 삭제", + description = "인증된 사용자의 프로필 이미지를 삭제합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "프로필 이미지 삭제 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> deleteProfileImage( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId + ); } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index 9ee0383..53967e3 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -4,6 +4,7 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -31,6 +32,8 @@ public class User extends BaseTimeEntity { private String imageUrl; private Boolean isActive; + private LocalDateTime nameUpdateAt; // displayName 변경 시 업데이트 + @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) private List pets = new ArrayList<>(); diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserUpdateRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserUpdateRequest.java new file mode 100644 index 0000000..d300747 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserUpdateRequest.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.User.domain.request; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Getter +@Setter +public class UserUpdateRequest { + + @Size(min = 2, max = 12, message = "닉네임은 2자 이상 12자 이하로 설정해야 합니다.") + @Pattern(regexp = "^[a-zA-Z0-9가-힣]*$", message = "닉네임에 특수문자나 공백을 사용할 수 없습니다.") + private String displayName; + private List image; +} + diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java new file mode 100644 index 0000000..b5a2bc7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java @@ -0,0 +1,42 @@ +package com.swyp.catsgotogedog.User.domain.response; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.pet.domain.response.PetProfileResponse; +import lombok.Builder; +import lombok.Getter; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +public class UserProfileResponse { + + private String displayName; + private String email; + private String provider; + private String imageFilename; + private String imageUrl; + private List pets; + + public static UserProfileResponse from(User user) { + return UserProfileResponse.builder() + .displayName(user.getDisplayName()) + .email(user.getEmail()) + .provider(user.getProvider()) + .imageFilename(user.getImageFilename()) + .imageUrl(user.getImageUrl()) + .pets(convertPets(user)) + .build(); + } + + private static List convertPets(User user) { + if (user.getPets() == null) { + return Collections.emptyList(); + } + return user.getPets().stream() + .map(PetProfileResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java index c5dbde8..308d61e 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java @@ -9,4 +9,5 @@ public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderId(String provider, String providerId); Optional findByProviderId(String providerId); Optional findByDisplayName(String displayName); + boolean existsByDisplayName(String displayName); } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java index 1b36cf9..1cab9f5 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -1,16 +1,22 @@ package com.swyp.catsgotogedog.User.service; +import com.swyp.catsgotogedog.User.domain.request.UserUpdateRequest; +import com.swyp.catsgotogedog.common.util.image.storage.ImageStorageService; +import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.global.exception.*; import org.springframework.stereotype.Service; import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; -import com.swyp.catsgotogedog.global.exception.ErrorCode; -import com.swyp.catsgotogedog.global.exception.InvalidTokenException; -import com.swyp.catsgotogedog.global.exception.UnAuthorizedAccessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; @Service @RequiredArgsConstructor @@ -20,6 +26,7 @@ public class UserService { private final UserRepository userRepository; private final RefreshTokenService rtService; private final JwtTokenUtil jwt; + private final ImageStorageService imageStorageService; public String reIssue(String refreshToken) { @@ -43,4 +50,65 @@ public void logout(String refreshToken) { } rtService.delete(refreshToken); } + + public User profile(String userId) { + return findUserById(userId); + } + + public User update(String userId, UserUpdateRequest request) { + User user = findUserById(userId); + + if (request.getDisplayName() != null) { + String newDisplayName = request.getDisplayName(); + + // 같은 닉네임으로 변경 시도하는 경우 체크 + if (newDisplayName.equals(user.getDisplayName())) { + throw new CatsgotogedogException(ErrorCode.SAME_DISPLAY_NAME); + } else { + // 24시간 이내 닉네임 변경 제한 체크 + if (user.getNameUpdateAt() != null) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime lastUpdate = user.getNameUpdateAt(); + if (lastUpdate.plusHours(24).isAfter(now)) { + throw new CatsgotogedogException(ErrorCode.DISPLAY_NAME_UPDATE_TOO_SOON); + } + } + + if (userRepository.existsByDisplayName(newDisplayName)) { + throw new CatsgotogedogException(ErrorCode.DUPLICATE_DISPLAY_NAME); + } + user.setDisplayName(newDisplayName); + user.setNameUpdateAt(LocalDateTime.now()); + } + } + + if (request.getImage() != null && !request.getImage().isEmpty()) { + if (StringUtils.hasText(user.getImageFilename())) { + imageStorageService.delete(user.getImageFilename()); + } + List imageInfos = imageStorageService.upload( + request.getImage(), "profile/", ImageUploadType.PROFILE); + ImageInfo imageInfo = imageInfos.get(0); + user.setImageFilename(imageInfo.key()); + user.setImageUrl(imageInfo.url()); + } + return userRepository.save(user); + } + + public void deleteProfileImage(String userId) { + User user = findUserById(userId); + + if (StringUtils.hasText(user.getImageFilename())) { + imageStorageService.delete(user.getImageFilename()); + user.setImageFilename(null); + } + user.setImageUrl("https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/no_image.png"); + userRepository.save(user); + } + + private User findUserById(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 46b6052..6804a0c 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -33,6 +33,21 @@ public enum ErrorCode { // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), + // User 관련 + DUPLICATE_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "이미 사용 중인 닉네임입니다."), + DISPLAY_NAME_UPDATE_TOO_SOON(HttpStatus.BAD_REQUEST.value(), "닉네임은 24시간마다 한 번만 변경할 수 있습니다."), + SAME_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "현재 닉네임과 동일합니다."), + + // 반려동물 관련 (Pet) + PET_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 반려동물입니다."), + PET_SIZE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 반려동물 크기입니다."), + INVALID_PET_DATA(HttpStatus.BAD_REQUEST.value(), "반려동물 데이터가 유효하지 않습니다."), + INVALID_PET_GENDER(HttpStatus.BAD_REQUEST.value(), "반려동물 성별은 M(수컷) 또는 F(암컷)이어야 합니다."), + PET_NAME_REQUIRED(HttpStatus.BAD_REQUEST.value(), "반려동물 이름은 필수입니다."), + PET_BIRTH_REQUIRED(HttpStatus.BAD_REQUEST.value(), "반려동물 생년월일은 필수입니다."), + PET_SIZE_REQUIRED(HttpStatus.BAD_REQUEST.value(), "반려동물 크기는 필수입니다."), + PET_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "반려동물은 최대 10마리까지만 등록할 수 있습니다."), + // Image Validator Error INVALID_IMAGE_NAME(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 이미지 이름입니다."), INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 이미지 확장자입니다."), diff --git a/src/main/resources/db/migration/mysql/V3__add_name_update_at_to_user_table.sql b/src/main/resources/db/migration/mysql/V3__add_name_update_at_to_user_table.sql new file mode 100644 index 0000000..f9c5215 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V3__add_name_update_at_to_user_table.sql @@ -0,0 +1,2 @@ +ALTER TABLE `catsgotogedog`.`user` +ADD COLUMN `name_update_at` DATETIME NULL; From 9b845507610a32df6cf862176b656552b48bef90 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Fri, 1 Aug 2025 18:03:19 +0900 Subject: [PATCH 065/191] =?UTF-8?q?=EB=B0=98=EB=A0=A4=EB=8F=99=EB=AC=BC=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/PetLimitExceededException.java | 11 ++ .../pet/controller/PetController.java | 58 ++++++- .../pet/controller/PetControllerSwagger.java | 99 ++++++++++++ .../pet/domain/request/PetProfileRequest.java | 36 +++++ .../domain/response/PetProfileResponse.java | 37 +++++ .../domain/validation/PetNameValidator.java | 72 +++++++++ .../pet/domain/validation/ValidPetName.java | 16 ++ .../pet/repository/PetRepository.java | 7 +- .../pet/repository/PetSizeRepository.java | 10 ++ .../catsgotogedog/pet/service/PetService.java | 141 +++++++++++++++++- .../mysql/V4__insert_pet_size_data.sql | 3 + 11 files changed, 483 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/PetLimitExceededException.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/validation/ValidPetName.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/repository/PetSizeRepository.java create mode 100644 src/main/resources/db/migration/mysql/V4__insert_pet_size_data.sql diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/PetLimitExceededException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/PetLimitExceededException.java new file mode 100644 index 0000000..c7e85e0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/PetLimitExceededException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class PetLimitExceededException extends CatsgotogedogException { + + public PetLimitExceededException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java index 4d4158f..d11a46d 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java @@ -1,12 +1,64 @@ package com.swyp.catsgotogedog.pet.controller; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.pet.domain.entity.Pet; +import com.swyp.catsgotogedog.pet.domain.request.PetProfileRequest; +import com.swyp.catsgotogedog.pet.domain.response.PetProfileResponse; +import com.swyp.catsgotogedog.pet.service.PetService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/pet") -public class PetController { +@Slf4j +public class PetController implements PetControllerSwagger { + + private final PetService petService; + + @GetMapping("/profile") + public ResponseEntity> getAllProfiles( + @AuthenticationPrincipal String userId) { + List pets = petService.getAllPets(userId); + List response = pets.stream() + .map(PetProfileResponse::from) + .toList(); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("반려동물 프로필 목록 조회 성공", response)); + } + + @PostMapping("/profile") + public ResponseEntity> createProfile( + @AuthenticationPrincipal String userId, + @Valid @ModelAttribute PetProfileRequest petProfileRequest) { + petService.create(userId, petProfileRequest); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("반려동물 프로필 생성 성공", null)); + } + + @PatchMapping("/profile/{petId}") + public ResponseEntity> updateProfile( + @AuthenticationPrincipal String userId, + @PathVariable int petId, + @Valid @ModelAttribute PetProfileRequest petProfileRequest) { + petService.updateById(userId, petId, petProfileRequest); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("반려동물 프로필 수정 성공", null)); + } + + @DeleteMapping("/profile/{petId}") + public ResponseEntity> deleteProfile( + @AuthenticationPrincipal String userId, + @PathVariable int petId) { + petService.deleteById(userId, petId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("반려동물 프로필 삭제 성공", null)); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java index 19dfd53..8fa3966 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java @@ -1,7 +1,106 @@ package com.swyp.catsgotogedog.pet.controller; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.pet.domain.request.PetProfileRequest; +import com.swyp.catsgotogedog.pet.domain.response.PetProfileResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; @Tag(name = "Pet", description = "반려동물 관련 API") public interface PetControllerSwagger { + + @Operation( + summary = "반려동물 프로필 목록 조회", + description = "인증된 사용자의 모든 반려동물 프로필 목록을 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "반려동물 프로필 목록 조회 성공", + content = @Content(schema = @Schema(implementation = PetProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> getAllProfiles( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId + ); + + @Operation( + summary = "반려동물 프로필 등록", + description = "사용자의 새로운 반려동물 프로필을 등록합니다. 반려동물의 정보와 이미지를 함께 업로드할 수 있습니다. 최대 10마리까지 등록 가능합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "반려동물 프로필 생성 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터 또는 반려동물 등록 제한 초과 (최대 10마리)", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> createProfile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "반려동물 프로필 등록 정보", required = true) + PetProfileRequest petProfileRequest + ); + + @Operation( + summary = "반려동물 프로필 수정", + description = "등록된 반려동물의 프로필 정보를 수정합니다. 본인의 반려동물만 수정할 수 있습니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "반려동물 프로필 수정 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "수정 권한이 없음 (본인의 반려동물이 아님)", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "반려동물을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> updateProfile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "수정할 반려동물 ID", required = true) + int petId, + @Parameter(description = "반려동물 프로필 수정 정보", required = true) + PetProfileRequest petProfileRequest + ); + + @Operation( + summary = "반려동물 프로필 삭제", + description = "등록된 반려동물의 프로필을 삭제합니다. 본인의 반려동물만 삭제할 수 있습니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "반려동물 프로필 삭제 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "삭제 권한이 없음 (본인의 반려동물이 아님)", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "반려동물을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> deleteProfile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "삭제할 반려동물 ID", required = true) + int petId + ); } diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java new file mode 100644 index 0000000..8ff5fe2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java @@ -0,0 +1,36 @@ +package com.swyp.catsgotogedog.pet.domain.request; + +import com.swyp.catsgotogedog.pet.domain.validation.ValidPetName; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +public class PetProfileRequest { + + @NotBlank(message = "반려동물 이름은 필수입니다.") + @ValidPetName + private String name; + + @NotBlank(message = "반려동물 성별은 필수입니다.") + @Pattern(regexp = "[MF]", message = "반려동물 성별은 M(수컷) 또는 F(암컷)이어야 합니다.") + private String gender; + + @NotNull(message = "반려동물 생년월일은 필수입니다.") + private LocalDate birth; + + @NotNull(message = "맹견 여부는 필수입니다.") + private boolean fierceDog; + + @NotBlank(message = "반려동물 크기는 필수입니다.") + private String size; + + private List image; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java new file mode 100644 index 0000000..c0f3eaf --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java @@ -0,0 +1,37 @@ +package com.swyp.catsgotogedog.pet.domain.response; + +import com.swyp.catsgotogedog.pet.domain.entity.Pet; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PetProfileResponse { + private int petId; + private String name; + private char gender; + private LocalDate birth; + private boolean fierceDog; + private String size; + private String imageFilename; + private String imageUrl; + + public static PetProfileResponse from(Pet pet) { + return PetProfileResponse.builder() + .petId(pet.getPetId()) + .name(pet.getName()) + .gender(pet.getGender()) + .birth(pet.getBirth()) + .fierceDog(pet.isFierceDog()) + .size(pet.getSizeId().getSize()) + .imageFilename(pet.getImageFilename()) + .imageUrl(pet.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java new file mode 100644 index 0000000..d0f9b0e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java @@ -0,0 +1,72 @@ +package com.swyp.catsgotogedog.pet.domain.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.regex.Pattern; + +public class PetNameValidator implements ConstraintValidator { + + // 허용된 특수문자: 쉼표(,), 마침표(.), 작은따옴표(') + private static final Pattern ALLOWED_SPECIAL_CHARS = Pattern.compile("[,.'_]"); + + // 허용되지 않은 문자 체크 (숫자 추가, 한글 자음/모음은 3번에서 별도 처리) + private static final Pattern FORBIDDEN_CHARS = Pattern.compile("[^가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9,.'_]"); + + // 특수문자 연속 사용 체크 + private static final Pattern CONSECUTIVE_SPECIAL_CHARS = Pattern.compile("[,.'_]{2,}"); + + // 한글 불완전한 단어 체크 (자음만 또는 모음만) + private static final Pattern INCOMPLETE_KOREAN = Pattern.compile(".*[ㄱ-ㅎㅏ-ㅣ].*"); + + // 영어 자음 또는 모음 4회 이상 연속 + private static final Pattern CONSECUTIVE_CONSONANTS = Pattern.compile(".*[bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ]{4,}.*"); + private static final Pattern CONSECUTIVE_VOWELS = Pattern.compile(".*[aeiouAEIOU]{4,}.*"); + + @Override + public void initialize(ValidPetName constraintAnnotation) { + // 초기화 로직 (필요시) + } + + @Override + public boolean isValid(String name, ConstraintValidatorContext context) { + if (name == null || name.trim().isEmpty()) { + return true; // @NotBlank에서 처리 + } + + String trimmedName = name.trim(); + + // 1. 허용되지 않은 특수문자 체크 + if (FORBIDDEN_CHARS.matcher(trimmedName).find()) { + setCustomMessage(context, "한글, 영어, 숫자, 쉼표(,), 마침표(.), 작은따옴표(')만 사용할 수 있습니다."); + return false; + } + + // 2. 특수문자 연속 사용 체크 + if (CONSECUTIVE_SPECIAL_CHARS.matcher(trimmedName).find()) { + setCustomMessage(context, "특수문자는 연속으로 사용할 수 없습니다."); + return false; + } + + // 3. 한글 불완전한 단어 체크 (자음만 또는 모음만) + if (INCOMPLETE_KOREAN.matcher(trimmedName).find()) { + setCustomMessage(context, "올바른 단어를 입력해주세요."); + return false; + } + + // 4. 영어 자음 또는 모음 4회 이상 연속 체크 + if (CONSECUTIVE_CONSONANTS.matcher(trimmedName).find() || + CONSECUTIVE_VOWELS.matcher(trimmedName).find()) { + setCustomMessage(context, "올바른 단어를 입력해주세요."); + return false; + } + + return true; + } + + private void setCustomMessage(ConstraintValidatorContext context, String message) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(message) + .addConstraintViolation(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/ValidPetName.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/ValidPetName.java new file mode 100644 index 0000000..d115825 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/ValidPetName.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.pet.domain.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = PetNameValidator.class) +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPetName { + String message() default "반려동물 이름이 올바르지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java index f9dacf3..e238bcd 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java @@ -1,8 +1,11 @@ package com.swyp.catsgotogedog.pet.repository; +import com.swyp.catsgotogedog.pet.domain.entity.Pet; import org.springframework.data.jpa.repository.JpaRepository; -import com.swyp.catsgotogedog.pet.domain.entity.Pet; +import java.util.List; -public interface PetRepository extends JpaRepository { +public interface PetRepository extends JpaRepository { + List findAllByUser_UserIdOrderByPetId(int userUserId); + int countByUser_UserId(int userId); } diff --git a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetSizeRepository.java b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetSizeRepository.java new file mode 100644 index 0000000..bed843f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetSizeRepository.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.pet.repository; + +import com.swyp.catsgotogedog.pet.domain.entity.PetSize; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PetSizeRepository extends JpaRepository { + Optional findBySize(String size); +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java index c4a064f..561e96d 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java @@ -1,12 +1,149 @@ package com.swyp.catsgotogedog.pet.service; -import org.springframework.stereotype.Service; - +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.util.image.storage.ImageStorageService; +import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.ForbiddenAccessException; +import com.swyp.catsgotogedog.global.exception.MemberNotFoundException; +import com.swyp.catsgotogedog.global.exception.PetLimitExceededException; +import com.swyp.catsgotogedog.global.exception.ResourceNotFoundException; +import com.swyp.catsgotogedog.global.exception.images.ImageUploadException; +import com.swyp.catsgotogedog.pet.domain.entity.Pet; +import com.swyp.catsgotogedog.pet.domain.entity.PetSize; +import com.swyp.catsgotogedog.pet.domain.request.PetProfileRequest; +import com.swyp.catsgotogedog.pet.repository.PetRepository; +import com.swyp.catsgotogedog.pet.repository.PetSizeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; @Service @RequiredArgsConstructor @Slf4j public class PetService { + + private final PetRepository petRepository; + private final PetSizeRepository petSizeRepository; + private final UserRepository userRepository; + private final ImageStorageService imageStorageService; + + public List getAllPets(String userId) { + return petRepository.findAllByUser_UserIdOrderByPetId(Integer.parseInt(userId)); + } + + public void create(String userId, PetProfileRequest petProfileRequest) { + User user = findUserById(userId); + validatePetLimit(userId); + + PetSize petSize = findPetSizeBySize(petProfileRequest.getSize()); + + String imageUrl = "https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/no_image.png"; + String imageFilename = null; + + // 이미지 업로드 처리 (이미지가 있는 경우) + if (hasImage(petProfileRequest)) { + ImageInfo imageInfo = uploadImage(petProfileRequest); + imageUrl = imageInfo.url(); + imageFilename = imageInfo.key(); + } + + Pet pet = Pet.builder() + .user(user) + .name(petProfileRequest.getName()) + .gender(petProfileRequest.getGender().charAt(0)) + .birth(petProfileRequest.getBirth()) + .fierceDog(petProfileRequest.isFierceDog()) + .sizeId(petSize) + .imageUrl(imageUrl) + .imageFilename(imageFilename) + .build(); + + petRepository.save(pet); + } + + public void updateById(String userId, int petId, PetProfileRequest petProfileRequest) { + + Pet pet = findPetByIdAndUserId(petId, userId); + PetSize petSize = findPetSizeBySize(petProfileRequest.getSize()); + + // 새 이미지가 업로드된 경우 기존 이미지 삭제 후 새 이미지 업로드 + if (hasImage(petProfileRequest)) { + deleteExistingImageIfExists(pet); + ImageInfo imageInfo = uploadImage(petProfileRequest); + pet.setImageUrl(imageInfo.url()); + pet.setImageFilename(imageInfo.key()); + } + + // 펫 정보 업데이트 + pet.setName(petProfileRequest.getName()); + pet.setGender(petProfileRequest.getGender().charAt(0)); + pet.setBirth(petProfileRequest.getBirth()); + pet.setFierceDog(petProfileRequest.isFierceDog()); + pet.setSizeId(petSize); + + petRepository.save(pet); + } + + public void deleteById(String userId, int petId) { + Pet pet = findPetByIdAndUserId(petId, userId); + + deleteExistingImageIfExists(pet); + petRepository.delete(pet); + } + + private User findUserById(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new MemberNotFoundException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private void validatePetLimit(String userId) { + int petCount = petRepository.countByUser_UserId(Integer.parseInt(userId)); + if (petCount >= 10) { + throw new PetLimitExceededException(ErrorCode.PET_LIMIT_EXCEEDED); + } + } + + private Pet findPetByIdAndUserId(int petId, String userId) { + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.PET_NOT_FOUND)); + + if (pet.getUser().getUserId() != Integer.parseInt(userId)) { + throw new ForbiddenAccessException(ErrorCode.FORBIDDEN_ACCESS); + } + + return pet; + } + + private PetSize findPetSizeBySize(String size) { + return petSizeRepository.findBySize(size) + .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.PET_SIZE_NOT_FOUND)); + } + + private boolean hasImage(PetProfileRequest request) { + return request.getImage() != null && !request.getImage().isEmpty(); + } + + private ImageInfo uploadImage(PetProfileRequest request) { + try { + List imageInfos = imageStorageService.upload( + request.getImage(), "pet_profile/", ImageUploadType.PROFILE); + return imageInfos.get(0); + } catch (Exception e) { + throw new ImageUploadException(ErrorCode.IMAGE_UPLOAD_FAILED); + } + } + + private void deleteExistingImageIfExists(Pet pet) { + if (StringUtils.hasText(pet.getImageFilename())) { + imageStorageService.delete(pet.getImageFilename()); + pet.setImageFilename(null); + } + pet.setImageUrl("https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/no_image.png"); + } } diff --git a/src/main/resources/db/migration/mysql/V4__insert_pet_size_data.sql b/src/main/resources/db/migration/mysql/V4__insert_pet_size_data.sql new file mode 100644 index 0000000..e2e7dee --- /dev/null +++ b/src/main/resources/db/migration/mysql/V4__insert_pet_size_data.sql @@ -0,0 +1,3 @@ +INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('소형', '소형견: 성견 된 몸무게가 대략 10kg 미만 (성견: 생후 2년 이상)'); +INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('중형', '중형견: 성견 된 몸무게가 대략 10~25kg 미만'); +INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('대형', '대형견: 성견 된 몸무게가 대략 25kg 이상'); From e71e3acf9862cb90ac8f5b6f7dd5bc3028cb5777 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sat, 2 Aug 2025 15:19:55 +0900 Subject: [PATCH 066/191] change user update method return type to void --- .../swyp/catsgotogedog/User/controller/UserController.java | 2 +- .../java/com/swyp/catsgotogedog/User/service/UserService.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index 16428ac..d03b103 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -68,7 +68,7 @@ public ResponseEntity> profile( public ResponseEntity> updateProfile( @AuthenticationPrincipal String userId, @Valid @ModelAttribute UserUpdateRequest request) { - User updatedUser = userService.update(userId, request); + userService.update(userId, request); return ResponseEntity.ok( CatsgotogedogApiResponse.success("프로필 수정 성공", null)); } diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java index 1cab9f5..856b3e9 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -55,7 +55,7 @@ public User profile(String userId) { return findUserById(userId); } - public User update(String userId, UserUpdateRequest request) { + public void update(String userId, UserUpdateRequest request) { User user = findUserById(userId); if (request.getDisplayName() != null) { @@ -92,7 +92,7 @@ public User update(String userId, UserUpdateRequest request) { user.setImageFilename(imageInfo.key()); user.setImageUrl(imageInfo.url()); } - return userRepository.save(user); + userRepository.save(user); } public void deleteProfileImage(String userId) { From 83450f1845d5e148810453aeb342379fcc3ffbb0 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 2 Aug 2025 17:41:05 +0900 Subject: [PATCH 067/191] =?UTF-8?q?feat/=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=98=20=EB=B0=B0=EC=B9=98=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A5=B4=EB=9F=AC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매일 새벽 1시 각 API별 데이터 받아올 수 있도록 배치 작성 --- .../com/batch/client/MigrationClient.java | 69 ------ .../java/com/batch/config/BatchConfig.java | 147 ++++++++++++- .../com/batch/dto/DetailInfoApiResponse.java | 43 ++++ .../batch/dto/DetailInfoProcessResult.java | 17 ++ .../batch/dto/DetailIntroProcessResult.java | 21 ++ .../com/batch/dto/DetailIntroResponse.java | 76 +++++++ .../com/batch/dto/DetailPetTourResponse.java | 81 +++++++ .../batch/listener/CustomSkipListener.java | 28 +++ .../listener/CustomStepExecutionListener.java | 1 + .../processor/DetailCommonProcessor.java | 8 +- .../batch/processor/DetailImageProcessor.java | 4 +- .../batch/processor/DetailInfoProcessor.java | 154 ++++++++++++++ .../batch/processor/DetailIntroProcessor.java | 200 ++++++++++++++++++ .../processor/DetailPetTourProcessor.java | 81 +++++++ .../com/batch/reader/DetailImageReader.java | 4 +- .../com/batch/reader/DetailInfoReader.java | 33 +++ .../com/batch/reader/DetailIntroReader.java | 72 +++++++ .../com/batch/reader/DetailPetTourReader.java | 35 +++ .../com/batch/writer/DetailInfoWriter.java | 36 ++++ .../com/batch/writer/DetailIntroWriter.java | 51 +++++ .../com/batch/writer/DetailPetTourWriter.java | 22 ++ .../CatsgotogedogApplication.java | 27 +-- .../common/milvus/service/MilvusService.java | 48 ----- .../content/domain/entity/.gitkeep | 0 .../information/FestivalInformation.java | 88 ++++++++ .../batch/information/LodgeInformation.java | 104 +++++++++ .../information/RestaurantInformation.java | 77 +++++++ .../batch/information/SightsInformation.java | 64 ++++++ .../entity/batch/recur/RecurInformation.java | 39 ++++ .../batch/recur/RecurInformationRoom.java | 75 +++++++ .../recur/RecurInformationRoomImage.java | 39 ++++ .../FestivalInformationRepository.java | 8 + .../LodgeInformationRepository.java | 8 + .../RecurInformationRepository.java | 8 + .../RecurInformationRoomImageRepository.java | 8 + .../RecurInformationRoomRepository.java | 8 + .../repository/RegionCodeRepository.java | 2 - .../RestaurantInformationRepository.java | 8 + .../SightsInformationRepository.java | 8 + .../global/config/MilvusConfig.java | 39 ---- .../pet/domain/entity/PetGuide.java | 63 ++++++ .../pet/repository/PetGuideRepository.java | 8 + .../migration/mysql/V5__init_region_data.sql | 6 + .../db/migration/mysql/V6__table.sql | 13 ++ .../milvus/service/MilvusServiceTest.java | 55 ----- 45 files changed, 1733 insertions(+), 253 deletions(-) create mode 100644 src/main/java/com/batch/dto/DetailInfoApiResponse.java create mode 100644 src/main/java/com/batch/dto/DetailInfoProcessResult.java create mode 100644 src/main/java/com/batch/dto/DetailIntroProcessResult.java create mode 100644 src/main/java/com/batch/dto/DetailIntroResponse.java create mode 100644 src/main/java/com/batch/dto/DetailPetTourResponse.java create mode 100644 src/main/java/com/batch/listener/CustomSkipListener.java create mode 100644 src/main/java/com/batch/processor/DetailInfoProcessor.java create mode 100644 src/main/java/com/batch/processor/DetailIntroProcessor.java create mode 100644 src/main/java/com/batch/processor/DetailPetTourProcessor.java create mode 100644 src/main/java/com/batch/reader/DetailInfoReader.java create mode 100644 src/main/java/com/batch/reader/DetailIntroReader.java create mode 100644 src/main/java/com/batch/reader/DetailPetTourReader.java create mode 100644 src/main/java/com/batch/writer/DetailInfoWriter.java create mode 100644 src/main/java/com/batch/writer/DetailIntroWriter.java create mode 100644 src/main/java/com/batch/writer/DetailPetTourWriter.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/FestivalInformation.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/SightsInformation.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformation.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoomImage.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomImageRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetGuide.java create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java create mode 100644 src/main/resources/db/migration/mysql/V6__table.sql delete mode 100644 src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java diff --git a/src/main/java/com/batch/client/MigrationClient.java b/src/main/java/com/batch/client/MigrationClient.java index b9e1658..a1ff38b 100644 --- a/src/main/java/com/batch/client/MigrationClient.java +++ b/src/main/java/com/batch/client/MigrationClient.java @@ -133,73 +133,4 @@ public List getCategoryCode(String contentTypeId, Str return Collections.emptyList(); } } - - - - // detailInfo - public DetailInfoResponse getDetailInfoAccom(String contentId, String contentTypeId) { - URI uri = UriComponentsBuilder.fromUriString(baseUrl + "/detailInfo") - .queryParam("serviceKey", serviceKey) - .queryParam("MobileOS", "ETC") - .queryParam("MobileApp", "CatsGoToGedog") - .queryParam("contentTypeId", ContentTypeId.숙박.getContentTypeId()) - .queryParam("_type", "json") - .encode(StandardCharsets.UTF_8) - .build() - .toUri(); - log.info("API Request URL (detailInfo) : {}", uri); - - try { - return restClient.get() - .uri(uri) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .onStatus(HttpStatusCode::isError, (request, response) -> { - String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); - log.error("detailInfo API 요청중 오류 발생(컨텐츠 ID: {}) ({} {}) : Status {}, Body {}", contentId, request.getMethod(), request.getURI(), response.getStatusCode(), errorBody); - }) - .body(DetailInfoResponse.class); - }catch (Exception e) { - log.error("detailInfo API 요청중 예상치 못한 오류 발생(컨텐츠 ID: {}) : {}", contentId, e.getMessage(), e); - return null; - } - } - - // Content Image - public List getDetailImageList(int contentId) { - URI uri = UriComponentsBuilder.fromUriString(baseUrl + "/detailImage") - .queryParam("serviceKey", serviceKey) - .queryParam("MobileOS", "ETC") - .queryParam("MobileApp", "CatsGoToGedog") - .queryParam("contentId ", contentId) - .queryParam("imageYN", "Y") - .queryParam("_type", "json") - .encode(StandardCharsets.UTF_8) - .build() - .toUri(); - - log.info("API Request URL (detailImage) : {}", uri); - - try { - DetailImageResponse response = restClient.get() - .uri(uri) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .onStatus(HttpStatusCode::isError, MigrationClient::handle) - .body(DetailImageResponse.class); - - return Optional.ofNullable(response) - .map(DetailImageResponse::response) - .map(DetailImageResponse.Response::body) - .map(DetailImageResponse.Body::items) - .map(DetailImageResponse.Items::item) - .orElse(Collections.emptyList()); - } catch (RuntimeException e) { - log.error("detailImage API 요청 중 오류 발생 (contentId: {}): {}", contentId, e.getMessage(), e); - return Collections.emptyList(); - } catch (Exception e) { - log.error("detailImage API 요청 중 예상치 못한 오류 발생 (contentId: {}): {}", contentId, e.getMessage(), e); - return Collections.emptyList(); - } - } } diff --git a/src/main/java/com/batch/config/BatchConfig.java b/src/main/java/com/batch/config/BatchConfig.java index bbab9ae..c55847b 100644 --- a/src/main/java/com/batch/config/BatchConfig.java +++ b/src/main/java/com/batch/config/BatchConfig.java @@ -10,6 +10,7 @@ import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.item.database.JpaCursorItemReader; import org.springframework.batch.item.database.JpaPagingItemReader; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -17,19 +18,28 @@ import org.springframework.web.client.ResourceAccessException; import com.batch.dto.AreaBasedListResponse; +import com.batch.dto.DetailInfoProcessResult; +import com.batch.dto.DetailIntroProcessResult; import com.batch.listener.CustomJobExecutionListener; +import com.batch.listener.CustomSkipListener; import com.batch.listener.CustomStepExecutionListener; import com.batch.processor.AreaBasedListItemProcessor; import com.batch.processor.DetailCommonProcessor; import com.batch.processor.DetailImageProcessor; +import com.batch.processor.DetailInfoProcessor; +import com.batch.processor.DetailIntroProcessor; +import com.batch.processor.DetailPetTourProcessor; import com.batch.reader.AreaBasedListApiReader; -import com.batch.reader.DetailImageReader; import com.batch.tasklet.CategoryFetchTasklet; import com.batch.writer.ContentImageWriter; import com.batch.writer.DetailCommonWriter; +import com.batch.writer.DetailInfoWriter; +import com.batch.writer.DetailIntroWriter; +import com.batch.writer.DetailPetTourWriter; import com.batch.writer.ItemWriterConfig; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; import jakarta.persistence.EntityManagerFactory; @@ -40,25 +50,40 @@ public class BatchConfig { private final EntityManagerFactory entityManagerFactory; private final PlatformTransactionManager transactionManager; - private final CustomJobExecutionListener customJobExecutionListener; - private final CustomStepExecutionListener customStepExecutionListener; private final JobRepository jobRepository; private final CategoryFetchTasklet categoryFetchTasklet; + // Listener + private final CustomJobExecutionListener customJobExecutionListener; + private final CustomStepExecutionListener customStepExecutionListener; + private final CustomSkipListener customSkipListener; + // Reader private final JpaPagingItemReader detailImageContentReader; private final AreaBasedListApiReader contentReader; private final JpaPagingItemReader detailCommonItemReader; + private final JpaPagingItemReader detailPetTourItemReader; + private final JpaPagingItemReader detailInfoItemReader; + private final JpaCursorItemReader sightsInformationItemReader; + private final JpaCursorItemReader lodgeInformationItemReader; + private final JpaCursorItemReader festivalInformationItemReader; + private final JpaCursorItemReader restaurantInformationItemReader; // Writer private final ItemWriterConfig itemWriterConfig; private final ContentImageWriter contentImageWriter; private final DetailCommonWriter detailCommonWriter; + private final DetailPetTourWriter detailPetTourWriter; + private final DetailInfoWriter detailInfoWriter; + private final DetailIntroWriter detailIntroWriter; // Processor private final DetailImageProcessor detailImageProcessor; private final AreaBasedListItemProcessor contentProcessor; private final DetailCommonProcessor detailCommonProcessor; + private final DetailPetTourProcessor detailPetTourProcessor; + private final DetailInfoProcessor detailInfoProcessor; + private final DetailIntroProcessor detailIntroProcessor; private final int CHUNK_SIZE = 100; @@ -72,7 +97,13 @@ public Job contentBatchJob() { .listener(customJobExecutionListener) .start(contentDataFetchStep()) .next(detailCommonFetchStep()) - //.next(detailImageFetchStep()) + .next(detailImageFetchStep()) + .next(petGuideFetchStep()) + .next(detailInfoFetchStep()) + .next(detailIntroSightsFetchStep()) + .next(detailIntroLodgeFetchStep()) + .next(detailIntroRestaurantFetchStep()) + .next(detailIntroFestivalFetchStep()) .build(); } @@ -85,12 +116,12 @@ public Step contentDataFetchStep() { .reader(contentReader) .processor(contentProcessor) .writer(itemWriterConfig.step1ContentWriter(entityManagerFactory)) + .listener(customStepExecutionListener) .faultTolerant() .skipLimit(2000) .skip(Exception.class) - .retryLimit(3) .retry(ResourceAccessException.class) - .listener(customStepExecutionListener) + .listener(customSkipListener) .build(); } @@ -119,15 +150,16 @@ public Job categoryCodeBatchJob() { @Bean public Step detailImageFetchStep() { return new StepBuilder("detailImageFetchStep", jobRepository) - .>chunk(100, transactionManager) + .>chunk(CHUNK_SIZE, transactionManager) .reader(detailImageContentReader) .processor(detailImageProcessor) .writer(contentImageWriter) + .listener(customStepExecutionListener) .faultTolerant() .skipLimit(2000) .skip(Exception.class) .retry(ResourceAccessException.class) - .listener(customStepExecutionListener) + .listener(customSkipListener) .build(); } @@ -135,15 +167,112 @@ public Step detailImageFetchStep() { @Bean public Step detailCommonFetchStep() { return new StepBuilder("detailCommonFetchStep", jobRepository) - .chunk(100, transactionManager) + .chunk(CHUNK_SIZE, transactionManager) .reader(detailCommonItemReader) .processor(detailCommonProcessor) .writer(detailCommonWriter) + .listener(customStepExecutionListener) .faultTolerant() .skipLimit(2000) .skip(Exception.class) .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + + // PetGuide 스텝 + @Bean + public Step petGuideFetchStep() { + return new StepBuilder("petGuideFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(detailPetTourItemReader) + .processor(detailPetTourProcessor) + .writer(detailPetTourWriter) .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + + // detailInfo 스텝 + @Bean + public Step detailInfoFetchStep() { + return new StepBuilder("detailInfoFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(detailInfoItemReader) + .processor(detailInfoProcessor) + .writer(detailInfoWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + + // detailIntro 스텝 + @Bean + public Step detailIntroSightsFetchStep() { + return new StepBuilder("detailIntroSightsFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sightsInformationItemReader) + .processor(detailIntroProcessor) + .writer(detailIntroWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + @Bean + public Step detailIntroLodgeFetchStep() { + return new StepBuilder("detailIntroLodgeFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(lodgeInformationItemReader) + .processor(detailIntroProcessor) + .writer(detailIntroWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + @Bean + public Step detailIntroRestaurantFetchStep() { + return new StepBuilder("detailIntroRestaurantFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(restaurantInformationItemReader) + .processor(detailIntroProcessor) + .writer(detailIntroWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + @Bean + public Step detailIntroFestivalFetchStep() { + return new StepBuilder("detailIntroFestivalFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(festivalInformationItemReader) + .processor(detailIntroProcessor) + .writer(detailIntroWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) .build(); } } diff --git a/src/main/java/com/batch/dto/DetailInfoApiResponse.java b/src/main/java/com/batch/dto/DetailInfoApiResponse.java new file mode 100644 index 0000000..9559b47 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailInfoApiResponse.java @@ -0,0 +1,43 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.io.IOException; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailInfo) +public record DetailInfoApiResponse(Response response){ + + public record Response(Header header, Body body) {} + public record Header(String resultCode, String resultMsg) {} + + public record Body(JsonNode items, int numOfRows, int pageNo, int totalCount) {} + + public record Items(List item) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record GeneralItem(String contentid, String contentypeid, String serialnum, String infoname, String infotext, String fldgubun) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record RoomItem( + String contentid, String roomtitle, String roomsize1, String roomcount, + String roombasecount, String roommaxcount, String roomintro, + String roombathfacility, String roombath, String roomhometheater, + String roomaircondition, String roomtv, String roompc, String roomcable, + String roominternet, String roomrefrigerator, String roomtoiletries, + String roomsofa, String roomcook, String roomtable, String roomhairdryer, + String roomimg1, String roomimg1alt, String roomimg1cpyrhtdiv, + String roomimg2, String roomimg2alt, String roomimg2cpyrhtdiv, + String roomimg3, String roomimg3alt, String roomimg3cpyrhtdiv, + String roomimg4, String roomimg4alt, String roomimg4cpyrhtdiv, + String roomimg5, String roomimg5alt, String roomimg5cpyrhtdiv, + String roomoffseasonminfee1, String roomoffseasonminfee2, String roompeakseasonminfee1, + String roompeakseasonminfee2, String roomsize2 + ) {} +} diff --git a/src/main/java/com/batch/dto/DetailInfoProcessResult.java b/src/main/java/com/batch/dto/DetailInfoProcessResult.java new file mode 100644 index 0000000..9bb8042 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailInfoProcessResult.java @@ -0,0 +1,17 @@ +package com.batch.dto; + +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoom; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class DetailInfoProcessResult { + private final List generalInfoList; + private final List roomInfoList; + +} diff --git a/src/main/java/com/batch/dto/DetailIntroProcessResult.java b/src/main/java/com/batch/dto/DetailIntroProcessResult.java new file mode 100644 index 0000000..4864951 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailIntroProcessResult.java @@ -0,0 +1,21 @@ +package com.batch.dto; + +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class DetailIntroProcessResult { + private final List sightsInfoList; + private final List lodgeInfoList; + private final List restaurantInfoList; + private final List festivalInformationList; + +} diff --git a/src/main/java/com/batch/dto/DetailIntroResponse.java b/src/main/java/com/batch/dto/DetailIntroResponse.java new file mode 100644 index 0000000..a7f50ce --- /dev/null +++ b/src/main/java/com/batch/dto/DetailIntroResponse.java @@ -0,0 +1,76 @@ +package com.batch.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailIntro) +public record DetailIntroResponse(Response response){ + + public record Response(Header header, Body body) {} + public record Header(String resultCode, String resultMsg) {} + + public record Body(JsonNode items, int numOfRows, int pageNo, int totalCount) {} + + public record Items(List item) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SightsItem( + String contentid, String contenttypeid, + String heritage1, String heritage2, + String heritage3, String infocenter, + String opendate, String restdate, + String expguide, String expagerange, + String accomcount, String useseason, + String usetime, String parking, + String chkcreditcard + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record FestivalItem( + String contentid, String contenttypeid, + String sponsor1, String sponsor1tel, + String sponsor2, String sponsor2tel, + String eventenddate, String playtime, + String eventplace, String eventhomepage, + String agelimit, String bookingplace, + String placeinfo, String subevent, + String program, String eventstartdate, + String usetimefestival, String discountinfofestival, + String spendtimefestival, String festivalgrade + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LodgeItem( + String contentid, String contenttypeid, + String goodstay, String benikia, + String hanok, String roomcount, + String roomtype, String checkintime, + String checkouttime, String chkcooking, + String seminar, String sports, + String sauna, String beauty, + String beverage, String karaoke, + String barbecue, String campfire, + String bicycle, String fitness, + String publicpc, String publicbath, + String subfacility, String foodplace, + String pickup, String infocenterlodging, + String parkinglodging, String reservationlodging, + String scalelodging, String accomcountlodging, + String reservationurl, String refundregulation + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record RestaurantItem( + String contentid, String contenttypeid, + String seat, String kidsfacility, + String firstmenu, String treatmenu, + String smoking, String packing, + String infocenterfood, String scalefood, + String parkingfood, String opendatefood, + String opentimefood, String restdatefood, + String discountinfofood, String chkcreditcardfood, + String reservationfood, String lcnsno + ) {} +} diff --git a/src/main/java/com/batch/dto/DetailPetTourResponse.java b/src/main/java/com/batch/dto/DetailPetTourResponse.java new file mode 100644 index 0000000..9038b1c --- /dev/null +++ b/src/main/java/com/batch/dto/DetailPetTourResponse.java @@ -0,0 +1,81 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.io.IOException; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailPetTour) +public record DetailPetTourResponse(Response response){ + + public record Response( + Header header, + Body body + ) {} + + public record Header( + String resultCode, + String resultMsg + ) {} + + public record Body( + Items items, + int numOfRows, + int pageNo, + int totalCount + ) {} + + public record Items(List item) { + + @JsonCreator + public static Items from(JsonNode node) throws IOException { + + // ObjectMapper는 한 번만 생성하는 것이 효율적입니다. + final ObjectMapper mapper = new ObjectMapper(); + + // 1. items 필드가 비어있는 문자열("")일 경우 + if (node.isTextual() && node.asText().isEmpty()) { + return new Items(Collections.emptyList()); + } + + // 2. 정상적인 JSON 객체일 경우 + if (node.isObject()) { + // "item"이라는 이름의 하위 노드를 찾습니다. + JsonNode itemNode = node.get("item"); + + // 하위 노드가 없거나 배열이 아니면 빈 리스트로 처리합니다. + if (itemNode == null || !itemNode.isArray()) { + return new Items(Collections.emptyList()); + } + + // ✨ itemNode(배열)를 List 타입으로 직접 변환합니다. + List itemList = mapper.convertValue(itemNode, new TypeReference<>() {}); + return new Items(itemList); + } + + // 3. 그 외의 모든 경우 (null 등) + return new Items(Collections.emptyList()); + } + + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Item( + String acmpyNeedMtr, // 동반시 준비물 + String contentid, + String relaAcdntRiskMtr,// 사고 대비사항 + String acmpyTypeCd, // 동반가능 동물 + String relaPosesFclty, // 보유시설 + String relaFrnshPrdlst, // 비치품목 + String etcAcmpyInfo, // 기타 동반 정보 + String relaPurcPrdlst, // 구매가능 품목 + String acmpyPsblCpam, // 동반가능 크기 + String relaRntlPrdlst // 대여가능 품목 + ){} +} diff --git a/src/main/java/com/batch/listener/CustomSkipListener.java b/src/main/java/com/batch/listener/CustomSkipListener.java new file mode 100644 index 0000000..26d7314 --- /dev/null +++ b/src/main/java/com/batch/listener/CustomSkipListener.java @@ -0,0 +1,28 @@ +package com.batch.listener; + +import org.springframework.batch.core.SkipListener; +import org.springframework.stereotype.Component; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class CustomSkipListener implements SkipListener { + + @Override + public void onSkipInRead(Throwable t) { + log.warn("[스킵] Reader 단계에서 스킵 사유 : {}", t.getMessage()); + } + + @Override + public void onSkipInWrite(Object item, Throwable t) { + log.warn("[스킵] Writer 단계에서 스킵 발생, Item: {}, 사유 : {}", item, t.getMessage()); + } + + @Override + public void onSkipInProcess(Content item, Throwable t) { + log.warn("[SKIP] Processor 단계에서 스킵 발생, ContentId : {}, 사유 : {}, {}", item.getContentId(), t.getMessage(), t.getStackTrace()); + } +} diff --git a/src/main/java/com/batch/listener/CustomStepExecutionListener.java b/src/main/java/com/batch/listener/CustomStepExecutionListener.java index 5a97eb4..cb147ec 100644 --- a/src/main/java/com/batch/listener/CustomStepExecutionListener.java +++ b/src/main/java/com/batch/listener/CustomStepExecutionListener.java @@ -5,6 +5,7 @@ import org.springframework.batch.core.StepExecutionListener; import org.springframework.stereotype.Component; + import lombok.extern.slf4j.Slf4j; @Component diff --git a/src/main/java/com/batch/processor/DetailCommonProcessor.java b/src/main/java/com/batch/processor/DetailCommonProcessor.java index 5b6fc35..1495363 100644 --- a/src/main/java/com/batch/processor/DetailCommonProcessor.java +++ b/src/main/java/com/batch/processor/DetailCommonProcessor.java @@ -1,16 +1,11 @@ package com.batch.processor; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - import org.springframework.batch.item.ItemProcessor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import com.batch.dto.DetailCommonResponse; -import com.batch.dto.DetailImageResponse; import com.swyp.catsgotogedog.content.domain.entity.Content; import lombok.extern.slf4j.Slf4j; @@ -35,7 +30,7 @@ public DetailCommonProcessor( @Override public Content process(Content content) throws Exception { - log.info("ContentId : ({}) 설명 정보 수집 중", content.getContentId()); + log.info("{} ({}), 설명 정보 수집 중", content.getTitle(), content.getContentId()); DetailCommonResponse response = restClient.get() .uri(uriBuilder -> uriBuilder @@ -56,6 +51,7 @@ public Content process(Content content) throws Exception { if (response != null && response.response().body().items() != null && !response.response().body().items().item().isEmpty()) { String overview = response.response().body().items().item().get(0).overview(); + if (overview == null || overview.isEmpty()) log.warn("{} ({}), 설명 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); content.setOverview(overview); } return content; diff --git a/src/main/java/com/batch/processor/DetailImageProcessor.java b/src/main/java/com/batch/processor/DetailImageProcessor.java index eaf1090..2706a9b 100644 --- a/src/main/java/com/batch/processor/DetailImageProcessor.java +++ b/src/main/java/com/batch/processor/DetailImageProcessor.java @@ -35,7 +35,7 @@ public DetailImageProcessor( @Override public List process(Content content) throws Exception { - log.info("ContentId : ({}) 이미지 수집 중", content.getContentId()); + log.info("{} ({}), 이미지 정보 수집 중", content.getTitle(), content.getContentId()); DetailImageResponse response = restClient.get() .uri(uriBuilder -> uriBuilder @@ -54,8 +54,8 @@ public List process(Content content) throws Exception { DetailImageResponse.Body body = (bodyResponse != null) ? bodyResponse.body() : null; DetailImageResponse.Items items = (body != null) ? body.items() : null; - // items 객체가 null이거나, 그 안의 item 리스트가 비어있는 경우를 안전하게 확인 if (items == null || items.item() == null || items.item().isEmpty()) { + log.warn("{} ({}), 이미지 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); return Collections.emptyList(); // 이미지가 없으면 빈 리스트 반환 } diff --git a/src/main/java/com/batch/processor/DetailInfoProcessor.java b/src/main/java/com/batch/processor/DetailInfoProcessor.java new file mode 100644 index 0000000..e00faf9 --- /dev/null +++ b/src/main/java/com/batch/processor/DetailInfoProcessor.java @@ -0,0 +1,154 @@ +package com.batch.processor; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailInfoApiResponse; +import com.batch.dto.DetailInfoProcessResult; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoom; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoomImage; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DetailInfoProcessor implements ItemProcessor { + + private final RestClient restClient; + private final String serviceKey; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public DetailInfoProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public DetailInfoProcessResult process(Content content) throws Exception { + log.info("{} ({}, {}), 장소 반복 정보 수집 중", content.getTitle(), content.getContentId(), content.getContentTypeId()); + // DetailInfoApiResponse response = restClient.get() + ResponseEntity responseEntity = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailInfo") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("contentId", content.getContentId()) + .queryParam("contentTypeId", content.getContentTypeId()) + .queryParam("_type", "json") + .build() + ).retrieve() + // .body(DetailInfoApiResponse.class); + .toEntity(String.class); + + log.info("status :: {} ", responseEntity.getStatusCode()); + log.info("body :: {} ", responseEntity.getBody()); + + DetailInfoApiResponse response = objectMapper.readValue(responseEntity.getBody(), DetailInfoApiResponse.class); + + if(response == null || response.response() == null || response.response().body() == null) { + log.warn("{} ({}), 장소의 반복 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return new DetailInfoProcessResult(null, null); + } + + JsonNode itemsNode = response.response().body().items(); + if(itemsNode == null || itemsNode.isEmpty()) { + log.warn("{} ({}), ItemsNode 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return new DetailInfoProcessResult(null, null); + } + + // 숙박 + if("32".equals(String.valueOf(content.getContentTypeId()))) { + log.info("{} ({}), 숙소 반복 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + DetailInfoApiResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + if(items == null || items.item() == null || items.item().isEmpty()) { + log.warn("{} ({}), 장소의 숙소 반복 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return new DetailInfoProcessResult(null, null); + } + + List rooms = items.item().stream().map(dto -> { + RecurInformationRoom room = RecurInformationRoom.builder() + .content(content) + .roomTitle(dto.roomtitle()) + .roomSize1(Integer.valueOf(dto.roomsize1())) + .roomCount(Integer.valueOf(dto.roomcount())) + .roomBaseCount(Integer.valueOf(dto.roombasecount())) + .roomMaxCount(Integer.valueOf(dto.roommaxcount())) + .offSeasonWeekMinFee(Integer.valueOf(dto.roomoffseasonminfee1())) + .offSeasonWeekendMinFee(Integer.valueOf(dto.roomoffseasonminfee2())) + .peakSeasonWeekMinFee(Integer.valueOf(dto.roompeakseasonminfee1())) + .peakSeasonWeekendMinFee(Integer.valueOf(dto.roompeakseasonminfee2())) + .roomIntro(dto.roomintro()) + .roomBathFacility(Boolean.valueOf(dto.roombathfacility())) + .roomBath(Boolean.valueOf(dto.roombath())) + .roomHomeTheater(Boolean.valueOf(dto.roomhometheater())) + .roomAircondition(Boolean.valueOf(dto.roomaircondition())) + .roomTv(Boolean.valueOf(dto.roomtv())) + .roomPc(Boolean.valueOf(dto.roompc())) + .roomCable(Boolean.valueOf(dto.roomcable())) + .roomInternet(Boolean.valueOf(dto.roominternet())) + .roomRefrigerator(Boolean.valueOf(dto.roomrefrigerator())) + .roomToiletries(Boolean.valueOf(dto.roomtoiletries())) + .roomSofa(Boolean.valueOf(dto.roomsofa())) + .roomCook(Boolean.valueOf(dto.roomcook())) + .roomTable(Boolean.valueOf(dto.roomtable())) + .roomHairdryer(Boolean.valueOf(dto.roomhairdryer())) + .roomSize2(new BigDecimal(dto.roomsize2())) + .images(new ArrayList<>()).build(); + addRoomImageIfPresent(room, dto.roomimg1(), dto.roomimg1alt(), dto.roomimg1cpyrhtdiv()); + addRoomImageIfPresent(room, dto.roomimg2(), dto.roomimg2alt(), dto.roomimg2cpyrhtdiv()); + addRoomImageIfPresent(room, dto.roomimg3(), dto.roomimg3alt(), dto.roomimg3cpyrhtdiv()); + addRoomImageIfPresent(room, dto.roomimg4(), dto.roomimg4alt(), dto.roomimg4cpyrhtdiv()); + addRoomImageIfPresent(room, dto.roomimg5(), dto.roomimg5alt(), dto.roomimg5cpyrhtdiv()); + return room; + }).collect(Collectors.toList()); + return new DetailInfoProcessResult(null, rooms); + } else { + log.info("{} ({}), 일반 반복 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + DetailInfoApiResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + if(items == null || items.item() == null || items.item().isEmpty()) { + return new DetailInfoProcessResult(null, null); + } + List infos = items.item().stream() + .map(dto -> RecurInformation.builder() + .content(content) + .infoName(dto.infoname()) + .infoText(dto.infotext()) + .build() + ).collect(Collectors.toList()); + return new DetailInfoProcessResult(infos, null); + } + } + + private void addRoomImageIfPresent(RecurInformationRoom room, String url, String alt, String copyright) { + if(url != null && !url.trim().isEmpty()) { + RecurInformationRoomImage image = RecurInformationRoomImage.builder() + .room(room) + .imageUrl(url) + .imageAlt(alt) + .imageCopyright(copyright) + .build(); + room.getImages().add(image); + } + } +} diff --git a/src/main/java/com/batch/processor/DetailIntroProcessor.java b/src/main/java/com/batch/processor/DetailIntroProcessor.java new file mode 100644 index 0000000..187fb2f --- /dev/null +++ b/src/main/java/com/batch/processor/DetailIntroProcessor.java @@ -0,0 +1,200 @@ +package com.batch.processor; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailIntroProcessResult; +import com.batch.dto.DetailIntroResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class DetailIntroProcessor implements ItemProcessor { + + private final RestClient restClient; + private final String serviceKey; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public DetailIntroProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public DetailIntroProcessResult process(Content content) throws Exception { + log.info("{} ({}), 소개 정보 수집 중", content.getTitle(), content.getContentId()); + + DetailIntroResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailIntro") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("_type", "json") + .queryParam("contentId", content.getContentId()) + .queryParam("contentTypeId", content.getContentTypeId()) + .build() + ) + .retrieve() + .body(DetailIntroResponse.class); + + if(response == null || response.response() == null || response.response().body() == null) { + log.warn("{} ({}), 장소의 소개 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return new DetailIntroProcessResult(null, null, null, null); + } + + JsonNode itemsNode = response.response().body().items(); + if(itemsNode == null || itemsNode.isEmpty()) { + log.warn("{} ({}), ItemsNode 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return new DetailIntroProcessResult(null, null, null, null); + } + switch (content.getContentTypeId()) { + case 12 -> { + log.info("{} ({}), 관광지 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List infos = items.item().stream() + .map(dto -> SightsInformation.builder() + .content(content) + .contentTypeId(content.getContentTypeId()) + .accomCount(Integer.valueOf(dto.accomcount().replaceAll("[^0-9]", ""))) + .chkCreditcard(dto.chkcreditcard()) + .expAgeRange(dto.expagerange()) + .expGuide(dto.expguide()) + .infoCenter(dto.infocenter()) + .openDate(dto.opendate().isEmpty() ? null : LocalDate.parse(dto.opendate())) + .parking(dto.parking()) + .restDate(dto.restdate()) + .useSeason(dto.useseason()) + .heritage1(Boolean.valueOf(dto.heritage1())) + .heritage2(Boolean.valueOf(dto.heritage2())) + .heritage3(Boolean.valueOf(dto.heritage3())) + .build() + ) + .collect(Collectors.toList()); + return new DetailIntroProcessResult(infos, null, null, null); + } + + case 15 -> { + log.info("{} ({}), 축제공연행사 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List infos = items.item().stream() + .map(dto -> FestivalInformation.builder() + .content(content) + .ageLimit(dto.agelimit()) + .bookingPlace(dto.bookingplace()) + .discountInfo(dto.discountinfofestival()) + .eventStartDate(LocalDate.parse(dto.eventstartdate())) + .eventEndDate(LocalDate.parse(dto.eventenddate())) + .eventHomepage(dto.eventhomepage()) + .eventPlace(dto.eventplace()) + .placeInfo(dto.placeinfo()) + .playTime(dto.playtime()) + .program(dto.program()) + .spendTime(dto.spendtimefestival()) + .organizer(dto.sponsor1()) + .organizerTel(dto.sponsor1tel()) + .supervisor(dto.sponsor2()) + .supervisorTel(dto.sponsor2tel()) + .subEvent(dto.subevent()) + .feeInfo(dto.usetimefestival()) + .build() + ) + .collect(Collectors.toList()); + return new DetailIntroProcessResult(null, null, null, infos); + } + + case 32 -> { + log.info("{} ({}), 숙박 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List infos = items.item().stream() + .map(dto -> LodgeInformation.builder() + .content(content) + .capacityCount(Integer.valueOf(dto.accomcountlodging())) + .benikia(Boolean.valueOf(dto.benikia())) + .checkInTime(LocalTime.parse(dto.checkintime())) + .checkOutTime(LocalTime.parse(dto.checkouttime())) + .cooking(dto.chkcooking()) + .foodplace(dto.foodplace()) + .goodstay(Boolean.valueOf(dto.goodstay())) + .hanok(Boolean.valueOf(dto.hanok())) + .information(dto.infocenterlodging()) + .parking(dto.parkinglodging()) + .roomCount(Integer.valueOf(dto.roomcount())) + .reservationInfo(dto.reservationlodging()) + .reservationUrl(dto.reservationurl()) + .roomType(dto.roomtype()) + .scale(dto.scalelodging()) + .subFacility(dto.subfacility()) + .barbecue(Boolean.valueOf(dto.barbecue())) + .beauty(Boolean.valueOf(dto.beauty())) + .beverage(Boolean.valueOf(dto.beverage())) + .bicycle(Boolean.valueOf(dto.bicycle())) + .campfire(Boolean.valueOf(dto.campfire())) + .fitness(Boolean.valueOf(dto.fitness())) + .karaoke(Boolean.valueOf(dto.karaoke())) + .publicBath(Boolean.valueOf(dto.publicbath())) + .publicPcRoom(Boolean.valueOf(dto.publicpc())) + .sauna(Boolean.valueOf(dto.sauna())) + .seminar(Boolean.valueOf(dto.seminar())) + .sports(Boolean.valueOf(dto.sports())) + .refundRegulation(dto.refundregulation()) + .build() + ) + .collect(Collectors.toList()); + return new DetailIntroProcessResult(null, infos, null, null); + } + + case 39 -> { + log.info("{} ({}), 음식점 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List infos = items.item().stream() + .map(dto -> RestaurantInformation.builder() + .content(content) + .chkCreditcard(dto.chkcreditcardfood()) + .discountInfo(dto.discountinfofood()) + .signatureMenu(dto.firstmenu()) + .information(dto.infocenterfood()) + .kidsFacility(Boolean.valueOf(dto.kidsfacility())) + .openDate(LocalDate.parse(dto.opendatefood())) + .openTime(dto.opentimefood()) + .parking(dto.parkingfood()) + .reservation(dto.reservationfood()) + .scale(Integer.valueOf(dto.scalefood())) + .smoking(Boolean.valueOf(dto.smoking())) + .treatMenu(dto.treatmenu()) + .build() + ) + .collect(Collectors.toList()); + return new DetailIntroProcessResult(null, null, infos, null); + } + + } + return null; + } +} diff --git a/src/main/java/com/batch/processor/DetailPetTourProcessor.java b/src/main/java/com/batch/processor/DetailPetTourProcessor.java new file mode 100644 index 0000000..a6a7e08 --- /dev/null +++ b/src/main/java/com/batch/processor/DetailPetTourProcessor.java @@ -0,0 +1,81 @@ +package com.batch.processor; + +import java.util.Collections; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailImageResponse; +import com.batch.dto.DetailPetTourResponse; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DetailPetTourProcessor implements ItemProcessor { + + private final RestClient restClient; + private final String serviceKey; + + public DetailPetTourProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public PetGuide process(Content content) throws Exception { + log.info("{} ({}), 반려동물 이용 가이드 수집 중", content.getTitle(), content.getContentId()); + + DetailPetTourResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailPetTour") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("_type", "json") + .queryParam("contentId", content.getContentId()) + .build() + ) + .retrieve() + .body(DetailPetTourResponse.class); + + DetailPetTourResponse.Response bodyResponse = (response != null) ? response.response() : null; + DetailPetTourResponse.Body body = (bodyResponse != null) ? bodyResponse.body() : null; + DetailPetTourResponse.Items items = (body != null) ? body.items() : null; + + if (response == null || response.response().body().items() == null || response.response().body().items().item() == null) { + log.info("{} ({}), 반려동물 이용 가이드 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return null; + } + + DetailPetTourResponse.Item item = response.response().body().items().item().get(0); + + if(item.acmpyPsblCpam().equals("불가능")) { + log.info("{} ({}) 반려동물 동반 불가능하여 스킵", content.getTitle(), content.getContentId()); + return null; + } + + return PetGuide.builder() + .content(content) + .petPrep(item.acmpyNeedMtr()) //동반시 준비물 + .accidentPrep(item.relaAcdntRiskMtr()) // 사고 대비사항 + .allowedPetType(item.acmpyPsblCpam()) // 동반가능 동물 + .availableFacility(item.relaPosesFclty()) // 보유시설 + .providedItem(item.relaFrnshPrdlst()) // 비치품목 + .etcInfo(item.etcAcmpyInfo()) // 기타 동반 정보 + .purchasableItem(item.relaPurcPrdlst()) // 구매가능 품목 + .withPet(item.acmpyTypeCd()) // 동반 가능 구역 + .rentItem(item.relaRntlPrdlst()) // 대여가능 품목 + .build(); + } +} diff --git a/src/main/java/com/batch/reader/DetailImageReader.java b/src/main/java/com/batch/reader/DetailImageReader.java index ae84dde..d26c34e 100644 --- a/src/main/java/com/batch/reader/DetailImageReader.java +++ b/src/main/java/com/batch/reader/DetailImageReader.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Configuration; import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentImageRepository; import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; @@ -14,6 +15,7 @@ @RequiredArgsConstructor public class DetailImageReader { + private final ContentImageRepository contentImageRepository; private final EntityManagerFactory entityManagerFactory; @Bean @@ -22,7 +24,7 @@ public JpaPagingItemReader detailImageContentReader() { .name("contentReader") .entityManagerFactory(entityManagerFactory) .pageSize(100) - .queryString("SELECT c FROM Content c ORDER BY c.contentId ASC") + .queryString("SELECT c FROM Content c WHERE NOT EXISTS (SELECT 1 FROM ContentImage ci WHERE ci.contentId = c) ORDER BY c.contentId ASC") .build(); } } diff --git a/src/main/java/com/batch/reader/DetailInfoReader.java b/src/main/java/com/batch/reader/DetailInfoReader.java new file mode 100644 index 0000000..3f254a6 --- /dev/null +++ b/src/main/java/com/batch/reader/DetailInfoReader.java @@ -0,0 +1,33 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailInfoReader { + + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader detailInfoItemReader() { + String jpqlQuery = "SELECT c FROM Content c " + + "WHERE NOT EXISTS (SELECT 1 FROM RecurInformation ri WHERE ri.content = c) " + + "AND NOT EXISTS (SELECT 1 FROM RecurInformationRoom rir WHERE rir.content = c) " + + "ORDER BY c.contentId ASC"; + + return new JpaPagingItemReaderBuilder() + .name("detailInfoItemReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(100) + .queryString(jpqlQuery) + .build(); + } +} diff --git a/src/main/java/com/batch/reader/DetailIntroReader.java b/src/main/java/com/batch/reader/DetailIntroReader.java new file mode 100644 index 0000000..9eb0b57 --- /dev/null +++ b/src/main/java/com/batch/reader/DetailIntroReader.java @@ -0,0 +1,72 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaCursorItemReader; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailIntroReader { + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaCursorItemReader sightsInformationItemReader() { + String jpqlQuery = + "SELECT c " + + "FROM Content c " + + "WHERE NOT EXISTS (SELECT 1 FROM SightsInformation si WHERE si.content = c)"; + return new JpaCursorItemReaderBuilder() + .name("sightsInformationItemReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpqlQuery) + .build(); + } + + @Bean + public JpaCursorItemReader lodgeInformationItemReader() { + String jpqlQuery = + "SELECT c " + + "FROM Content c " + + "WHERE NOT EXISTS (SELECT 1 FROM LodgeInformation li WHERE li.content = c)"; + return new JpaCursorItemReaderBuilder() + .name("lodgeInformationItemReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpqlQuery) + .build(); + } + + @Bean + public JpaCursorItemReader festivalInformationItemReader() { + String jpqlQuery = + "SELECT c " + + "FROM Content c " + + "WHERE NOT EXISTS (SELECT 1 FROM FestivalInformation fi WHERE fi.content = c)"; + return new JpaCursorItemReaderBuilder() + .name("festivalInformationItemReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpqlQuery) + .build(); + } + + // + @Bean + public JpaCursorItemReader restaurantInformationItemReader() { + String jpqlQuery = + "SELECT c " + + "FROM Content c " + + "WHERE NOT EXISTS (SELECT 1 FROM RestaurantInformation ri WHERE ri.content = c)"; + return new JpaCursorItemReaderBuilder() + .name("restaurantInformationItemReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpqlQuery) + .build(); + } +} diff --git a/src/main/java/com/batch/reader/DetailPetTourReader.java b/src/main/java/com/batch/reader/DetailPetTourReader.java new file mode 100644 index 0000000..92d66a8 --- /dev/null +++ b/src/main/java/com/batch/reader/DetailPetTourReader.java @@ -0,0 +1,35 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailPetTourReader { + + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader detailPetTourItemReader() { + String jpqlQuery = "SELECT c" + + " FROM Content c" + + " WHERE NOT EXISTS" + + " (SELECT 1 FROM PetGuide pg" + + " WHERE pg.content = c)" + + " ORDER BY c.contentId ASC"; + + return new JpaPagingItemReaderBuilder() + .name("detailPetTourItemReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(100) + .queryString(jpqlQuery) + .build(); + } +} diff --git a/src/main/java/com/batch/writer/DetailInfoWriter.java b/src/main/java/com/batch/writer/DetailInfoWriter.java new file mode 100644 index 0000000..a63d69a --- /dev/null +++ b/src/main/java/com/batch/writer/DetailInfoWriter.java @@ -0,0 +1,36 @@ +package com.batch.writer; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import com.batch.dto.DetailInfoProcessResult; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoom; +import com.swyp.catsgotogedog.content.repository.RecurInformationRepository; +import com.swyp.catsgotogedog.content.repository.RecurInformationRoomRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DetailInfoWriter implements ItemWriter { + + private final RecurInformationRepository recurInformationRepository; + private final RecurInformationRoomRepository recurInformationRoomRepository; + + @Override + public void write(Chunk chunk) throws Exception { + List generalToSave = new ArrayList<>(); + List roomsToSave = new ArrayList<>(); + for(DetailInfoProcessResult result : chunk.getItems()) { + if(result.getGeneralInfoList() != null) generalToSave.addAll(result.getGeneralInfoList()); + if(result.getRoomInfoList() != null) roomsToSave.addAll(result.getRoomInfoList()); + } + if(!generalToSave.isEmpty()) recurInformationRepository.saveAll(generalToSave); + if(!roomsToSave.isEmpty()) recurInformationRoomRepository.saveAll(roomsToSave); + } +} diff --git a/src/main/java/com/batch/writer/DetailIntroWriter.java b/src/main/java/com/batch/writer/DetailIntroWriter.java new file mode 100644 index 0000000..0d4a684 --- /dev/null +++ b/src/main/java/com/batch/writer/DetailIntroWriter.java @@ -0,0 +1,51 @@ +package com.batch.writer; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import com.batch.dto.DetailIntroProcessResult; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; +import com.swyp.catsgotogedog.content.repository.FestivalInformationRepository; +import com.swyp.catsgotogedog.content.repository.LodgeInformationRepository; +import com.swyp.catsgotogedog.content.repository.RestaurantInformationRepository; +import com.swyp.catsgotogedog.content.repository.SightsInformationRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DetailIntroWriter implements ItemWriter { + + private final SightsInformationRepository sightsInformationRepository; + private final LodgeInformationRepository lodgeInformationRepository; + private final RestaurantInformationRepository restaurantInformationRepository; + private final FestivalInformationRepository festivalInformationRepository; + + @Override + public void write(Chunk chunk) throws Exception { + List sightsToSave = new ArrayList<>(); + List lodgeToSave = new ArrayList<>(); + List restaurantToSave = new ArrayList<>(); + List festivalToSave = new ArrayList<>(); + + for(DetailIntroProcessResult result : chunk.getItems()) { + if(result.getSightsInfoList() != null) sightsToSave.addAll(result.getSightsInfoList()); + if(result.getLodgeInfoList() != null) lodgeToSave.addAll(result.getLodgeInfoList()); + if(result.getRestaurantInfoList() != null) restaurantToSave.addAll(result.getRestaurantInfoList()); + if(result.getFestivalInformationList() != null) festivalToSave.addAll(result.getFestivalInformationList()); + } + + if(!sightsToSave.isEmpty()) sightsInformationRepository.saveAll(sightsToSave); + if(!lodgeToSave.isEmpty()) lodgeInformationRepository.saveAll(lodgeToSave); + if(!restaurantToSave.isEmpty()) restaurantInformationRepository.saveAll(restaurantToSave); + if(!festivalToSave.isEmpty()) festivalInformationRepository.saveAll(festivalToSave); + + } +} diff --git a/src/main/java/com/batch/writer/DetailPetTourWriter.java b/src/main/java/com/batch/writer/DetailPetTourWriter.java new file mode 100644 index 0000000..ab824ca --- /dev/null +++ b/src/main/java/com/batch/writer/DetailPetTourWriter.java @@ -0,0 +1,22 @@ +package com.batch.writer; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import com.swyp.catsgotogedog.pet.repository.PetGuideRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DetailPetTourWriter implements ItemWriter { + + private final PetGuideRepository petGuideRepository; + + @Override + public void write(Chunk chunk) throws Exception { + petGuideRepository.saveAll(chunk.getItems()); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index 0e10879..0cac81a 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -8,6 +8,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; @@ -20,12 +21,10 @@ @EnableScheduling @RequiredArgsConstructor @Slf4j -public class CatsgotogedogApplication implements CommandLineRunner { +public class CatsgotogedogApplication { private final JobLauncher jobLauncher; private final ApplicationContext applicationContext; - // private final Job contentDataFetchStep; - // private final Job categoryCodeFetchStep; public static void main(String[] args) { SpringApplication.run(CatsgotogedogApplication.class, args); @@ -33,34 +32,18 @@ public static void main(String[] args) { @Scheduled(cron = "0 0 1 * * ?") public void runBatch() throws Exception { - Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); - - // category job (서버 최초 실행시에만 실행) - JobParameters jobParameters = new JobParametersBuilder() - .addLong("time", System.currentTimeMillis()) - .toJobParameters(); - - //content job - jobLauncher.run(contentBatchJob, jobParameters); - log.info(">> CommandLineRunner: content 배치 수동 실행 완료"); - - } - - @Override - public void run(String... args) throws Exception { + log.info("############# 01시 데이터 마이그레이션 배치 진행 ##############"); Job categoryCodeBatchJob = (Job) applicationContext.getBean("categoryCodeBatchJob"); Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); - // category job (서버 최초 실행시에만 실행) JobParameters jobParameters = new JobParametersBuilder() .addLong("time", System.currentTimeMillis()) .toJobParameters(); jobLauncher.run(categoryCodeBatchJob, jobParameters); - log.info(">> CommandLineRunner: categoryCode 배치 수동 실행 완료"); + log.info(">> 01:00 AM CategoryCode 배치 스케쥴러 작동"); - //content job jobLauncher.run(contentBatchJob, jobParameters); - log.info(">> CommandLineRunner: content 배치 수동 실행 완료"); + log.info(">> 01:00 AM Content Fetch 배치 스케쥴러 작동"); } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java b/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java deleted file mode 100644 index 96e91e0..0000000 --- a/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.swyp.catsgotogedog.common.milvus.service; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import io.milvus.client.MilvusClient; -import io.milvus.grpc.GetCollectionStatisticsResponse; -import io.milvus.param.R; -import io.milvus.param.collection.CreateDatabaseParam; -import io.milvus.response.GetCollStatResponseWrapper; -import io.milvus.grpc.GetCollectionStatisticsResponse; -import io.milvus.param.collection.GetCollectionStatisticsParam; -import io.milvus.param.collection.LoadCollectionParam; -import io.milvus.response.SearchResultsWrapper; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MilvusService { - - private final MilvusClient milvusClient; - - /** - * TODO : 개발, 운영 milvus 컬렉션을 나누어야 할 필요가 있어보임 우선 하나의 컬렉션을 사용 - */ - @Value("${milvus.collection-name}") - private String collectionName; - - /** - * 컬렉션 로딩 - * 스프링 시작시 milvus 컬렉션 로드 - * 메모리에 올라가는 작업 - */ - @PostConstruct - public void loadCollection() { - try { - milvusClient.loadCollection(LoadCollectionParam.newBuilder() - .withCollectionName(collectionName) - .build()); - log.info("Collection 로드 완료 :: {}", collectionName); - } catch (Exception e) { - throw new RuntimeException("Milvus user Creation Failed"); - } - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/FestivalInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/FestivalInformation.java new file mode 100644 index 0000000..c2de73d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/FestivalInformation.java @@ -0,0 +1,88 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.information; + +import java.time.LocalDate; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "festival_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FestivalInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "festival_id") + private Integer festivalId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "age_limit") + private String ageLimit; + + @Column(name = "booking_place") + private String bookingPlace; + + @Column(name = "discount_info") + private String discountInfo; + + @Column(name = "event_start_date") + private LocalDate eventStartDate; + + @Column(name = "event_end_date") + private LocalDate eventEndDate; + + @Column(name = "event_homepage") + private String eventHomepage; + + @Column(name = "event_place") + private String eventPlace; + + @Column(name = "place_info") + private String placeInfo; + + @Column(name = "play_time") + private String playTime; + + @Column(name = "program") + private String program; + + @Column(name = "spend_time") + private String spendTime; + + @Column(name = "organizer") + private String organizer; + + @Column(name = "organizer_tel") + private String organizerTel; + + @Column(name = "supervisor") + private String supervisor; + + @Column(name = "supervisor_tel") + private String supervisorTel; + + @Column(name = "sub_event") + private String subEvent; + + @Column(name = "fee_info") + private String feeInfo; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java new file mode 100644 index 0000000..84a75ef --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java @@ -0,0 +1,104 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.information; + +import java.time.LocalTime; + + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "lodge_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LodgeInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "lodge_id") + private Integer lodgeId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "capacity_count") + private Integer capacityCount; + + private Boolean goodstay; + + private Boolean benikia; + + @Column(name = "check_in_time") + private LocalTime checkInTime; + + @Column(name = "check_out_time") + private LocalTime checkOutTime; + + @Column(name = "cooking", length = 50) + private String cooking; + + @Column(name = "foodplace", length = 50) + private String foodplace; + + private Boolean hanok; + + @Column(name = "information", length = 50) + private String information; + + @Column(name = "parking", length = 50) + private String parking; + + @Column(name = "pickup_service") + private Boolean pickupService; + + @Column(name = "room_count") + private Integer roomCount; + + @Column(name = "reservation_info", length = 30) + private String reservationInfo; + + @Column(name = "reservation_url", length = 50) + private String reservationUrl; + + @Column(name = "room_type", length = 30) + private String roomType; + + @Column(name = "scale", length = 30) + private String scale; + + @Column(name = "sub_facility", length = 50) + private String subFacility; + + private Boolean barbecue; + private Boolean beauty; + private Boolean beverage; + private Boolean bicycle; + private Boolean campfire; + private Boolean fitness; + private Boolean karaoke; + @Column(name = "public_bath") + private Boolean publicBath; + @Column(name = "public_pc_room") + private Boolean publicPcRoom; + private Boolean sauna; + private Boolean seminar; + private Boolean sports; + + @Column(name = "refund_regulation", length = 100) + private String refundRegulation; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java new file mode 100644 index 0000000..282b5c7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java @@ -0,0 +1,77 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.information; + +import java.time.LocalDate; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "restaurant_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RestaurantInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "restaurant_id") + private Integer restaurantId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "chk_creditcard") + private String chkCreditcard; + + @Column(name = "discount_info") + private String discountInfo; + + @Column(name = "signature_menu") + private String signatureMenu; + + @Column(name = "information") + private String information; + + @Column(name = "kids_facility") + private Boolean kidsFacility; + + @Column(name = "open_date") + private LocalDate openDate; + + @Column(name = "open_time") + private String openTime; + + @Column(name = "takeout") + private String takeout; + + @Column(name = "parking") + private String parking; + + @Column(name = "reservation") + private String reservation; + + @Column(name = "rest_date") + private String restDate; + + private Integer scale; + private Integer seat; + private Boolean smoking; + + @Column(name = "treat_menu") + private String treatMenu; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/SightsInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/SightsInformation.java new file mode 100644 index 0000000..bf051d8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/SightsInformation.java @@ -0,0 +1,64 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.information; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDate; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +@Entity +@Table(name = "sights_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SightsInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sights_id") + private Integer sightsId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "content_type_id") + private Integer contentTypeId; + + @Column(name = "accom_count") + private Integer accomCount; + + @Column(name = "chk_creditcard") + private String chkCreditcard; + + @Column(name = "exp_age_range") + private String expAgeRange; + + @Column(name = "exp_guide") + private String expGuide; + + @Column(name = "info_center") + private String infoCenter; + + @Column(name = "open_date") + private LocalDate openDate; + + @Column(name = "parking") + private String parking; + + @Column(name = "rest_date") + private String restDate; + + @Column(name = "use_season") + private String useSeason; + + @Column(name = "use_time") + private String useTime; + + private Boolean heritage1; + private Boolean heritage2; + private Boolean heritage3; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformation.java new file mode 100644 index 0000000..eedef13 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformation.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.recur; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "recur_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecurInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int recurId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + private String infoName; + + @Column(columnDefinition = "TEXT") + private String infoText; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java new file mode 100644 index 0000000..7c93c65 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java @@ -0,0 +1,75 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.recur; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "recur_information_room") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecurInformationRoom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer recurRoomId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + private String roomTitle; + private Integer roomSize1; + private Integer roomCount; + private Integer roomBaseCount; + private Integer roomMaxCount; + private Integer offSeasonWeekMinFee; + private Integer offSeasonWeekendMinFee; + private Integer peakSeasonWeekMinFee; + private Integer peakSeasonWeekendMinFee; + + @Column(columnDefinition = "TEXT") + private String roomIntro; + + @Column(name = "room_bath_facility") + private Boolean roomBathFacility; // 오타 수정: facility + private Boolean roomBath; + private Boolean roomHomeTheater; + private Boolean roomAircondition; + private Boolean roomTv; + private Boolean roomPc; + private Boolean roomCable; + private Boolean roomInternet; + private Boolean roomRefrigerator; + private Boolean roomToiletries; + private Boolean roomSofa; + private Boolean roomCook; + private Boolean roomTable; + private Boolean roomHairdryer; + + @Column(precision = 10, scale = 2) + private BigDecimal roomSize2; + + @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List images = new ArrayList<>(); + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoomImage.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoomImage.java new file mode 100644 index 0000000..100b3b2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoomImage.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.recur; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "recur_information_room_image") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecurInformationRoomImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer recurRoomImageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recur_room_id") + private RecurInformationRoom room; + + private String imageUrl; + private String imageFilename; + private String imageAlt; + + @Column(length = 50) + private String imageCopyright; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java new file mode 100644 index 0000000..d8aa645 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; + +public interface FestivalInformationRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java new file mode 100644 index 0000000..920e6aa --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; + +public interface LodgeInformationRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRepository.java new file mode 100644 index 0000000..af2b2a0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformation; + +public interface RecurInformationRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomImageRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomImageRepository.java new file mode 100644 index 0000000..d2500b7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomImageRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoomImage; + +public interface RecurInformationRoomImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomRepository.java new file mode 100644 index 0000000..a67a6a5 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoom; + +public interface RecurInformationRoomRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java index 846e509..4e94091 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java @@ -4,8 +4,6 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import com.swyp.catsgotogedog.content.domain.entity.RegionCode; diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java new file mode 100644 index 0000000..ab42d00 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; + +public interface RestaurantInformationRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java new file mode 100644 index 0000000..f5b1b28 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; + +public interface SightsInformationRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java deleted file mode 100644 index 9ded0db..0000000 --- a/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.swyp.catsgotogedog.global.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import io.milvus.client.MilvusClient; -import io.milvus.client.MilvusServiceClient; -import io.milvus.param.ConnectParam; -import io.milvus.param.ConnectParam.Builder; - -@Configuration -public class MilvusConfig { - - @Value("${milvus.host}") - private String milvusHost; - - @Value("${milvus.port}") - private int milvusPort; - - @Value("${milvus.username}") - private String milvusUsername; - - @Value("${milvus.password}") - private String milvusPassword; - - @Bean - public MilvusClient milvusClient() { - Builder connectParamBuilder = ConnectParam.newBuilder() - .withHost(milvusHost) - .withPort(milvusPort); - - if (milvusUsername != null && milvusPassword != null) { - connectParamBuilder.withAuthorization(milvusUsername, milvusPassword); - } - - return new MilvusServiceClient(connectParamBuilder.build()); - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetGuide.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetGuide.java new file mode 100644 index 0000000..7cfa0a0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetGuide.java @@ -0,0 +1,63 @@ +package com.swyp.catsgotogedog.pet.domain.entity; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +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 +@Table(name = "pet_guide") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PetGuide { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "pet_guide_id") + private Integer petGuideId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "accident_prep", length = 50) + private String accidentPrep; + + @Column(name = "available_facility", length = 50) + private String availableFacility; + + @Column(name = "provided_item", length = 50) + private String providedItem; + + @Column(name = "etc_info") + private String etcInfo; + + @Column(name = "purchasable_item", length = 50) + private String purchasableItem; + + @Column(name = "allowed_pet_type", length = 255) + private String allowedPetType; + + @Column(name = "rent_item", length = 50) + private String rentItem; + + @Column(name = "pet_prep", length = 50) + private String petPrep; + + @Column(name = "with_pet", length = 50) + private String withPet; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java new file mode 100644 index 0000000..5a9d6ad --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.pet.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; + +public interface PetGuideRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/mysql/V5__init_region_data.sql b/src/main/resources/db/migration/mysql/V5__init_region_data.sql index c9b80e5..ddd39ef 100644 --- a/src/main/resources/db/migration/mysql/V5__init_region_data.sql +++ b/src/main/resources/db/migration/mysql/V5__init_region_data.sql @@ -1,5 +1,11 @@ ALTER TABLE `catsgotogedog`.`sigtes_information` RENAME TO `catsgotogedog`.`sights_information` ; +ALTER TABLE `catsgotogedog`.`pet_guide` + CHANGE COLUMN `allowed_pet_type` `allowed_pet_type` VARCHAR(255) NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`pet_guide` + CHANGE COLUMN `etc_info` `etc_info` TEXT NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`recur_information_room` + CHANGE COLUMN `room_bath_pacility` `room_bath_facility` TINYINT NULL DEFAULT NULL ; INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('1', '서울', '1', '1'); INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('2', '인천', '2', '1'); INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('3', '대전', '3', '1'); diff --git a/src/main/resources/db/migration/mysql/V6__table.sql b/src/main/resources/db/migration/mysql/V6__table.sql new file mode 100644 index 0000000..ab34206 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V6__table.sql @@ -0,0 +1,13 @@ +ALTER TABLE `catsgotogedog`.`recur_information_room` + CHANGE COLUMN `room_base_couint` `room_base_count` INT NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`festival_information` + ADD COLUMN `supervisor_tel` VARCHAR(50) NULL AFTER `supervisor`; +ALTER TABLE `catsgotogedog`.`lodge_information` +DROP COLUMN `lodge_informationcol`, +ADD COLUMN `goodstay` TINYINT NULL AFTER `capacity_count`; +ALTER TABLE `catsgotogedog`.`content` + CHANGE COLUMN `tel` `tel` VARCHAR(50) NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`pet_guide` + CHANGE COLUMN `available_facility` `available_facility` VARCHAR(250) NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`restaurant_information` + CHANGE COLUMN `open_date` `open_date` VARCHAR(100) NULL DEFAULT NULL ; diff --git a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java b/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java deleted file mode 100644 index 4e43bb9..0000000 --- a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.swyp.catsgotogedog.milvus.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import org.junit.jupiter.api.BeforeEach; -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.test.util.ReflectionTestUtils; - -import com.swyp.catsgotogedog.common.milvus.service.MilvusService; - -import io.milvus.client.MilvusClient; -import io.milvus.param.collection.LoadCollectionParam; - -@ExtendWith(MockitoExtension.class) -public class MilvusServiceTest { - - @Mock - private MilvusClient milvusClient; - - @InjectMocks - private MilvusService milvusService; - - private static final String TEST_COLLECTION_NAME = "catsgotogedog_test_collection"; - - @BeforeEach - void setUp() throws Exception { - ReflectionTestUtils.setField(milvusService, "collectionName", TEST_COLLECTION_NAME); - } - - @Test - @DisplayName("컬렉션 로드 테스트 (성공)") - void loadCollection_success() { - assertDoesNotThrow(() -> milvusService.loadCollection()); - - verify(milvusClient, times(1)).loadCollection(any(LoadCollectionParam.class)); - } - - @Test - @DisplayName("컬렉션 로드 테스트 (실패)") - void loadCollection_Fail() { - doThrow(new RuntimeException("Milvus client load 실패")).when(milvusClient).loadCollection(any(LoadCollectionParam.class)); - - RuntimeException thrown = assertThrows(RuntimeException.class, () -> milvusService.loadCollection()); - - assert(thrown.getMessage().equals("Milvus user Creation Failed")); - - verify(milvusClient, times(1)).loadCollection(any(LoadCollectionParam.class)); - } -} From bb84efe8c996e9209f4485b2865f76f0a986fd1e Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 2 Aug 2025 17:52:52 +0900 Subject: [PATCH 068/191] =?UTF-8?q?user=20pets=20builder=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user pets builder 초기화 수정 --- .../java/com/swyp/catsgotogedog/User/domain/entity/User.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index 4d2e2fb..8b26ea4 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -30,8 +30,4 @@ public class User extends BaseTimeEntity { private String imageFilename; private String imageUrl; private Boolean isActive; - - @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) - @Builder.Default - private final List pets = new ArrayList<>(); } \ No newline at end of file From 1b6f8201710ba6e8901e3018366044d641d88f76 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 2 Aug 2025 18:02:03 +0900 Subject: [PATCH 069/191] =?UTF-8?q?entity=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/batch/dto/DetailCommonResponse.java | 7 ------- src/main/java/com/batch/dto/DetailImageResponse.java | 10 ++++------ .../swyp/catsgotogedog/User/domain/entity/User.java | 2 ++ .../entity/batch/recur/RecurInformationRoom.java | 2 +- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/batch/dto/DetailCommonResponse.java b/src/main/java/com/batch/dto/DetailCommonResponse.java index fd25470..f843561 100644 --- a/src/main/java/com/batch/dto/DetailCommonResponse.java +++ b/src/main/java/com/batch/dto/DetailCommonResponse.java @@ -35,30 +35,23 @@ public record Items(List item) { @JsonCreator public static Items from(JsonNode node) throws IOException { - // ObjectMapper는 한 번만 생성하는 것이 효율적입니다. final ObjectMapper mapper = new ObjectMapper(); - // 1. items 필드가 비어있는 문자열("")일 경우 if (node.isTextual() && node.asText().isEmpty()) { return new Items(Collections.emptyList()); } - // 2. 정상적인 JSON 객체일 경우 if (node.isObject()) { - // "item"이라는 이름의 하위 노드를 찾습니다. JsonNode itemNode = node.get("item"); - // 하위 노드가 없거나 배열이 아니면 빈 리스트로 처리합니다. if (itemNode == null || !itemNode.isArray()) { return new Items(Collections.emptyList()); } - // ✨ itemNode(배열)를 List 타입으로 직접 변환합니다. List itemList = mapper.convertValue(itemNode, new TypeReference<>() {}); return new Items(itemList); } - // 3. 그 외의 모든 경우 (null 등) return new Items(Collections.emptyList()); } diff --git a/src/main/java/com/batch/dto/DetailImageResponse.java b/src/main/java/com/batch/dto/DetailImageResponse.java index 5ff0fa1..c617ada 100644 --- a/src/main/java/com/batch/dto/DetailImageResponse.java +++ b/src/main/java/com/batch/dto/DetailImageResponse.java @@ -38,30 +38,28 @@ public record Items(List item) { @JsonCreator public static Items from(JsonNode node) throws IOException { - // ObjectMapper는 한 번만 생성하는 것이 효율적입니다. final ObjectMapper mapper = new ObjectMapper(); - // 1. items 필드가 비어있는 문자열("")일 경우 + // items 필드가 비어있을 경우 if (node.isTextual() && node.asText().isEmpty()) { return new Items(Collections.emptyList()); } - // 2. 정상적인 JSON 객체일 경우 + // 정상적인 JSON 객체일 경우 if (node.isObject()) { // "item"이라는 이름의 하위 노드를 찾습니다. JsonNode itemNode = node.get("item"); - // 하위 노드가 없거나 배열이 아니면 빈 리스트로 처리합니다. + // 하위 노드가 없을 경우 if (itemNode == null || !itemNode.isArray()) { return new Items(Collections.emptyList()); } - // ✨ itemNode(배열)를 List 타입으로 직접 변환합니다. + // itemNode > List List itemList = mapper.convertValue(itemNode, new TypeReference<>() {}); return new Items(itemList); } - // 3. 그 외의 모든 경우 (null 등) return new Items(Collections.emptyList()); } diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java index 8b26ea4..d037600 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -30,4 +30,6 @@ public class User extends BaseTimeEntity { private String imageFilename; private String imageUrl; private Boolean isActive; + @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + private List pets; } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java index 7c93c65..0c5d8e7 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java @@ -70,6 +70,6 @@ public class RecurInformationRoom { private BigDecimal roomSize2; @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private List images = new ArrayList<>(); + private List images; } From 88bb5ada8fc0b92d80e06e73351dd5f123b0ee42 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:00:08 +0900 Subject: [PATCH 070/191] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=97=90=20?= =?UTF-8?q?=EB=B3=84=EC=A0=90,=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/entity/ContentImage.java | 24 ++++++++++++++ .../domain/response/ContentResponse.java | 11 +++++-- .../repository/ContentImageRepository.java | 8 +++++ .../content/service/ContentSearchService.java | 31 ++++++++++++++++--- .../review/domain/entity/ContentReview.java | 28 +++++++++++++++++ .../review/domain/entity/Reivew.java | 4 --- .../repository/ContentReviewRepository.java | 14 +++++++++ .../review/repository/ReviewRepository.java | 4 --- 8 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/ContentReview.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java new file mode 100644 index 0000000..b900bdf --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java @@ -0,0 +1,24 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; + +@Entity +@Getter +public class ContentImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int contentImageId; + + private int contentId; + + private String imageUrl; + + private String imageFilename; + + private String smallImageUrl; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index acdf6bc..fadba11 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -21,11 +21,14 @@ public class ContentResponse { private String copyright; private BigDecimal mapx; private BigDecimal mapy; - private int mlevel; + private int mlevel; private String tel; - private int zipcode; + private int zipcode; - public static ContentResponse from(Content c){ + private String smallImageUrl; + private Double avgScore; + + public static ContentResponse from(Content c, String smallImageUrl, Double avgScore){ return ContentResponse.builder() .contentId(c.getContentId()) .title(c.getTitle()) @@ -42,6 +45,8 @@ public static ContentResponse from(Content c){ .mlevel(c.getMlevel()) .tel(c.getTel()) .zipcode(c.getZipcode()) + .smallImageUrl(smallImageUrl) + .avgScore(avgScore) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java new file mode 100644 index 0000000..6570a95 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContentImageRepository extends JpaRepository { + ContentImage findByContentId(int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 758cb8d..3118468 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -4,9 +4,12 @@ import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; +import com.swyp.catsgotogedog.content.repository.ContentImageRepository; import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.elasticsearch.client.elc.NativeQuery; @@ -27,6 +30,8 @@ public class ContentSearchService { private final ContentRepository contentRepository; private final ContentElasticRepository contentElasticRepository; private final ElasticsearchOperations elasticsearchOperations; + private final ContentImageRepository contentImageRepository; + private final ContentReviewRepository contentReviewRepository; public List searchByKeyword(String keyword){ return contentElasticRepository.findByTitleContaining(keyword); @@ -81,21 +86,39 @@ public List search(String title, .withPageable(PageRequest.of(0, 20)) .build(); - List ids = elasticsearchOperations.search(nativeQuery, ContentDocument.class).stream() + List ids = elasticsearchOperations + .search(nativeQuery, ContentDocument.class).stream() .map(SearchHit::getContent) .map(ContentDocument::getContentId) .toList(); if (ids.isEmpty()) return List.of(); - Map map = contentRepository.findAllById(ids).stream() + Map contentMap = contentRepository.findAllById(ids).stream() .collect(Collectors.toMap(Content::getContentId, c -> c)); return ids.stream() - .map(map::get) + .map(id -> { + Content content = contentMap.get(id); + if (content == null) return null; + + ContentImage image = contentImageRepository.findByContentId(id); + String smallImageUrl = (image != null) ? image.getSmallImageUrl() : null; + + double avg = getAverageScore(id); + + return ContentResponse.from(content, smallImageUrl,avg); + }) .filter(Objects::nonNull) - .map(ContentResponse::from) .toList(); + } + public double getAverageScore(int contentId) { + Double avg = contentReviewRepository.findAvgScoreByContentId(contentId); + double value = (avg != null) ? avg : 0.0; + return Math.round(value * 10.0) / 10.0; + } + + } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ContentReview.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ContentReview.java new file mode 100644 index 0000000..77341d0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ContentReview.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import com.swyp.catsgotogedog.global.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.Getter; + +import java.math.BigDecimal; + +@Entity +@Getter +public class ContentReview extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int reviewId; + + private int userId; + + private int contentId; + + private String content; + + @Column(precision = 2, scale = 1) + private BigDecimal score; + +// private int like; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java deleted file mode 100644 index 8d54adc..0000000 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.swyp.catsgotogedog.review.domain.entity; - -public class Reivew { -} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java new file mode 100644 index 0000000..8cb0657 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.review.repository; + +import com.swyp.catsgotogedog.review.domain.entity.ContentReview; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; + + +public interface ContentReviewRepository extends JpaRepository { + ContentReview findByContentId(int contentId); + + @Query("select avg(cr.score) from ContentReview cr where cr.contentId = :contentId") + Double findAvgScoreByContentId(@Param("contentId") int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java deleted file mode 100644 index 803f62d..0000000 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.swyp.catsgotogedog.review.repository; - -public interface ReviewRepository { -} From a25bf04c60e94bff0109d388ca5a68a2c966a5da Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:08:56 +0900 Subject: [PATCH 071/191] =?UTF-8?q?faet:=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 허용 경로 추가 --- .../com/swyp/catsgotogedog/common/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index f44dbb5..d1c3d08 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -48,7 +48,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/error", "/swagger-ui/**", "/v3/api-docs/**", - "/api/user/reissue" + "/api/user/reissue", + "/api/content/**" // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 ).permitAll() .anyRequest().authenticated()) From 329f8b2c577170e0fc0f46e3f9b0668ce7b899ed Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:09:07 +0900 Subject: [PATCH 072/191] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 밀버스 --- .../common/milvus/service/MilvusService.java | 48 ------------------- .../global/config/MilvusConfig.java | 39 --------------- 2 files changed, 87 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java b/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java deleted file mode 100644 index 96e91e0..0000000 --- a/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.swyp.catsgotogedog.common.milvus.service; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import io.milvus.client.MilvusClient; -import io.milvus.grpc.GetCollectionStatisticsResponse; -import io.milvus.param.R; -import io.milvus.param.collection.CreateDatabaseParam; -import io.milvus.response.GetCollStatResponseWrapper; -import io.milvus.grpc.GetCollectionStatisticsResponse; -import io.milvus.param.collection.GetCollectionStatisticsParam; -import io.milvus.param.collection.LoadCollectionParam; -import io.milvus.response.SearchResultsWrapper; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MilvusService { - - private final MilvusClient milvusClient; - - /** - * TODO : 개발, 운영 milvus 컬렉션을 나누어야 할 필요가 있어보임 우선 하나의 컬렉션을 사용 - */ - @Value("${milvus.collection-name}") - private String collectionName; - - /** - * 컬렉션 로딩 - * 스프링 시작시 milvus 컬렉션 로드 - * 메모리에 올라가는 작업 - */ - @PostConstruct - public void loadCollection() { - try { - milvusClient.loadCollection(LoadCollectionParam.newBuilder() - .withCollectionName(collectionName) - .build()); - log.info("Collection 로드 완료 :: {}", collectionName); - } catch (Exception e) { - throw new RuntimeException("Milvus user Creation Failed"); - } - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java deleted file mode 100644 index 9ded0db..0000000 --- a/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.swyp.catsgotogedog.global.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import io.milvus.client.MilvusClient; -import io.milvus.client.MilvusServiceClient; -import io.milvus.param.ConnectParam; -import io.milvus.param.ConnectParam.Builder; - -@Configuration -public class MilvusConfig { - - @Value("${milvus.host}") - private String milvusHost; - - @Value("${milvus.port}") - private int milvusPort; - - @Value("${milvus.username}") - private String milvusUsername; - - @Value("${milvus.password}") - private String milvusPassword; - - @Bean - public MilvusClient milvusClient() { - Builder connectParamBuilder = ConnectParam.newBuilder() - .withHost(milvusHost) - .withPort(milvusPort); - - if (milvusUsername != null && milvusPassword != null) { - connectParamBuilder.withAuthorization(milvusUsername, milvusPassword); - } - - return new MilvusServiceClient(connectParamBuilder.build()); - } -} From 62ab258606555b104f2d9c82ac90d29ba5220f4b Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:11:32 +0900 Subject: [PATCH 073/191] =?UTF-8?q?chore:=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 밀버스 --- .../milvus/service/MilvusServiceTest.java | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java diff --git a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java b/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java deleted file mode 100644 index 4e43bb9..0000000 --- a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.swyp.catsgotogedog.milvus.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import org.junit.jupiter.api.BeforeEach; -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.test.util.ReflectionTestUtils; - -import com.swyp.catsgotogedog.common.milvus.service.MilvusService; - -import io.milvus.client.MilvusClient; -import io.milvus.param.collection.LoadCollectionParam; - -@ExtendWith(MockitoExtension.class) -public class MilvusServiceTest { - - @Mock - private MilvusClient milvusClient; - - @InjectMocks - private MilvusService milvusService; - - private static final String TEST_COLLECTION_NAME = "catsgotogedog_test_collection"; - - @BeforeEach - void setUp() throws Exception { - ReflectionTestUtils.setField(milvusService, "collectionName", TEST_COLLECTION_NAME); - } - - @Test - @DisplayName("컬렉션 로드 테스트 (성공)") - void loadCollection_success() { - assertDoesNotThrow(() -> milvusService.loadCollection()); - - verify(milvusClient, times(1)).loadCollection(any(LoadCollectionParam.class)); - } - - @Test - @DisplayName("컬렉션 로드 테스트 (실패)") - void loadCollection_Fail() { - doThrow(new RuntimeException("Milvus client load 실패")).when(milvusClient).loadCollection(any(LoadCollectionParam.class)); - - RuntimeException thrown = assertThrows(RuntimeException.class, () -> milvusService.loadCollection()); - - assert(thrown.getMessage().equals("Milvus user Creation Failed")); - - verify(milvusClient, times(1)).loadCollection(any(LoadCollectionParam.class)); - } -} From 5b10e56cd0241e34b5d2a60caa811f8e73da3f4b Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:44:20 +0900 Subject: [PATCH 074/191] =?UTF-8?q?feat:=20ci=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker 실행 코드 추가 --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f635728..907b4a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ jobs: java-version: '17' distribution: 'temurin' + - name: Start Elasticsearch & Kibana + run: | + docker compose up -d + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - name: Setup Gradle From 7347c65f20b5a63bea0e0ec503f0e8ba610e6f1d Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 2 Aug 2025 21:55:14 +0900 Subject: [PATCH 075/191] =?UTF-8?q?refactor/recur=5Finformation=5Froom=20r?= =?UTF-8?q?esponse=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문자열 Y를 매핑하도록 수정 --- .../batch/processor/DetailInfoProcessor.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/batch/processor/DetailInfoProcessor.java b/src/main/java/com/batch/processor/DetailInfoProcessor.java index e00faf9..1a3523b 100644 --- a/src/main/java/com/batch/processor/DetailInfoProcessor.java +++ b/src/main/java/com/batch/processor/DetailInfoProcessor.java @@ -99,20 +99,20 @@ public DetailInfoProcessResult process(Content content) throws Exception { .peakSeasonWeekMinFee(Integer.valueOf(dto.roompeakseasonminfee1())) .peakSeasonWeekendMinFee(Integer.valueOf(dto.roompeakseasonminfee2())) .roomIntro(dto.roomintro()) - .roomBathFacility(Boolean.valueOf(dto.roombathfacility())) - .roomBath(Boolean.valueOf(dto.roombath())) - .roomHomeTheater(Boolean.valueOf(dto.roomhometheater())) - .roomAircondition(Boolean.valueOf(dto.roomaircondition())) - .roomTv(Boolean.valueOf(dto.roomtv())) - .roomPc(Boolean.valueOf(dto.roompc())) - .roomCable(Boolean.valueOf(dto.roomcable())) - .roomInternet(Boolean.valueOf(dto.roominternet())) - .roomRefrigerator(Boolean.valueOf(dto.roomrefrigerator())) - .roomToiletries(Boolean.valueOf(dto.roomtoiletries())) - .roomSofa(Boolean.valueOf(dto.roomsofa())) - .roomCook(Boolean.valueOf(dto.roomcook())) - .roomTable(Boolean.valueOf(dto.roomtable())) - .roomHairdryer(Boolean.valueOf(dto.roomhairdryer())) + .roomBathFacility(dto.roombathfacility().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomBath(dto.roombath().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomHomeTheater(dto.roomhometheater().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomAircondition(dto.roomaircondition().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomTv(dto.roomtv().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomPc(dto.roompc().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomCable(dto.roomcable().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomInternet(dto.roominternet().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomRefrigerator(dto.roomrefrigerator().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomToiletries(dto.roomtoiletries().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomSofa(dto.roomsofa().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomCook(dto.roomcook().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomTable(dto.roomtable().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomHairdryer(dto.roomhairdryer().equals("Y") ? Boolean.TRUE : Boolean.FALSE) .roomSize2(new BigDecimal(dto.roomsize2())) .images(new ArrayList<>()).build(); addRoomImageIfPresent(room, dto.roomimg1(), dto.roomimg1alt(), dto.roomimg1cpyrhtdiv()); From da646572f67c067c7c811fab3e5e70ab7a1039ad Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:55:41 +0900 Subject: [PATCH 076/191] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker compose 기본 인식 파일명으로 수정 --- Docker-compose.yml => docker-compose.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Docker-compose.yml => docker-compose.yml (100%) diff --git a/Docker-compose.yml b/docker-compose.yml similarity index 100% rename from Docker-compose.yml rename to docker-compose.yml From 18d1cf08343c84f0ec760af264e9685dc6444f2e Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:04:03 +0900 Subject: [PATCH 077/191] =?UTF-8?q?fix:=20docker=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=EB=A3=A8=ED=94=84=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker가 실행되기 전까지 대기하는 코드 --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 907b4a2..e86c3a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,10 @@ jobs: - name: Start Elasticsearch & Kibana run: | docker compose up -d + until curl -sSf http://localhost:9200 >/dev/null; do + echo "Waiting for Elasticsearch" + sleep 5 + done # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md From fa79f176eec36bf8cc7321483aa0e475ca5c8757 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:21:17 +0900 Subject: [PATCH 078/191] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EC=8A=A4=ED=85=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci파일 --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e86c3a0..2679e3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,11 @@ jobs: sleep 5 done - # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. + - name: Check ES container logs + run: docker compose logs --tail=50 elasticsearch || true + + + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 From b870c76599c1339a599e79dac71845b4e93b6d72 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:40:40 +0900 Subject: [PATCH 079/191] =?UTF-8?q?fix:=20test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=82=B4=20uris=20=ED=99=95=EC=9D=B8=20=EB=B0=8F=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=98=A4=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2679e3f..3c678a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Check ES container logs run: docker compose logs --tail=50 elasticsearch || true + - name: test uris check + run: grep -R --line-number --no-color "spring.elasticsearch.uris" src/test || true + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md @@ -51,5 +54,10 @@ jobs: - name: Give execute permission to gradlew run: chmod +x ./gradlew + - name: Override ES URI for tests + run: | + echo "spring.elasticsearch.uris=http://localhost:9200" >> src/test/resources/application.properties + + - name: Build with Gradle Wrapper run: ./gradlew clean build From bae1da57eb45e0e749ae1c85260bdbca816193c5 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:46:41 +0900 Subject: [PATCH 080/191] Update ci.yml --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c678a6..36218b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: run: docker compose logs --tail=50 elasticsearch || true - name: test uris check - run: grep -R --line-number --no-color "spring.elasticsearch.uris" src/test || true + run: grep -R --line-number "spring.elasticsearch.uris" src/test || true # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. @@ -54,10 +54,6 @@ jobs: - name: Give execute permission to gradlew run: chmod +x ./gradlew - - name: Override ES URI for tests - run: | - echo "spring.elasticsearch.uris=http://localhost:9200" >> src/test/resources/application.properties - - name: Build with Gradle Wrapper run: ./gradlew clean build From cdce3fcec228ce88751d9625e9ef533d7c733b38 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:55:50 +0900 Subject: [PATCH 081/191] =?UTF-8?q?fix:=20=ED=81=B4=EB=9F=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=ED=97=AC=EC=8A=A4=20=EC=B2=B4=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36218b2..d594331 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,18 +29,17 @@ jobs: - name: Start Elasticsearch & Kibana run: | docker compose up -d - until curl -sSf http://localhost:9200 >/dev/null; do + timeout 120s bash -c ' + until [[ "$(curl -s http://localhost:9200/_cluster/health | jq -r ".status" 2>/dev/null)" =~ ^(green|yellow)$ ]]; do echo "Waiting for Elasticsearch" sleep 5 done + echo "-- ES health is ok " + ' - name: Check ES container logs run: docker compose logs --tail=50 elasticsearch || true - - name: test uris check - run: grep -R --line-number "spring.elasticsearch.uris" src/test || true - - # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - name: Setup Gradle From f9a157102e62b98d18ec6e790f70271936bf50ef Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:10:57 +0900 Subject: [PATCH 082/191] =?UTF-8?q?fix:=20docker=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit host alias 추가 --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 85ca12d..b8a4079 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: ports: - "9200:9200" - "9300:9300" + + extra_hosts: + - "elasticsearch:127.0.0.1" volumes: - esdata:/usr/share/elasticsearch/data networks: From e48290bc6a9acbe20da03f2e79ba8a46b401096a Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:32:46 +0900 Subject: [PATCH 083/191] =?UTF-8?q?fix:=20docker=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20ci=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 7 +++++++ docker-compose.yml | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d594331..8ec9ff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,10 @@ jobs: - name: Build with Gradle Wrapper run: ./gradlew clean build + + - name: Upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: backend/build/reports/tests/test diff --git a/docker-compose.yml b/docker-compose.yml index b8a4079..a08ca71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,12 +6,13 @@ services: environment: - discovery.type=single-node - xpack.security.enabled=false + - xpack.security.http.ssl.enabled=false ports: - "9200:9200" - "9300:9300" extra_hosts: - - "elasticsearch:127.0.0.1" + - "elasticsearch:127.0.0.1" volumes: - esdata:/usr/share/elasticsearch/data networks: From d72a12240935e5e45c5ecb508db36874eb3aaa95 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:42:11 +0900 Subject: [PATCH 084/191] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ec9ff4..99b9c81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,4 +62,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: test-report - path: backend/build/reports/tests/test + path: build/reports/tests/test From 2f444b9822e9b9be22cbd71c933687a241eaa91e Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 00:21:10 +0900 Subject: [PATCH 085/191] =?UTF-8?q?fix:=20analysis-nori=20=ED=94=8C?= =?UTF-8?q?=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index a08ca71..260da3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,14 @@ services: - discovery.type=single-node - xpack.security.enabled=false - xpack.security.http.ssl.enabled=false + - + + command: > + bash -c " + elasticsearch-plugin install --batch analysis-nori && + /usr/local/bin/docker-entrypoint.sh eswrapper + " + ports: - "9200:9200" - "9300:9300" From 42b67c91670baf5bc82b571a7fed420582ab50ff Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 00:24:23 +0900 Subject: [PATCH 086/191] =?UTF-8?q?fix:=20=EB=B9=88=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=20=EC=98=A4=ED=83=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 260da3b..ab2ccac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,6 @@ services: - discovery.type=single-node - xpack.security.enabled=false - xpack.security.http.ssl.enabled=false - - command: > bash -c " From 5f5378b073dc735f52ee0b32ef19d4128077dc0a Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 3 Aug 2025 02:33:48 +0900 Subject: [PATCH 087/191] =?UTF-8?q?feat/=EB=A6=AC=EB=B7=B0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 작성 기능 구현 --- .../common/milvus/service/MilvusService.java | 48 ---------- .../util/image/validator/ImageUploadType.java | 2 +- .../content/domain/entity/.gitkeep | 0 .../content/domain/entity/Content.java | 89 +++++++++++++++++++ .../content/repository/ContentRepository.java | 8 ++ .../global/config/MilvusConfig.java | 39 -------- .../global/exception/ErrorCode.java | 1 + .../exception/GlobalExceptionHandler.java | 9 ++ .../review/controller/ReviewController.java | 39 +++++++- .../controller/ReviewControllerSwagger.java | 54 +++++++++++ .../review/domain/entity/Reivew.java | 4 - .../review/domain/entity/Review.java | 54 +++++++++++ .../review/domain/entity/ReviewImage.java | 35 ++++++++ .../review/domain/request/.gitkeep | 0 .../domain/request/CreateReviewRequest.java | 28 ++++++ .../repository/ReviewImageRepository.java | 8 ++ .../review/repository/ReviewRepository.java | 18 +++- .../review/service/ReviewService.java | 78 ++++++++++++++++ .../milvus/service/MilvusServiceTest.java | 55 ------------ 19 files changed, 420 insertions(+), 149 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewImage.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/request/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java delete mode 100644 src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java b/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java deleted file mode 100644 index 96e91e0..0000000 --- a/src/main/java/com/swyp/catsgotogedog/common/milvus/service/MilvusService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.swyp.catsgotogedog.common.milvus.service; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import io.milvus.client.MilvusClient; -import io.milvus.grpc.GetCollectionStatisticsResponse; -import io.milvus.param.R; -import io.milvus.param.collection.CreateDatabaseParam; -import io.milvus.response.GetCollStatResponseWrapper; -import io.milvus.grpc.GetCollectionStatisticsResponse; -import io.milvus.param.collection.GetCollectionStatisticsParam; -import io.milvus.param.collection.LoadCollectionParam; -import io.milvus.response.SearchResultsWrapper; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MilvusService { - - private final MilvusClient milvusClient; - - /** - * TODO : 개발, 운영 milvus 컬렉션을 나누어야 할 필요가 있어보임 우선 하나의 컬렉션을 사용 - */ - @Value("${milvus.collection-name}") - private String collectionName; - - /** - * 컬렉션 로딩 - * 스프링 시작시 milvus 컬렉션 로드 - * 메모리에 올라가는 작업 - */ - @PostConstruct - public void loadCollection() { - try { - milvusClient.loadCollection(LoadCollectionParam.newBuilder() - .withCollectionName(collectionName) - .build()); - log.info("Collection 로드 완료 :: {}", collectionName); - } catch (Exception e) { - throw new RuntimeException("Milvus user Creation Failed"); - } - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java index b393bed..d0fda74 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java @@ -5,7 +5,7 @@ @Getter public enum ImageUploadType { PROFILE(1), - REVIEW(5), + REVIEW(3), GENERAL(10); private final int maxFiles; diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java new file mode 100644 index 0000000..61c2f6b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java @@ -0,0 +1,89 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "content") +@ToString +@Builder +public class Content { + @Id + @Column(name = "content_id", updatable = false) + private int contentId; + + @Column(name = "content_type_id", nullable = false) + private int contentTypeId; + + @Column(name = "category_id") + private String categoryId; + + @Column(name = "sido_code") + private int sidoCode; + + @Column(name = "sigungu_code") + private int sigunguCode; + + @Column(name = "addr1") + private String addr1; + + @Column(name = "addr2") + private String addr2; + + @Column(name = "image") + private String imageUrl; + + @Column(name = "thumb_image") + private String thumbImageUrl; + + @Column(name = "copyright") + private String copyright; + + @Column(name = "mapx") + private double mapX; + + @Column(name = "mapy") + private double mapy; + + @Column(name = "mlevel") + private int mLevel; + + @Column(name = "tel") + private String tel; + + @Column(name = "title") + private String title; + + @Column(name = "zipcode") + private int zipCode; + + @Lob + @Column(name = "overview") + private String overview; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java new file mode 100644 index 0000000..870c394 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +public interface ContentRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java deleted file mode 100644 index 9ded0db..0000000 --- a/src/main/java/com/swyp/catsgotogedog/global/config/MilvusConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.swyp.catsgotogedog.global.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import io.milvus.client.MilvusClient; -import io.milvus.client.MilvusServiceClient; -import io.milvus.param.ConnectParam; -import io.milvus.param.ConnectParam.Builder; - -@Configuration -public class MilvusConfig { - - @Value("${milvus.host}") - private String milvusHost; - - @Value("${milvus.port}") - private int milvusPort; - - @Value("${milvus.username}") - private String milvusUsername; - - @Value("${milvus.password}") - private String milvusPassword; - - @Bean - public MilvusClient milvusClient() { - Builder connectParamBuilder = ConnectParam.newBuilder() - .withHost(milvusHost) - .withPort(milvusPort); - - if (milvusUsername != null && milvusPassword != null) { - connectParamBuilder.withAuthorization(milvusUsername, milvusPassword); - } - - return new MilvusServiceClient(connectParamBuilder.build()); - } -} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 46b6052..72762ad 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -41,6 +41,7 @@ public enum ErrorCode { IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "이미지 크기가 허용 범위를 초과했습니다."), IMAGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "최대 이미지 업로드 개수를 초과했습니다."), IMAGE_VALIDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 유효성 검사에 실패했습니다."), + REVIEW_IMAGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "리뷰 이미지는 최대 3개까지 업로드 가능합니다."), // Image Storage Error IMAGE_KEY_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 키가 누락 되었습니다."), diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java index 6240891..7f6810e 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -118,5 +118,14 @@ protected ResponseEntity> handleUnAuthorizedAcc .body(response); } + @ExceptionHandler(ExpiredTokenException.class) + protected ResponseEntity> handleExpiredTokenException(ExpiredTokenException e) { + log.warn("ExpiredTokenException : {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.EXPIRED_TOKEN); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(response); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index f6dfd0d..a2ae914 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -1,12 +1,49 @@ package com.swyp.catsgotogedog.review.controller; +import java.util.List; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.service.ReviewService; +import io.jsonwebtoken.io.IOException; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @RestController @RequiredArgsConstructor @RequestMapping("/api/review") -public class ReviewController implements ReviewControllerSwagger{ +@Slf4j +public class ReviewController implements ReviewControllerSwagger { + + private final ReviewService reviewService; + + @Override + @PostMapping(value = "/{contentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> createReview( + @PathVariable int contentId, + @AuthenticationPrincipal String userId, + @Valid @ModelAttribute @ParameterObject CreateReviewRequest createReviewRequest, + @RequestParam(value = "images", required = false)List images) throws IOException { + + reviewService.createReview(contentId, userId, createReviewRequest, images); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 생성 성공", null) + ); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index f840df1..4d9862a 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -1,7 +1,61 @@ package com.swyp.catsgotogedog.review.controller; +import java.security.Principal; +import java.util.List; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; + +import io.jsonwebtoken.io.IOException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Review", description = "리뷰 관련 API") public interface ReviewControllerSwagger { + + @Operation( + summary = "리뷰를 작성합니다.", + description = "사용자 인증을 통해 리뷰를 작성합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "리뷰 작성 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "ContentId가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> createReview( + @Parameter(description = "리뷰를 작성할 컨텐츠 ID", required = true) + @PathVariable int contentId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @RequestPart(value = "createReviewRequest") + @ModelAttribute @ParameterObject CreateReviewRequest createReviewRequest, + + @Parameter(description = "이미지 업로드 (최대 3장)") + @RequestParam(value = "images") List images + ) throws IOException; } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java deleted file mode 100644 index 8d54adc..0000000 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Reivew.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.swyp.catsgotogedog.review.domain.entity; - -public class Reivew { -} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java new file mode 100644 index 0000000..7a94511 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java @@ -0,0 +1,54 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import java.math.BigDecimal; +import java.util.List; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.global.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Builder +@Table(name = "content_review") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Review extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int reviewId; + private int userId; + private String content; + private int contentId; + + @Column(precision = 2, scale = 1) + @DecimalMin(value = "0.5", message = "별점은 0.5 이상이어야 합니다.") + @DecimalMax(value = "5.0", message = "별점은 5.0 이하이어야 합니다.") + private BigDecimal score; + + private int recommendedNumber; + + @OneToMany(fetch = FetchType.LAZY) + @JoinColumn(name = "reviewId") + private List reviewImages; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewImage.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewImage.java new file mode 100644 index 0000000..dd3f710 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewImage.java @@ -0,0 +1,35 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ReviewImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int imageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reviewId") + private Review review; + + private String imageFilename; + + private String imageUrl; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/review/domain/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java b/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java new file mode 100644 index 0000000..70c6187 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.review.domain.request; + +import java.math.BigDecimal; +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateReviewRequest { + + @NotNull(message = "별점을 입력해주세요") + @DecimalMin(value = "0.5", message = "별점은 0.5 이상이어야 합니다.") + @DecimalMax(value = "5.0", message = "별점은 5.0 이하이어야 합니다.") + @Schema(example = "3.5", description = "0.5 ~ 5.0 (.5 단위) 입력") + private BigDecimal score; + @NotEmpty(message = "리뷰 내용을 입력해주세요.") + @Schema(example = "리뷰 내용", description = "리뷰 내용 입력") + private String content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java new file mode 100644 index 0000000..fcd2b3f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; + +public interface ReviewImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java index 803f62d..f3e71cd 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -1,4 +1,20 @@ package com.swyp.catsgotogedog.review.repository; -public interface ReviewRepository { +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.swyp.catsgotogedog.review.domain.entity.Review; + +public interface ReviewRepository extends JpaRepository { + + /** + * reviewId를 이용해 reviewImage 컬렉션도 함께 조회 + * @param reviewId + * @return Review Entity + */ + @Query("SELECT r FROM Review r LEFT JOIN FETCH r.reviewImages WHERE r.reviewId = :reviewId") + Optional findByIdWithImages(@Param("reviewId") int reviewId); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index 11685ef..e724d97 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -1,6 +1,27 @@ package com.swyp.catsgotogedog.review.service; +import java.util.List; +import java.util.Optional; + import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.util.image.storage.ImageStorageService; +import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.common.util.image.validator.ImageValidator; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; +import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.repository.ReviewImageRepository; +import com.swyp.catsgotogedog.review.repository.ReviewRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -9,4 +30,61 @@ @RequiredArgsConstructor @Slf4j public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ReviewImageRepository reviewImageRepository; + private final UserRepository userRepository; + private final ContentRepository contentRepository; + private final ImageStorageService imageStorageService; + + // 리뷰 작성 + @Transactional + public void createReview(int contentId, String userId, CreateReviewRequest request, List images) { + User user = validateDatas(userId); + Content content = validateDatas(contentId); + + + Review uploadedReview = reviewRepository.save(Review.builder() + .userId(user.getUserId()) + .contentId(content.getContentId()) + .score(request.getScore()) + .content(request.getContent()) + .build()); + + if(images != null && !images.isEmpty()) { + if(images.size() > ImageUploadType.REVIEW.getMaxFiles()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_LIMIT_EXCEEDED); + } + uploadAndSaveReviewImages(uploadedReview, images); + } + + } + + private User validateDatas(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private Content validateDatas(int contentId) { + return contentRepository.findById(contentId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.CONTENT_NOT_FOUND)); + } + + private void uploadAndSaveReviewImages(Review review, List images) { + if(images.size() > ImageUploadType.REVIEW.getMaxFiles()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_LIMIT_EXCEEDED); + } + + List imageInfos = imageStorageService.upload(images, ImageUploadType.REVIEW); + + List saveImages = imageInfos.stream() + .map(imageInfo -> ReviewImage.builder() + .review(review) + .imageFilename(imageInfo.key()) + .imageUrl(imageInfo.url()) + .build() + ).toList(); + + reviewImageRepository.saveAll(saveImages); + } } diff --git a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java b/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java deleted file mode 100644 index 4e43bb9..0000000 --- a/src/test/java/com/swyp/catsgotogedog/milvus/service/MilvusServiceTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.swyp.catsgotogedog.milvus.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import org.junit.jupiter.api.BeforeEach; -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.test.util.ReflectionTestUtils; - -import com.swyp.catsgotogedog.common.milvus.service.MilvusService; - -import io.milvus.client.MilvusClient; -import io.milvus.param.collection.LoadCollectionParam; - -@ExtendWith(MockitoExtension.class) -public class MilvusServiceTest { - - @Mock - private MilvusClient milvusClient; - - @InjectMocks - private MilvusService milvusService; - - private static final String TEST_COLLECTION_NAME = "catsgotogedog_test_collection"; - - @BeforeEach - void setUp() throws Exception { - ReflectionTestUtils.setField(milvusService, "collectionName", TEST_COLLECTION_NAME); - } - - @Test - @DisplayName("컬렉션 로드 테스트 (성공)") - void loadCollection_success() { - assertDoesNotThrow(() -> milvusService.loadCollection()); - - verify(milvusClient, times(1)).loadCollection(any(LoadCollectionParam.class)); - } - - @Test - @DisplayName("컬렉션 로드 테스트 (실패)") - void loadCollection_Fail() { - doThrow(new RuntimeException("Milvus client load 실패")).when(milvusClient).loadCollection(any(LoadCollectionParam.class)); - - RuntimeException thrown = assertThrows(RuntimeException.class, () -> milvusService.loadCollection()); - - assert(thrown.getMessage().equals("Milvus user Creation Failed")); - - verify(milvusClient, times(1)).loadCollection(any(LoadCollectionParam.class)); - } -} From 253da8183dd45007413e547b06f8238a4bc86fc1 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 3 Aug 2025 02:58:05 +0900 Subject: [PATCH 088/191] =?UTF-8?q?feat/=EB=A6=AC=EB=B7=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 수정 기능 구현 --- .../review/controller/ReviewController.java | 17 ++++++++- .../controller/ReviewControllerSwagger.java | 29 ++++++++++++++ .../review/domain/entity/Review.java | 1 + .../review/service/ReviewService.java | 38 ++++++++++++++++--- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index a2ae914..fedfb5b 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -9,9 +9,9 @@ import org.springframework.web.bind.annotation.ModelAttribute; 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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -46,4 +46,19 @@ public ResponseEntity> createReview( CatsgotogedogApiResponse.success("리뷰 생성 성공", null) ); } + + @Override + @PutMapping(value = "/{reviewId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> updateReview( + @PathVariable int reviewId, + @AuthenticationPrincipal String userId, + @Valid @ModelAttribute @ParameterObject CreateReviewRequest createReviewRequest, + @RequestParam(value = "images", required = false)List images) { + + reviewService.updateReview(reviewId, userId, createReviewRequest, images); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 수정 성공", null) + ); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index 4d9862a..742fb0d 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -58,4 +58,33 @@ ResponseEntity> createReview( @Parameter(description = "이미지 업로드 (최대 3장)") @RequestParam(value = "images") List images ) throws IOException; + + @Operation( + summary = "작성 리뷰를 수정합니다.", + description = "사용자 인증을 통해 리뷰를 수정합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 수정 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "ContentId가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> updateReview( + @Parameter(description = "수정할 리뷰 ID", required = true) + @PathVariable int reviewId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @RequestPart(value = "createReviewRequest") + @ModelAttribute @ParameterObject CreateReviewRequest createReviewRequest, + + @Parameter(description = "이미지 업로드 (최대 3장)") + @RequestParam(value = "images") List images + ); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java index 7a94511..42d7a5b 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java @@ -28,6 +28,7 @@ @Entity @Getter +@Setter @Builder @Table(name = "content_review") @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index e724d97..d2a0cf7 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -1,7 +1,6 @@ package com.swyp.catsgotogedog.review.service; import java.util.List; -import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,7 +11,6 @@ import com.swyp.catsgotogedog.common.util.image.storage.ImageStorageService; import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; -import com.swyp.catsgotogedog.common.util.image.validator.ImageValidator; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.repository.ContentRepository; import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; @@ -40,8 +38,8 @@ public class ReviewService { // 리뷰 작성 @Transactional public void createReview(int contentId, String userId, CreateReviewRequest request, List images) { - User user = validateDatas(userId); - Content content = validateDatas(contentId); + User user = validateUser(userId); + Content content = validateContent(contentId); Review uploadedReview = reviewRepository.save(Review.builder() @@ -60,16 +58,44 @@ public void createReview(int contentId, String userId, CreateReviewRequest reque } - private User validateDatas(String userId) { + @Transactional + public void updateReview(int reviewId, String userId, CreateReviewRequest request, List images) { + User user = validateUser(userId); + Review review = validateReview(reviewId); + + + review.setScore(request.getScore()); + review.setContent(request.getContent()); + + if(images != null && !images.isEmpty()) { + List imageInfos = imageStorageService.upload(images, ImageUploadType.REVIEW); + List saveImages = imageInfos.stream() + .map(imageInfo -> ReviewImage.builder() + .review(review) + .imageFilename(imageInfo.key()) + .imageUrl(imageInfo.url()) + .build() + ).toList(); + reviewImageRepository.saveAll(saveImages); + } + } + + + private User validateUser(String userId) { return userRepository.findById(Integer.parseInt(userId)) .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); } - private Content validateDatas(int contentId) { + private Content validateContent(int contentId) { return contentRepository.findById(contentId) .orElseThrow(() -> new CatsgotogedogException(ErrorCode.CONTENT_NOT_FOUND)); } + private Review validateReview(int reviewId) { + return reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + } + private void uploadAndSaveReviewImages(Review review, List images) { if(images.size() > ImageUploadType.REVIEW.getMaxFiles()) { throw new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_LIMIT_EXCEEDED); From 2242958b2c8ce63fb42acc09fded1cdb6dd7acb6 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 3 Aug 2025 04:01:28 +0900 Subject: [PATCH 089/191] =?UTF-8?q?feat/=EB=A6=AC=EB=B7=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EB=A6=AC=EB=B7=B0=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 삭제 및 리뷰 이미지 삭제 기능 구현 Object Storage 포함 삭제 구현 --- .../global/exception/ErrorCode.java | 1 + .../review/controller/ReviewController.java | 39 ++++++++++++-- .../controller/ReviewControllerSwagger.java | 53 +++++++++++++++++++ .../review/domain/entity/Review.java | 4 +- .../repository/ReviewImageRepository.java | 4 ++ .../review/repository/ReviewRepository.java | 12 ++++- .../review/service/ReviewService.java | 39 +++++++++++++- 7 files changed, 144 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 72762ad..208e10d 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 컨텐츠 게시글입니다."), REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰입니다."), RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "리소스를 찾을 수 없습니다."), + REVIEW_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰 이미지입니다."), // 405 Method not allowed METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메소드입니다."), diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index fedfb5b..85c6908 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -3,9 +3,11 @@ import java.util.List; import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; 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.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -32,6 +34,7 @@ public class ReviewController implements ReviewControllerSwagger { private final ReviewService reviewService; + // 리뷰 작성 @Override @PostMapping(value = "/{contentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> createReview( @@ -42,11 +45,12 @@ public ResponseEntity> createReview( reviewService.createReview(contentId, userId, createReviewRequest, images); - return ResponseEntity.ok( - CatsgotogedogApiResponse.success("리뷰 생성 성공", null) - ); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(CatsgotogedogApiResponse.success("리뷰 생성 성공", null)); } + // 리뷰 수정 @Override @PutMapping(value = "/{reviewId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateReview( @@ -61,4 +65,33 @@ public ResponseEntity> updateReview( CatsgotogedogApiResponse.success("리뷰 수정 성공", null) ); } + + // 리뷰 삭제 + @Override + @DeleteMapping("/{reviewId}") + public ResponseEntity> deleteReview( + @PathVariable int reviewId, + @AuthenticationPrincipal String userId) { + + reviewService.deleteReview(reviewId, userId); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 삭제 성공", null) + ); + } + + // 리뷰 이미지 삭제 + @Override + @DeleteMapping("/{reviewId}/image/{imageId}") + public ResponseEntity> deleteReviewImage( + @PathVariable(name = "reviewId") int reviewId, + @PathVariable(name = "imageId") int imageId, + @AuthenticationPrincipal String userId) { + + reviewService.deleteReviewImage(reviewId, imageId, userId); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 이미지 삭제 성공", null) + ); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index 742fb0d..593d00f 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -87,4 +87,57 @@ ResponseEntity> updateReview( @Parameter(description = "이미지 업로드 (최대 3장)") @RequestParam(value = "images") List images ); + + @Operation( + summary = "작성한 리뷰를 삭제합니다.", + description = "사용자 인증을 통해 리뷰를 수정합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 삭제 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "접근 권한이 없음, 다른 사람의 리뷰 삭제시" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> deleteReview( + @Parameter(description = "삭제할 리뷰 ID", required = true) + @PathVariable int reviewId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); + + @Operation( + summary = "작성한 리뷰의 이미지를 삭제합니다.", + description = "사용자 인증을 통해 리뷰의 이미지를 삭제합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "이미지 삭제 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "접근 권한이 없음, 다른 사람의 리뷰 or 이미지 삭제시" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> deleteReviewImage( + @Parameter(description = "삭제할 리뷰 ID", required = true) + @PathVariable int reviewId, + + @Parameter(description = "삭제할 이미지 ID", required = true) + @PathVariable int imageId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java index 42d7a5b..0fb20c9 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java @@ -7,6 +7,7 @@ import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.global.BaseTimeEntity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -49,7 +50,6 @@ public class Review extends BaseTimeEntity { private int recommendedNumber; - @OneToMany(fetch = FetchType.LAZY) - @JoinColumn(name = "reviewId") + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true) private List reviewImages; } diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java index fcd2b3f..688965d 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java @@ -1,8 +1,12 @@ package com.swyp.catsgotogedog.review.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import com.swyp.catsgotogedog.review.domain.entity.Review; import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; public interface ReviewImageRepository extends JpaRepository { + List findByReview(Review review); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java index f3e71cd..b5944d6 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -13,8 +13,18 @@ public interface ReviewRepository extends JpaRepository { /** * reviewId를 이용해 reviewImage 컬렉션도 함께 조회 * @param reviewId - * @return Review Entity + * @return Optional */ @Query("SELECT r FROM Review r LEFT JOIN FETCH r.reviewImages WHERE r.reviewId = :reviewId") Optional findByIdWithImages(@Param("reviewId") int reviewId); + + /** + * reviewId와 userId 를 통한 리뷰 컬렉션 조회 + * @param reviewId + * @param userId + * @return Optional + */ + @Query("SELECT r FROM Review r WHERE r.reviewId = :reviewId AND r.userId = :userId") + Optional findByIdAndUserId(@Param("reviewId") int reviewId, String userId); + } diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index d2a0cf7..f42146c 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -1,6 +1,7 @@ package com.swyp.catsgotogedog.review.service; import java.util.List; +import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -58,11 +59,12 @@ public void createReview(int contentId, String userId, CreateReviewRequest reque } + // 리뷰 수정 @Transactional public void updateReview(int reviewId, String userId, CreateReviewRequest request, List images) { User user = validateUser(userId); - Review review = validateReview(reviewId); - + Review review = reviewRepository.findByIdAndUserId(reviewId, userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); review.setScore(request.getScore()); review.setContent(request.getContent()); @@ -80,6 +82,38 @@ public void updateReview(int reviewId, String userId, CreateReviewRequest reques } } + // 리뷰 삭제 + @Transactional + public void deleteReview(int reviewId, String userId) { + User user = validateUser(userId); + validateReview(reviewId); + Review review = reviewRepository.findByIdAndUserId(reviewId, userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.FORBIDDEN_ACCESS)); + + List images = reviewImageRepository.findByReview(review); + + images.forEach(image -> imageStorageService.delete(image.getImageFilename())); + + reviewRepository.delete(review); + + } + + // 리뷰 이미지 삭제 + @Transactional + public void deleteReviewImage(int reviewId, int imageId, String userId) { + validateUser(userId); + validateReview(reviewId); + + reviewRepository.findByIdAndUserId(reviewId, userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.FORBIDDEN_ACCESS)); + + ReviewImage image = reviewImageRepository.findById(imageId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_NOT_FOUND)); + + imageStorageService.delete(image.getImageFilename()); + reviewImageRepository.deleteById(imageId); + } + private User validateUser(String userId) { return userRepository.findById(Integer.parseInt(userId)) @@ -113,4 +147,5 @@ private void uploadAndSaveReviewImages(Review review, List images reviewImageRepository.saveAll(saveImages); } + } From fb98fb456aadb13126653f0079938c06da594fdf Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 3 Aug 2025 05:21:14 +0900 Subject: [PATCH 090/191] =?UTF-8?q?feat/=EC=BB=A8=ED=85=90=EC=B8=A0?= =?UTF-8?q?=EC=9D=98=20=EB=A6=AC=EB=B7=B0=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 특정 컨텐츠의 리뷰 목록 조회 및 정렬 필터링 개발 --- .../common/config/SecurityConfig.java | 3 +- .../review/controller/ReviewController.java | 15 +++++++ .../controller/ReviewControllerSwagger.java | 20 +++++++++ .../review/domain/response/.gitkeep | 0 .../domain/response/ReviewImageResponse.java | 14 ++++++ .../domain/response/ReviewResponse.java | 16 +++++++ .../domain/response/ReviewSortType.java | 16 +++++++ .../review/repository/ReviewRepository.java | 13 ++++++ .../review/service/ReviewService.java | 43 ++++++++++++++++++- 9 files changed, 138 insertions(+), 2 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewImageResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewSortType.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index f44dbb5..9c08993 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -48,8 +48,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/error", "/swagger-ui/**", "/v3/api-docs/**", - "/api/user/reissue" + "/api/user/reissue", // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 + "/api/review/content/**" ).permitAll() .anyRequest().authenticated()) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index 85c6908..a0cca4c 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -8,6 +8,7 @@ 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.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -19,6 +20,7 @@ import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; import com.swyp.catsgotogedog.review.service.ReviewService; import io.jsonwebtoken.io.IOException; @@ -94,4 +96,17 @@ public ResponseEntity> deleteReviewImage( CatsgotogedogApiResponse.success("리뷰 이미지 삭제 성공", null) ); } + + @Override + @GetMapping("/content/{contentId}") + public ResponseEntity> fetchReviewsByContentId( + @PathVariable int contentId, + @RequestParam(defaultValue = "r") String sort) { + + List reviewResponses = reviewService.fetchReviewsByContentId(contentId, sort); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 조회 성공", reviewResponses) + ); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index 593d00f..af0001e 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -16,6 +16,7 @@ import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; import io.jsonwebtoken.io.IOException; import io.swagger.v3.oas.annotations.Operation; @@ -140,4 +141,23 @@ ResponseEntity> deleteReviewImage( @Parameter(hidden = true) @AuthenticationPrincipal String userId ); + + @Operation( + summary = "컨텐츠에 작성된 리뷰 목록을 조회합니다.", + description = "ContentId를 통해 리뷰 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 목록 조회 성공" + , content = @Content(schema = @Schema(implementation = ReviewResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "컨텐츠가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchReviewsByContentId( + @Parameter(description = "조회할 컨텐츠 ID", required = true) + @PathVariable int contentId, + @Parameter(description = "정렬 기준 (좋아요 순: r, 최신순: c, 기본: r)", required = false, + example = "r") + @RequestParam String sort); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/review/domain/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewImageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewImageResponse.java new file mode 100644 index 0000000..ec324de --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewImageResponse.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; + +public record ReviewImageResponse ( + int imageId, String imageUrl +) { + public static ReviewImageResponse from(ReviewImage reviewImage) { + return new ReviewImageResponse( + reviewImage.getImageId(), + reviewImage.getImageUrl() + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java new file mode 100644 index 0000000..0f6ee20 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import java.math.BigDecimal; +import java.util.List; + +public record ReviewResponse ( + int contentId, + int reviewId, + int userId, + String displayName, + String profileImageUrl, + String content, + BigDecimal score, + int recommendedNumber, + List images +) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewSortType.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewSortType.java new file mode 100644 index 0000000..ad11d19 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewSortType.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.review.domain.response; + +public enum ReviewSortType { + LATEST("c"), + RECOMMENDED("r"); + + private final String value; + + private ReviewSortType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java index b5944d6..1c818e1 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -1,5 +1,6 @@ package com.swyp.catsgotogedog.review.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -27,4 +28,16 @@ public interface ReviewRepository extends JpaRepository { @Query("SELECT r FROM Review r WHERE r.reviewId = :reviewId AND r.userId = :userId") Optional findByIdAndUserId(@Param("reviewId") int reviewId, String userId); + List findByContentId(int contentId); + + @Query("SELECT DISTINCT r FROM Review r " + + "LEFT JOIN FETCH r.reviewImages " + + "WHERE r.contentId = :contentId " + + "ORDER BY " + + "CASE WHEN :sort = 'r' THEN r.recommendedNumber END DESC, " + + "CASE WHEN :sort = 'c' THEN r.createdAt END DESC, " + + "r.recommendedNumber DESC") + List findByContentIdWithUserAndReviewImages( + @Param("contentId") int contentId, + @Param("sort") String sort); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index f42146c..cdbc7e2 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -1,7 +1,9 @@ package com.swyp.catsgotogedog.review.service; import java.util.List; -import java.util.Optional; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +21,8 @@ import com.swyp.catsgotogedog.review.domain.entity.Review; import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.ReviewImageResponse; +import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; import com.swyp.catsgotogedog.review.repository.ReviewImageRepository; import com.swyp.catsgotogedog.review.repository.ReviewRepository; @@ -114,6 +118,42 @@ public void deleteReviewImage(int reviewId, int imageId, String userId) { reviewImageRepository.deleteById(imageId); } + // ContentId를 통한 리뷰 목록 조회 + @Transactional(readOnly = true) + public List fetchReviewsByContentId(int contentId, String sort) { + validateContent(contentId); + + List reviews = reviewRepository.findByContentIdWithUserAndReviewImages(contentId, sort); + + List userIds = reviews.stream() + .map(Review::getUserId) + .distinct() + .collect(Collectors.toList()); + + List users = userRepository.findAllById(userIds); + Map userMap = users.stream() + .collect(Collectors.toMap(User::getUserId, Function.identity())); + + return reviews.stream() + .map(review -> { + User user = userMap.get(review.getUserId()); + return new ReviewResponse( + review.getContentId(), + review.getReviewId(), + user != null ? user.getUserId() : 0, + user != null ? user.getDisplayName() : "알 수 없음", + user != null ? user.getImageUrl() : "", + review.getContent(), + review.getScore(), + review.getRecommendedNumber(), + review.getReviewImages().stream() + .map(ReviewImageResponse::from) + .collect(Collectors.toList()) + ); + }) + .collect(Collectors.toList()); + } + private User validateUser(String userId) { return userRepository.findById(Integer.parseInt(userId)) @@ -148,4 +188,5 @@ private void uploadAndSaveReviewImages(Review review, List images reviewImageRepository.saveAll(saveImages); } + } From 1991fae49595a4933003a4138d485067c21b2b2a Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 3 Aug 2025 16:32:05 +0900 Subject: [PATCH 091/191] =?UTF-8?q?feat/=EB=A6=AC=EB=B7=B0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 페이징을 통한 작성 리뷰 목록, 컨텐츠별 리뷰 목록 조회 --- .../review/controller/ReviewController.java | 29 ++++++- .../controller/ReviewControllerSwagger.java | 27 +++++- .../review/domain/entity/Review.java | 5 +- .../domain/request/CreateReviewRequest.java | 3 - .../response/ContentReviewPageResponse.java | 14 +++ .../domain/response/MyReviewPageResponse.java | 13 +++ .../domain/response/MyReviewResponse.java | 16 ++++ .../domain/response/ReviewResponse.java | 2 + .../review/repository/ReviewRepository.java | 27 ++++-- .../review/service/ReviewService.java | 87 ++++++++++++++++--- 10 files changed, 197 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index a0cca4c..6915274 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -3,6 +3,9 @@ import java.util.List; import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -20,6 +23,7 @@ import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.ContentReviewPageResponse; import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; import com.swyp.catsgotogedog.review.service.ReviewService; @@ -97,16 +101,37 @@ public ResponseEntity> deleteReviewImage( ); } + // 컨텐츠별 리뷰 조회 @Override @GetMapping("/content/{contentId}") public ResponseEntity> fetchReviewsByContentId( @PathVariable int contentId, - @RequestParam(defaultValue = "r") String sort) { + @RequestParam(defaultValue = "r") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "4") int size) { - List reviewResponses = reviewService.fetchReviewsByContentId(contentId, sort); + Pageable pageable = PageRequest.of(page, size); + + ContentReviewPageResponse reviewResponses = reviewService.fetchReviewsByContentId(contentId, sort, pageable); return ResponseEntity.ok( CatsgotogedogApiResponse.success("리뷰 조회 성공", reviewResponses) ); } + + @Override + @GetMapping("/") + public ResponseEntity> fetchReviewsByUserId( + @AuthenticationPrincipal String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "4") int size) { + + Pageable pageable = PageRequest.of(page, size, Sort.Direction.DESC, "createdAt"); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 조회 성공", + reviewService.fetchReviewsByUserId(userId, pageable)) + ); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index af0001e..b4360c5 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -159,5 +159,30 @@ ResponseEntity> fetchReviewsByContentId( @PathVariable int contentId, @Parameter(description = "정렬 기준 (좋아요 순: r, 최신순: c, 기본: r)", required = false, example = "r") - @RequestParam String sort); + @RequestParam String sort, + @Parameter(description = "요청 페이지") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지당 결과 갯수") + @RequestParam(defaultValue = "4") int size); + + @Operation( + summary = "자신의 리뷰 목록을 조회합니다.", + description = "사용자 인증을 통해 리뷰 목록을 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 목록 조회 성공" + , content = @Content(schema = @Schema(implementation = ReviewResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "컨텐츠가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchReviewsByUserId( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @Parameter(description = "요청 페이지") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지당 결과 갯수") + @RequestParam(defaultValue = "4") int size); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java index 0fb20c9..6e4510f 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java @@ -41,7 +41,10 @@ public class Review extends BaseTimeEntity { private int reviewId; private int userId; private String content; - private int contentId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "contentId") + private Content contentEntity; @Column(precision = 2, scale = 1) @DecimalMin(value = "0.5", message = "별점은 0.5 이상이어야 합니다.") diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java b/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java index 70c6187..1adbed3 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java @@ -1,9 +1,6 @@ package com.swyp.catsgotogedog.review.domain.request; import java.math.BigDecimal; -import java.util.List; - -import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.DecimalMax; diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java new file mode 100644 index 0000000..c0df3f5 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import java.util.List; + +public record ContentReviewPageResponse ( + List reviews, + int totalElements, + int totalPages, + int currentPage, + int size, + boolean hasNext, + boolean hasPrevious +) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java new file mode 100644 index 0000000..b38c907 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import java.util.List; + +public record MyReviewPageResponse( + List reviews, + int totalElements, + int totalPages, + int currentPage, + int size, + boolean hasNext, + boolean hasPrevious +) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java new file mode 100644 index 0000000..8ed2a3c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record MyReviewResponse( + int contentId, + String contentTitle, + int reviewId, + String content, + BigDecimal score, + int recommendedNumber, + LocalDateTime createdAt, + List images +) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java index 0f6ee20..9c019e8 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java @@ -1,6 +1,7 @@ package com.swyp.catsgotogedog.review.domain.response; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; public record ReviewResponse ( @@ -11,6 +12,7 @@ public record ReviewResponse ( String profileImageUrl, String content, BigDecimal score, + LocalDateTime createdAt, int recommendedNumber, List images ) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java index 1c818e1..fe2fbfe 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -3,6 +3,9 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -28,16 +31,22 @@ public interface ReviewRepository extends JpaRepository { @Query("SELECT r FROM Review r WHERE r.reviewId = :reviewId AND r.userId = :userId") Optional findByIdAndUserId(@Param("reviewId") int reviewId, String userId); - List findByContentId(int contentId); - + // 페이징 컨텐츠 리뷰 목록 조회 @Query("SELECT DISTINCT r FROM Review r " + "LEFT JOIN FETCH r.reviewImages " - + "WHERE r.contentId = :contentId " - + "ORDER BY " - + "CASE WHEN :sort = 'r' THEN r.recommendedNumber END DESC, " - + "CASE WHEN :sort = 'c' THEN r.createdAt END DESC, " - + "r.recommendedNumber DESC") - List findByContentIdWithUserAndReviewImages( + + "WHERE r.contentEntity.contentId = :contentId") + Page findByContentIdWithUserAndReviewImages( @Param("contentId") int contentId, - @Param("sort") String sort); + Pageable pageable); + + @EntityGraph(attributePaths = {"reviewImages"}) + Page findByUserId(int userId, Pageable pageable); + + // 페이징 자신이 작성한 리뷰 목록 조회 + @Query(value = "SELECT DISTINCT r FROM Review r " + + "LEFT JOIN FETCH r.contentEntity c " + + "LEFT JOIN FETCH r.reviewImages " + + "WHERE r.userId = :userId", + countQuery = "SELECT COUNT(r) FROM Review r WHERE r.userId = :userId") + Page findByUserIdWithContent(@Param("userId") int userId, Pageable pageable); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index cdbc7e2..967cb7d 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -1,10 +1,15 @@ package com.swyp.catsgotogedog.review.service; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -21,6 +26,9 @@ import com.swyp.catsgotogedog.review.domain.entity.Review; import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.ContentReviewPageResponse; +import com.swyp.catsgotogedog.review.domain.response.MyReviewPageResponse; +import com.swyp.catsgotogedog.review.domain.response.MyReviewResponse; import com.swyp.catsgotogedog.review.domain.response.ReviewImageResponse; import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; import com.swyp.catsgotogedog.review.repository.ReviewImageRepository; @@ -49,7 +57,7 @@ public void createReview(int contentId, String userId, CreateReviewRequest reque Review uploadedReview = reviewRepository.save(Review.builder() .userId(user.getUserId()) - .contentId(content.getContentId()) + .contentEntity(content) .score(request.getScore()) .content(request.getContent()) .build()); @@ -120,38 +128,73 @@ public void deleteReviewImage(int reviewId, int imageId, String userId) { // ContentId를 통한 리뷰 목록 조회 @Transactional(readOnly = true) - public List fetchReviewsByContentId(int contentId, String sort) { + public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String sort, Pageable pageable) { validateContent(contentId); - List reviews = reviewRepository.findByContentIdWithUserAndReviewImages(contentId, sort); + Sort sortObj = createSort(sort); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortObj); - List userIds = reviews.stream() + Page reviewPage = reviewRepository.findByContentIdWithUserAndReviewImages(contentId, sortedPageable); + + + List userIds = reviewPage.getContent().stream() .map(Review::getUserId) .distinct() .collect(Collectors.toList()); - List users = userRepository.findAllById(userIds); - Map userMap = users.stream() + Map userMap = userRepository.findAllById(userIds).stream() .collect(Collectors.toMap(User::getUserId, Function.identity())); - return reviews.stream() + List reviewResponses = reviewPage.getContent().stream() .map(review -> { User user = userMap.get(review.getUserId()); return new ReviewResponse( - review.getContentId(), + review.getContentEntity().getContentId(), review.getReviewId(), user != null ? user.getUserId() : 0, user != null ? user.getDisplayName() : "알 수 없음", user != null ? user.getImageUrl() : "", review.getContent(), review.getScore(), + review.getCreatedAt(), review.getRecommendedNumber(), review.getReviewImages().stream() .map(ReviewImageResponse::from) .collect(Collectors.toList()) ); - }) - .collect(Collectors.toList()); + }).toList(); + + return new ContentReviewPageResponse( + reviewResponses, + (int) reviewPage.getTotalElements(), + reviewPage.getTotalPages(), + reviewPage.getNumber(), + reviewPage.getSize(), + reviewPage.hasNext(), + reviewPage.hasPrevious() + ); + } + + // 자신이 작성한 리뷰 목록 페이징 + @Transactional(readOnly = true) + public MyReviewPageResponse fetchReviewsByUserId(String userId, Pageable pageable) { + validateUser(userId); + + Page reviewPage = reviewRepository.findByUserId(Integer.parseInt(userId), pageable); + + List myReviewResponses = reviewPage.getContent().stream() + .map(this::toReviewResponse) + .toList(); + + return new MyReviewPageResponse( + myReviewResponses, + (int) reviewPage.getTotalElements(), + reviewPage.getTotalPages(), + reviewPage.getNumber(), + reviewPage.getSize(), + reviewPage.hasNext(), + reviewPage.hasPrevious() + ); } @@ -188,5 +231,29 @@ private void uploadAndSaveReviewImages(Review review, List images reviewImageRepository.saveAll(saveImages); } + private MyReviewResponse toReviewResponse(Review review) { + return new MyReviewResponse( + review.getContentEntity().getContentId(), + review.getContentEntity().getTitle(), + review.getReviewId(), + review.getContent(), + review.getScore(), + review.getRecommendedNumber(), + review.getCreatedAt(), + review.getReviewImages().stream() + .map(ReviewImageResponse::from) + .toList() + ); + } + + private Sort createSort(String sort) { + return switch(sort) { + case "r" -> Sort.by(Sort.Direction.DESC, "recommendedNumber") + .and(Sort.by(Sort.Direction.DESC, "createdAt")); + case "c" -> Sort.by(Sort.Direction.DESC, "createdAt"); + default -> Sort.by(Sort.Direction.DESC, "recommendedNumber") + .and(Sort.by(Sort.Direction.DESC, "createdAt")); + }; + } } From 3292626268a7340b27d78bde739dfcec894adc4d Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:48:04 +0900 Subject: [PATCH 092/191] =?UTF-8?q?feat:=20=EC=B0=9C=20=EC=97=AC=EB=B6=80?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 6 ++-- .../content/domain/entity/ContentWish.java | 20 +++++++++++++ .../domain/response/ContentResponse.java | 5 +++- .../repository/ContentWishRepository.java | 11 +++++++ .../content/service/ContentSearchService.java | 29 +++++++++++++++++-- 5 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 1487370..f3a7113 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -5,6 +5,7 @@ import com.swyp.catsgotogedog.content.service.ContentSearchService; import com.swyp.catsgotogedog.content.service.ContentService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import lombok.RequiredArgsConstructor; @@ -23,9 +24,10 @@ public ResponseEntity> search( @RequestParam(required = false) String title, @RequestParam(required = false) String addr1, @RequestParam(required = false) String addr2, - @RequestParam(required = false) Integer contentTypeId) { + @RequestParam(required = false) Integer contentTypeId, + @AuthenticationPrincipal String userId) { - List list = contentSearchService.search(title, addr1, addr2, contentTypeId); + List list = contentSearchService.search(title, addr1, addr2, contentTypeId, userId); return list.isEmpty() ? ResponseEntity.noContent().build() // 204 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java new file mode 100644 index 0000000..075f7e0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; + +@Entity +@Getter +public class ContentWish { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int wishId; + + private int userId; + + private int contentId; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index fadba11..eb0b1cb 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -28,7 +28,9 @@ public class ContentResponse { private String smallImageUrl; private Double avgScore; - public static ContentResponse from(Content c, String smallImageUrl, Double avgScore){ + private boolean wishData; + + public static ContentResponse from(Content c, String smallImageUrl, Double avgScore, boolean wishData){ return ContentResponse.builder() .contentId(c.getContentId()) .title(c.getTitle()) @@ -47,6 +49,7 @@ public static ContentResponse from(Content c, String smallImageUrl, Double avgSc .zipcode(c.getZipcode()) .smallImageUrl(smallImageUrl) .avgScore(avgScore) + .wishData(wishData) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java new file mode 100644 index 0000000..6f5a5c7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; +import org.springframework.data.elasticsearch.annotations.Query; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ContentWishRepository extends JpaRepository { + Optional findByUserIdAndContentId(int userId, int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 3118468..e2fbbed 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -2,13 +2,19 @@ import co.elastic.clients.elasticsearch._types.query_dsl.Query; import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; import com.swyp.catsgotogedog.content.repository.ContentImageRepository; import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.ContentWishRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; @@ -21,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -32,6 +39,8 @@ public class ContentSearchService { private final ElasticsearchOperations elasticsearchOperations; private final ContentImageRepository contentImageRepository; private final ContentReviewRepository contentReviewRepository; + private final ContentWishRepository contentWishRepository; + private final UserRepository userRepository; public List searchByKeyword(String keyword){ return contentElasticRepository.findByTitleContaining(keyword); @@ -41,7 +50,12 @@ public List searchByKeyword(String keyword){ public List search(String title, String addr1, String addr2, - Integer contentTypeId) { + Integer contentTypeId, + String userId) { + + if (userId != null) { + Optional user = userRepository.findById(Integer.parseInt(userId)); + } boolean noTitle = (title == null || title.isBlank()); boolean noAddr1 = (addr1 == null || addr1.isBlank()); @@ -107,7 +121,9 @@ public List search(String title, double avg = getAverageScore(id); - return ContentResponse.from(content, smallImageUrl,avg); + boolean wishData = (userId != null) ? getWishData(userId, id) : false; + + return ContentResponse.from(content, smallImageUrl,avg, wishData); }) .filter(Objects::nonNull) .toList(); @@ -120,5 +136,14 @@ public double getAverageScore(int contentId) { return Math.round(value * 10.0) / 10.0; } + public Boolean getWishData(String userId, int contentId){ + var existing = contentWishRepository.findByUserIdAndContentId(Integer.parseInt(userId), contentId); + + boolean liked; + + liked = existing.isPresent(); + + return liked; + } } From bb18287f906e5b07a7bfa1055d55b1bccc0c67e6 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 3 Aug 2025 18:54:50 +0900 Subject: [PATCH 093/191] =?UTF-8?q?refactor/=20=EC=BB=A8=ED=85=90=EC=B8=A0?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=ED=95=9C=20=EB=A6=AC=EB=B7=B0=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=8F=84=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컨텐츠 리뷰 조회시 좋아요한 리뷰 상태를 반환하도록 수정 --- .../CatsgotogedogApplication.java | 20 ++++++++- .../review/controller/ReviewController.java | 6 ++- .../controller/ReviewControllerSwagger.java | 6 ++- .../domain/entity/ReviewRecommendHistory.java | 45 +++++++++++++++++++ .../domain/response/ReviewResponse.java | 1 + .../ReviewRecommendHistoryRepository.java | 21 +++++++++ .../review/repository/ReviewRepository.java | 31 +++++-------- .../review/service/ReviewService.java | 17 ++++++- .../V7__create_review_recommend_history.sql | 25 +++++++++++ 9 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java create mode 100644 src/main/resources/db/migration/mysql/V7__create_review_recommend_history.sql diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index 0cac81a..5a95a7d 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -21,12 +21,12 @@ @EnableScheduling @RequiredArgsConstructor @Slf4j -public class CatsgotogedogApplication { +public class CatsgotogedogApplication implements CommandLineRunner { private final JobLauncher jobLauncher; private final ApplicationContext applicationContext; - public static void main(String[] args) { + public static void main(String[] args) { SpringApplication.run(CatsgotogedogApplication.class, args); } @@ -46,4 +46,20 @@ public void runBatch() throws Exception { log.info(">> 01:00 AM Content Fetch 배치 스케쥴러 작동"); } + + @Override + public void run(String... args) throws Exception { + // log.info("############# 01시 데이터 마이그레이션 배치 진행 ##############"); + // Job categoryCodeBatchJob = (Job) applicationContext.getBean("categoryCodeBatchJob"); + // Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); + // + // JobParameters jobParameters = new JobParametersBuilder() + // .addLong("time", System.currentTimeMillis()) + // .toJobParameters(); + // jobLauncher.run(categoryCodeBatchJob, jobParameters); + // log.info(">> 01:00 AM CategoryCode 배치 스케쥴러 작동"); + // + // jobLauncher.run(contentBatchJob, jobParameters); + // log.info(">> 01:00 AM Content Fetch 배치 스케쥴러 작동"); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index 6915274..d9790e0 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -106,19 +106,23 @@ public ResponseEntity> deleteReviewImage( @GetMapping("/content/{contentId}") public ResponseEntity> fetchReviewsByContentId( @PathVariable int contentId, + @AuthenticationPrincipal String userId, @RequestParam(defaultValue = "r") String sort, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "4") int size) { Pageable pageable = PageRequest.of(page, size); - ContentReviewPageResponse reviewResponses = reviewService.fetchReviewsByContentId(contentId, sort, pageable); + String actUserId = (userId != null && !userId.equals("anonymousUser")) ? userId : null; + log.info("actUserId: {}", actUserId); + ContentReviewPageResponse reviewResponses = reviewService.fetchReviewsByContentId(contentId, sort, pageable, actUserId); return ResponseEntity.ok( CatsgotogedogApiResponse.success("리뷰 조회 성공", reviewResponses) ); } + // 자신의 작성 리뷰 조회 @Override @GetMapping("/") public ResponseEntity> fetchReviewsByUserId( diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index b4360c5..ca86611 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -144,8 +144,10 @@ ResponseEntity> deleteReviewImage( @Operation( summary = "컨텐츠에 작성된 리뷰 목록을 조회합니다.", - description = "ContentId를 통해 리뷰 목록을 조회합니다." + description = "Bearer 키 입력시 현재 사용자가 좋아요 누른 리뷰 반환 값을 매핑하여 반환합니다." + + " 키 입력을 안할 경우 좋아요를 누른 리뷰인지 판단하지 않습니다." ) + @SecurityRequirement(name = "bearer-key") @ApiResponses({ @ApiResponse(responseCode = "200", description = "리뷰 목록 조회 성공" , content = @Content(schema = @Schema(implementation = ReviewResponse.class))), @@ -157,6 +159,8 @@ ResponseEntity> deleteReviewImage( ResponseEntity> fetchReviewsByContentId( @Parameter(description = "조회할 컨텐츠 ID", required = true) @PathVariable int contentId, + @Parameter(description = "로그인 상태일 경우 자동 기입", required = false) + @AuthenticationPrincipal String userId, @Parameter(description = "정렬 기준 (좋아요 순: r, 최신순: c, 기본: r)", required = false, example = "r") @RequestParam String sort, diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java new file mode 100644 index 0000000..610dc92 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java @@ -0,0 +1,45 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import org.springframework.data.annotation.CreatedDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Setter +@Getter +@Table(name = "review_recommend_history", +uniqueConstraints = @UniqueConstraint(columnNames = {"userId", "reviewId"})) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ReviewRecommendHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recommend_history_id") + private int recommendHistoryId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; + + @Column(name = "user_id", nullable = false) + private int userId; + + @CreatedDate + @Column(updatable = false) + private String createdAt; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java index 9c019e8..2d4c7c6 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java @@ -14,5 +14,6 @@ public record ReviewResponse ( BigDecimal score, LocalDateTime createdAt, int recommendedNumber, + boolean isRecommended, List images ) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java new file mode 100644 index 0000000..373fc4d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java @@ -0,0 +1,21 @@ +package com.swyp.catsgotogedog.review.repository; + +import java.util.List; +import java.util.Set; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewRecommendHistory; + +public interface ReviewRecommendHistoryRepository extends JpaRepository { + + @Query("SELECT r.review.reviewId FROM ReviewRecommendHistory r " + + "WHERE r.userId = :userId " + + "AND r.review IN :reviews") + Set findRecommendedReviewIdsByUserIdAndReviewIds( + @Param("userId") int userId, + @Param("reviews") List reviews); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java index fe2fbfe..fec67dc 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -1,6 +1,5 @@ package com.swyp.catsgotogedog.review.repository; -import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -13,15 +12,6 @@ import com.swyp.catsgotogedog.review.domain.entity.Review; public interface ReviewRepository extends JpaRepository { - - /** - * reviewId를 이용해 reviewImage 컬렉션도 함께 조회 - * @param reviewId - * @return Optional - */ - @Query("SELECT r FROM Review r LEFT JOIN FETCH r.reviewImages WHERE r.reviewId = :reviewId") - Optional findByIdWithImages(@Param("reviewId") int reviewId); - /** * reviewId와 userId 를 통한 리뷰 컬렉션 조회 * @param reviewId @@ -31,7 +21,12 @@ public interface ReviewRepository extends JpaRepository { @Query("SELECT r FROM Review r WHERE r.reviewId = :reviewId AND r.userId = :userId") Optional findByIdAndUserId(@Param("reviewId") int reviewId, String userId); - // 페이징 컨텐츠 리뷰 목록 조회 + /** + * contentId와 pageable 요소를 통한 페이징 Review 목록 + * @param contentId + * @param pageable + * @return 페이지네이션 Review + */ @Query("SELECT DISTINCT r FROM Review r " + "LEFT JOIN FETCH r.reviewImages " + "WHERE r.contentEntity.contentId = :contentId") @@ -39,14 +34,12 @@ Page findByContentIdWithUserAndReviewImages( @Param("contentId") int contentId, Pageable pageable); + /** + * userId와 pageable 요소를 통한 자신이 작성한 페이징 Review 목록 + * @param userId + * @param pageable + * @return 페이지네이션 Review + */ @EntityGraph(attributePaths = {"reviewImages"}) Page findByUserId(int userId, Pageable pageable); - - // 페이징 자신이 작성한 리뷰 목록 조회 - @Query(value = "SELECT DISTINCT r FROM Review r " - + "LEFT JOIN FETCH r.contentEntity c " - + "LEFT JOIN FETCH r.reviewImages " - + "WHERE r.userId = :userId", - countQuery = "SELECT COUNT(r) FROM Review r WHERE r.userId = :userId") - Page findByUserIdWithContent(@Param("userId") int userId, Pageable pageable); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index 967cb7d..7b80ade 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -1,8 +1,10 @@ package com.swyp.catsgotogedog.review.service; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -25,6 +27,7 @@ import com.swyp.catsgotogedog.global.exception.ErrorCode; import com.swyp.catsgotogedog.review.domain.entity.Review; import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; +import com.swyp.catsgotogedog.review.domain.entity.ReviewRecommendHistory; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; import com.swyp.catsgotogedog.review.domain.response.ContentReviewPageResponse; import com.swyp.catsgotogedog.review.domain.response.MyReviewPageResponse; @@ -32,6 +35,7 @@ import com.swyp.catsgotogedog.review.domain.response.ReviewImageResponse; import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; import com.swyp.catsgotogedog.review.repository.ReviewImageRepository; +import com.swyp.catsgotogedog.review.repository.ReviewRecommendHistoryRepository; import com.swyp.catsgotogedog.review.repository.ReviewRepository; import lombok.RequiredArgsConstructor; @@ -47,6 +51,7 @@ public class ReviewService { private final UserRepository userRepository; private final ContentRepository contentRepository; private final ImageStorageService imageStorageService; + private final ReviewRecommendHistoryRepository reviewRecommendHistoryRepository; // 리뷰 작성 @Transactional @@ -128,7 +133,7 @@ public void deleteReviewImage(int reviewId, int imageId, String userId) { // ContentId를 통한 리뷰 목록 조회 @Transactional(readOnly = true) - public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String sort, Pageable pageable) { + public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String sort, Pageable pageable, String userId) { validateContent(contentId); Sort sortObj = createSort(sort); @@ -136,6 +141,15 @@ public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String s Page reviewPage = reviewRepository.findByContentIdWithUserAndReviewImages(contentId, sortedPageable); + Set recommendedReviewIds; + if(userId != null) { + recommendedReviewIds = reviewRecommendHistoryRepository.findRecommendedReviewIdsByUserIdAndReviewIds( + Integer.parseInt(userId), + reviewPage.getContent() + ); + } else { + recommendedReviewIds = new HashSet<>(); + } List userIds = reviewPage.getContent().stream() .map(Review::getUserId) @@ -158,6 +172,7 @@ public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String s review.getScore(), review.getCreatedAt(), review.getRecommendedNumber(), + recommendedReviewIds.contains(review.getReviewId()), review.getReviewImages().stream() .map(ReviewImageResponse::from) .collect(Collectors.toList()) diff --git a/src/main/resources/db/migration/mysql/V7__create_review_recommend_history.sql b/src/main/resources/db/migration/mysql/V7__create_review_recommend_history.sql new file mode 100644 index 0000000..35e7101 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V7__create_review_recommend_history.sql @@ -0,0 +1,25 @@ +CREATE TABLE `catsgotogedog`.`review_recommend_history` ( +`recommend_history_id` INT NOT NULL AUTO_INCREMENT, +`review_id` INT NOT NULL, +`user_id` INT NOT NULL, +`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, +PRIMARY KEY (`recommend_history_id`), +INDEX `review_review_id_fk_idx` (`review_id` ASC) VISIBLE, +INDEX `recommend_history_user_user_id_fk_idx` (`user_id` ASC) VISIBLE, +CONSTRAINT `recommend_history_review_review_id_fk` + FOREIGN KEY (`review_id`) + REFERENCES `catsgotogedog`.`content_review` (`review_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION, +CONSTRAINT `recommend_history_user_user_id_fk` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION); + +ALTER TABLE `catsgotogedog`.`review_recommend_history` + ADD UNIQUE INDEX `review_id_user_id_recommend_uq` (`user_id` ASC, `review_id` ASC) VISIBLE; +ALTER TABLE `catsgotogedog`.`review_recommend_history` ALTER INDEX `recommend_history_user_user_id_fk_idx` INVISIBLE; + +ALTER TABLE `catsgotogedog`.`content_review` + CHANGE COLUMN `like` `recommended_number` INT NULL DEFAULT '0' ; From 3fb16248a50cc391dd6e6416c737011818aa7d10 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 3 Aug 2025 20:39:46 +0900 Subject: [PATCH 094/191] =?UTF-8?q?refactor/=20DetailIntro=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A7=A4=ED=95=91=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치 로직 DetailIntro 데이터 매핑 로직 수정 --- .../batch/processor/DetailIntroProcessor.java | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/batch/processor/DetailIntroProcessor.java b/src/main/java/com/batch/processor/DetailIntroProcessor.java index 187fb2f..c82ee7e 100644 --- a/src/main/java/com/batch/processor/DetailIntroProcessor.java +++ b/src/main/java/com/batch/processor/DetailIntroProcessor.java @@ -7,6 +7,7 @@ import org.springframework.batch.item.ItemProcessor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -46,7 +47,8 @@ public DetailIntroProcessor( public DetailIntroProcessResult process(Content content) throws Exception { log.info("{} ({}), 소개 정보 수집 중", content.getTitle(), content.getContentId()); - DetailIntroResponse response = restClient.get() + //DetailIntroResponse response = restClient.get() + ResponseEntity responseEntity = restClient.get() .uri(uriBuilder -> uriBuilder .path("/detailIntro") .queryParam("serviceKey", serviceKey) @@ -58,17 +60,25 @@ public DetailIntroProcessResult process(Content content) throws Exception { .build() ) .retrieve() - .body(DetailIntroResponse.class); + // .body(DetailIntroResponse.class); + .toEntity(String.class); + + if(responseEntity.getBody() != null && responseEntity.getBody().contains("LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR")) { + log.warn("DetailIntroProcessor API 호출 한도 도달. 아이템 스킵"); + return null; + } + + DetailIntroResponse response = objectMapper.readValue(responseEntity.getBody(), DetailIntroResponse.class); if(response == null || response.response() == null || response.response().body() == null) { log.warn("{} ({}), 장소의 소개 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); - return new DetailIntroProcessResult(null, null, null, null); + return null; } JsonNode itemsNode = response.response().body().items(); if(itemsNode == null || itemsNode.isEmpty()) { log.warn("{} ({}), ItemsNode 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); - return new DetailIntroProcessResult(null, null, null, null); + return null; } switch (content.getContentTypeId()) { case 12 -> { @@ -88,9 +98,10 @@ public DetailIntroProcessResult process(Content content) throws Exception { .parking(dto.parking()) .restDate(dto.restdate()) .useSeason(dto.useseason()) - .heritage1(Boolean.valueOf(dto.heritage1())) - .heritage2(Boolean.valueOf(dto.heritage2())) - .heritage3(Boolean.valueOf(dto.heritage3())) + .useTime(dto.usetime()) + .heritage1(dto.heritage1().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .heritage2(dto.heritage2().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .heritage3(dto.heritage3().equals("1") ? Boolean.TRUE : Boolean.FALSE) .build() ) .collect(Collectors.toList()); @@ -135,13 +146,13 @@ public DetailIntroProcessResult process(Content content) throws Exception { .map(dto -> LodgeInformation.builder() .content(content) .capacityCount(Integer.valueOf(dto.accomcountlodging())) - .benikia(Boolean.valueOf(dto.benikia())) + .benikia(dto.benikia().equals("1") ? Boolean.TRUE : Boolean.FALSE) .checkInTime(LocalTime.parse(dto.checkintime())) .checkOutTime(LocalTime.parse(dto.checkouttime())) .cooking(dto.chkcooking()) .foodplace(dto.foodplace()) - .goodstay(Boolean.valueOf(dto.goodstay())) - .hanok(Boolean.valueOf(dto.hanok())) + .goodstay(dto.goodstay().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .hanok(dto.hanok().equals("1") ? Boolean.TRUE : Boolean.FALSE) .information(dto.infocenterlodging()) .parking(dto.parkinglodging()) .roomCount(Integer.valueOf(dto.roomcount())) @@ -150,18 +161,18 @@ public DetailIntroProcessResult process(Content content) throws Exception { .roomType(dto.roomtype()) .scale(dto.scalelodging()) .subFacility(dto.subfacility()) - .barbecue(Boolean.valueOf(dto.barbecue())) - .beauty(Boolean.valueOf(dto.beauty())) - .beverage(Boolean.valueOf(dto.beverage())) - .bicycle(Boolean.valueOf(dto.bicycle())) - .campfire(Boolean.valueOf(dto.campfire())) - .fitness(Boolean.valueOf(dto.fitness())) - .karaoke(Boolean.valueOf(dto.karaoke())) - .publicBath(Boolean.valueOf(dto.publicbath())) - .publicPcRoom(Boolean.valueOf(dto.publicpc())) - .sauna(Boolean.valueOf(dto.sauna())) - .seminar(Boolean.valueOf(dto.seminar())) - .sports(Boolean.valueOf(dto.sports())) + .barbecue(dto.barbecue().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .beauty(dto.beauty().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .beverage(dto.beverage().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .bicycle(dto.bicycle().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .campfire(dto.campfire().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .fitness(dto.fitness().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .karaoke(dto.karaoke().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .publicBath(dto.publicbath().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .publicPcRoom(dto.publicpc().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .sauna(dto.sauna().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .seminar(dto.seminar().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .sports(dto.sports().equals("1") ? Boolean.TRUE : Boolean.FALSE) .refundRegulation(dto.refundregulation()) .build() ) @@ -180,13 +191,13 @@ public DetailIntroProcessResult process(Content content) throws Exception { .discountInfo(dto.discountinfofood()) .signatureMenu(dto.firstmenu()) .information(dto.infocenterfood()) - .kidsFacility(Boolean.valueOf(dto.kidsfacility())) + .kidsFacility(dto.kidsfacility().equals("1") ? Boolean.TRUE : Boolean.FALSE) .openDate(LocalDate.parse(dto.opendatefood())) .openTime(dto.opentimefood()) .parking(dto.parkingfood()) .reservation(dto.reservationfood()) .scale(Integer.valueOf(dto.scalefood())) - .smoking(Boolean.valueOf(dto.smoking())) + .smoking(dto.smoking().equals("1") ? Boolean.TRUE : Boolean.FALSE) .treatMenu(dto.treatmenu()) .build() ) From dc8f1663157710bd1d73f420eb2a2841bca3dad3 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 21:17:52 +0900 Subject: [PATCH 095/191] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20api=20?= =?UTF-8?q?=EB=A7=A4=EA=B0=9C=EB=B3=80=EC=88=98=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 매개변수를 시도 코드, 시군구 코드로 변경, 지역코드 반환 추가, search-mapping 파일 내 필드 추가 --- .../content/controller/ContentController.java | 15 ++++-- .../content/domain/entity/Content.java | 8 ++- .../domain/entity/ContentDocument.java | 8 +-- .../content/domain/entity/RegionCode.java | 18 +++++++ .../domain/response/ContentResponse.java | 11 +++- .../domain/response/RegionCodeResponse.java | 4 ++ .../repository/RegionCodeRepository.java | 10 ++++ .../content/service/ContentSearchService.java | 54 ++++++++++++------- .../elasticsearch/search-mapping.json | 6 +++ 9 files changed, 104 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/RegionCodeResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index f3a7113..4b67325 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -4,6 +4,8 @@ import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.service.ContentSearchService; import com.swyp.catsgotogedog.content.service.ContentService; +import org.apache.commons.lang3.math.NumberUtils; +import org.flywaydb.core.internal.util.StringUtils; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -22,12 +24,17 @@ public class ContentController implements ContentControllerSwagger{ @GetMapping("/search") public ResponseEntity> search( @RequestParam(required = false) String title, - @RequestParam(required = false) String addr1, - @RequestParam(required = false) String addr2, + @RequestParam(required = false) String sido, + @RequestParam(required = false) String sigungu, @RequestParam(required = false) Integer contentTypeId, - @AuthenticationPrincipal String userId) { + @AuthenticationPrincipal String principal) { - List list = contentSearchService.search(title, addr1, addr2, contentTypeId, userId); + String userId = null; + if (StringUtils.hasText(principal) && NumberUtils.isCreatable(principal)) { + userId = principal; + } + + List list = contentSearchService.search(title, sido, sigungu, contentTypeId, userId); return list.isEmpty() ? ResponseEntity.noContent().build() // 204 diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java index 494e7be..063efc8 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java @@ -34,10 +34,10 @@ public class Content extends BaseTimeEntity { private String copyright; - @Column(precision = 10, scale = 8) + @Column(precision = 13, scale = 10) private BigDecimal mapx; - @Column(precision = 11, scale = 8) + @Column(precision = 13, scale = 10) private BigDecimal mapy; private int mlevel; @@ -50,4 +50,8 @@ public class Content extends BaseTimeEntity { private int contentTypeId; + private int sidoCode; + + private int sigunguCode; + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java index df084f2..73fbf9c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java @@ -19,9 +19,9 @@ public class ContentDocument { private int categoryId; - private String addr1; + private int sidoCode; - private String addr2; + private int sigunguCode; private String title; @@ -31,8 +31,8 @@ public static ContentDocument from(Content content){ return ContentDocument.builder() .contentId(content.getContentId()) .categoryId(content.getCategoryId()) - .addr1(content.getAddr1()) - .addr2(content.getAddr2()) + .sidoCode(content.getSidoCode()) + .sigunguCode(content.getSigunguCode()) .title(content.getTitle()) .contentTypeId(content.getContentTypeId()) .build(); diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java new file mode 100644 index 0000000..0af416f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java @@ -0,0 +1,18 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.Getter; + +@Entity +@Getter +public class RegionCode { + @Id + private Integer regionId; + + private String regionName; + private Integer sidoCode; + private Integer sigunguCode; + private Integer parentCode; + private Short regionLevel; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index eb0b1cb..5f14c47 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -30,7 +30,15 @@ public class ContentResponse { private boolean wishData; - public static ContentResponse from(Content c, String smallImageUrl, Double avgScore, boolean wishData){ + private RegionCodeResponse regionName; + + public static ContentResponse from( + Content c, + String smallImageUrl, + Double avgScore, + boolean wishData, + RegionCodeResponse regionName){ + return ContentResponse.builder() .contentId(c.getContentId()) .title(c.getTitle()) @@ -50,6 +58,7 @@ public static ContentResponse from(Content c, String smallImageUrl, Double avgSc .smallImageUrl(smallImageUrl) .avgScore(avgScore) .wishData(wishData) + .regionName(regionName) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/RegionCodeResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/RegionCodeResponse.java new file mode 100644 index 0000000..17b764c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/RegionCodeResponse.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.content.domain.response; + +public record RegionCodeResponse(String sidoName, String sigunguName) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java new file mode 100644 index 0000000..b2e4ea1 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionCodeRepository extends JpaRepository { + RegionCode findRegionNameBySidoCodeAndRegionLevel(int sidoCode, int regionLevel); + + RegionCode findRegionNameByParentCodeAndSigunguCodeAndRegionLevel(int parentCode, int sigunguCode, int regionLevel); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index e2fbbed..bf898a0 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -7,14 +7,10 @@ import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; import com.swyp.catsgotogedog.content.domain.entity.ContentImage; -import com.swyp.catsgotogedog.content.domain.entity.ContentWish; +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; -import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; -import com.swyp.catsgotogedog.content.repository.ContentImageRepository; -import com.swyp.catsgotogedog.content.repository.ContentRepository; -import com.swyp.catsgotogedog.content.repository.ContentWishRepository; -import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; -import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; +import com.swyp.catsgotogedog.content.repository.*; import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; @@ -41,6 +37,7 @@ public class ContentSearchService { private final ContentReviewRepository contentReviewRepository; private final ContentWishRepository contentWishRepository; private final UserRepository userRepository; + private final RegionCodeRepository regionCodeRepository; public List searchByKeyword(String keyword){ return contentElasticRepository.findByTitleContaining(keyword); @@ -48,8 +45,8 @@ public List searchByKeyword(String keyword){ public List search(String title, - String addr1, - String addr2, + String sidoCode, + String sigunguCode, Integer contentTypeId, String userId) { @@ -58,13 +55,13 @@ public List search(String title, } boolean noTitle = (title == null || title.isBlank()); - boolean noAddr1 = (addr1 == null || addr1.isBlank()); - boolean noAddr2 = (addr2 == null || addr2.isBlank()); + boolean noSidoCode = (sidoCode == null || sidoCode.isBlank()); + boolean noSigunguCode = (sigunguCode == null || sigunguCode.isBlank()); boolean noTypeId = (contentTypeId == null || contentTypeId <= 0); BoolQuery.Builder boolBuilder = new BoolQuery.Builder(); - if (noTitle && noAddr1 && noAddr2 && noTypeId) { + if (noTitle && noSidoCode && noSigunguCode && noTypeId) { boolBuilder.must(m -> m.matchAll(ma -> ma)); } else { if (!noTitle) { @@ -75,14 +72,14 @@ public List search(String title, boolBuilder.must(m -> m.matchAll(ma -> ma)); } - if (!noAddr1) { - boolBuilder.filter(f -> f.term(t -> t.field("addr1") - .value(addr1))); + if (!noSidoCode) { + boolBuilder.filter(f -> f.term(t -> t.field("sidoCode") + .value(sidoCode))); } - if (!noAddr2) { - boolBuilder.filter(f -> f.term(t -> t.field("addr2") - .value(addr2))); + if (!noSigunguCode) { + boolBuilder.filter(f -> f.term(t -> t.field("sigunguCode") + .value(sigunguCode))); } if (!noTypeId) { @@ -123,7 +120,14 @@ public List search(String title, boolean wishData = (userId != null) ? getWishData(userId, id) : false; - return ContentResponse.from(content, smallImageUrl,avg, wishData); + System.out.println("Test -- sido : "+content.getSidoCode()); + + System.out.println("Test -- sigungu : "+content.getSigunguCode()); + + RegionCodeResponse regionName + = getRegionName(content.getSidoCode(), content.getSigunguCode()); + + return ContentResponse.from(content, smallImageUrl,avg, wishData, regionName); }) .filter(Objects::nonNull) .toList(); @@ -146,4 +150,16 @@ public Boolean getWishData(String userId, int contentId){ return liked; } + public RegionCodeResponse getRegionName(int sidoCode, int sigunguCode){ + RegionCode sido = regionCodeRepository.findRegionNameBySidoCodeAndRegionLevel(sidoCode,1); + + RegionCode sigungu = regionCodeRepository.findRegionNameByParentCodeAndSigunguCodeAndRegionLevel(sidoCode, sigunguCode,2); + + String sidoName = sido.getRegionName(); + String sigunguName = sigungu.getRegionName(); + + return new RegionCodeResponse(sidoName,sigunguName); + } + + } diff --git a/src/main/resources/elasticsearch/search-mapping.json b/src/main/resources/elasticsearch/search-mapping.json index 8593597..0d5455a 100644 --- a/src/main/resources/elasticsearch/search-mapping.json +++ b/src/main/resources/elasticsearch/search-mapping.json @@ -44,6 +44,12 @@ }, "contentTypeId": { "type": "integer" + }, + "sidoCode": { + "type": "integer" + }, + "sigunguCode": { + "type": "integer" } } } From 7ab1a32fac1af21e69165a5027808cc35d52a98f Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 21:19:13 +0900 Subject: [PATCH 096/191] =?UTF-8?q?feat:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비회원 anonymousUser 관련 --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index e0ab72e..1bff3b8 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,9 @@ dependencies { // Tika implementation 'org.apache.tika:tika-core:3.1.0' + //로그인 관련 + implementation 'org.apache.commons:commons-lang3:3.14.0' + } tasks.named('test') { From e17f132622ab41e6a60bd4d2e84f42cd02676906 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 22:51:58 +0900 Subject: [PATCH 097/191] =?UTF-8?q?fix:=20merge=20=ED=9B=84=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/processor/AreaBasedListItemProcessor.java | 6 +++--- .../com/batch/processor/DetailImageProcessor.java | 2 +- .../catsgotogedog/content/domain/entity/Content.java | 6 +++--- .../content/domain/entity/ContentDocument.java | 2 +- .../content/domain/entity/ContentImage.java | 2 +- .../content/domain/request/ContentRequest.java | 7 +++---- .../content/domain/response/ContentResponse.java | 12 +++++------- .../content/repository/ContentImageRepository.java | 2 +- .../content/service/ContentSearchService.java | 2 +- .../content/service/ContentService.java | 5 ++--- 10 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java b/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java index d68e1c0..90102a1 100644 --- a/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java +++ b/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java @@ -35,9 +35,9 @@ public Content process(Item item) throws Exception { .title(item.title()) .addr1(item.addr1()) .addr2(item.addr2()) - .imageUrl(item.firstimage()) - .thumbImageUrl(item.firstimage2()) - .mapX(Double.parseDouble(item.mapx())) + .image(item.firstimage()) + .thumbImage(item.firstimage2()) + .mapx(Double.parseDouble(item.mapx())) .mapy(Double.parseDouble(item.mapy())) .mLevel(Integer.parseInt(item.mlevel().isEmpty() ? "0" : item.mlevel())) .tel(item.tel()) diff --git a/src/main/java/com/batch/processor/DetailImageProcessor.java b/src/main/java/com/batch/processor/DetailImageProcessor.java index 2706a9b..98635c5 100644 --- a/src/main/java/com/batch/processor/DetailImageProcessor.java +++ b/src/main/java/com/batch/processor/DetailImageProcessor.java @@ -62,7 +62,7 @@ public List process(Content content) throws Exception { return response.response().body().items().item().stream() .map(item -> { return ContentImage.builder() - .contentId(content) + .content(content) .imageUrl(item.originimgurl()) .smallImageUrl(item.smallimageurl()) .build(); diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java index 76fd649..63dd62e 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java @@ -50,16 +50,16 @@ public class Content { private String addr2; @Column(name = "image") - private String imageUrl; + private String image; @Column(name = "thumb_image") - private String thumbImageUrl; + private String thumbImage; @Column(name = "copyright") private String copyright; @Column(name = "mapx") - private double mapX; + private double mapx; @Column(name = "mapy") private double mapy; diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java index 73fbf9c..69a1303 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java @@ -17,7 +17,7 @@ public class ContentDocument { @Field(type= FieldType.Integer) private int contentId; - private int categoryId; + private String categoryId; private int sidoCode; diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java index 747962b..1f8c1b7 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java @@ -30,7 +30,7 @@ public class ContentImage { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "content_id") - private Content contentId; + private Content content; @Column(name = "image_url") private String imageUrl; diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java index 82c81f2..ac013ab 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java @@ -7,15 +7,14 @@ @Getter public class ContentRequest { private int contentId; - private int categoryId; - private int regionId; + private String categoryId; private String addr1; private String addr2; private String image; private String thumbImage; private String copyright; - private BigDecimal mapx; - private BigDecimal mapy; + private double mapx; + private double mapy; private int mlevel; private String tel; private String title; diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index 5f14c47..1f3247d 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -15,12 +15,11 @@ public class ContentResponse { private String addr2; private String image; private String thumbImage; - private int categoryId; - private int regionId; + private String categoryId; private int contentTypeId; private String copyright; - private BigDecimal mapx; - private BigDecimal mapy; + private double mapx; + private double mapy; private int mlevel; private String tel; private int zipcode; @@ -47,14 +46,13 @@ public static ContentResponse from( .image(c.getImage()) .thumbImage(c.getThumbImage()) .categoryId(c.getCategoryId()) - .regionId(c.getRegionId()) .contentTypeId(c.getContentTypeId()) .copyright(c.getCopyright()) .mapx(c.getMapx()) .mapy(c.getMapy()) - .mlevel(c.getMlevel()) + .mlevel(c.getMLevel()) .tel(c.getTel()) - .zipcode(c.getZipcode()) + .zipcode(c.getZipCode()) .smallImageUrl(smallImageUrl) .avgScore(avgScore) .wishData(wishData) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java index ab4b1ed..6e88899 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java @@ -4,5 +4,5 @@ import com.swyp.catsgotogedog.content.domain.entity.ContentImage; public interface ContentImageRepository extends JpaRepository { - ContentImage findByContentId(int contentId); + ContentImage findByContent_ContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index bf898a0..18f4f30 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -113,7 +113,7 @@ public List search(String title, Content content = contentMap.get(id); if (content == null) return null; - ContentImage image = contentImageRepository.findByContentId(id); + ContentImage image = contentImageRepository.findByContent_ContentId(id); String smallImageUrl = (image != null) ? image.getSmallImageUrl() : null; double avg = getAverageScore(id); diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 534862b..c4b607a 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -20,7 +20,6 @@ public class ContentService { public void saveContent(ContentRequest request){ Content content = Content.builder() .categoryId(request.getCategoryId()) - .regionId(request.getRegionId()) .addr1(request.getAddr1()) .addr2(request.getAddr2()) .image(request.getImage()) @@ -28,10 +27,10 @@ public void saveContent(ContentRequest request){ .copyright(request.getCopyright()) .mapx(request.getMapx()) .mapy(request.getMapy()) - .mlevel(request.getMlevel()) + .mLevel(request.getMlevel()) .tel(request.getTel()) .title(request.getTitle()) - .zipcode(request.getZipcode()) + .zipCode(request.getZipcode()) .contentTypeId(request.getContentTypeId()) .build(); contentRepository.save(content); From 86c43ff701b6e8c9ee7c463aeecfe69032f91179 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 22:55:05 +0900 Subject: [PATCH 098/191] =?UTF-8?q?feat:=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegionCode 엔티티 관련 --- .../content/repository/RegionCodeRepository.java | 4 ++-- .../catsgotogedog/content/service/ContentSearchService.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java index 2a04433..722f794 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java @@ -12,7 +12,7 @@ public interface RegionCodeRepository extends JpaRepository Optional findBySidoCodeAndSigunguCodeIsNull(int sidoCode); List findBySidoCode(int sidoCode); - RegionCode findRegionNameBySidoCodeAndRegionLevel(int sidoCode, int regionLevel); + RegionCode findBySidoCodeAndRegionLevel(int sidoCode, int regionLevel); - RegionCode findRegionNameByParentCodeAndSigunguCodeAndRegionLevel(int parentCode, int sigunguCode, int regionLevel); + RegionCode findByParentCodeAndSigunguCodeAndRegionLevel(int parentCode, int sigunguCode, int regionLevel); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 18f4f30..9d2e370 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -151,9 +151,9 @@ public Boolean getWishData(String userId, int contentId){ } public RegionCodeResponse getRegionName(int sidoCode, int sigunguCode){ - RegionCode sido = regionCodeRepository.findRegionNameBySidoCodeAndRegionLevel(sidoCode,1); + RegionCode sido = regionCodeRepository.findBySidoCodeAndRegionLevel(sidoCode,1); - RegionCode sigungu = regionCodeRepository.findRegionNameByParentCodeAndSigunguCodeAndRegionLevel(sidoCode, sigunguCode,2); + RegionCode sigungu = regionCodeRepository.findByParentCodeAndSigunguCodeAndRegionLevel(sidoCode, sigunguCode,2); String sidoName = sido.getRegionName(); String sigunguName = sigungu.getRegionName(); From 191a0ea5187dd2742137c3de010d12ab5d0424bb Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 22:55:42 +0900 Subject: [PATCH 099/191] =?UTF-8?q?feat:=20=EC=8B=9C=EB=8F=84,=20=EC=8B=9C?= =?UTF-8?q?=EA=B5=B0=EA=B5=AC=20=EC=9E=90=EB=A3=8C=ED=98=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 컬럼 조회시 null인 경우 반영 --- .../catsgotogedog/content/domain/entity/RegionCode.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java index c230ae3..58e7aa7 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java @@ -28,13 +28,13 @@ public class RegionCode { private String regionName; @Column(name = "sido_code") - private int sidoCode; + private Integer sidoCode; @Column(name = "sigungu_code") - private int sigunguCode; + private Integer sigunguCode; @Column(name = "parent_code") - private int parentCode; + private Integer parentCode; @Column(name = "region_level") private int regionLevel; From b6b06d5ecfdabce8867e20af1d64e97141a59f9b Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 23:24:21 +0900 Subject: [PATCH 100/191] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=84=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 13 +++++ .../domain/response/PlaceDetailResponse.java | 52 +++++++++++++++++++ .../content/repository/ContentRepository.java | 1 + .../content/service/ContentService.java | 25 +++++++++ 4 files changed, 91 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 4b67325..412abb1 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -2,6 +2,7 @@ import com.swyp.catsgotogedog.content.domain.request.ContentRequest; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.service.ContentSearchService; import com.swyp.catsgotogedog.content.service.ContentService; import org.apache.commons.lang3.math.NumberUtils; @@ -53,4 +54,16 @@ public ResponseEntity saveList(@RequestBody List requests) { return ResponseEntity.ok().build(); } + @GetMapping("/placedetail") + public ResponseEntity getPlaceDetail(@RequestParam int contentId, @AuthenticationPrincipal String principal){ + + String userId = null; + if (StringUtils.hasText(principal) && NumberUtils.isCreatable(principal)) { + userId = principal; + } + + PlaceDetailResponse placeDetailResponse = contentService.getPlaceDetail(contentId,userId); + return ResponseEntity.ok(placeDetailResponse); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java new file mode 100644 index 0000000..549473f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -0,0 +1,52 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import lombok.Builder; + +@Builder +public record PlaceDetailResponse( + int contentId, + String title, + String addr1, + String addr2, + String image, + String thumbImage, + String categoryId, + int contentTypeId, + String copyright, + double mapx, + double mapy, + int mlevel, + String tel, + int zipcode, + String smallImageUrl, + Double avgScore, + boolean wishData) { + + public static PlaceDetailResponse from( + Content c, + String smallImageUrl, + Double avgScore, + boolean wishData){ + + return PlaceDetailResponse.builder() + .contentId(c.getContentId()) + .title(c.getTitle()) + .addr1(c.getAddr1()) + .addr2(c.getAddr2()) + .image(c.getImage()) + .thumbImage(c.getThumbImage()) + .categoryId(c.getCategoryId()) + .contentTypeId(c.getContentTypeId()) + .copyright(c.getCopyright()) + .mapx(c.getMapx()) + .mapy(c.getMapy()) + .mlevel(c.getMLevel()) + .tel(c.getTel()) + .zipcode(c.getZipCode()) + .smallImageUrl(smallImageUrl) + .avgScore(avgScore) + .wishData(wishData) + .build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java index 5d6ba96..4998a2c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -4,4 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface ContentRepository extends JpaRepository { + Content findByContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index c4b607a..6d0f4ca 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -2,9 +2,16 @@ import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; +import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; +import com.swyp.catsgotogedog.content.repository.ContentImageRepository; import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.review.domain.entity.ContentReview; +import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -16,6 +23,10 @@ public class ContentService { private final ContentRepository contentRepository; private final ContentElasticRepository contentElasticRepository; + private final ContentImageRepository contentImageRepository; + private final ContentReviewRepository contentReviewRepository; + + private final ContentSearchService contentSearchService; public void saveContent(ContentRequest request){ Content content = Content.builder() @@ -37,4 +48,18 @@ public void saveContent(ContentRequest request){ contentElasticRepository.save(ContentDocument.from(content)); } + public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ + + Content content = contentRepository.findByContentId(contentId); + + ContentImage contentImage = contentImageRepository.findByContent_ContentId(contentId); + + String smallImageUrl = (contentImage != null) ? contentImage.getSmallImageUrl() : null; + + double avg = contentSearchService.getAverageScore(contentId); + + boolean wishData = (userId != null) ? contentSearchService.getWishData(userId, contentId) : false; + + return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData); + } } From 32e03b710f5c74405b407de49832ed414e267fb0 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sun, 3 Aug 2025 23:25:15 +0900 Subject: [PATCH 101/191] =?UTF-8?q?refactor:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/service/ContentSearchService.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 9d2e370..70bfc31 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -120,10 +120,6 @@ public List search(String title, boolean wishData = (userId != null) ? getWishData(userId, id) : false; - System.out.println("Test -- sido : "+content.getSidoCode()); - - System.out.println("Test -- sigungu : "+content.getSigunguCode()); - RegionCodeResponse regionName = getRegionName(content.getSidoCode(), content.getSigunguCode()); From 129d28b42032e0f4e96dec7ba0d199866f68bfe5 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 4 Aug 2025 03:06:12 +0900 Subject: [PATCH 102/191] =?UTF-8?q?bug/=20security=20=ED=83=88=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/swyp/catsgotogedog/common/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index 31a847a..edb3897 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -49,7 +49,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-ui/**", "/v3/api-docs/**", "/api/user/reissue", - "/api/content/**" + "/api/content/**", // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 "/api/review/content/**" ).permitAll() From 9090e194c34ec86f89158cb5a3a11a48a9e7f2ad Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 4 Aug 2025 03:47:42 +0900 Subject: [PATCH 103/191] =?UTF-8?q?bug/=20content=20entity=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=B4=20batch=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content entity 구조 변경으로 인해 batch 수정 --- src/main/java/com/batch/processor/DetailIntroProcessor.java | 5 ++--- src/main/java/com/batch/reader/DetailImageReader.java | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/batch/processor/DetailIntroProcessor.java b/src/main/java/com/batch/processor/DetailIntroProcessor.java index c82ee7e..027cb8b 100644 --- a/src/main/java/com/batch/processor/DetailIntroProcessor.java +++ b/src/main/java/com/batch/processor/DetailIntroProcessor.java @@ -34,13 +34,12 @@ public class DetailIntroProcessor implements ItemProcessor detailImageContentReader() { .name("contentReader") .entityManagerFactory(entityManagerFactory) .pageSize(100) - .queryString("SELECT c FROM Content c WHERE NOT EXISTS (SELECT 1 FROM ContentImage ci WHERE ci.contentId = c) ORDER BY c.contentId ASC") + .queryString("SELECT c FROM Content c WHERE NOT EXISTS (SELECT 1 FROM ContentImage ci WHERE ci.content.contentId = c.contentId) ORDER BY c.contentId ASC") .build(); } } From fedb899a250aa6c259b32deb375352545edcd88a Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 4 Aug 2025 03:50:35 +0900 Subject: [PATCH 104/191] =?UTF-8?q?refactor/=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20pet?= =?UTF-8?q?guide=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=ED=83=80=EC=9E=85=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이그레이션 데이터 petguide 테이블 데이터타입 확장 --- .../db/migration/mysql/V8__pet_guide_dataType_extend.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/resources/db/migration/mysql/V8__pet_guide_dataType_extend.sql diff --git a/src/main/resources/db/migration/mysql/V8__pet_guide_dataType_extend.sql b/src/main/resources/db/migration/mysql/V8__pet_guide_dataType_extend.sql new file mode 100644 index 0000000..701e7a5 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V8__pet_guide_dataType_extend.sql @@ -0,0 +1,7 @@ +ALTER TABLE `catsgotogedog`.`pet_guide` + CHANGE COLUMN `accident_prep` `accident_prep` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `provided_item` `provided_item` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `purchasable_item` `purchasable_item` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `rent_item` `rent_item` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `pet_prep` `pet_prep` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `with_pet` `with_pet` VARCHAR(200) NULL DEFAULT NULL ; From bd7d9ee15daec86b45dc225662e44de7d419fd8a Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 4 Aug 2025 06:10:42 +0900 Subject: [PATCH 105/191] =?UTF-8?q?feat/=EC=B0=9C=20=EB=AA=A9=EB=A1=9D,=20?= =?UTF-8?q?=EC=B5=9C=EA=B7=BC=20=EB=B3=B8=20=EC=9E=A5=EC=86=8C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 찜 목록, 최근 본 장소 조회 기능 구현 --- .../content/domain/entity/ContentWish.java | 7 +- .../repository/ContentWishRepository.java | 16 ++- .../controller/MyPageHistoryController.java | 47 +++++++++ .../MyPageHistoryControllerSwagger.java | 59 +++++++++++ .../mypage/domain/entity/.gitkeep | 0 .../mypage/domain/entity/LastViewHistory.java | 59 +++++++++++ .../domain/entity/LastViewHistoryId.java | 24 +++++ .../mypage/domain/response/.gitkeep | 0 .../response/ContentWishPageResponse.java | 17 ++++ .../domain/response/ContentWishResponse.java | 8 ++ .../response/LastViewHistoryResponse.java | 22 +++++ .../repository/MyPageHistoryRepository.java | 15 +++ .../mypage/service/MyPageHistoryService.java | 97 +++++++++++++++++++ .../mysql/V9__create_content_wish.sql | 19 ++++ 14 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryControllerSwagger.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistory.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java delete mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishPageResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/domain/response/LastViewHistoryResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/repository/MyPageHistoryRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java create mode 100644 src/main/resources/db/migration/mysql/V9__create_content_wish.sql diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java index 075f7e0..6ddce65 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java @@ -1,9 +1,12 @@ package com.swyp.catsgotogedog.content.domain.entity; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import lombok.Getter; @Entity @@ -16,5 +19,7 @@ public class ContentWish { private int userId; - private int contentId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java index 6f5a5c7..6f68813 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -1,11 +1,23 @@ package com.swyp.catsgotogedog.content.repository; import com.swyp.catsgotogedog.content.domain.entity.ContentWish; -import org.springframework.data.elasticsearch.annotations.Query; + +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 java.util.List; import java.util.Optional; +import java.util.Set; public interface ContentWishRepository extends JpaRepository { - Optional findByUserIdAndContentId(int userId, int contentId); + @Query("SELECT cw FROM ContentWish cw WHERE cw.userId = :userId AND cw.content.contentId = :contentId") + Optional findByUserIdAndContentId(@Param("userId") int userId, @Param("contentId") int contentId); + + @Query("SELECT cw.content.contentId FROM ContentWish cw WHERE cw.userId = :userId AND cw.content.contentId IN :contentIds") + Set findWishedContentIdsByUserIdAndContentIds(@Param("userId") Integer userId, @Param("contentIds") List contentIds); + + Page findAllByUserId(int userId, Pageable pageable); } diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryController.java b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryController.java new file mode 100644 index 0000000..6305bcf --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryController.java @@ -0,0 +1,47 @@ +package com.swyp.catsgotogedog.mypage.controller; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.mypage.service.MyPageHistoryService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mypage") +@Slf4j +public class MyPageHistoryController implements MyPageHistoryControllerSwagger { + + private final MyPageHistoryService myPageHistoryService; + + @Override + @GetMapping("/history") + public ResponseEntity> lastViewedHistory( + @AuthenticationPrincipal String userId) { + + return ResponseEntity.ok(CatsgotogedogApiResponse.success("최근 방문 컨텐츠 조회 성공", + myPageHistoryService.fetchLastViewHistory(userId))); + } + + @Override + @GetMapping("/wish") + public ResponseEntity> fetchWishedContent( + @AuthenticationPrincipal String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "8") int size) { + + Pageable pageable = PageRequest.of(page, size); + + return ResponseEntity.ok(CatsgotogedogApiResponse.success( + "찜 목록 조회 성공", myPageHistoryService.fetchWishLists(userId, pageable))); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryControllerSwagger.java new file mode 100644 index 0000000..4255f5d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryControllerSwagger.java @@ -0,0 +1,59 @@ +package com.swyp.catsgotogedog.mypage.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.mypage.domain.response.ContentWishPageResponse; +import com.swyp.catsgotogedog.mypage.domain.response.LastViewHistoryResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "MyPage", description = "마이페이지 관련 API") +public interface MyPageHistoryControllerSwagger { + + @Operation( + summary = "최근 본 장소 목록 조회", + description = "사용자 인증을 통해 최근 본 장소 리스트를 최근 20개 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "장소 조회 성공" + , content = @Content(schema = @Schema(implementation = LastViewHistoryResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> lastViewedHistory( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); + + @Operation( + summary = "찜 목록 조회", + description = "사용자 인증을 통해 사용자가 찜한 목록을 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "찜 목록 조회 성공" + , content = @Content(schema = @Schema(implementation = ContentWishPageResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchWishedContent( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @Parameter(description = "요청 페이지") int page, + @Parameter(description = "페이지당 결과 갯수") int size + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/.gitkeep b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistory.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistory.java new file mode 100644 index 0000000..b83a79d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistory.java @@ -0,0 +1,59 @@ +package com.swyp.catsgotogedog.mypage.domain.entity; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.UpdateTimestamp; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "last_view_history") +public class LastViewHistory { + + @EmbeddedId + private LastViewHistoryId id; + + @MapsId("userId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @MapsId("contentId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @UpdateTimestamp + @Column(name = "last_viewed_at") + private LocalDateTime lastViewedAt; + + @Builder + public LastViewHistory(User user, Content content) { + this.id = new LastViewHistoryId(user.getUserId(), content.getContentId()); + this.user = user; + this.content = content; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java new file mode 100644 index 0000000..707c890 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java @@ -0,0 +1,24 @@ +package com.swyp.catsgotogedog.mypage.domain.entity; + +import java.io.Serializable; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class LastViewHistoryId implements Serializable { + + @Column(name = "user_id") + private Integer userId; + + @Column(name = "content_id") + private Integer contentId; +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishPageResponse.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishPageResponse.java new file mode 100644 index 0000000..867ea01 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishPageResponse.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.mypage.domain.response; + +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; +import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; + +public record ContentWishPageResponse( + List wishes, + Long totalElements, + int totalPages, + int currentPage, + int size, + boolean hasNext, + boolean hasPrevious +) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java new file mode 100644 index 0000000..4fb93e3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.mypage.domain.response; + +public record ContentWishResponse ( + String imageUrl, + String thumbnailUrl, + Boolean isWish +) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/LastViewHistoryResponse.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/LastViewHistoryResponse.java new file mode 100644 index 0000000..f7d7864 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/LastViewHistoryResponse.java @@ -0,0 +1,22 @@ +package com.swyp.catsgotogedog.mypage.domain.response; + +public record LastViewHistoryResponse ( + Integer contentId, + String contentTitle, + String imageUrl, + String thumbnailUrl, + Boolean isWish +) { + public LastViewHistoryResponse( + Integer contentId, + String contentTitle, + String imageUrl, + String thumbnailUrl, + Boolean isWish) { + this.contentId = contentId; + this.contentTitle = contentTitle; + this.imageUrl = imageUrl; + this.thumbnailUrl = thumbnailUrl; + this.isWish = isWish; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/repository/MyPageHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/mypage/repository/MyPageHistoryRepository.java new file mode 100644 index 0000000..f4c4b32 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/repository/MyPageHistoryRepository.java @@ -0,0 +1,15 @@ +package com.swyp.catsgotogedog.mypage.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistoryId; + +public interface MyPageHistoryRepository extends JpaRepository { + @Query("SELECT lvh FROM LastViewHistory lvh WHERE lvh.user.userId = :userId ORDER BY lvh.lastViewedAt DESC") + List findAllByUserId(Integer userId, Pageable pageable); +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java new file mode 100644 index 0000000..48a661a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java @@ -0,0 +1,97 @@ +package com.swyp.catsgotogedog.mypage.service; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; +import com.swyp.catsgotogedog.content.repository.ContentWishRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; +import com.swyp.catsgotogedog.mypage.domain.response.ContentWishPageResponse; +import com.swyp.catsgotogedog.mypage.domain.response.ContentWishResponse; +import com.swyp.catsgotogedog.mypage.domain.response.LastViewHistoryResponse; +import com.swyp.catsgotogedog.mypage.repository.MyPageHistoryRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MyPageHistoryService { + + private final MyPageHistoryRepository myPageHistoryRepository; + private final UserRepository userRepository; + private final ContentWishRepository contentWishRepository; + + public List fetchLastViewHistory(String stringUserId) { + Integer userId = Integer.parseInt(stringUserId); + validateUser(userId); + + Pageable pageable = PageRequest.of(0, 20); + + List histories = myPageHistoryRepository.findAllByUserId(userId, pageable); + + if(histories.isEmpty()) { + return Collections.emptyList(); + } + + List contentIdsFromHistory = histories.stream() + .map(history -> history.getContent().getContentId()) + .toList(); + + Set wishedContentIds = contentWishRepository.findWishedContentIdsByUserIdAndContentIds(userId, contentIdsFromHistory); + + return histories.stream() + .map(history -> { + boolean isWished = wishedContentIds.contains(history.getContent().getContentId()); + return new LastViewHistoryResponse( + history.getContent().getContentId(), + history.getContent().getTitle(), + history.getContent().getImage(), + history.getContent().getThumbImage(), + isWished + ); + }) + .toList(); + } + + public ContentWishPageResponse fetchWishLists(String stringUserId, Pageable pageable) { + Integer userId = Integer.parseInt(stringUserId); + validateUser(userId); + + Pageable wishPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); + + Page wishPage = contentWishRepository.findAllByUserId(userId, wishPageable); + + return new ContentWishPageResponse( + wishPage.stream() + .map(wish -> new ContentWishResponse( + wish.getContent().getImage(), + wish.getContent().getThumbImage(), + Boolean.TRUE + )).toList(), + wishPage.getTotalElements(), + wishPage.getTotalPages(), + wishPage.getNumber(), + wishPage.getSize(), + wishPage.hasNext(), + wishPage.hasPrevious() + ); + } + + private void validateUser(Integer userId) { + userRepository.findById(userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/resources/db/migration/mysql/V9__create_content_wish.sql b/src/main/resources/db/migration/mysql/V9__create_content_wish.sql new file mode 100644 index 0000000..58ab249 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V9__create_content_wish.sql @@ -0,0 +1,19 @@ +CREATE TABLE `catsgotogedog`.`content_wish` ( +`wish_id` INT NOT NULL AUTO_INCREMENT, +`user_id` INT NOT NULL, +`content_id` INT NOT NULL, +PRIMARY KEY (`wish_id`), +INDEX `content_wish_user_id_fk_idx` (`user_id` ASC) VISIBLE, +INDEX `content_wish_content_id_fk_idx` (`content_id` ASC) VISIBLE, +UNIQUE INDEX `content_wish_wish_uq` (`user_id` ASC, `content_id` ASC) VISIBLE, +CONSTRAINT `content_wish_user_id_fk` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION, +CONSTRAINT `content_wish_content_id_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION) +COMMENT = '장소 찜 목록'; From c870a78dc5274264486aca385c049ecc97829f56 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Mon, 4 Aug 2025 10:19:02 +0900 Subject: [PATCH 106/191] =?UTF-8?q?flyway=20=EB=B2=84=EC=A0=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/mysql/V3__add_name_update_at_to_user_table.sql | 2 -- ...d_name_update_at_to_user_table_and_insert_pet_size_data.sql} | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 src/main/resources/db/migration/mysql/V3__add_name_update_at_to_user_table.sql rename src/main/resources/db/migration/mysql/{V4__insert_pet_size_data.sql => V9__add_name_update_at_to_user_table_and_insert_pet_size_data.sql} (85%) diff --git a/src/main/resources/db/migration/mysql/V3__add_name_update_at_to_user_table.sql b/src/main/resources/db/migration/mysql/V3__add_name_update_at_to_user_table.sql deleted file mode 100644 index f9c5215..0000000 --- a/src/main/resources/db/migration/mysql/V3__add_name_update_at_to_user_table.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE `catsgotogedog`.`user` -ADD COLUMN `name_update_at` DATETIME NULL; diff --git a/src/main/resources/db/migration/mysql/V4__insert_pet_size_data.sql b/src/main/resources/db/migration/mysql/V9__add_name_update_at_to_user_table_and_insert_pet_size_data.sql similarity index 85% rename from src/main/resources/db/migration/mysql/V4__insert_pet_size_data.sql rename to src/main/resources/db/migration/mysql/V9__add_name_update_at_to_user_table_and_insert_pet_size_data.sql index e2e7dee..f266315 100644 --- a/src/main/resources/db/migration/mysql/V4__insert_pet_size_data.sql +++ b/src/main/resources/db/migration/mysql/V9__add_name_update_at_to_user_table_and_insert_pet_size_data.sql @@ -1,3 +1,5 @@ +ALTER TABLE `catsgotogedog`.`user` +ADD COLUMN `name_update_at` DATETIME NULL; INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('소형', '소형견: 성견 된 몸무게가 대략 10kg 미만 (성견: 생후 2년 이상)'); INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('중형', '중형견: 성견 된 몸무게가 대략 10~25kg 미만'); INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('대형', '대형견: 성견 된 몸무게가 대략 25kg 이상'); From 80c790ba04e25f1c59b68f7492d1cebf39717473 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 4 Aug 2025 19:11:29 +0900 Subject: [PATCH 107/191] Update cd.yml --- .github/workflows/cd.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 1336f77..65949d9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -35,6 +35,13 @@ jobs: - name: Build with Gradle Wrapper run: ./gradlew clean build + - name: Upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test + - name: Upload build artifact # only cd.yml uses: actions/upload-artifact@v4 with: From 731bdbd2c78135138b2c990652fa4ee057481a8e Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 4 Aug 2025 21:20:58 +0900 Subject: [PATCH 108/191] =?UTF-8?q?feat:=20=EC=B0=9C=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/response/PlaceDetailResponse.java | 7 +++++-- .../content/repository/ContentWishRepository.java | 1 + .../swyp/catsgotogedog/content/service/ContentService.java | 7 +++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java index 549473f..3863857 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -21,13 +21,15 @@ public record PlaceDetailResponse( int zipcode, String smallImageUrl, Double avgScore, - boolean wishData) { + boolean wishData, + int wishCnt) { public static PlaceDetailResponse from( Content c, String smallImageUrl, Double avgScore, - boolean wishData){ + boolean wishData, + int wishCnt){ return PlaceDetailResponse.builder() .contentId(c.getContentId()) @@ -47,6 +49,7 @@ public static PlaceDetailResponse from( .smallImageUrl(smallImageUrl) .avgScore(avgScore) .wishData(wishData) + .wishCnt(wishCnt) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java index 6f5a5c7..c0ecb21 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -8,4 +8,5 @@ public interface ContentWishRepository extends JpaRepository { Optional findByUserIdAndContentId(int userId, int contentId); + int countByContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 6d0f4ca..20ec373 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -10,6 +10,7 @@ import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; import com.swyp.catsgotogedog.content.repository.ContentImageRepository; import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.ContentWishRepository; import com.swyp.catsgotogedog.review.domain.entity.ContentReview; import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; import org.springframework.stereotype.Service; @@ -24,7 +25,7 @@ public class ContentService { private final ContentRepository contentRepository; private final ContentElasticRepository contentElasticRepository; private final ContentImageRepository contentImageRepository; - private final ContentReviewRepository contentReviewRepository; + private final ContentWishRepository contentWishRepository; private final ContentSearchService contentSearchService; @@ -60,6 +61,8 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ boolean wishData = (userId != null) ? contentSearchService.getWishData(userId, contentId) : false; - return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData); + int wishCnt = contentWishRepository.countByContentId(contentId); + + return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData,wishCnt); } } From ad68e4cad84b7495d35b439758a55af121d571e5 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Mon, 4 Aug 2025 22:28:26 +0900 Subject: [PATCH 109/191] =?UTF-8?q?feat/=EC=A7=80=EC=97=AD=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지역 코드 조회 API 개발 --- docker-compose.yml | 4 +- .../controller/CategoryController.java | 23 +++++++- .../controller/CategoryControllerSwagger.java | 34 ++++++++++++ .../category/domain/response/.gitkeep | 0 .../response/RegionHierarchyResponse.java | 21 +++++++ .../domain/response/SubRegionResponse.java | 12 ++++ .../category/service/CategoryService.java | 55 ++++++++++++++++++- .../common/config/SecurityConfig.java | 3 +- .../content/domain/entity/RegionCode.java | 6 ++ .../repository/RegionCodeRepository.java | 11 ++-- .../global/exception/ErrorCode.java | 5 ++ 11 files changed, 166 insertions(+), 8 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/category/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/category/domain/response/RegionHierarchyResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/category/domain/response/SubRegionResponse.java diff --git a/docker-compose.yml b/docker-compose.yml index ab2ccac..f35aac7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,9 @@ services: command: > bash -c " - elasticsearch-plugin install --batch analysis-nori && + if [ ! -d '/usr/share/elasticsearch/plugins/analysis-nori' ]; then + elasticsearch-plugin install --batch analysis-nori; + fi && /usr/local/bin/docker-entrypoint.sh eswrapper " diff --git a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java index 6d411e2..3f01e8b 100644 --- a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java +++ b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java @@ -1,12 +1,33 @@ package com.swyp.catsgotogedog.category.controller; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.swyp.catsgotogedog.category.service.CategoryService; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/api/category") -public class CategoryController { +public class CategoryController implements CategoryControllerSwagger { + + private final CategoryService categoryService; + + @Override + @GetMapping("/regionCode") + public ResponseEntity> fetchRegionCodes( + @RequestParam(name = "시/도 코드", required = false) + Integer sidoCode, + @RequestParam(name = "시군구 코드", required = false) + Integer sigunguCode) { + return ResponseEntity.ok(CatsgotogedogApiResponse.success( + "지역 코드 조회 성공", + categoryService.findRegions(sidoCode, sigunguCode) + )); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java index fdce65f..0153b0c 100644 --- a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java @@ -1,7 +1,41 @@ package com.swyp.catsgotogedog.category.controller; +import org.springframework.http.ResponseEntity; + +import com.swyp.catsgotogedog.category.domain.response.RegionHierarchyResponse; +import com.swyp.catsgotogedog.category.domain.response.SubRegionResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Category", description = "카테고리 관련 API") public interface CategoryControllerSwagger { + + @Operation( + summary = "지역별 코드를 조회합니다.", + description = "입력 파라미터별로 동적으로 반환합니다. " + + "아무것도 입력하지 않을 경우 모든 sidoCode와 하위 sigunguCode까지 반환하며며," + + "sidoCode만 입력할 경우 해당 sidoCode와 하위 sigunguCode까지 반환합니다." + + "sigunguCode를 검색할 경우 sidoCode도 함께 필수로 입력해주어야 합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "지역 코드 조회 성공" + , content = @Content(schema = @Schema(implementation = RegionHierarchyResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchRegionCodes( + @Parameter(description = "시/도 코드", required = false) + Integer sidoCode, + @Parameter(description = "시군구 코드", required = false) + Integer sigunguCode + ); } diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/category/domain/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/response/RegionHierarchyResponse.java b/src/main/java/com/swyp/catsgotogedog/category/domain/response/RegionHierarchyResponse.java new file mode 100644 index 0000000..09affaa --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/domain/response/RegionHierarchyResponse.java @@ -0,0 +1,21 @@ +package com.swyp.catsgotogedog.category.domain.response; + +import java.util.List; +import java.util.stream.Collectors; + +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; + + +public record RegionHierarchyResponse ( + int sidoCode, + String regionName, + List subRegions +) { + public RegionHierarchyResponse(RegionCode regionCode, List subRegions) { + this( + regionCode.getSidoCode(), + regionCode.getRegionName(), + subRegions.stream().map(SubRegionResponse::new).collect(Collectors.toList()) + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/response/SubRegionResponse.java b/src/main/java/com/swyp/catsgotogedog/category/domain/response/SubRegionResponse.java new file mode 100644 index 0000000..c11fc16 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/domain/response/SubRegionResponse.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.category.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; + +public record SubRegionResponse( + int sigunguCode, + String regionName +){ + public SubRegionResponse(RegionCode regionCode) { + this(regionCode.getSigunguCode(), regionCode.getRegionName()); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java b/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java index 9c7656e..6ebbd82 100644 --- a/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java +++ b/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java @@ -1,12 +1,65 @@ package com.swyp.catsgotogedog.category.service; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.category.domain.response.SubRegionResponse; +import com.swyp.catsgotogedog.category.domain.response.RegionHierarchyResponse; +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; +import com.swyp.catsgotogedog.content.repository.RegionCodeRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Service -@RequiredArgsConstructor @Slf4j +@Transactional(readOnly = true) +@RequiredArgsConstructor public class CategoryService { + + private final RegionCodeRepository regionCodeRepository; + + public Object findRegions(Integer sidoCode, Integer sigunguCode) { + // sido, sigungu 함께 검색 + if(sidoCode != null && sigunguCode != null) { + RegionCode sido = regionCodeRepository.findBySidoCodeAndRegionLevel(sidoCode, 1); + if(sido != null) { + RegionCode sigungu = regionCodeRepository.findByParentCodeAndSigunguCode(sido.getRegionId(), sigunguCode) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.SIGUNGU_CODE_NOT_FOUND)); + + return new SubRegionResponse(sigungu); + }else + throw new CatsgotogedogException(ErrorCode.SIDO_CODE_NOT_FOUND); + // sidoCode만 입력 + } else if(sidoCode != null && sigunguCode == null) { + RegionCode sido = regionCodeRepository.findBySidoCodeAndRegionLevel(sidoCode, 1); + + List sigunguList = regionCodeRepository.findByParentCode(sido.getRegionId()); + + return new RegionHierarchyResponse(sido, sigunguList); + + // 모든 지역코드 + } else if(sidoCode == null && sigunguCode == null) { + List allRegions = regionCodeRepository.findAll(); + + Map> childrenMap = allRegions.stream() + .filter(region -> region.getRegionLevel() == 2) + .collect(Collectors.groupingBy(RegionCode::getParentCode)); + + return allRegions.stream() + .filter(region -> region.getRegionLevel() == 1) + .map(sido -> new RegionHierarchyResponse( + sido, + childrenMap.getOrDefault(sido.getSidoCode(), List.of()) + )).toList(); + } else { + throw new CatsgotogedogException(ErrorCode.SIGUNGU_NEEDS_WITH_SIDO_CODE); + } + } } diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index edb3897..eec0eeb 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -51,7 +51,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/user/reissue", "/api/content/**", // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 - "/api/review/content/**" + "/api/review/content/**", + "/api/category/**" ).permitAll() .anyRequest().authenticated()) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java index 58e7aa7..381ddcb 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java @@ -1,10 +1,16 @@ package com.swyp.catsgotogedog.content.domain.entity; +import java.util.List; + import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java index 722f794..181cada 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java @@ -4,15 +4,18 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import com.swyp.catsgotogedog.content.domain.entity.RegionCode; public interface RegionCodeRepository extends JpaRepository { - Optional findBySidoCodeAndSigunguCode(int sidoCode, int sigunguCode); - Optional findBySidoCodeAndSigunguCodeIsNull(int sidoCode); - List findBySidoCode(int sidoCode); - RegionCode findBySidoCodeAndRegionLevel(int sidoCode, int regionLevel); RegionCode findByParentCodeAndSigunguCodeAndRegionLevel(int parentCode, int sigunguCode, int regionLevel); + + List findByRegionLevel(int regionLevel); + + Optional findByParentCodeAndSigunguCode(int regionId, Integer sigunguCode); + + List findByParentCode(int regionId); } diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 208e10d..2ebcd18 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -2,6 +2,8 @@ import org.springframework.http.HttpStatus; +import com.google.api.Http; + import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -22,6 +24,8 @@ public enum ErrorCode { REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰입니다."), RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "리소스를 찾을 수 없습니다."), REVIEW_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰 이미지입니다."), + SIGUNGU_CODE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 지역을 찾을 수 없습니다."), + SIDO_CODE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 시/도를 찾을 수 없습니다."), // 405 Method not allowed METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메소드입니다."), @@ -30,6 +34,7 @@ public enum ErrorCode { MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST.value(), "요청 본문 형식이 올바르지 않습니다."), ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "유효성 검사에 실패했습니다."), MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST.value(), "필수 파라미터가 누락되었습니다."), + SIGUNGU_NEEDS_WITH_SIDO_CODE(HttpStatus.BAD_REQUEST.value(), "시/군/구 코드는 반드시 시/도 코드와 함께 요청해야 합니다."), // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), From 60efd5c6eaf351f1003f49d74952ad45b70ecb7e Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:33:29 +0900 Subject: [PATCH 110/191] =?UTF-8?q?feat:=20MockitoBean=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CatsgotogedogApplication.java | 2 -- .../CatsgotogedogApplicationTests.java | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index 0cac81a..d662406 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -4,11 +4,9 @@ import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java new file mode 100644 index 0000000..ea98b2c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java @@ -0,0 +1,19 @@ +package com.swyp.catsgotogedog; + +import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; +import org.junit.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockReset; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest +public class CatsgotogedogApplicationTests { + + @MockitoBean(reset = MockReset.AFTER) + ContentElasticRepository contentElasticRepository; + + @Test + public void contextLoads() { + } + +} \ No newline at end of file From 18c25858a26f4df86580e72b940a845ab6618a44 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:40:18 +0900 Subject: [PATCH 111/191] =?UTF-8?q?fix:=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CatsgotogedogApplicationTests.java | 19 ------------------- .../CatsgotogedogApplicationTests.java | 7 +++++++ 2 files changed, 7 insertions(+), 19 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java deleted file mode 100644 index ea98b2c..0000000 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.swyp.catsgotogedog; - -import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; -import org.junit.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockReset; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -@SpringBootTest -public class CatsgotogedogApplicationTests { - - @MockitoBean(reset = MockReset.AFTER) - ContentElasticRepository contentElasticRepository; - - @Test - public void contextLoads() { - } - -} \ No newline at end of file diff --git a/src/test/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java b/src/test/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java index 9367ee9..36af000 100644 --- a/src/test/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java +++ b/src/test/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java @@ -1,11 +1,18 @@ package com.swyp.catsgotogedog; +import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockReset; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class CatsgotogedogApplicationTests { + @MockitoBean(reset = MockReset.AFTER) + ContentElasticRepository contentElasticRepository; + + @Test void contextLoads() { } From fc4ba291612ee7ff25a999e197bf9bdf41f9ab06 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:46:28 +0900 Subject: [PATCH 112/191] Update CatsgotogedogApplication.java --- .../java/com/swyp/catsgotogedog/CatsgotogedogApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index d662406..0cac81a 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -4,9 +4,11 @@ import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; From a49d543677b56f98a7a79d903deb12dc80c59d64 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 4 Aug 2025 23:20:14 +0900 Subject: [PATCH 113/191] =?UTF-8?q?feat:=20swagger=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ContentControllerSwagger.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index 274dd82..85d34ee 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -1,7 +1,55 @@ package com.swyp.catsgotogedog.content.controller; +import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; + +import java.io.IOException; + +import java.util.List; @Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") public interface ContentControllerSwagger { + + @Operation( + summary = "컨텐츠 검색", + description = "제목, 시/도, 시/군/구, 컨텐츠 유형으로 장소를 검색합니다. " + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "검색 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ContentResponse.class)))), + @ApiResponse(responseCode = "204", description = "검색 결과 없음"), + @ApiResponse(responseCode = "400", description = "요청 파라미터가 유효하지 않음") + }) + ResponseEntity> search( + @Parameter(description = "장소 검색어", required = false) + @RequestParam(required = false) String title, + + @Parameter(description = "시/도 코드", required = false) + @RequestParam(required = false) String sido, + + @Parameter(description = "시/군/구 코드", required = false) + @RequestParam(required = false) String sigungu, + + @Parameter(description = "컨텐츠 유형 ID", required = false) + @RequestParam(required = false) Integer contentTypeId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String principal + )throws IOException; } From 28ba284d9833481e1941078cdf28c35cc692dd0e Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 4 Aug 2025 23:58:02 +0900 Subject: [PATCH 114/191] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=88=98=20=EC=B6=94=EA=B0=80=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/entity/ViewTotal.java | 29 +++++++++++++++++++ .../repository/ViewTotalRepository.java | 16 ++++++++++ .../content/service/ContentService.java | 17 ++++++++--- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java new file mode 100644 index 0000000..4a7bcd0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java @@ -0,0 +1,29 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +public class ViewTotal { + @Id + @Column(name = "content_id") + private Long contentId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "total_view", nullable = false) + private Integer totalView; + + @LastModifiedDate + @Column(name = "updated_at", + nullable = false) + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java new file mode 100644 index 0000000..924040b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.ViewTotal; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ViewTotalRepository extends JpaRepository { + @Query(value = """ + INSERT INTO view_total (content_id, total_view, updated_at) + VALUES (:contentId, 1, NOW()) + ON DUPLICATE KEY UPDATE + total_view = total_view + 1, + updated_at = NOW() + """, nativeQuery = true) + void upsertAndIncrease(int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 20ec373..e802b7b 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -1,23 +1,26 @@ package com.swyp.catsgotogedog.content.service; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import com.swyp.catsgotogedog.content.domain.entity.ViewLog; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; -import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; -import com.swyp.catsgotogedog.content.repository.ContentImageRepository; -import com.swyp.catsgotogedog.content.repository.ContentRepository; -import com.swyp.catsgotogedog.content.repository.ContentWishRepository; +import com.swyp.catsgotogedog.content.repository.*; import com.swyp.catsgotogedog.review.domain.entity.ContentReview; import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; +import jakarta.persistence.EntityNotFoundException; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.Optional; + @Service @RequiredArgsConstructor @Slf4j @@ -26,6 +29,9 @@ public class ContentService { private final ContentElasticRepository contentElasticRepository; private final ContentImageRepository contentImageRepository; private final ContentWishRepository contentWishRepository; + private final ViewTotalRepository viewTotalRepository; + private final UserRepository userRepository; + private final ViewLogRepository viewLogRepository; private final ContentSearchService contentSearchService; @@ -51,6 +57,8 @@ public void saveContent(ContentRequest request){ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ + viewTotalRepository.upsertAndIncrease(contentId); + Content content = contentRepository.findByContentId(contentId); ContentImage contentImage = contentImageRepository.findByContent_ContentId(contentId); @@ -65,4 +73,5 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData,wishCnt); } + } From 64033bb0d1d1ee837061ca2f16371d67db323ebe Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 00:00:32 +0900 Subject: [PATCH 115/191] =?UTF-8?q?feat:=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/entity/ViewLog.java | 33 +++++++++++++++++++ .../content/repository/ViewLogRepository.java | 7 ++++ .../content/service/ContentService.java | 28 ++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java new file mode 100644 index 0000000..f77d8af --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java @@ -0,0 +1,33 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +public class ViewLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "view_id") + private Long viewId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "content_id") + private Content content; + + @CreatedDate + @Column(name = "viewed_at", + nullable = false, + updatable = false) + private LocalDateTime viewedAt; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java new file mode 100644 index 0000000..7553775 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.ViewLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ViewLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index e802b7b..c1f9ed2 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -59,6 +59,10 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ viewTotalRepository.upsertAndIncrease(contentId); + if(userId != null){ + recordView(userId, contentId); + } + Content content = contentRepository.findByContentId(contentId); ContentImage contentImage = contentImageRepository.findByContent_ContentId(contentId); @@ -74,4 +78,28 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData,wishCnt); } + public void recordView(String userId, int contentId){ + +// if (userId != null) { +// Optional user = userRepository.findById(Integer.parseInt(userId)); +// } + + Content content = contentRepository.findByContentId(contentId); + if (content == null) { + throw new EntityNotFoundException("contentId=" + contentId); + } + + User user = null; + if (userId != null && !userId.isBlank()) { + user = userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new EntityNotFoundException("userId=" + userId)); + } + + viewLogRepository.save( + ViewLog.builder() + .user(user) + .content(content) + .build() + ); + } } From 016ae4d1b5a1175e3d14ccd9d5933d7fac51000e Mon Sep 17 00:00:00 2001 From: yhs99 Date: Tue, 5 Aug 2025 00:57:43 +0900 Subject: [PATCH 116/191] =?UTF-8?q?rename/flyway=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{V9__create_content_wish.sql => V10__create_content_wish.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/mysql/{V9__create_content_wish.sql => V10__create_content_wish.sql} (100%) diff --git a/src/main/resources/db/migration/mysql/V9__create_content_wish.sql b/src/main/resources/db/migration/mysql/V10__create_content_wish.sql similarity index 100% rename from src/main/resources/db/migration/mysql/V9__create_content_wish.sql rename to src/main/resources/db/migration/mysql/V10__create_content_wish.sql From e30158a6ecd75c669472c8e6628f58ab9ec72bb1 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 01:06:39 +0900 Subject: [PATCH 117/191] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EB=B3=B8?= =?UTF-8?q?=20=EC=9E=A5=EC=86=8C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20#71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 8 +++++++ .../response/LastViewHistoryResponse.java | 17 ++++++++++++++ .../content/repository/ViewLogRepository.java | 13 +++++++++++ .../content/service/ContentService.java | 23 +++++++++++++++---- 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/LastViewHistoryResponse.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 412abb1..686de5b 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -1,7 +1,9 @@ package com.swyp.catsgotogedog.content.controller; +import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.service.ContentSearchService; import com.swyp.catsgotogedog.content.service.ContentService; @@ -66,4 +68,10 @@ public ResponseEntity getPlaceDetail(@RequestParam int contentId, @Authentica return ResponseEntity.ok(placeDetailResponse); } + @GetMapping("/recent") + public ResponseEntity> getRecentViews(@AuthenticationPrincipal String userId) { + List recent = contentService.getRecentViews(userId); + return ResponseEntity.ok().body(recent); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/LastViewHistoryResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/LastViewHistoryResponse.java new file mode 100644 index 0000000..330cea0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/LastViewHistoryResponse.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +public record LastViewHistoryResponse( + int contentId, + String title, + String thumbImage) { + + public static LastViewHistoryResponse from(Content c) { + return new LastViewHistoryResponse( + c.getContentId(), + c.getTitle(), + c.getThumbImage() + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java index 7553775..72e05c8 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java @@ -1,7 +1,20 @@ package com.swyp.catsgotogedog.content.repository; +import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ViewLog; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; public interface ViewLogRepository extends JpaRepository { + @Query(""" + SELECT v.content + FROM ViewLog v + WHERE v.user.id = :userId + GROUP BY v.content + ORDER BY MAX(v.viewedAt) DESC + """) + List findRecentContentByUser(int userId, Pageable pageable); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index c1f9ed2..b7f1231 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -7,19 +7,18 @@ import com.swyp.catsgotogedog.content.domain.entity.ContentImage; import com.swyp.catsgotogedog.content.domain.entity.ViewLog; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; -import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; -import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; import com.swyp.catsgotogedog.content.repository.*; -import com.swyp.catsgotogedog.review.domain.entity.ContentReview; -import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; import jakarta.persistence.EntityNotFoundException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import java.util.Optional; +import java.util.List; @Service @RequiredArgsConstructor @@ -102,4 +101,18 @@ public void recordView(String userId, int contentId){ .build() ); } + + public List getRecentViews(String userId) { + + if (userId == null || userId.isBlank()) { + return null; + } + + Pageable top = PageRequest.of(0, 20); + List contents = viewLogRepository.findRecentContentByUser(Integer.parseInt(userId), top); + + return contents.stream() + .map(LastViewHistoryResponse::from) + .toList(); + } } From 5c23816f221746dbabbe0afd9c7411e61e4c58d4 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 01:15:27 +0900 Subject: [PATCH 118/191] =?UTF-8?q?feat:=20=EB=B0=A9=EB=AC=B8=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/entity/VisitHistory.java | 22 +++++++++++++++++++ .../domain/response/PlaceDetailResponse.java | 7 ++++-- .../repository/VisitHistoryRepository.java | 8 +++++++ .../content/service/ContentService.java | 12 +++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java new file mode 100644 index 0000000..bfa4c19 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java @@ -0,0 +1,22 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +public class VisitHistory { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "visit_id") + private Long visitId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "content_id") + private Content content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java index 3863857..9d451c9 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -22,14 +22,16 @@ public record PlaceDetailResponse( String smallImageUrl, Double avgScore, boolean wishData, - int wishCnt) { + int wishCnt, + boolean visited) { public static PlaceDetailResponse from( Content c, String smallImageUrl, Double avgScore, boolean wishData, - int wishCnt){ + int wishCnt, + boolean visited){ return PlaceDetailResponse.builder() .contentId(c.getContentId()) @@ -50,6 +52,7 @@ public static PlaceDetailResponse from( .avgScore(avgScore) .wishData(wishData) .wishCnt(wishCnt) + .visited(visited) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java new file mode 100644 index 0000000..c9be9da --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.VisitHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VisitHistoryRepository extends JpaRepository { + boolean existsByUser_IdAndContent_ContentId(int userId, Integer contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index b7f1231..6e51f4a 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -31,6 +31,7 @@ public class ContentService { private final ViewTotalRepository viewTotalRepository; private final UserRepository userRepository; private final ViewLogRepository viewLogRepository; + private final VisitHistoryRepository visitHistoryRepository; private final ContentSearchService contentSearchService; @@ -74,7 +75,9 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ int wishCnt = contentWishRepository.countByContentId(contentId); - return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData,wishCnt); + boolean visited = hasVisited(userId, contentId); + + return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData,wishCnt,visited); } public void recordView(String userId, int contentId){ @@ -115,4 +118,11 @@ public List getRecentViews(String userId) { .map(LastViewHistoryResponse::from) .toList(); } + + public boolean hasVisited(String userId, int contentId) { + if (userId == null || userId.isBlank()) { + return false; + } + return visitHistoryRepository.existsByUser_IdAndContent_ContentId(Integer.parseInt(userId), contentId); + } } From 14332929fb369a782b48b39473a2594e6961dbfc Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 01:21:52 +0900 Subject: [PATCH 119/191] =?UTF-8?q?feat:=20response=20DTO=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/controller/ContentController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 686de5b..681d2c5 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -1,6 +1,5 @@ package com.swyp.catsgotogedog.content.controller; -import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; @@ -57,7 +56,7 @@ public ResponseEntity saveList(@RequestBody List requests) { } @GetMapping("/placedetail") - public ResponseEntity getPlaceDetail(@RequestParam int contentId, @AuthenticationPrincipal String principal){ + public ResponseEntity getPlaceDetail(@RequestParam int contentId, @AuthenticationPrincipal String principal){ String userId = null; if (StringUtils.hasText(principal) && NumberUtils.isCreatable(principal)) { From f1780cc0af4a6e45adaed2391d9dc66b6d0ebedb Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 01:22:49 +0900 Subject: [PATCH 120/191] =?UTF-8?q?feat:=20swagger=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?#54=20#71?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ContentControllerSwagger.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index 274dd82..57ab52a 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -1,7 +1,84 @@ package com.swyp.catsgotogedog.content.controller; +import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; +import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; 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.RequestParam; + +import java.io.IOException; +import java.util.List; @Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") public interface ContentControllerSwagger { + + @Operation( + summary = "컨텐츠 검색", + description = "제목, 시/도, 시/군/구, 컨텐츠 유형으로 장소를 검색합니다. " + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "검색 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ContentResponse.class)))), + @ApiResponse(responseCode = "204", description = "검색 결과 없음"), + @ApiResponse(responseCode = "400", description = "요청 파라미터가 유효하지 않음") + }) + ResponseEntity> search( + @Parameter(description = "장소 검색어", required = false) + @RequestParam(required = false) String title, + + @Parameter(description = "시/도 코드", required = false) + @RequestParam(required = false) String sido, + + @Parameter(description = "시/군/구 코드", required = false) + @RequestParam(required = false) String sigungu, + + @Parameter(description = "컨텐츠 유형 ID", required = false) + @RequestParam(required = false) Integer contentTypeId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String principal + )throws IOException; + + @Operation( + summary = "공간 상세 조회", + description = "contentId로 장소 상세 정보를 조회" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = PlaceDetailResponse.class))), + @ApiResponse(responseCode = "404", description = "해당 contentId에 대한 데이터 없음") + }) + ResponseEntity getPlaceDetail( + @Parameter(description = "조회할 컨텐츠 ID", required = true) + @RequestParam int contentId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String principal + ); + + @Operation( + summary = "최근 본 장소 목록 조회", + description = "로그인 사용자의 최근 본 장소 목록을 조회합, 비회원은 null", + security = { @SecurityRequirement(name = "bearer-key") } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = LastViewHistoryResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) + ResponseEntity> getRecentViews( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); + } From 7701906d7390ca9256889f25511c60d53a7dd214 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Tue, 5 Aug 2025 19:53:30 +0900 Subject: [PATCH 121/191] =?UTF-8?q?feat/=EB=A6=AC=EB=B7=B0=20=EC=8B=A0?= =?UTF-8?q?=EA=B3=A0=ED=95=98=EA=B8=B0=20=EB=B0=8F=20=EB=B8=94=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=EB=93=9C=20=EC=97=AC=EB=B6=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor/컨텐츠별 리뷰 조회시 신고 누적으로 인한 블라인드 리뷰 여부 데이터도 함께 조회되도록 수정 - feat/신고 기능 추가 , 자신이 작성한 리뷰는 신고할 수 없음 - feat/신고 사유 목록 조회 추가 --- .../controller/CategoryController.java | 13 ++- .../controller/CategoryControllerSwagger.java | 20 ++++- .../common/config/SecurityConfig.java | 2 +- .../global/exception/ErrorCode.java | 3 + .../review/controller/ReviewController.java | 7 +- .../controller/ReviewControllerSwagger.java | 5 +- .../controller/ReviewReportController.java | 39 ++++++++ .../ReviewReportControllerSwagger.java | 47 ++++++++++ .../review/domain/entity/ReportReason.java | 21 +++++ .../review/domain/entity/ReviewReport.java | 43 +++++++++ .../response/ContentReviewPageResponse.java | 9 ++ .../domain/response/MyReviewPageResponse.java | 9 ++ .../domain/response/MyReviewResponse.java | 10 +++ .../domain/response/ReportReasonResponse.java | 11 +++ .../domain/response/ReviewResponse.java | 15 ++++ .../repository/ReportReasonRepository.java | 8 ++ .../repository/ReviewReportRepository.java | 16 ++++ .../review/service/ReviewReportService.java | 90 +++++++++++++++++++ .../review/service/ReviewService.java | 2 + .../mysql/V11__insert_review_report_reson.sql | 24 +++++ 20 files changed, 383 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReportReason.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewReport.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/domain/response/ReportReasonResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/repository/ReportReasonRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/repository/ReviewReportRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/service/ReviewReportService.java create mode 100644 src/main/resources/db/migration/mysql/V11__insert_review_report_reson.sql diff --git a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java index 3f01e8b..52e0c69 100644 --- a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java +++ b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java @@ -8,15 +8,17 @@ import com.swyp.catsgotogedog.category.service.CategoryService; import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.review.service.ReviewReportService; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor -@RequestMapping("/api/category") +@RequestMapping("/api/code") public class CategoryController implements CategoryControllerSwagger { private final CategoryService categoryService; + private final ReviewReportService reviewReportService; @Override @GetMapping("/regionCode") @@ -30,4 +32,13 @@ public ResponseEntity> fetchRegionCodes( categoryService.findRegions(sidoCode, sigunguCode) )); } + + @Override + @GetMapping("/reasonCode") + public ResponseEntity> fetchResons() { + + return ResponseEntity.ok(CatsgotogedogApiResponse.success( + "신고 사유 목록 조회 성공", + reviewReportService.fetchReasons())); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java index 0153b0c..669b670 100644 --- a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java @@ -14,7 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "Category", description = "카테고리 관련 API") +@Tag(name = "Code", description = "코드 관련 API") public interface CategoryControllerSwagger { @Operation( @@ -38,4 +38,22 @@ ResponseEntity> fetchRegionCodes( @Parameter(description = "시군구 코드", required = false) Integer sigunguCode ); + + @Operation( + summary = "신고 사유 목록을 조회합니다.", + description = "리뷰 신고를 위한 신고 사유 목록입니다. 해당 사유 ID를 통해 리뷰 신고 처리를 진행해주세요" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사유 목록 조회 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "접근 권한이 없음, 다른 사람의 리뷰 삭제시" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchResons(); } diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index eec0eeb..c8fd753 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -52,7 +52,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/content/**", // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 "/api/review/content/**", - "/api/category/**" + "/api/code/**" ).permitAll() .anyRequest().authenticated()) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 38278f8..da1a4db 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -35,6 +35,9 @@ public enum ErrorCode { ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "유효성 검사에 실패했습니다."), MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST.value(), "필수 파라미터가 누락되었습니다."), SIGUNGU_NEEDS_WITH_SIDO_CODE(HttpStatus.BAD_REQUEST.value(), "시/군/구 코드는 반드시 시/도 코드와 함께 요청해야 합니다."), + REPORT_REASON_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 신고 사유입니다."), + ALREADY_REPORTED(HttpStatus.BAD_REQUEST.value(), "이미 신고한 리뷰입니다."), + OWN_REVIEW_CANT_REPORT(HttpStatus.BAD_REQUEST.value(), "자신이 작성한 리뷰는 신고할 수 없어요."), // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index d9790e0..8782580 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -24,7 +24,6 @@ import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; import com.swyp.catsgotogedog.review.domain.response.ContentReviewPageResponse; -import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; import com.swyp.catsgotogedog.review.service.ReviewService; import io.jsonwebtoken.io.IOException; @@ -46,7 +45,7 @@ public class ReviewController implements ReviewControllerSwagger { public ResponseEntity> createReview( @PathVariable int contentId, @AuthenticationPrincipal String userId, - @Valid @ModelAttribute @ParameterObject CreateReviewRequest createReviewRequest, + @ModelAttribute @Valid CreateReviewRequest createReviewRequest, @RequestParam(value = "images", required = false)List images) throws IOException { reviewService.createReview(contentId, userId, createReviewRequest, images); @@ -74,7 +73,7 @@ public ResponseEntity> updateReview( // 리뷰 삭제 @Override - @DeleteMapping("/{reviewId}") + @DeleteMapping(value = "/{reviewId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity> deleteReview( @PathVariable int reviewId, @AuthenticationPrincipal String userId) { @@ -88,7 +87,7 @@ public ResponseEntity> deleteReview( // 리뷰 이미지 삭제 @Override - @DeleteMapping("/{reviewId}/image/{imageId}") + @DeleteMapping(value = "/{reviewId}/image/{imageId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity> deleteReviewImage( @PathVariable(name = "reviewId") int reviewId, @PathVariable(name = "imageId") int imageId, diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index ca86611..cf04731 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -1,15 +1,12 @@ package com.swyp.catsgotogedog.review.controller; -import java.security.Principal; import java.util.List; import org.springdoc.core.annotations.ParameterObject; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; @@ -58,7 +55,7 @@ ResponseEntity> createReview( @Parameter(description = "이미지 업로드 (최대 3장)") @RequestParam(value = "images") List images - ) throws IOException; + ); @Operation( summary = "작성 리뷰를 수정합니다.", diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportController.java new file mode 100644 index 0000000..c8879df --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportController.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.review.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.review.service.ReviewReportService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/review/report") +@Slf4j +public class ReviewReportController implements ReviewReportControllerSwagger { + + private final ReviewReportService reviewReportService; + + @Override + @PostMapping(value = "/{reviewId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity> reportReview( + @AuthenticationPrincipal String userId, + @PathVariable int reviewId, + @RequestParam int reasonId) { + + reviewReportService.reportReview(reviewId, reasonId, userId); + + return ResponseEntity.ok(CatsgotogedogApiResponse.success( + "리뷰 신고 완료", null)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportControllerSwagger.java new file mode 100644 index 0000000..0d6ceea --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportControllerSwagger.java @@ -0,0 +1,47 @@ +package com.swyp.catsgotogedog.review.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Review", description = "리뷰 관련 API") +public interface ReviewReportControllerSwagger { + + @Operation( + summary = "특정 리뷰를 신고합니다.", + description = "사용자 인증을 통해 리뷰를 신고합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 삭제 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나, 이미 신고 처리된 리뷰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰 또는 유저가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> reportReview( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @Parameter(description = "신고할 리뷰 ID", required = true) + @PathVariable int reviewId, + + @Parameter(description = "신고 사유 ID", required = true) + @RequestBody int reasonId + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReportReason.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReportReason.java new file mode 100644 index 0000000..f5563c3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReportReason.java @@ -0,0 +1,21 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "report_reason") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ReportReason { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int reasonId; + private String content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewReport.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewReport.java new file mode 100644 index 0000000..aff9e0f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewReport.java @@ -0,0 +1,43 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import com.swyp.catsgotogedog.User.domain.entity.User; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Table(name = "review_report") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class ReviewReport { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int reportId; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "review_id") + private Review review; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reason_id") + private ReportReason reportReason; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "user_id") + private User user; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java index c0df3f5..45fd6b6 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java @@ -2,13 +2,22 @@ import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + public record ContentReviewPageResponse ( + @Schema(description = "컨텐츠 리뷰 목록") List reviews, + @Schema(description = "총 조회 갯수") int totalElements, + @Schema(description = "총 페이지 갯수") int totalPages, + @Schema(description = "현재 페이지") int currentPage, + @Schema(description = "사이즈") int size, + @Schema(description = "다음 존재 여부") boolean hasNext, + @Schema(description = "이전 존재 여부") boolean hasPrevious ) { } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java index b38c907..d965c43 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java @@ -2,12 +2,21 @@ import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + public record MyReviewPageResponse( + @Schema(description = "작성 리뷰 목록") List reviews, + @Schema(description = "총 조회 갯수") int totalElements, + @Schema(description = "총 페이지") int totalPages, + @Schema(description = "현재 조회 페이지") int currentPage, + @Schema(description = "사이즈") int size, + @Schema(description = "다음 존재 여부") boolean hasNext, + @Schema(description = "이전 존재 여부") boolean hasPrevious ) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java index 8ed2a3c..f1a86ba 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java @@ -4,13 +4,23 @@ import java.time.LocalDateTime; import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + public record MyReviewResponse( + @Schema(description = "컨텐츠 ID") int contentId, + @Schema(description = "컨텐츠 제목") String contentTitle, + @Schema(description = "리뷰 ID") int reviewId, + @Schema(description = "리뷰 내용") String content, + @Schema(description = "별점") BigDecimal score, + @Schema(description = "좋아요 수") int recommendedNumber, + @Schema(description = "작성일시") LocalDateTime createdAt, + @Schema(description = "이미지 목록") List images ) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReportReasonResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReportReasonResponse.java new file mode 100644 index 0000000..9337b93 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReportReasonResponse.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public class ReportReasonResponse { + @Schema(description = "신고 사유 ID") + private int reasonId; + + @Schema(description = "신고 사유") + private String content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java index 2d4c7c6..f8d8368 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java @@ -4,16 +4,31 @@ import java.time.LocalDateTime; import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + public record ReviewResponse ( + @Schema(description = "조회 컨텐츠 ID") int contentId, + @Schema(description = "리뷰 ID") int reviewId, + @Schema(description = "리뷰 작성자 ID") int userId, + @Schema(description = "리뷰 작성자 닉네임") String displayName, + @Schema(description = "리뷰 작성자 프로필 이미지") String profileImageUrl, + @Schema(description = "리뷰 내용") String content, + @Schema(description = "별점") BigDecimal score, + @Schema(description = "리뷰 작성일시") LocalDateTime createdAt, + @Schema(description = "리뷰 추천수") int recommendedNumber, + @Schema(description = "자신의 추천 여부") boolean isRecommended, + @Schema(description = "신고 누적 5회 이상 리뷰 여부") + boolean isBlind, + @Schema(description = "리뷰 이미지 목록") List images ) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReportReasonRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReportReasonRepository.java new file mode 100644 index 0000000..b6c43c8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReportReasonRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.review.domain.entity.ReportReason; + +public interface ReportReasonRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewReportRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewReportRepository.java new file mode 100644 index 0000000..21d2b35 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewReportRepository.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.review.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewReport; + +public interface ReviewReportRepository extends JpaRepository { + Optional findByUserAndReview(User reporter, Review targetReview); + + List findByReview(Review review); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewReportService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewReportService.java new file mode 100644 index 0000000..51f1560 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewReportService.java @@ -0,0 +1,90 @@ +package com.swyp.catsgotogedog.review.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.review.domain.entity.ReportReason; +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewReport; +import com.swyp.catsgotogedog.review.repository.ReportReasonRepository; +import com.swyp.catsgotogedog.review.repository.ReviewReportRepository; +import com.swyp.catsgotogedog.review.repository.ReviewRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewReportService { + + private final int REPORT_BLIND_COUNTS = 5; + + private final UserRepository userRepository; + private final ReportReasonRepository reportReasonRepository; + private final ReviewReportRepository reviewReportRepository; + private final ReviewRepository reviewRepository; + + // 리뷰 신고 + public void reportReview(Integer reviewId, Integer reviewReportId, String stringUserId) { + User reporter = validateUser(stringUserId); + + ReportReason reason = reportReasonRepository.findById(reviewReportId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REPORT_REASON_NOT_FOUND)); + + Review targetReview = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + if(reporter.getUserId() == targetReview.getUserId()) { + throw new CatsgotogedogException(ErrorCode.OWN_REVIEW_CANT_REPORT); + } + + reviewReportRepository.findByUserAndReview(reporter, targetReview) + .ifPresentOrElse( + // 신고 내역이 존재하여 신고 처리 안됨. + existReport -> { + throw new CatsgotogedogException(ErrorCode.ALREADY_REPORTED); + }, + // 신고 내역이 없을 경우 신고 처리 + () -> reviewReportRepository.save( + ReviewReport.builder() + .user(reporter) + .reportReason(reason) + .review(targetReview) + .build())); + } + + // 특정 리뷰 블라인드 여부 + public boolean isBlindReview(Integer reviewId) { + Review targetReview = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + List reports = reviewReportRepository.findByReview(targetReview); + + Map reportCountByReason = reports.stream() + .collect(Collectors.groupingBy( + ReviewReport::getReportReason, + Collectors.counting() + )); + + return reportCountByReason.values().stream() + .anyMatch(count -> count >= REPORT_BLIND_COUNTS); + } + + + public List fetchReasons() { + return reportReasonRepository.findAll(); + } + + private User validateUser(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index 7b80ade..08928d9 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -52,6 +52,7 @@ public class ReviewService { private final ContentRepository contentRepository; private final ImageStorageService imageStorageService; private final ReviewRecommendHistoryRepository reviewRecommendHistoryRepository; + private final ReviewReportService reviewReportService; // 리뷰 작성 @Transactional @@ -173,6 +174,7 @@ public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String s review.getCreatedAt(), review.getRecommendedNumber(), recommendedReviewIds.contains(review.getReviewId()), + reviewReportService.isBlindReview(review.getReviewId()), review.getReviewImages().stream() .map(ReviewImageResponse::from) .collect(Collectors.toList()) diff --git a/src/main/resources/db/migration/mysql/V11__insert_review_report_reson.sql b/src/main/resources/db/migration/mysql/V11__insert_review_report_reson.sql new file mode 100644 index 0000000..07d8c79 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V11__insert_review_report_reson.sql @@ -0,0 +1,24 @@ +ALTER TABLE `catsgotogedog`.`review_report` +DROP FOREIGN KEY `review_report_review_id`; +ALTER TABLE `catsgotogedog`.`review_report` + ADD INDEX `review_report_user_id_idx` (`user_id` ASC) INVISIBLE, +ADD UNIQUE INDEX `user_id_review_id_uq` (`user_id` ASC, `review_id` ASC) INVISIBLE; +; +ALTER TABLE `catsgotogedog`.`review_report` + ADD CONSTRAINT `review_report_review_id` + FOREIGN KEY (`review_id`) + REFERENCES `catsgotogedog`.`content_review` (`review_id`) + ON DELETE CASCADE, +ADD CONSTRAINT `review_report_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION; + + + +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('1', '욕설 및 비속어 사용'); +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('2', '개인정보 노출'); +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('3', '광고 및 홍보성 댓글'); +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('4', '도배성 댓글'); +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('5', '부적절하거나 불쾌한 내용'); From fa50964c15e2bfb21c5b78dbe2ffd3f989265ca2 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:43:11 +0900 Subject: [PATCH 122/191] =?UTF-8?q?fix:=20=ED=95=84=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/ContentSearchService.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 9d2e370..8f99af5 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -59,6 +59,9 @@ public List search(String title, boolean noSigunguCode = (sigunguCode == null || sigunguCode.isBlank()); boolean noTypeId = (contentTypeId == null || contentTypeId <= 0); + System.out.println("noTypeId : "+noTypeId); + System.out.println("contentTypeId : "+contentTypeId); + BoolQuery.Builder boolBuilder = new BoolQuery.Builder(); if (noTitle && noSidoCode && noSigunguCode && noTypeId) { @@ -73,17 +76,17 @@ public List search(String title, } if (!noSidoCode) { - boolBuilder.filter(f -> f.term(t -> t.field("sidoCode") + boolBuilder.filter(f -> f.term(t -> t.field("sido_code") .value(sidoCode))); } if (!noSigunguCode) { - boolBuilder.filter(f -> f.term(t -> t.field("sigunguCode") + boolBuilder.filter(f -> f.term(t -> t.field("sigungu_code") .value(sigunguCode))); } if (!noTypeId) { - boolBuilder.filter(f -> f.term(t -> t.field("contentTypeId") + boolBuilder.filter(f -> f.term(t -> t.field("content_type_id") .value(contentTypeId))); } } From db2dec1288f96408c91981cf41be2b4246179ee9 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:43:50 +0900 Subject: [PATCH 123/191] =?UTF-8?q?fix:=20=EB=A7=A4=ED=95=91=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=B3=80=EA=B2=BD=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/elasticsearch/search-mapping.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/elasticsearch/search-mapping.json b/src/main/resources/elasticsearch/search-mapping.json index 0d5455a..7051e99 100644 --- a/src/main/resources/elasticsearch/search-mapping.json +++ b/src/main/resources/elasticsearch/search-mapping.json @@ -4,7 +4,7 @@ "type": "integer" }, "categoryId": { - "type": "integer" + "type": "text" }, "regionId": { "type": "integer" From 34e5c0541480edc84901f14202d79b08c05fed61 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:48:20 +0900 Subject: [PATCH 124/191] =?UTF-8?q?fix:=20smallImageUrl=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content 테이블과 썸네일 이미지 중복으로 인함 --- .../content/domain/response/ContentResponse.java | 3 --- .../content/service/ContentSearchService.java | 9 +-------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index 1f3247d..22ae35f 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -24,7 +24,6 @@ public class ContentResponse { private String tel; private int zipcode; - private String smallImageUrl; private Double avgScore; private boolean wishData; @@ -33,7 +32,6 @@ public class ContentResponse { public static ContentResponse from( Content c, - String smallImageUrl, Double avgScore, boolean wishData, RegionCodeResponse regionName){ @@ -53,7 +51,6 @@ public static ContentResponse from( .mlevel(c.getMLevel()) .tel(c.getTel()) .zipcode(c.getZipCode()) - .smallImageUrl(smallImageUrl) .avgScore(avgScore) .wishData(wishData) .regionName(regionName) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 8f99af5..9be93ab 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -116,21 +116,14 @@ public List search(String title, Content content = contentMap.get(id); if (content == null) return null; - ContentImage image = contentImageRepository.findByContent_ContentId(id); - String smallImageUrl = (image != null) ? image.getSmallImageUrl() : null; - double avg = getAverageScore(id); boolean wishData = (userId != null) ? getWishData(userId, id) : false; - System.out.println("Test -- sido : "+content.getSidoCode()); - - System.out.println("Test -- sigungu : "+content.getSigunguCode()); - RegionCodeResponse regionName = getRegionName(content.getSidoCode(), content.getSigunguCode()); - return ContentResponse.from(content, smallImageUrl,avg, wishData, regionName); + return ContentResponse.from(content, avg, wishData, regionName); }) .filter(Objects::nonNull) .toList(); From 93be5ac6c1bc4ae9b0967d3500259a67a0c22793 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 21:20:21 +0900 Subject: [PATCH 125/191] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=97=90=20=ED=95=B4=EC=8B=9C=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/entity/Hashtag.java | 20 +++++++++++++++++++ .../domain/response/ContentResponse.java | 9 +++++---- .../content/repository/HashtagRepository.java | 12 +++++++++++ .../content/service/ContentSearchService.java | 8 ++++++-- 4 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java new file mode 100644 index 0000000..8291c44 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; + +@Entity +@Getter +public class Hashtag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int hashtagId; + + private int contentId; + + private String content; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index 22ae35f..ed87a48 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -4,7 +4,7 @@ import lombok.Builder; import lombok.Getter; -import java.math.BigDecimal; +import java.util.List; @Getter @Builder @@ -25,16 +25,16 @@ public class ContentResponse { private int zipcode; private Double avgScore; - private boolean wishData; - private RegionCodeResponse regionName; + private List hashtag; public static ContentResponse from( Content c, Double avgScore, boolean wishData, - RegionCodeResponse regionName){ + RegionCodeResponse regionName, + List hashtag){ return ContentResponse.builder() .contentId(c.getContentId()) @@ -54,6 +54,7 @@ public static ContentResponse from( .avgScore(avgScore) .wishData(wishData) .regionName(regionName) + .hashtag(hashtag) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java new file mode 100644 index 0000000..963fb3d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.Hashtag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface HashtagRepository extends JpaRepository { + @Query("select h.content from Hashtag h where h.contentId = :contentId") + List findContentsByContentId(int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 9be93ab..3299b33 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -33,11 +33,13 @@ public class ContentSearchService { private final ContentRepository contentRepository; private final ContentElasticRepository contentElasticRepository; private final ElasticsearchOperations elasticsearchOperations; - private final ContentImageRepository contentImageRepository; private final ContentReviewRepository contentReviewRepository; private final ContentWishRepository contentWishRepository; private final UserRepository userRepository; private final RegionCodeRepository regionCodeRepository; + private final SightsInformationRepository sightsInformationRepository; + private final RestaurantInformationRepository restaurantInformationRepository; + private final HashtagRepository hashtagRepository; public List searchByKeyword(String keyword){ return contentElasticRepository.findByTitleContaining(keyword); @@ -123,7 +125,9 @@ public List search(String title, RegionCodeResponse regionName = getRegionName(content.getSidoCode(), content.getSigunguCode()); - return ContentResponse.from(content, avg, wishData, regionName); + List hashtag = hashtagRepository.findContentsByContentId(id); + + return ContentResponse.from(content, avg, wishData, regionName,hashtag); }) .filter(Objects::nonNull) .toList(); From f3f5ba7f6c2192937df21cc9c287de775b197b39 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 21:43:11 +0900 Subject: [PATCH 126/191] =?UTF-8?q?feat:=20=ED=9C=B4=EC=9D=BC=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/response/ContentResponse.java | 5 ++++- .../RestaurantInformationRepository.java | 3 +++ .../repository/SightsInformationRepository.java | 3 +++ .../content/service/ContentSearchService.java | 15 ++++++++++++++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index ed87a48..adebfac 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -28,13 +28,15 @@ public class ContentResponse { private boolean wishData; private RegionCodeResponse regionName; private List hashtag; + private String restDate; public static ContentResponse from( Content c, Double avgScore, boolean wishData, RegionCodeResponse regionName, - List hashtag){ + List hashtag, + String restDate){ return ContentResponse.builder() .contentId(c.getContentId()) @@ -55,6 +57,7 @@ public static ContentResponse from( .wishData(wishData) .regionName(regionName) .hashtag(hashtag) + .restDate(restDate) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java index ab42d00..445df89 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java @@ -3,6 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import org.springframework.data.jpa.repository.Query; public interface RestaurantInformationRepository extends JpaRepository { + @Query("select r.restDate from RestaurantInformation r where r.content.contentId = :contentId") + String findRestDateByContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java index f5b1b28..98bfcf3 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java @@ -3,6 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; +import org.springframework.data.jpa.repository.Query; public interface SightsInformationRepository extends JpaRepository { + @Query("select s.restDate from SightsInformation s where s.content.contentId = :contentId") + String findRestDateByContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 3299b33..58abc5f 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -127,7 +127,9 @@ public List search(String title, List hashtag = hashtagRepository.findContentsByContentId(id); - return ContentResponse.from(content, avg, wishData, regionName,hashtag); + String restDate = getRestDate(id); + + return ContentResponse.from(content, avg, wishData, regionName, hashtag, restDate); }) .filter(Objects::nonNull) .toList(); @@ -161,5 +163,16 @@ public RegionCodeResponse getRegionName(int sidoCode, int sigunguCode){ return new RegionCodeResponse(sidoName,sigunguName); } + public String getRestDate(int contentId) { + + String restDate = sightsInformationRepository.findRestDateByContentId(contentId); + if (restDate != null) { + return restDate; + } + + restDate = restaurantInformationRepository.findRestDateByContentId(contentId); + return restDate; + } + } From 252fe7501e1f21166475733b32043f1a67b55d0a Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:23:34 +0900 Subject: [PATCH 127/191] =?UTF-8?q?fix:=20ci=20=EB=82=B4=20mysql=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99b9c81..a148434 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,21 @@ on: jobs: build: runs-on: ubuntu-22.04 # NCP Server version + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD_DEV }} + MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }} + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + environment: ${{ github.base_ref == 'main' && 'Product' || 'Develop' }} permissions: contents: read From 3e6cda4d5d8d39a1116fc6a20e592d0284c7ff18 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:31:19 +0900 Subject: [PATCH 128/191] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a148434..659fc44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: image: mysql:8.0 env: MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD_DEV }} - MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }} + MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE_DEV }} ports: - 3306:3306 options: >- From 3592a34ae0b1918a15fd39678276be1535a1664b Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:37:09 +0900 Subject: [PATCH 129/191] Update ci.yml --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 659fc44..fcce06b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: env: MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD_DEV }} MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE_DEV }} + MYSQL_ROOT_HOST: '%' ports: - 3306:3306 options: >- From 98c4dda1df16ebab61a5434c42a2421d204e653f Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:19:44 +0900 Subject: [PATCH 130/191] Rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로퍼티 내 url 마지막 '/' 제거 후 오류 해결 --- .github/workflows/ci.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcce06b..99b9c81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,22 +14,6 @@ on: jobs: build: runs-on: ubuntu-22.04 # NCP Server version - - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD_DEV }} - MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE_DEV }} - MYSQL_ROOT_HOST: '%' - ports: - - 3306:3306 - options: >- - --health-cmd="mysqladmin ping --silent" - --health-interval=10s - --health-timeout=5s - --health-retries=3 - environment: ${{ github.base_ref == 'main' && 'Product' || 'Develop' }} permissions: contents: read From ebb99ba38e23b96b9456478bd0b7ba7fa640f974 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Wed, 6 Aug 2025 01:22:34 +0900 Subject: [PATCH 131/191] =?UTF-8?q?bug/information=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AA=BB=EB=B0=9B?= =?UTF-8?q?=EC=95=84=EC=98=A4=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit information 테이블 데이터 못받아오는 현상 수정 --- .../java/com/batch/config/BatchConfig.java | 3 +- .../batch/processor/DetailIntroProcessor.java | 125 +++++++++++++----- .../com/batch/reader/DetailIntroReader.java | 16 ++- .../CatsgotogedogApplication.java | 12 +- .../batch/information/LodgeInformation.java | 6 +- .../information/RestaurantInformation.java | 2 +- .../response/ContentReviewPageResponse.java | 1 + .../mysql/V12__column_type_extend.sql | 33 +++++ 8 files changed, 153 insertions(+), 45 deletions(-) create mode 100644 src/main/resources/db/migration/mysql/V12__column_type_extend.sql diff --git a/src/main/java/com/batch/config/BatchConfig.java b/src/main/java/com/batch/config/BatchConfig.java index c55847b..d54acc0 100644 --- a/src/main/java/com/batch/config/BatchConfig.java +++ b/src/main/java/com/batch/config/BatchConfig.java @@ -230,10 +230,11 @@ public Step detailIntroSightsFetchStep() { .listener(customSkipListener) .build(); } + // lodge 청크이슈로 1로 설정 @Bean public Step detailIntroLodgeFetchStep() { return new StepBuilder("detailIntroLodgeFetchStep", jobRepository) - .chunk(CHUNK_SIZE, transactionManager) + .chunk(1, transactionManager) .reader(lodgeInformationItemReader) .processor(detailIntroProcessor) .writer(detailIntroWriter) diff --git a/src/main/java/com/batch/processor/DetailIntroProcessor.java b/src/main/java/com/batch/processor/DetailIntroProcessor.java index 027cb8b..7f1621a 100644 --- a/src/main/java/com/batch/processor/DetailIntroProcessor.java +++ b/src/main/java/com/batch/processor/DetailIntroProcessor.java @@ -1,7 +1,10 @@ package com.batch.processor; +import java.net.URI; import java.time.LocalDate; import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.List; import java.util.stream.Collectors; @@ -34,32 +37,33 @@ public class DetailIntroProcessor implements ItemProcessor responseEntity = restClient.get() - .uri(uriBuilder -> uriBuilder - .path("/detailIntro") - .queryParam("serviceKey", serviceKey) - .queryParam("MobileOS", "ETC") - .queryParam("MobileApp", "Catsgotogedog") - .queryParam("_type", "json") - .queryParam("contentId", content.getContentId()) - .queryParam("contentTypeId", content.getContentTypeId()) - .build() + .uri(uriBuilder -> { + URI uri = uriBuilder + .path("/detailIntro") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("contentId", content.getContentId()) + .queryParam("contentTypeId", content.getContentTypeId()) + .queryParam("_type", "json") + .build(); + log.info("소개정보 :: {}", uri); + return uri; + } ) .retrieve() - // .body(DetailIntroResponse.class); .toEntity(String.class); if(responseEntity.getBody() != null && responseEntity.getBody().contains("LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR")) { @@ -74,26 +78,37 @@ public DetailIntroProcessResult process(Content content) throws Exception { return null; } - JsonNode itemsNode = response.response().body().items(); + JsonNode itemsNode = response.response().body().items().get("item"); if(itemsNode == null || itemsNode.isEmpty()) { log.warn("{} ({}), ItemsNode 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); return null; } + + log.info("itemsNode :: {}", itemsNode); + switch (content.getContentTypeId()) { case 12 -> { log.info("{} ({}), 관광지 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); - DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + //DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List sightsItems = objectMapper.convertValue( + itemsNode, + new TypeReference>() {} + ); + + DetailIntroResponse.Items items = + new DetailIntroResponse.Items<>(sightsItems); List infos = items.item().stream() .map(dto -> SightsInformation.builder() .content(content) .contentTypeId(content.getContentTypeId()) - .accomCount(Integer.valueOf(dto.accomcount().replaceAll("[^0-9]", ""))) + .accomCount(parseAccom(dto.accomcount())) .chkCreditcard(dto.chkcreditcard()) .expAgeRange(dto.expagerange()) .expGuide(dto.expguide()) .infoCenter(dto.infocenter()) - .openDate(dto.opendate().isEmpty() ? null : LocalDate.parse(dto.opendate())) + .openDate(parseDate(dto.opendate(), 12)) .parking(dto.parking()) .restDate(dto.restdate()) .useSeason(dto.useseason()) @@ -104,12 +119,21 @@ public DetailIntroProcessResult process(Content content) throws Exception { .build() ) .collect(Collectors.toList()); + log.info("infos :: {}", infos); return new DetailIntroProcessResult(infos, null, null, null); } case 15 -> { log.info("{} ({}), 축제공연행사 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); - DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + //DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List festivalItems = objectMapper.convertValue( + itemsNode, + new TypeReference>() {} + ); + + DetailIntroResponse.Items items = + new DetailIntroResponse.Items<>(festivalItems); List infos = items.item().stream() .map(dto -> FestivalInformation.builder() @@ -117,8 +141,8 @@ public DetailIntroProcessResult process(Content content) throws Exception { .ageLimit(dto.agelimit()) .bookingPlace(dto.bookingplace()) .discountInfo(dto.discountinfofestival()) - .eventStartDate(LocalDate.parse(dto.eventstartdate())) - .eventEndDate(LocalDate.parse(dto.eventenddate())) + .eventStartDate(parseDate(dto.eventstartdate(), 15)) + .eventEndDate(parseDate(dto.eventenddate(), 15)) .eventHomepage(dto.eventhomepage()) .eventPlace(dto.eventplace()) .placeInfo(dto.placeinfo()) @@ -139,22 +163,31 @@ public DetailIntroProcessResult process(Content content) throws Exception { case 32 -> { log.info("{} ({}), 숙박 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); - DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + //DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List lodgeItems = objectMapper.convertValue( + itemsNode, + new TypeReference>() {} + ); + + DetailIntroResponse.Items items = + new DetailIntroResponse.Items<>(lodgeItems); List infos = items.item().stream() .map(dto -> LodgeInformation.builder() .content(content) - .capacityCount(Integer.valueOf(dto.accomcountlodging())) + .capacityCount(parseAccom(dto.accomcountlodging())) .benikia(dto.benikia().equals("1") ? Boolean.TRUE : Boolean.FALSE) - .checkInTime(LocalTime.parse(dto.checkintime())) - .checkOutTime(LocalTime.parse(dto.checkouttime())) + .checkInTime(dto.checkintime()) + .checkOutTime(dto.checkouttime()) .cooking(dto.chkcooking()) .foodplace(dto.foodplace()) + .pickupService(dto.pickup()) .goodstay(dto.goodstay().equals("1") ? Boolean.TRUE : Boolean.FALSE) .hanok(dto.hanok().equals("1") ? Boolean.TRUE : Boolean.FALSE) .information(dto.infocenterlodging()) .parking(dto.parkinglodging()) - .roomCount(Integer.valueOf(dto.roomcount())) + .roomCount(parseAccom(dto.roomcount())) .reservationInfo(dto.reservationlodging()) .reservationUrl(dto.reservationurl()) .roomType(dto.roomtype()) @@ -181,7 +214,15 @@ public DetailIntroProcessResult process(Content content) throws Exception { case 39 -> { log.info("{} ({}), 음식점 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); - DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + //DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List restaurantItems = objectMapper.convertValue( + itemsNode, + new TypeReference>() {} + ); + + DetailIntroResponse.Items items = + new DetailIntroResponse.Items<>(restaurantItems); List infos = items.item().stream() .map(dto -> RestaurantInformation.builder() @@ -191,13 +232,14 @@ public DetailIntroProcessResult process(Content content) throws Exception { .signatureMenu(dto.firstmenu()) .information(dto.infocenterfood()) .kidsFacility(dto.kidsfacility().equals("1") ? Boolean.TRUE : Boolean.FALSE) - .openDate(LocalDate.parse(dto.opendatefood())) + .openDate(parseDate(dto.opendatefood(), 39)) .openTime(dto.opentimefood()) .parking(dto.parkingfood()) .reservation(dto.reservationfood()) - .scale(Integer.valueOf(dto.scalefood())) + .scale(dto.scalefood().isEmpty() ? 0 : Integer.parseInt(dto.scalefood())) .smoking(dto.smoking().equals("1") ? Boolean.TRUE : Boolean.FALSE) .treatMenu(dto.treatmenu()) + .seat(dto.seat()) .build() ) .collect(Collectors.toList()); @@ -207,4 +249,27 @@ public DetailIntroProcessResult process(Content content) throws Exception { } return null; } + + private LocalDate parseDate(String dateStr, int contentType) { + if(dateStr == null || dateStr.isEmpty()) { + return null; + } + try { + switch (contentType) { + case 15 -> { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + } + default -> { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd")); + } + } + } catch (DateTimeParseException e) { + return null; + } + } + + private int parseAccom(String accom) { + String replaceAccom = accom.replaceAll("[^0-9]", ""); + return replaceAccom.isEmpty() ? 0 : Integer.parseInt(replaceAccom); + } } diff --git a/src/main/java/com/batch/reader/DetailIntroReader.java b/src/main/java/com/batch/reader/DetailIntroReader.java index 9eb0b57..aea802d 100644 --- a/src/main/java/com/batch/reader/DetailIntroReader.java +++ b/src/main/java/com/batch/reader/DetailIntroReader.java @@ -11,9 +11,11 @@ import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Configuration @RequiredArgsConstructor +@Slf4j public class DetailIntroReader { private final EntityManagerFactory entityManagerFactory; @@ -22,7 +24,8 @@ public JpaCursorItemReader sightsInformationItemReader() { String jpqlQuery = "SELECT c " + "FROM Content c " - + "WHERE NOT EXISTS (SELECT 1 FROM SightsInformation si WHERE si.content = c)"; + + "WHERE c.contentTypeId = 12 " + + "AND NOT EXISTS (SELECT 1 FROM SightsInformation si WHERE si.content = c)"; return new JpaCursorItemReaderBuilder() .name("sightsInformationItemReader") .entityManagerFactory(entityManagerFactory) @@ -35,7 +38,10 @@ public JpaCursorItemReader lodgeInformationItemReader() { String jpqlQuery = "SELECT c " + "FROM Content c " - + "WHERE NOT EXISTS (SELECT 1 FROM LodgeInformation li WHERE li.content = c)"; + + "WHERE c.contentTypeId = 32 " + + "AND NOT EXISTS (SELECT 1 FROM LodgeInformation li WHERE li.content = c) " + + "ORDER BY c.contentId"; + log.info("JPA Query : {}", jpqlQuery); return new JpaCursorItemReaderBuilder() .name("lodgeInformationItemReader") .entityManagerFactory(entityManagerFactory) @@ -48,7 +54,8 @@ public JpaCursorItemReader festivalInformationItemReader() { String jpqlQuery = "SELECT c " + "FROM Content c " - + "WHERE NOT EXISTS (SELECT 1 FROM FestivalInformation fi WHERE fi.content = c)"; + + "WHERE c.contentTypeId = 15 " + + "AND NOT EXISTS (SELECT 1 FROM FestivalInformation fi WHERE fi.content = c)"; return new JpaCursorItemReaderBuilder() .name("festivalInformationItemReader") .entityManagerFactory(entityManagerFactory) @@ -62,7 +69,8 @@ public JpaCursorItemReader restaurantInformationItemReader() { String jpqlQuery = "SELECT c " + "FROM Content c " - + "WHERE NOT EXISTS (SELECT 1 FROM RestaurantInformation ri WHERE ri.content = c)"; + + "WHERE c.contentTypeId = 39 " + + "AND NOT EXISTS (SELECT 1 FROM RestaurantInformation ri WHERE ri.content = c)"; return new JpaCursorItemReaderBuilder() .name("restaurantInformationItemReader") .entityManagerFactory(entityManagerFactory) diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index 5a95a7d..7ebf9ce 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -30,9 +30,9 @@ public static void main(String[] args) { SpringApplication.run(CatsgotogedogApplication.class, args); } - @Scheduled(cron = "0 0 1 * * ?") + @Scheduled(cron = "0 0 2 * * ?") public void runBatch() throws Exception { - log.info("############# 01시 데이터 마이그레이션 배치 진행 ##############"); + log.info("############# 02시 데이터 마이그레이션 배치 진행 ##############"); Job categoryCodeBatchJob = (Job) applicationContext.getBean("categoryCodeBatchJob"); Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); @@ -40,16 +40,16 @@ public void runBatch() throws Exception { .addLong("time", System.currentTimeMillis()) .toJobParameters(); jobLauncher.run(categoryCodeBatchJob, jobParameters); - log.info(">> 01:00 AM CategoryCode 배치 스케쥴러 작동"); + log.info(">> 02:00 AM CategoryCode 배치 스케쥴러 작동"); jobLauncher.run(contentBatchJob, jobParameters); - log.info(">> 01:00 AM Content Fetch 배치 스케쥴러 작동"); + log.info(">> 02:00 AM Content Fetch 배치 스케쥴러 작동"); } @Override public void run(String... args) throws Exception { - // log.info("############# 01시 데이터 마이그레이션 배치 진행 ##############"); + // log.info("############# 02시 데이터 마이그레이션 배치 진행 ##############"); // Job categoryCodeBatchJob = (Job) applicationContext.getBean("categoryCodeBatchJob"); // Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); // @@ -57,7 +57,7 @@ public void run(String... args) throws Exception { // .addLong("time", System.currentTimeMillis()) // .toJobParameters(); // jobLauncher.run(categoryCodeBatchJob, jobParameters); - // log.info(">> 01:00 AM CategoryCode 배치 스케쥴러 작동"); + // log.info(">> 02:00 AM CategoryCode 배치 스케쥴러 작동"); // // jobLauncher.run(contentBatchJob, jobParameters); // log.info(">> 01:00 AM Content Fetch 배치 스케쥴러 작동"); diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java index 84a75ef..64f35ba 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java @@ -44,10 +44,10 @@ public class LodgeInformation { private Boolean benikia; @Column(name = "check_in_time") - private LocalTime checkInTime; + private String checkInTime; @Column(name = "check_out_time") - private LocalTime checkOutTime; + private String checkOutTime; @Column(name = "cooking", length = 50) private String cooking; @@ -64,7 +64,7 @@ public class LodgeInformation { private String parking; @Column(name = "pickup_service") - private Boolean pickupService; + private String pickupService; @Column(name = "room_count") private Integer roomCount; diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java index 282b5c7..25d7c7f 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java @@ -69,7 +69,7 @@ public class RestaurantInformation { private String restDate; private Integer scale; - private Integer seat; + private String seat; private Boolean smoking; @Column(name = "treat_menu") diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java index c0df3f5..29d5093 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java @@ -4,6 +4,7 @@ public record ContentReviewPageResponse ( List reviews, + List reviewImages, int totalElements, int totalPages, int currentPage, diff --git a/src/main/resources/db/migration/mysql/V12__column_type_extend.sql b/src/main/resources/db/migration/mysql/V12__column_type_extend.sql new file mode 100644 index 0000000..0c213e4 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V12__column_type_extend.sql @@ -0,0 +1,33 @@ +ALTER TABLE `catsgotogedog`.`sights_information` + CHANGE COLUMN `parking` `parking` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `rest_date` `rest_date` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `use_season` `use_season` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `use_time` `use_time` TEXT NULL DEFAULT NULL, + CHANGE COLUMN `exp_age_range` `exp_age_range` TEXT NULL DEFAULT NULL; + +ALTER TABLE `catsgotogedog`.`restaurant_information` + CHANGE COLUMN `open_date` `open_date` DATE NULL DEFAULT NULL , + CHANGE COLUMN `open_time` `open_time` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `takeout` `takeout` VARCHAR(50) NULL DEFAULT NULL , + CHANGE COLUMN `reservation` `reservation` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `rest_date` `rest_date` VARCHAR(200) NULL DEFAULT NULL ; + +ALTER TABLE `catsgotogedog`.`restaurant_information` + CHANGE COLUMN `smoking` `smoking` VARCHAR(100) NULL DEFAULT NULL, + CHANGE COLUMN `treat_menu` `treat_menu` VARCHAR(200) NULL DEFAULT NULL, + CHANGE COLUMN `seat` `seat` VARCHAR(100) NULL DEFAULT NULL; + +ALTER TABLE `catsgotogedog`.`lodge_information` + CHANGE COLUMN `pickup_service` `pickup_service` VARCHAR(100) NULL DEFAULT NULL , + CHANGE COLUMN `reservation_url` `reservation_url` VARCHAR(300) NULL DEFAULT NULL, + CHANGE COLUMN `room_type` `room_type` VARCHAR(100) NULL DEFAULT NULL, + CHANGE COLUMN `scale` `scale` TEXT NULL DEFAULT NULL , + CHANGE COLUMN `sub_facility` `sub_facility` VARCHAR(200) NULL DEFAULT NULL, + CHANGE COLUMN `refund_regulation` `refund_regulation` TEXT NULL DEFAULT NULL, + CHANGE COLUMN `cooking` `cooking` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `foodplace` `foodplace` VARCHAR(200) NULL DEFAULT NULL, + CHANGE COLUMN `check_in_time` `check_in_time` VARCHAR(100) NULL DEFAULT NULL , + CHANGE COLUMN `check_out_time` `check_out_time` VARCHAR(100) NULL DEFAULT NULL, + CHANGE COLUMN `information` `information` VARCHAR(300) NULL DEFAULT NULL , + CHANGE COLUMN `parking` `parking` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `reservation_info` `reservation_info` VARCHAR(1000) NULL DEFAULT NULL; \ No newline at end of file From cbbfc12cd8f7b2ca8bdd8a5ece0572f656900ee0 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Wed, 6 Aug 2025 01:23:38 +0900 Subject: [PATCH 132/191] =?UTF-8?q?refactor/=EC=BB=A8=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컨텐츠 리뷰 목록 조회시 컨텐츠 리뷰내 모든 이미지도 불러오도록 수정 --- .../catsgotogedog/review/controller/ReviewController.java | 1 - .../catsgotogedog/review/repository/ReviewRepository.java | 6 ++++++ .../swyp/catsgotogedog/review/service/ReviewService.java | 8 ++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index d9790e0..5e5aabf 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -114,7 +114,6 @@ public ResponseEntity> fetchReviewsByContentId( Pageable pageable = PageRequest.of(page, size); String actUserId = (userId != null && !userId.equals("anonymousUser")) ? userId : null; - log.info("actUserId: {}", actUserId); ContentReviewPageResponse reviewResponses = reviewService.fetchReviewsByContentId(contentId, sort, pageable, actUserId); return ResponseEntity.ok( diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java index fec67dc..c8a9b0c 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -1,5 +1,6 @@ package com.swyp.catsgotogedog.review.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -9,6 +10,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.review.domain.entity.Review; public interface ReviewRepository extends JpaRepository { @@ -42,4 +44,8 @@ Page findByContentIdWithUserAndReviewImages( */ @EntityGraph(attributePaths = {"reviewImages"}) Page findByUserId(int userId, Pageable pageable); + + List findByContentEntity(Content contentEntity); + + List findByContentEntityContentId(int contentEntityContentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index 7b80ade..8108452 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -139,7 +139,14 @@ public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String s Sort sortObj = createSort(sort); Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortObj); + // 페이징 리뷰 Page reviewPage = reviewRepository.findByContentIdWithUserAndReviewImages(contentId, sortedPageable); + List reviews = reviewRepository.findByContentEntityContentId((contentId)); + + List contentReviewImages = reviews.stream() + .flatMap(review -> review.getReviewImages().stream()) + .map(ReviewImageResponse::from) + .toList(); Set recommendedReviewIds; if(userId != null) { @@ -181,6 +188,7 @@ public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String s return new ContentReviewPageResponse( reviewResponses, + contentReviewImages, (int) reviewPage.getTotalElements(), reviewPage.getTotalPages(), reviewPage.getNumber(), From 36a84cdfdeae156a53a54d9d3095c6ae6a2eaa21 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Wed, 6 Aug 2025 02:03:22 +0900 Subject: [PATCH 133/191] =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20?= =?UTF-8?q?2=EC=8B=9C=2020=EB=B6=84=EC=97=90=20=EB=8F=99=EC=9E=91=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/swyp/catsgotogedog/CatsgotogedogApplication.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index 7ebf9ce..eedddf1 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -30,7 +30,7 @@ public static void main(String[] args) { SpringApplication.run(CatsgotogedogApplication.class, args); } - @Scheduled(cron = "0 0 2 * * ?") + @Scheduled(cron = "0 20 2 * * ?") public void runBatch() throws Exception { log.info("############# 02시 데이터 마이그레이션 배치 진행 ##############"); Job categoryCodeBatchJob = (Job) applicationContext.getBean("categoryCodeBatchJob"); From 6b912e6ac9f643d0c9530005d66cf48a348c169d Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Wed, 6 Aug 2025 03:38:53 +0900 Subject: [PATCH 134/191] =?UTF-8?q?=EB=B0=98=EB=A0=A4=EB=8F=99=EB=AC=BC=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java | 1 + .../catsgotogedog/pet/domain/request/PetProfileRequest.java | 6 ++++++ .../pet/domain/response/PetProfileResponse.java | 2 ++ .../java/com/swyp/catsgotogedog/pet/service/PetService.java | 2 ++ src/main/resources/db/migration/mysql/V13__add_pet_type.sql | 1 + 5 files changed, 12 insertions(+) create mode 100644 src/main/resources/db/migration/mysql/V13__add_pet_type.sql diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java index 55be061..506cd07 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java @@ -39,6 +39,7 @@ public class Pet { private char gender; private LocalDate birth; + private String type; private boolean fierceDog; @ManyToOne @JoinColumn(name = "size_id", nullable = false) diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java index 8ff5fe2..9f951dd 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.Setter; import org.springframework.web.multipart.MultipartFile; @@ -26,6 +27,11 @@ public class PetProfileRequest { @NotNull(message = "반려동물 생년월일은 필수입니다.") private LocalDate birth; + @Size(min = 1, max = 20, message = "반려동물 종류는 최소 1자, 최대 20자까지 입력 가능합니다.") + @Pattern(regexp = "[가-힣a-zA-Z\\s()]+", message = "반려동물 종류는 한글, 영문, 공백, 소괄호만 입력 가능합니다.") + @NotBlank(message = "반려동물 종류는 필수입니다.") + private String type; + @NotNull(message = "맹견 여부는 필수입니다.") private boolean fierceDog; diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java index c0f3eaf..54cc44e 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java @@ -17,6 +17,7 @@ public class PetProfileResponse { private String name; private char gender; private LocalDate birth; + private String type; private boolean fierceDog; private String size; private String imageFilename; @@ -28,6 +29,7 @@ public static PetProfileResponse from(Pet pet) { .name(pet.getName()) .gender(pet.getGender()) .birth(pet.getBirth()) + .type(pet.getType()) .fierceDog(pet.isFierceDog()) .size(pet.getSizeId().getSize()) .imageFilename(pet.getImageFilename()) diff --git a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java index 561e96d..d832995 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java @@ -58,6 +58,7 @@ public void create(String userId, PetProfileRequest petProfileRequest) { .name(petProfileRequest.getName()) .gender(petProfileRequest.getGender().charAt(0)) .birth(petProfileRequest.getBirth()) + .type(petProfileRequest.getType()) .fierceDog(petProfileRequest.isFierceDog()) .sizeId(petSize) .imageUrl(imageUrl) @@ -84,6 +85,7 @@ public void updateById(String userId, int petId, PetProfileRequest petProfileReq pet.setName(petProfileRequest.getName()); pet.setGender(petProfileRequest.getGender().charAt(0)); pet.setBirth(petProfileRequest.getBirth()); + pet.setType(petProfileRequest.getType()); pet.setFierceDog(petProfileRequest.isFierceDog()); pet.setSizeId(petSize); diff --git a/src/main/resources/db/migration/mysql/V13__add_pet_type.sql b/src/main/resources/db/migration/mysql/V13__add_pet_type.sql new file mode 100644 index 0000000..8fdea0a --- /dev/null +++ b/src/main/resources/db/migration/mysql/V13__add_pet_type.sql @@ -0,0 +1 @@ +ALTER TABLE `catsgotogedog`.`pet` ADD COLUMN `type` VARCHAR(50) NULL; \ No newline at end of file From 2aaf4dcfc56466f975bb7b9353fbc2945ca9445e Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:57:41 +0900 Subject: [PATCH 135/191] =?UTF-8?q?fix:=20=EB=A6=AC=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contentId->content --- .../content/repository/ContentWishRepository.java | 3 ++- .../com/swyp/catsgotogedog/content/service/ContentService.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java index 2da8f27..ff17c25 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -14,7 +14,8 @@ public interface ContentWishRepository extends JpaRepository { - int countByContentId(int contentId); + @Query("SELECT COUNT(c) FROM ContentWish c WHERE c.content.contentId = :contentId") + int countByContentContentId(int contentId); @Query("SELECT cw FROM ContentWish cw WHERE cw.userId = :userId AND cw.content.contentId = :contentId") Optional findByUserIdAndContentId(@Param("userId") int userId, @Param("contentId") int contentId); diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 6e51f4a..40f4e8d 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -73,7 +73,7 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ boolean wishData = (userId != null) ? contentSearchService.getWishData(userId, contentId) : false; - int wishCnt = contentWishRepository.countByContentId(contentId); + int wishCnt = contentWishRepository.countByContentContentId(contentId); boolean visited = hasVisited(userId, contentId); From 8a4cba254ad3ea60b9db2cc8ac0dc4fa36d0e5d1 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:13:53 +0900 Subject: [PATCH 136/191] =?UTF-8?q?fix:=20=EB=A6=AC=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserId->User_userId --- .../content/repository/VisitHistoryRepository.java | 2 +- .../com/swyp/catsgotogedog/content/service/ContentService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java index c9be9da..07fd5e0 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java @@ -4,5 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface VisitHistoryRepository extends JpaRepository { - boolean existsByUser_IdAndContent_ContentId(int userId, Integer contentId); + boolean existsByUser_UserIdAndContent_ContentId(int userId, Integer contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 40f4e8d..81e59e0 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -123,6 +123,6 @@ public boolean hasVisited(String userId, int contentId) { if (userId == null || userId.isBlank()) { return false; } - return visitHistoryRepository.existsByUser_IdAndContent_ContentId(Integer.parseInt(userId), contentId); + return visitHistoryRepository.existsByUser_UserIdAndContent_ContentId(Integer.parseInt(userId), contentId); } } From 39fafc7e46d9d4134d8b935f34581de91959d541 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Wed, 6 Aug 2025 21:01:23 +0900 Subject: [PATCH 137/191] =?UTF-8?q?=EB=B9=84=EC=86=8D=EC=96=B4=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B0=98=EB=A0=A4=EB=8F=99=EB=AC=BC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EB=8B=A8=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../User/service/UserService.java | 10 +++ .../common/config/HttpClientConfig.java | 16 +++++ .../common/config/PerspectiveApiConfig.java | 20 ++++++ .../dto/PerspectiveApiRequest.java | 19 ++++++ .../dto/PerspectiveApiResponse.java | 61 +++++++++++++++++++ .../dto/ToxicityCheckResult.java | 23 +++++++ .../service/PerspectiveApiService.java | 60 ++++++++++++++++++ .../service/ToxicityCheckService.java | 60 ++++++++++++++++++ .../global/exception/ErrorCode.java | 6 ++ .../exception/PerspectiveApiException.java | 8 +++ .../domain/validation/PetNameValidator.java | 19 ++++++ 11 files changed, 302 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/HttpClientConfig.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/config/PerspectiveApiConfig.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/ToxicityCheckResult.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/PerspectiveApiService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/ToxicityCheckService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/exception/PerspectiveApiException.java diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java index 856b3e9..a609b06 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -4,6 +4,8 @@ import com.swyp.catsgotogedog.common.util.image.storage.ImageStorageService; import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.ToxicityCheckResult; +import com.swyp.catsgotogedog.common.util.perspectiveApi.service.ToxicityCheckService; import com.swyp.catsgotogedog.global.exception.*; import org.springframework.stereotype.Service; @@ -27,6 +29,7 @@ public class UserService { private final RefreshTokenService rtService; private final JwtTokenUtil jwt; private final ImageStorageService imageStorageService; + private final ToxicityCheckService toxicityCheckService; public String reIssue(String refreshToken) { @@ -77,6 +80,13 @@ public void update(String userId, UserUpdateRequest request) { if (userRepository.existsByDisplayName(newDisplayName)) { throw new CatsgotogedogException(ErrorCode.DUPLICATE_DISPLAY_NAME); } + // 닉네임 변경 + ToxicityCheckResult checkResult = toxicityCheckService.checkNickname(newDisplayName); + if (!checkResult.passed()) { + log.debug("닉네임 '{}'은(는) 독성 점수 {}로 부적절합니다. 기준치: {}", + newDisplayName, checkResult.toxicityScore(), checkResult.threshold()); + throw new CatsgotogedogException(ErrorCode.TOO_TOXIC_DISPLAY_NAME); + } user.setDisplayName(newDisplayName); user.setNameUpdateAt(LocalDateTime.now()); } diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/HttpClientConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/HttpClientConfig.java new file mode 100644 index 0000000..8ef12a2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/HttpClientConfig.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class HttpClientConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/PerspectiveApiConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/PerspectiveApiConfig.java new file mode 100644 index 0000000..e164ff2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/PerspectiveApiConfig.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.common.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Getter +@Configuration +public class PerspectiveApiConfig { + + @Value("${google.perspective.api.url}") + private String url; + + @Value("${google.perspective.api.key}") + private String apiKey; + + private final double nicknameThreshold = 0.6; + private final double petNameThreshold = 0.8; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiRequest.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiRequest.java new file mode 100644 index 0000000..41fc384 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiRequest.java @@ -0,0 +1,19 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Map; + +@Getter +@Setter +public class PerspectiveApiRequest { + + private Map comment; + private Map requestedAttributes; + + public PerspectiveApiRequest(String text) { + this.comment = Map.of("text", text); + this.requestedAttributes = Map.of("TOXICITY", Map.of()); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiResponse.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiResponse.java new file mode 100644 index 0000000..b825a5b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiResponse.java @@ -0,0 +1,61 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.PerspectiveApiException; +import lombok.extern.slf4j.Slf4j; + +/** + * Perspective API 응답 처리 유틸리티 + */ +@Slf4j +public class PerspectiveApiResponse { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Perspective API 응답 JSON에서 독성 점수를 추출 + * + * @param responseJson API 응답 JSON 문자열 + * @return 독성 점수 (0.0 ~ 1.0) + * @throws PerspectiveApiException API 응답 파싱 실패 시 + */ + public static double extractToxicityScore(String responseJson) { + try { + JsonNode response = objectMapper.readTree(responseJson); + + // attributeScores.TOXICITY.summaryScore.value 경로로 점수 추출 + JsonNode attributeScores = response.get("attributeScores"); + if (attributeScores == null) { + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + + JsonNode toxicity = attributeScores.get("TOXICITY"); + if (toxicity == null) { + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + + JsonNode summaryScore = toxicity.get("summaryScore"); + if (summaryScore == null) { + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + + JsonNode valueNode = summaryScore.get("value"); + if (valueNode == null) { + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + + double score = valueNode.asDouble(); + log.debug("독성 점수 추출 완료: {}", score); + + return score; + + } catch (PerspectiveApiException e) { + throw e; + } catch (Exception e) { + log.error("Perspective API 응답 파싱 실패: {}", e.getMessage(), e); + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/ToxicityCheckResult.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/ToxicityCheckResult.java new file mode 100644 index 0000000..7e93296 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/ToxicityCheckResult.java @@ -0,0 +1,23 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.dto; + +/** + * 독성 검사 결과를 나타내는 DTO 클래스 + * + * @param passed 검사 통과 여부 + * @param toxicityScore 독성 점수 (0.0 ~ 1.0) + * @param threshold 독성 점수 임계값 + * @param text 검사 대상 텍스트 + * @param errorMessage 오류 메시지 (검사 실패 시) + */ +public record ToxicityCheckResult(boolean passed, double toxicityScore, double threshold, String text, + String errorMessage) { + + public static ToxicityCheckResult success(String text, double toxicityScore, double threshold) { + boolean passed = toxicityScore <= threshold; + return new ToxicityCheckResult(passed, toxicityScore, threshold, text, null); + } + + public static ToxicityCheckResult failure(String text, double threshold, String errorMessage) { + return new ToxicityCheckResult(false, -1.0, threshold, text, errorMessage); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/PerspectiveApiService.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/PerspectiveApiService.java new file mode 100644 index 0000000..30dcc22 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/PerspectiveApiService.java @@ -0,0 +1,60 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.common.config.PerspectiveApiConfig; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.PerspectiveApiRequest; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.PerspectiveApiResponse; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.PerspectiveApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PerspectiveApiService { + + private final PerspectiveApiConfig config; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public double getToxicityScore(String text) { + // 1. 요청 객체 생성 + PerspectiveApiRequest request = new PerspectiveApiRequest(text); + + // 2. API 호출 + String response = callApi(request); + + // 3. 응답 파싱 + return parseResponse(response); + } + + private String callApi(PerspectiveApiRequest request) { + try { + String url = config.getUrl() + "?key=" + config.getApiKey(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String requestJson = objectMapper.writeValueAsString(request); + HttpEntity entity = new HttpEntity<>(requestJson, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + return response.getBody(); + } catch (Exception e) { + log.error("Perspective API 요청 실패: {}", e.getMessage(), e); + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_REQUEST_ERROR); + } + } + + private double parseResponse(String responseJson) { + return PerspectiveApiResponse.extractToxicityScore(responseJson); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/ToxicityCheckService.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/ToxicityCheckService.java new file mode 100644 index 0000000..6dd6ca6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/ToxicityCheckService.java @@ -0,0 +1,60 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.service; + +import com.swyp.catsgotogedog.common.config.PerspectiveApiConfig; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.ToxicityCheckResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ToxicityCheckService { + + private final PerspectiveApiService perspectiveApiService; + private final PerspectiveApiConfig config; + + /** + * 닉네임의 독성 점수를 체크합니다. + * @param nickname 검사할 닉네임 + * @return ToxicityCheckResult 객체 + */ + public ToxicityCheckResult checkNickname(String nickname) { + return checkText(nickname, config.getNicknameThreshold()); + } + + /** + * 애완동물 이름의 독성 점수를 체크합니다. + * @param petName 검사할 애완동물 이름 + * @return ToxicityCheckResult 객체 + */ + public ToxicityCheckResult checkPetName(String petName) { + return checkText(petName, config.getPetNameThreshold()); + } + + /** + * 독성 점수를 체크합니다. + * @param text 검사할 텍스트 + * @param threshold 독성 점수 기준 + * @return ToxicityCheckResult 객체 + */ + public ToxicityCheckResult checkText(String text, double threshold) { + if (text == null || text.trim().isEmpty()) { + return ToxicityCheckResult.failure(text, threshold, "텍스트가 비어있습니다."); + } + try { + double score = perspectiveApiService.getToxicityScore(text.trim()); + + boolean passed = score <= threshold; + log.debug("독성 검사 - 텍스트: '{}', 점수: {}, 기준: {}, 통과: {}", + text, score, threshold, passed); + + return ToxicityCheckResult.success(text, score, threshold); + + } catch (Exception e) { + log.error("독성 검사 실패: {}", e.getMessage(), e); + return ToxicityCheckResult.failure(text, threshold, + "독성 검사 중 오류가 발생했습니다: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index da1a4db..708eaf8 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -46,6 +46,7 @@ public enum ErrorCode { DUPLICATE_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "이미 사용 중인 닉네임입니다."), DISPLAY_NAME_UPDATE_TOO_SOON(HttpStatus.BAD_REQUEST.value(), "닉네임은 24시간마다 한 번만 변경할 수 있습니다."), SAME_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "현재 닉네임과 동일합니다."), + TOO_TOXIC_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "부적절한 닉네임입니다. 다른 닉네임을 사용해주세요."), // 반려동물 관련 (Pet) PET_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 반려동물입니다."), @@ -71,6 +72,11 @@ public enum ErrorCode { IMAGE_KEY_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 키가 누락 되었습니다."), IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."), + // 필터링 API 관련 + PERSPECTIVE_API_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Perspective API 연결에 실패했습니다."), + PERSPECTIVE_API_RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Perspective API 응답 처리에 실패했습니다."), + PERSPECTIVE_API_REQUEST_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Perspective API 요청 준비에 실패했습니다."), + // Stream IO Exception STREAM_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR.value(), "스트림 처리 중 오류가 발생했습니다."); diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/PerspectiveApiException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/PerspectiveApiException.java new file mode 100644 index 0000000..a5cb1fd --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/PerspectiveApiException.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.global.exception; + +public class PerspectiveApiException extends CatsgotogedogException { + + public PerspectiveApiException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java index d0f9b0e..16a6ee5 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java @@ -1,12 +1,20 @@ package com.swyp.catsgotogedog.pet.domain.validation; +import com.swyp.catsgotogedog.common.util.perspectiveApi.service.ToxicityCheckService; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.ToxicityCheckResult; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; import java.util.regex.Pattern; +@Component +@RequiredArgsConstructor public class PetNameValidator implements ConstraintValidator { + private final ToxicityCheckService toxicityCheckService; + // 허용된 특수문자: 쉼표(,), 마침표(.), 작은따옴표(') private static final Pattern ALLOWED_SPECIAL_CHARS = Pattern.compile("[,.'_]"); @@ -61,6 +69,17 @@ public boolean isValid(String name, ConstraintValidatorContext context) { return false; } + // 5. 비속어 필터링 체크 (반려동물 이름 기준치: 0.8) + ToxicityCheckResult toxicityResult = toxicityCheckService.checkPetName(trimmedName); + if (!toxicityResult.passed()) { + if (toxicityResult.errorMessage() != null) { + setCustomMessage(context, "반려동물 이름 검증 중 오류가 발생했습니다."); + } else { + setCustomMessage(context, "부적절한 반려동물 이름입니다. 다른 이름을 사용해주세요."); + } + return false; + } + return true; } From aa53030bebde7373d3f6bbf6f22d519e96614c02 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Wed, 6 Aug 2025 23:27:42 +0900 Subject: [PATCH 138/191] =?UTF-8?q?fix:=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modifying, 트랜잭션 처리 --- .../catsgotogedog/content/repository/ViewTotalRepository.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java index 924040b..12d236e 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java @@ -1,10 +1,14 @@ package com.swyp.catsgotogedog.content.repository; import com.swyp.catsgotogedog.content.domain.entity.ViewTotal; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; public interface ViewTotalRepository extends JpaRepository { + @Modifying + @Transactional @Query(value = """ INSERT INTO view_total (content_id, total_view, updated_at) VALUES (:contentId, 1, NOW()) From 2a38c96085cfd9c33128e3c3699444788306ab21 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Wed, 6 Aug 2025 23:28:31 +0900 Subject: [PATCH 139/191] =?UTF-8?q?feat:=20=EC=B4=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/domain/entity/ViewTotal.java | 4 ++-- .../content/domain/response/PlaceDetailResponse.java | 7 +++++-- .../content/repository/ViewTotalRepository.java | 7 +++++++ .../swyp/catsgotogedog/content/service/ContentService.java | 6 +++++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java index 4a7bcd0..33259ea 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java @@ -11,7 +11,7 @@ public class ViewTotal { @Id @Column(name = "content_id") - private Long contentId; + private int contentId; @MapsId @OneToOne(fetch = FetchType.LAZY, optional = false) @@ -19,7 +19,7 @@ public class ViewTotal { private Content content; @Column(name = "total_view", nullable = false) - private Integer totalView; + private int totalView; @LastModifiedDate @Column(name = "updated_at", diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java index 9d451c9..e7e4bff 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -23,7 +23,8 @@ public record PlaceDetailResponse( Double avgScore, boolean wishData, int wishCnt, - boolean visited) { + boolean visited, + int totalView) { public static PlaceDetailResponse from( Content c, @@ -31,7 +32,8 @@ public static PlaceDetailResponse from( Double avgScore, boolean wishData, int wishCnt, - boolean visited){ + boolean visited, + int totalView){ return PlaceDetailResponse.builder() .contentId(c.getContentId()) @@ -53,6 +55,7 @@ public static PlaceDetailResponse from( .wishData(wishData) .wishCnt(wishCnt) .visited(visited) + .totalView(totalView) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java index 12d236e..595b460 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java @@ -17,4 +17,11 @@ INSERT INTO view_total (content_id, total_view, updated_at) updated_at = NOW() """, nativeQuery = true) void upsertAndIncrease(int contentId); + + @Query(""" + SELECT vt.totalView + FROM ViewTotal vt + WHERE vt.contentId = :contentId + """) + int findTotalViewByContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 81e59e0..8a921a1 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -11,6 +11,7 @@ import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.repository.*; import jakarta.persistence.EntityNotFoundException; +import jakarta.transaction.Transactional; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -23,6 +24,7 @@ @Service @RequiredArgsConstructor @Slf4j +@Transactional public class ContentService { private final ContentRepository contentRepository; private final ContentElasticRepository contentElasticRepository; @@ -77,7 +79,9 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ boolean visited = hasVisited(userId, contentId); - return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData,wishCnt,visited); + int totalView = viewTotalRepository.findTotalViewByContentId(contentId); + + return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData,wishCnt,visited,totalView); } public void recordView(String userId, int contentId){ From 6492c3172d5015a969d271644e05869210e000ba Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Wed, 6 Aug 2025 23:31:48 +0900 Subject: [PATCH 140/191] =?UTF-8?q?feat:=20content=20=EB=82=B4=20overview?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/response/PlaceDetailResponse.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java index e7e4bff..f6ee919 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -24,7 +24,8 @@ public record PlaceDetailResponse( boolean wishData, int wishCnt, boolean visited, - int totalView) { + int totalView, + String overview) { public static PlaceDetailResponse from( Content c, @@ -56,6 +57,7 @@ public static PlaceDetailResponse from( .wishCnt(wishCnt) .visited(visited) .totalView(totalView) + .overview(c.getOverview()) .build(); } } From ca6cf65b7542b2b418416b0d93653a8002cc67b6 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 7 Aug 2025 00:15:34 +0900 Subject: [PATCH 141/191] =?UTF-8?q?feat:=20=EC=83=81=EC=84=B8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=84=EB=8B=AC=EC=97=90=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=B6=94=EA=B0=80,=20smallImageUrl?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/response/ContentImageResponse.java | 6 ++++++ .../domain/response/PlaceDetailResponse.java | 12 ++++++----- .../repository/ContentImageRepository.java | 5 +++++ .../content/service/ContentService.java | 21 ++++++++++++++----- 4 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentImageResponse.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentImageResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentImageResponse.java new file mode 100644 index 0000000..8417f20 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentImageResponse.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.content.domain.response; + +public record ContentImageResponse( + String imageUrl, + String imageFilename +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java index f6ee919..75a7592 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -3,6 +3,8 @@ import com.swyp.catsgotogedog.content.domain.entity.Content; import lombok.Builder; +import java.util.List; + @Builder public record PlaceDetailResponse( int contentId, @@ -19,22 +21,22 @@ public record PlaceDetailResponse( int mlevel, String tel, int zipcode, - String smallImageUrl, Double avgScore, boolean wishData, int wishCnt, boolean visited, int totalView, - String overview) { + String overview, + List detailImage) { public static PlaceDetailResponse from( Content c, - String smallImageUrl, Double avgScore, boolean wishData, int wishCnt, boolean visited, - int totalView){ + int totalView, + List detailImage){ return PlaceDetailResponse.builder() .contentId(c.getContentId()) @@ -51,13 +53,13 @@ public static PlaceDetailResponse from( .mlevel(c.getMLevel()) .tel(c.getTel()) .zipcode(c.getZipCode()) - .smallImageUrl(smallImageUrl) .avgScore(avgScore) .wishData(wishData) .wishCnt(wishCnt) .visited(visited) .totalView(totalView) .overview(c.getOverview()) + .detailImage(detailImage) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java index 6e88899..8c8266a 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java @@ -1,8 +1,13 @@ package com.swyp.catsgotogedog.content.repository; +import com.swyp.catsgotogedog.content.domain.entity.Content; import org.springframework.data.jpa.repository.JpaRepository; import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import java.util.List; + public interface ContentImageRepository extends JpaRepository { ContentImage findByContent_ContentId(int contentId); + + List findAllByContent(Content content); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 8a921a1..83ecbf3 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -7,6 +7,7 @@ import com.swyp.catsgotogedog.content.domain.entity.ContentImage; import com.swyp.catsgotogedog.content.domain.entity.ViewLog; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; +import com.swyp.catsgotogedog.content.domain.response.ContentImageResponse; import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.repository.*; @@ -67,10 +68,6 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ Content content = contentRepository.findByContentId(contentId); - ContentImage contentImage = contentImageRepository.findByContent_ContentId(contentId); - - String smallImageUrl = (contentImage != null) ? contentImage.getSmallImageUrl() : null; - double avg = contentSearchService.getAverageScore(contentId); boolean wishData = (userId != null) ? contentSearchService.getWishData(userId, contentId) : false; @@ -81,7 +78,9 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ int totalView = viewTotalRepository.findTotalViewByContentId(contentId); - return PlaceDetailResponse.from(content,smallImageUrl,avg,wishData,wishCnt,visited,totalView); + List detailImage = getDetailImage(contentId); + + return PlaceDetailResponse.from(content,avg,wishData,wishCnt,visited,totalView,detailImage); } public void recordView(String userId, int contentId){ @@ -129,4 +128,16 @@ public boolean hasVisited(String userId, int contentId) { } return visitHistoryRepository.existsByUser_UserIdAndContent_ContentId(Integer.parseInt(userId), contentId); } + + public List getDetailImage(int contentId){ + Content content = contentRepository.findByContentId(contentId); + List images = contentImageRepository.findAllByContent(content); + + return images.stream() + .map(ci -> new ContentImageResponse( + ci.getImageUrl(), + ci.getImageFilename() + )) + .toList(); + } } From d49ee2c0ed839982fc63d4bb80afde5040cdabd0 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 7 Aug 2025 01:07:35 +0900 Subject: [PATCH 142/191] =?UTF-8?q?feat:=20=EB=B0=A9=EB=AC=B8=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=ED=95=B4=EC=A0=9C?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 10 ++++++ .../content/domain/entity/VisitHistory.java | 2 ++ .../repository/VisitHistoryRepository.java | 7 +++- .../content/service/ContentService.java | 33 ++++++++++++++++--- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 681d2c5..36d37e6 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import java.util.List; +import java.util.Map; @RestController @RequiredArgsConstructor @@ -73,4 +74,13 @@ public ResponseEntity> getRecentViews(@Authenticat return ResponseEntity.ok().body(recent); } + @GetMapping("/visited-check") + public ResponseEntity checkVisited( + @AuthenticationPrincipal String userId, + @RequestParam int contentId + ) { + boolean visited = contentService.checkVisited(userId, contentId); + return ResponseEntity.ok(Map.of("visited", visited)); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java index bfa4c19..0fad924 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java @@ -2,10 +2,12 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; @Entity @Getter +@Builder public class VisitHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java index 07fd5e0..3a2d45b 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java @@ -1,8 +1,13 @@ package com.swyp.catsgotogedog.content.repository; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.VisitHistory; import org.springframework.data.jpa.repository.JpaRepository; public interface VisitHistoryRepository extends JpaRepository { - boolean existsByUser_UserIdAndContent_ContentId(int userId, Integer contentId); + boolean existsByUser_UserIdAndContent_ContentId(int userId, int contentId); + + void deleteByUserAndContent(User user, Content content); + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 83ecbf3..ec3f8e7 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -2,15 +2,14 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; -import com.swyp.catsgotogedog.content.domain.entity.Content; -import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; -import com.swyp.catsgotogedog.content.domain.entity.ContentImage; -import com.swyp.catsgotogedog.content.domain.entity.ViewLog; +import com.swyp.catsgotogedog.content.domain.entity.*; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; import com.swyp.catsgotogedog.content.domain.response.ContentImageResponse; import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.repository.*; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; import jakarta.persistence.EntityNotFoundException; import jakarta.transaction.Transactional; import org.springframework.data.domain.PageRequest; @@ -108,6 +107,32 @@ public void recordView(String userId, int contentId){ ); } + public boolean checkVisited(String userId, int contentId){ + if (userId == null || userId.isBlank()) { + return false; + } + + User user = userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + + Content content = contentRepository.findByContentId(contentId); + + boolean visited = hasVisited(userId, contentId); + + if(visited){ + visitHistoryRepository.deleteByUserAndContent(user,content); + return false; + }else{ + VisitHistory vh = VisitHistory.builder() + .user(user) + .content(content) + .build(); + visitHistoryRepository.save(vh); + return true; + } + } + + public List getRecentViews(String userId) { if (userId == null || userId.isBlank()) { From 24ea043936627a9290a9509ec4c7e1049ab5326c Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 7 Aug 2025 01:24:45 +0900 Subject: [PATCH 143/191] =?UTF-8?q?feat:=20swagger=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ContentControllerSwagger.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index 57ab52a..e03e1b5 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -14,10 +14,12 @@ 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.RequestParam; import java.io.IOException; import java.util.List; +import java.util.Map; @Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") public interface ContentControllerSwagger { @@ -81,4 +83,27 @@ ResponseEntity> getRecentViews( @AuthenticationPrincipal String userId ); + @Operation( + summary = "방문 여부 체크", + description = "로그인된 사용자의 해당 콘텐츠 방문 여부를 체크 또는 해제, " + + "체크돼 있으면 해제하고(false), 체크돼 있지 않으면 체크합니다(true). " + + "비회원인 경우 아무 동작 없이 false 반환", + security = { @SecurityRequirement(name = "bearer-key") } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "체크 결과", + content = @Content(schema = @Schema(implementation = Map.class)) + ), + @ApiResponse(responseCode = "401", description = "인증 필요"), + @ApiResponse(responseCode = "404", description = "해당 콘텐츠 또는 사용자 없음") + }) + @GetMapping("/visited-check") + ResponseEntity checkVisited( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @Parameter(description = "장소 ID", required = true) + @RequestParam int contentId + ); + } From 29f29613529a3ec7dd673d157b0bc40d96c906f5 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Thu, 7 Aug 2025 05:08:27 +0900 Subject: [PATCH 144/191] =?UTF-8?q?feat/=ED=95=B4=EC=8B=9C=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=83=9D=EC=84=B1=20=EC=8A=A4=EC=BC=80=EC=A5=B4?= =?UTF-8?q?=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 해시태그 생성 스케쥴러 작성 매일 새벽 3시에 동작 --- .../content/domain/entity/Hashtag.java | 11 ++ .../domain/request/ClovaApiRequest.java | 39 ++++ .../request/ClovaGenerationRequest.java | 13 ++ .../domain/response/HashtagClovaResponse.java | 14 ++ .../content/repository/ContentRepository.java | 9 + .../content/repository/HashtagRepository.java | 4 + .../content/service/HashtagServcie.java | 176 ++++++++++++++++++ .../global/HashtagGenerator.java | 41 ++++ .../global/exception/ErrorCode.java | 4 +- .../V13__alter_hashtag_autoincreament.sql | 2 + 10 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaApiRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaGenerationRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/HashtagClovaResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java create mode 100644 src/main/resources/db/migration/mysql/V13__alter_hashtag_autoincreament.sql diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java index 8291c44..6c02f26 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java @@ -1,20 +1,31 @@ package com.swyp.catsgotogedog.content.domain.entity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; 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 @Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Hashtag { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "hashtag_id") private int hashtagId; + @Column(name = "content_id") private int contentId; + @Column(name = "content") private String content; } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaApiRequest.java b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaApiRequest.java new file mode 100644 index 0000000..02dcd4a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaApiRequest.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.content.domain.request; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClovaApiRequest { + private List messages; + private double topP = 0.8; + private int topK = 40; + private int maxTokens = 256; + private double temperature = 0.3; + private double repetitionPenalty = 1.1; + private List stop = new ArrayList<>(); + private int seed = 0; + private boolean includeAiFilters = true; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Message { + private String role; + private List content; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Content { + private String type; + private String text; + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaGenerationRequest.java b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaGenerationRequest.java new file mode 100644 index 0000000..e2932a7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaGenerationRequest.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.content.domain.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClovaGenerationRequest { + private String title; + private String content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/HashtagClovaResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/HashtagClovaResponse.java new file mode 100644 index 0000000..30413d8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/HashtagClovaResponse.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HashtagClovaResponse { + private List hashtags; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java index 3a93af5..c6bfea2 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -1,10 +1,19 @@ package com.swyp.catsgotogedog.content.repository; +import java.util.List; + import com.swyp.catsgotogedog.content.domain.entity.Content; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import com.swyp.catsgotogedog.content.domain.entity.Content; public interface ContentRepository extends JpaRepository { Content findByContentId(int contentId); + + @Query(value = + "SELECT c.* FROM content c " + + "LEFT JOIN hashtag h ON c.content_id = h.content_id " + + "WHERE h.content_id IS NULL", nativeQuery = true) + List findContentsWithoutHashtags(); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java index 963fb3d..352dd66 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java @@ -9,4 +9,8 @@ public interface HashtagRepository extends JpaRepository { @Query("select h.content from Hashtag h where h.contentId = :contentId") List findContentsByContentId(int contentId); + + boolean existsByContentId(int contentId); + + List findByContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java b/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java new file mode 100644 index 0000000..7859071 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java @@ -0,0 +1,176 @@ +package com.swyp.catsgotogedog.content.service; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.Hashtag; +import com.swyp.catsgotogedog.content.domain.request.ClovaApiRequest; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class HashtagServcie { + + @Value("${clova.api.url}") + private String apiUrl; + + @Value("${clova.api.key}") + private String apiKey; + + @Value("${clova.api.request-id}") + private String requestId; + + private final RestClient.Builder restClientBuilder; + private final ObjectMapper objectMapper; + + private final ContentRepository contentRepository; + private final HashtagRepository hashtagRepository; + + private static final String SYSTEM_PROMPT = """ + 당신은 관광지 정보를 분석하여 효과적인 해시태그를 생성하는 전문 AI입니다. + 제공된 관광지의 제목과 내용을 분석하여 검색성과 마케팅 효과를 높이는 관련성 높은 해시태그를 생성합니다. + 형식: + 띄어쓰기 없이 연결하여 작성 + 기호 포함하여 출력 + 출력형식: + { + "hashtags": [ + "#해시태그1", + "#해시태그2", + "...", + "#해시태그10" + ] + } + """; + + @Transactional + public void generateAndSaveHashTags(int contentId) { + if(hashtagRepository.existsByContentId(contentId)) { + return; + } + Content content = contentRepository.findById(contentId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.CONTENT_NOT_FOUND)); + + try { + List hashtags = generateHashtags(content.getTitle(), content.getOverview()); + log.info("생성된 해시태그 :: {}", hashtags); + + if(hashtags != null && !hashtags.isEmpty()) { + if(hashtags.size() > 5) { + saveHashtags(contentId, hashtags); + } + } + } catch (Exception e) { + log.error("해시태그 생성중 오류 발생 : {}", contentId, e); + } + } + + private void saveHashtags(int contentId, List hashtags) { + List hashLists = hashtags.stream() + .map(tag -> { + return Hashtag.builder() + .contentId(contentId) + .content(tag) + .build(); + }) + .toList(); + + hashtagRepository.saveAll(hashLists); + log.info("해시태그 {}개 저장 완료 :: {}", hashtags.size(), contentId); + } + + private List generateHashtags(String title, String overview) { + log.info("{} 의 해시태그 생성중", title); + try { + ClovaApiRequest request = createRequest(title, overview); + + RestClient restClient = restClientBuilder + .baseUrl(apiUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("X-NCP-CLOVASTUDIO-REQUEST-ID", requestId) + .defaultHeader("X-NCP-CLOVASTUDIO-REQUEST-ID", requestId) + .defaultHeader("Accept", "text/event-stream") + .build(); + + String response = restClient.post() + .body(request) + .retrieve() + .body(String.class); + + return parseHashtags(response); + } catch(Exception e) { + log.error("해시태그 API 요청 중 오류 발생", e); + return null; + } + } + + + private ClovaApiRequest createRequest(String title, String overview) { + String userContent = String.format("제목: %s\n내용: %s", + title != null ? title : "", + overview != null ? overview.substring(0, Math.min(overview.length(), 500)) : ""); + + ClovaApiRequest.Message.Content systemContent = new ClovaApiRequest.Message.Content("text", SYSTEM_PROMPT); + ClovaApiRequest.Message.Content userContentObj = new ClovaApiRequest.Message.Content("text", userContent); + + ClovaApiRequest.Message systemMessage = new ClovaApiRequest.Message("system", List.of(systemContent)); + ClovaApiRequest.Message userMessage = new ClovaApiRequest.Message("user", List.of(userContentObj)); + + ClovaApiRequest request = new ClovaApiRequest(); + request.setMessages(List.of(systemMessage, userMessage)); + return request; + } + + private List parseHashtags(String response) { + try { + String[] lines = response.split("\n"); + + for(int i = 0; i < lines.length; i++) { + if(lines[i].trim().equals("event:result") && i + 1 < lines.length) { + String dataLine = lines[i + 1]; + + if(dataLine.startsWith("data:")) { + String jsonData = dataLine.substring(5); + JsonNode rootNode = objectMapper.readTree(jsonData); + + String contentJson = rootNode + .path("message") + .path("content").asText(); + + JsonNode contentNode = objectMapper.readTree(contentJson); + JsonNode hashtagArr = contentNode.path("hashtags"); + + if(hashtagArr.isArray() && hashtagArr.size() > 0) { + String hashtagString = hashtagArr.get(0).asText(); + + return Arrays.stream(hashtagString.split(" ")) + .filter(tag -> tag.startsWith("#")) + .limit(10) + .toList(); + } + } + } + } + return Collections.emptyList(); + + } catch(Exception e) { + throw new RuntimeException("해시태그 파싱 오류", e); + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java b/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java new file mode 100644 index 0000000..c56230d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java @@ -0,0 +1,41 @@ +package com.swyp.catsgotogedog.global; + +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.content.service.HashtagServcie; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class HashtagGenerator { + + private final HashtagRepository hashtagRepository; + private final ContentRepository contentRepository; + private final HashtagServcie hashtagServcie; + + @Scheduled(cron = "0 0 3 * * ?") + public void generateHashtags() { + List contentIds = contentRepository.findAll().stream() + .map(Content::getContentId) + .filter(id -> !hashtagRepository.existsByContentId(id)) + .toList(); + + for(Integer contentId : contentIds) { + try { + hashtagServcie.generateAndSaveHashTags(contentId); + Thread.sleep(5000); + } catch (Exception e) { + log.error("해시태그 생성 배치 실행 중 오류 발생 ({})", contentId, e); + } + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index da1a4db..77cc9ef 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -38,10 +38,12 @@ public enum ErrorCode { REPORT_REASON_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 신고 사유입니다."), ALREADY_REPORTED(HttpStatus.BAD_REQUEST.value(), "이미 신고한 리뷰입니다."), OWN_REVIEW_CANT_REPORT(HttpStatus.BAD_REQUEST.value(), "자신이 작성한 리뷰는 신고할 수 없어요."), + CLOVA_HASHTAG_CLIENT_ERROR(HttpStatus.BAD_REQUEST.value(), "Hashtag 생성 요청중 오류 발생"), + CLOVA_HASHTAG_SERVER_ERROR(HttpStatus.BAD_REQUEST.value(), "클로바 서버 연결 오류"), // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), - + // User 관련 DUPLICATE_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "이미 사용 중인 닉네임입니다."), DISPLAY_NAME_UPDATE_TOO_SOON(HttpStatus.BAD_REQUEST.value(), "닉네임은 24시간마다 한 번만 변경할 수 있습니다."), diff --git a/src/main/resources/db/migration/mysql/V13__alter_hashtag_autoincreament.sql b/src/main/resources/db/migration/mysql/V13__alter_hashtag_autoincreament.sql new file mode 100644 index 0000000..67fd937 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V13__alter_hashtag_autoincreament.sql @@ -0,0 +1,2 @@ +ALTER TABLE `catsgotogedog`.`hashtag` + CHANGE COLUMN `hashtag_id` `hashtag_id` INT NOT NULL AUTO_INCREMENT ; From 82cfa41e36c2d1554a275f8ddb109e51ded06347 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Thu, 7 Aug 2025 05:19:13 +0900 Subject: [PATCH 145/191] =?UTF-8?q?rename/FLYWAY=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=88=98=EC=A0=95=2013>14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...g_autoincreament.sql => V14__alter_hashtag_autoincreament.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/mysql/{V13__alter_hashtag_autoincreament.sql => V14__alter_hashtag_autoincreament.sql} (100%) diff --git a/src/main/resources/db/migration/mysql/V13__alter_hashtag_autoincreament.sql b/src/main/resources/db/migration/mysql/V14__alter_hashtag_autoincreament.sql similarity index 100% rename from src/main/resources/db/migration/mysql/V13__alter_hashtag_autoincreament.sql rename to src/main/resources/db/migration/mysql/V14__alter_hashtag_autoincreament.sql From 9601c57f5ea27a84b476345f462fb0c5f0b70e02 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:13:06 +0900 Subject: [PATCH 146/191] =?UTF-8?q?fix:=20=EC=83=9D=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=20=EB=B0=8F=20=EC=9D=B5=EB=AA=85=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/domain/entity/VisitHistory.java | 4 ++++ .../swyp/catsgotogedog/content/service/ContentService.java | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java index 0fad924..6a05f40 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java @@ -2,12 +2,16 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @Builder +@NoArgsConstructor +@AllArgsConstructor public class VisitHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index ec3f8e7..c264f30 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -108,7 +108,7 @@ public void recordView(String userId, int contentId){ } public boolean checkVisited(String userId, int contentId){ - if (userId == null || userId.isBlank()) { + if (userId == null || userId.isBlank()|| userId.equals("anonymousUser")) { return false; } From 35aa1f78c6d91fd84b4285a487231a5b33c24e27 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:21:52 +0900 Subject: [PATCH 147/191] =?UTF-8?q?fix:=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/repository/ContentWishRepository.java | 2 +- .../com/swyp/catsgotogedog/content/service/ContentService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java index ff17c25..a004998 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -15,7 +15,7 @@ public interface ContentWishRepository extends JpaRepository { @Query("SELECT COUNT(c) FROM ContentWish c WHERE c.content.contentId = :contentId") - int countByContentContentId(int contentId); + int countByContent_ContentId(int contentId); @Query("SELECT cw FROM ContentWish cw WHERE cw.userId = :userId AND cw.content.contentId = :contentId") Optional findByUserIdAndContentId(@Param("userId") int userId, @Param("contentId") int contentId); diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 83ecbf3..15cfe21 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -72,7 +72,7 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ boolean wishData = (userId != null) ? contentSearchService.getWishData(userId, contentId) : false; - int wishCnt = contentWishRepository.countByContentContentId(contentId); + int wishCnt = contentWishRepository.countByContent_ContentId(contentId); boolean visited = hasVisited(userId, contentId); From 5df146a9fa0fca057ce353f3f26f0487b6d0f313 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:32:53 +0900 Subject: [PATCH 148/191] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=97=90=20=EC=A1=B0=ED=9A=8C=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EA=B5=AC=ED=98=84=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/response/ContentResponse.java | 5 ++++- .../catsgotogedog/content/service/ContentSearchService.java | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index adebfac..d615224 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -29,6 +29,7 @@ public class ContentResponse { private RegionCodeResponse regionName; private List hashtag; private String restDate; + private int totalView; public static ContentResponse from( Content c, @@ -36,7 +37,8 @@ public static ContentResponse from( boolean wishData, RegionCodeResponse regionName, List hashtag, - String restDate){ + String restDate, + int totalView){ return ContentResponse.builder() .contentId(c.getContentId()) @@ -58,6 +60,7 @@ public static ContentResponse from( .regionName(regionName) .hashtag(hashtag) .restDate(restDate) + .totalView(totalView) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 58abc5f..157656a 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -40,6 +40,7 @@ public class ContentSearchService { private final SightsInformationRepository sightsInformationRepository; private final RestaurantInformationRepository restaurantInformationRepository; private final HashtagRepository hashtagRepository; + private final ViewTotalRepository viewTotalRepository; public List searchByKeyword(String keyword){ return contentElasticRepository.findByTitleContaining(keyword); @@ -129,7 +130,9 @@ public List search(String title, String restDate = getRestDate(id); - return ContentResponse.from(content, avg, wishData, regionName, hashtag, restDate); + int totalView = viewTotalRepository.findTotalViewByContentId(id); + + return ContentResponse.from(content, avg, wishData, regionName, hashtag, restDate, totalView); }) .filter(Objects::nonNull) .toList(); From 0ea4badfb33df45afcdd01f94f763358c2f82ce9 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:36:18 +0900 Subject: [PATCH 149/191] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=97=90=20=EC=A2=8B=EC=95=84=EC=9A=94=EC=88=98(?= =?UTF-8?q?=EC=B0=9C)=20=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/response/ContentResponse.java | 5 ++++- .../catsgotogedog/content/service/ContentSearchService.java | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index d615224..445161c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -30,6 +30,7 @@ public class ContentResponse { private List hashtag; private String restDate; private int totalView; + private int wishCnt; public static ContentResponse from( Content c, @@ -38,7 +39,8 @@ public static ContentResponse from( RegionCodeResponse regionName, List hashtag, String restDate, - int totalView){ + int totalView, + int wishCnt){ return ContentResponse.builder() .contentId(c.getContentId()) @@ -61,6 +63,7 @@ public static ContentResponse from( .hashtag(hashtag) .restDate(restDate) .totalView(totalView) + .wishCnt(wishCnt) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 157656a..0d6f870 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -132,7 +132,9 @@ public List search(String title, int totalView = viewTotalRepository.findTotalViewByContentId(id); - return ContentResponse.from(content, avg, wishData, regionName, hashtag, restDate, totalView); + int wishCnt = contentWishRepository.countByContent_ContentId(id); + + return ContentResponse.from(content, avg, wishData, regionName, hashtag, restDate, totalView, wishCnt); }) .filter(Objects::nonNull) .toList(); From 6682344ea82b84ac005a90f2152d7769cb3502c7 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:40:43 +0900 Subject: [PATCH 150/191] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=97=90=20=EC=9E=A5=EC=86=8C=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/domain/response/ContentResponse.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java index 445161c..7f7aa86 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; import java.util.List; @Getter @@ -31,6 +32,7 @@ public class ContentResponse { private String restDate; private int totalView; private int wishCnt; + private LocalDateTime createdAt; public static ContentResponse from( Content c, @@ -64,6 +66,7 @@ public static ContentResponse from( .restDate(restDate) .totalView(totalView) .wishCnt(wishCnt) + .createdAt(c.getCreatedAt()) .build(); } } From 9cd98a0e0a13a74e2f994e919a575aa4f9b02b45 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Fri, 8 Aug 2025 00:26:18 +0900 Subject: [PATCH 151/191] =?UTF-8?q?refactor:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=88=98=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조회수가 없는 데이터에 대한 예외 처리, 스트림 단계에서 sido, sigungu '0'인 값 필터링 적용, --- .../repository/ViewTotalRepository.java | 4 ++- .../content/service/ContentSearchService.java | 27 +++++++++---------- .../content/service/ContentService.java | 3 ++- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java index 595b460..e86cea8 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + public interface ViewTotalRepository extends JpaRepository { @Modifying @Transactional @@ -23,5 +25,5 @@ INSERT INTO view_total (content_id, total_view, updated_at) FROM ViewTotal vt WHERE vt.contentId = :contentId """) - int findTotalViewByContentId(int contentId); + Optional findTotalViewByContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 0d6f870..7eda237 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -14,6 +14,7 @@ import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.elasticsearch.client.elc.NativeQuery; import org.springframework.data.elasticsearch.core.ElasticsearchOperations; import org.springframework.data.elasticsearch.core.SearchHit; @@ -100,7 +101,7 @@ public List search(String title, NativeQuery nativeQuery = NativeQuery.builder() .withQuery(esQuery) - .withPageable(PageRequest.of(0, 20)) + .withPageable(Pageable.unpaged()) .build(); List ids = elasticsearchOperations @@ -115,28 +116,24 @@ public List search(String title, .collect(Collectors.toMap(Content::getContentId, c -> c)); return ids.stream() - .map(id -> { - Content content = contentMap.get(id); - if (content == null) return null; + .map(contentMap::get) + .filter(Objects::nonNull) + .filter(c -> c.getSidoCode() != 0 && c.getSigunguCode() != 0) + .map(content -> { + int id = content.getContentId(); double avg = getAverageScore(id); - boolean wishData = (userId != null) ? getWishData(userId, id) : false; - - RegionCodeResponse regionName - = getRegionName(content.getSidoCode(), content.getSigunguCode()); - + RegionCodeResponse regionName = getRegionName(content.getSidoCode(), content.getSigunguCode()); List hashtag = hashtagRepository.findContentsByContentId(id); - String restDate = getRestDate(id); - - int totalView = viewTotalRepository.findTotalViewByContentId(id); - + int totalView = viewTotalRepository.findTotalViewByContentId(id).orElse(0); int wishCnt = contentWishRepository.countByContent_ContentId(id); - return ContentResponse.from(content, avg, wishData, regionName, hashtag, restDate, totalView, wishCnt); + return ContentResponse.from( + content, avg, wishData, regionName, hashtag, restDate, totalView, wishCnt + ); }) - .filter(Objects::nonNull) .toList(); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 15cfe21..49bb30e 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -76,7 +76,8 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ boolean visited = hasVisited(userId, contentId); - int totalView = viewTotalRepository.findTotalViewByContentId(contentId); + int totalView = viewTotalRepository.findTotalViewByContentId(contentId) + .orElse(0); List detailImage = getDetailImage(contentId); From ad6b18748ccff50884a82ec8d19b734fdc58b153 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Fri, 8 Aug 2025 00:47:43 +0900 Subject: [PATCH 152/191] =?UTF-8?q?feat/=EB=A6=AC=EB=B7=B0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C(=EC=A2=8B=EC=95=84=EC=9A=94)=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 추천 API 추가,삭제 개발 MalformedJWTException 핸들링 추가 --- .../security/filter/JwtTokenFilter.java | 15 +++-- .../global/exception/ErrorCode.java | 2 + .../exception/GlobalExceptionHandler.java | 8 +++ .../review/controller/ReviewController.java | 24 +++++++ .../controller/ReviewControllerSwagger.java | 40 ++++++++++- .../domain/entity/ReviewRecommendHistory.java | 2 + .../response/ContentReviewPageResponse.java | 9 +-- .../domain/response/MyReviewPageResponse.java | 6 +- .../ReviewRecommendHistoryRepository.java | 3 + .../service/ReviewRecommendService.java | 67 +++++++++++++++++++ .../review/service/ReviewService.java | 10 +-- 11 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java index 886137c..7db3cc2 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java @@ -1,6 +1,9 @@ package com.swyp.catsgotogedog.common.security.filter; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; + +import io.jsonwebtoken.MalformedJwtException; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -26,12 +29,16 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) String bearer = request.getHeader("Authorization"); if (bearer != null && bearer.startsWith("Bearer ")) { - String token = bearer.substring(7); - String sub = jwt.getSubject(token); + try { + String token = bearer.substring(7); + String sub = jwt.getSubject(token); - var auth = new UsernamePasswordAuthenticationToken( + var auth = new UsernamePasswordAuthenticationToken( sub, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); - SecurityContextHolder.getContext().setAuthentication(auth); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (MalformedJwtException e) { + throw new MalformedJwtException("잘못된 토큰 형식입니다. 요청된 Authorization : " + bearer, e); + } } chain.doFilter(req, res); } diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 77cc9ef..3f558fc 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -40,6 +40,8 @@ public enum ErrorCode { OWN_REVIEW_CANT_REPORT(HttpStatus.BAD_REQUEST.value(), "자신이 작성한 리뷰는 신고할 수 없어요."), CLOVA_HASHTAG_CLIENT_ERROR(HttpStatus.BAD_REQUEST.value(), "Hashtag 생성 요청중 오류 발생"), CLOVA_HASHTAG_SERVER_ERROR(HttpStatus.BAD_REQUEST.value(), "클로바 서버 연결 오류"), + ALREADY_RECOMMENDED(HttpStatus.BAD_REQUEST.value(), "이미 좋아요된 리뷰입니다."), + NOT_RECOMMENDED_REVIEW(HttpStatus.BAD_REQUEST.value(), "좋아요 상태인 리뷰가 아닙니다."), // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java index 7f6810e..968cb08 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -17,6 +17,7 @@ import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import io.jsonwebtoken.MalformedJwtException; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -127,5 +128,12 @@ protected ResponseEntity> handleExpiredTokenExc .body(response); } + @ExceptionHandler(MalformedJwtException.class) + protected ResponseEntity> handleMalformedJwtException(MalformedJwtException e) { + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(HttpStatus.BAD_REQUEST.value() , e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index 298be70..abe8b6b 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -24,6 +24,7 @@ import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; import com.swyp.catsgotogedog.review.domain.response.ContentReviewPageResponse; +import com.swyp.catsgotogedog.review.service.ReviewRecommendService; import com.swyp.catsgotogedog.review.service.ReviewService; import io.jsonwebtoken.io.IOException; @@ -38,6 +39,7 @@ public class ReviewController implements ReviewControllerSwagger { private final ReviewService reviewService; + private final ReviewRecommendService reviewRecommendService; // 리뷰 작성 @Override @@ -136,4 +138,26 @@ public ResponseEntity> fetchReviewsByUserId( ); } + // 리뷰 좋아요 + @Override + @PostMapping("/recommend/{reviewId}") + public ResponseEntity> recommendReview( + @AuthenticationPrincipal String userId, + @PathVariable int reviewId) { + + reviewRecommendService.recommendReview(reviewId, userId); + return ResponseEntity.ok(CatsgotogedogApiResponse.success("리뷰 좋아요 완료", null)); + } + + // 리뷰 좋아요 취소 + @Override + @DeleteMapping("/recommend/{reviewId}") + public ResponseEntity> cancelRecommendReview( + @AuthenticationPrincipal String userId, + @PathVariable int reviewId) { + + reviewRecommendService.cancelRecommendReview(reviewId, userId); + return ResponseEntity.ok(CatsgotogedogApiResponse.success("리뷰 좋아요 취소 완료", null)); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index cf04731..3b84c17 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -176,7 +176,7 @@ ResponseEntity> fetchReviewsByContentId( , content = @Content(schema = @Schema(implementation = ReviewResponse.class))), @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), - @ApiResponse(responseCode = "404", description = "컨텐츠가 존재하지 않음" + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) }) ResponseEntity> fetchReviewsByUserId( @@ -186,4 +186,42 @@ ResponseEntity> fetchReviewsByUserId( @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지당 결과 갯수") @RequestParam(defaultValue = "4") int size); + + @Operation( + summary = "특정 리뷰를 좋아요 처리.", + description = "사용자 인증을 통해 리뷰에 좋아요 기능." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 좋아요 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음, 이미 좋아요된 리뷰입니다." + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> recommendReview( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @Parameter(description = "리뷰 ID", required = true) + @PathVariable int reviewId); + + @Operation( + summary = "특정 리뷰를 좋아요 해제 처리.", + description = "사용자 인증을 통해 리뷰에 좋아요 해제 기능." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 좋아요 해제 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음, 좋아요 상태인 리뷰가 아닙니다." + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> cancelRecommendReview( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @Parameter(description = "리뷰 ID", required = true) + @PathVariable int reviewId); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java index 610dc92..f4cef64 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java @@ -14,6 +14,7 @@ import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -25,6 +26,7 @@ uniqueConstraints = @UniqueConstraint(columnNames = {"userId", "reviewId"})) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder public class ReviewRecommendHistory { @Id diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java index 4721a95..7194cff 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java @@ -5,9 +5,6 @@ import io.swagger.v3.oas.annotations.media.Schema; public record ContentReviewPageResponse ( - @Schema(description = "컨텐츠 리뷰 목록") - List reviews, - List reviewImages, @Schema(description = "총 조회 갯수") int totalElements, @Schema(description = "총 페이지 갯수") @@ -19,6 +16,10 @@ public record ContentReviewPageResponse ( @Schema(description = "다음 존재 여부") boolean hasNext, @Schema(description = "이전 존재 여부") - boolean hasPrevious + boolean hasPrevious, + @Schema(description = "컨텐츠 리뷰 목록") + List reviews, + @Schema(description = "리뷰 전체 이미지 목록") + List reviewImages ) { } diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java index d965c43..4d3becc 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java @@ -5,8 +5,6 @@ import io.swagger.v3.oas.annotations.media.Schema; public record MyReviewPageResponse( - @Schema(description = "작성 리뷰 목록") - List reviews, @Schema(description = "총 조회 갯수") int totalElements, @Schema(description = "총 페이지") @@ -18,5 +16,7 @@ public record MyReviewPageResponse( @Schema(description = "다음 존재 여부") boolean hasNext, @Schema(description = "이전 존재 여부") - boolean hasPrevious + boolean hasPrevious, + @Schema(description = "작성 리뷰 목록") + List reviews ) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java index 373fc4d..21361f8 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java @@ -1,6 +1,7 @@ package com.swyp.catsgotogedog.review.repository; import java.util.List; +import java.util.Optional; import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; @@ -18,4 +19,6 @@ public interface ReviewRecommendHistoryRepository extends JpaRepository findRecommendedReviewIdsByUserIdAndReviewIds( @Param("userId") int userId, @Param("reviews") List reviews); + + Optional findReviewRecommendHistoryByReviewAndUserId(Review review, int userId); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java new file mode 100644 index 0000000..d31e3ac --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java @@ -0,0 +1,67 @@ +package com.swyp.catsgotogedog.review.service; + +import org.springframework.stereotype.Service; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewRecommendHistory; +import com.swyp.catsgotogedog.review.repository.ReviewRecommendHistoryRepository; +import com.swyp.catsgotogedog.review.repository.ReviewRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewRecommendService { + + private final UserRepository userRepository; + private final ReviewRepository reviewRepository; + private final ReviewRecommendHistoryRepository reviewRecommendHistoryRepository; + + // 좋아요 처리 + public void recommendReview(int reviewId, String strUserId) { + int userId = Integer.parseInt(strUserId); + User user = validateUser(userId); + Review targetReview = validateReview(reviewId); + + reviewRecommendHistoryRepository.findReviewRecommendHistoryByReviewAndUserId(targetReview, userId) + .ifPresentOrElse(reviewRecommendHistory -> { + throw new CatsgotogedogException(ErrorCode.ALREADY_RECOMMENDED); + }, + () -> reviewRecommendHistoryRepository.save(ReviewRecommendHistory.builder() + .userId(user.getUserId()) + .review(targetReview) + .build() + ) + ); + } + + // 좋아요 해제 처리 + public void cancelRecommendReview(int reviewId, String strUserId) { + int userId = Integer.parseInt(strUserId); + User user = validateUser(userId); + Review targetReview = validateReview(reviewId); + + reviewRecommendHistoryRepository.findReviewRecommendHistoryByReviewAndUserId(targetReview, userId) + .ifPresentOrElse(reviewRecommendHistoryRepository::delete, + () -> { + throw new CatsgotogedogException(ErrorCode.NOT_RECOMMENDED_REVIEW); + } + ); + } + + private User validateUser(int userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private Review validateReview(int reviewId) { + return reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index 5ed95dc..a74c9cb 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -189,14 +189,14 @@ public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String s }).toList(); return new ContentReviewPageResponse( - reviewResponses, - contentReviewImages, (int) reviewPage.getTotalElements(), reviewPage.getTotalPages(), reviewPage.getNumber(), reviewPage.getSize(), reviewPage.hasNext(), - reviewPage.hasPrevious() + reviewPage.hasPrevious(), + reviewResponses, + contentReviewImages ); } @@ -212,13 +212,13 @@ public MyReviewPageResponse fetchReviewsByUserId(String userId, Pageable pageabl .toList(); return new MyReviewPageResponse( - myReviewResponses, (int) reviewPage.getTotalElements(), reviewPage.getTotalPages(), reviewPage.getNumber(), reviewPage.getSize(), reviewPage.hasNext(), - reviewPage.hasPrevious() + reviewPage.hasPrevious(), + myReviewResponses ); } From 4e614be20acb2f599e6cc01b4d5ee8cd7448954e Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Fri, 8 Aug 2025 01:17:19 +0900 Subject: [PATCH 153/191] =?UTF-8?q?feat:=20=EC=B0=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 10 +++++ .../controller/ContentControllerSwagger.java | 25 +++++++++++ .../content/domain/entity/ContentWish.java | 2 + .../repository/ContentWishRepository.java | 5 +++ .../content/service/ContentService.java | 43 +++++++++++++++++-- 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 681d2c5..5247a5c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import java.util.List; +import java.util.Map; @RestController @RequiredArgsConstructor @@ -73,4 +74,13 @@ public ResponseEntity> getRecentViews(@Authenticat return ResponseEntity.ok().body(recent); } + @PostMapping("/wish-check") + public ResponseEntity checkWish( + @AuthenticationPrincipal String userId, + @RequestParam int contentId + ) { + boolean checkWish = contentService.checkWish(userId, contentId); + return ResponseEntity.ok(Map.of("checkWish", checkWish)); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index 57ab52a..fe9296e 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -14,10 +14,12 @@ 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.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import java.io.IOException; import java.util.List; +import java.util.Map; @Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") public interface ContentControllerSwagger { @@ -81,4 +83,27 @@ ResponseEntity> getRecentViews( @AuthenticationPrincipal String userId ); + @Operation( + summary = "찜 체크 기능", + description = "로그인된 사용자의 해당 콘텐츠 찜 상태를 설정 또는 해제. " + + "이미 찜돼 있으면 해제(false), 아니면 찜(true). " + + "비회원이거나 인증 정보가 없으면 false 반환", + security = { @SecurityRequirement(name = "bearer-key") } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "찜 처리 결과 반환", + content = @Content(schema = @Schema(implementation = Map.class)) + ), + @ApiResponse(responseCode = "401", description = "인증 필요"), + @ApiResponse(responseCode = "404", description = "해당 사용자 또는 콘텐츠 없음") + }) + @PostMapping("/wish-check") + ResponseEntity checkWish( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @Parameter(description = "컨텐츠 ID", required = true) + @RequestParam int contentId + ); + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java index 6ddce65..3ef829f 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java @@ -7,10 +7,12 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import lombok.Builder; import lombok.Getter; @Entity @Getter +@Builder public class ContentWish { @Id diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java index ff17c25..3d1dfd3 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -1,5 +1,7 @@ package com.swyp.catsgotogedog.content.repository; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentWish; import org.springframework.data.domain.Page; @@ -25,4 +27,7 @@ public interface ContentWishRepository extends JpaRepository findAllByUserId(int userId, Pageable pageable); + boolean existsByUserIdAndContent_ContentId(int userId, int contentId); + + void deleteByUserIdAndContent(int userId, Content content); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 83ecbf3..3ca2956 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -2,15 +2,14 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; -import com.swyp.catsgotogedog.content.domain.entity.Content; -import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; -import com.swyp.catsgotogedog.content.domain.entity.ContentImage; -import com.swyp.catsgotogedog.content.domain.entity.ViewLog; +import com.swyp.catsgotogedog.content.domain.entity.*; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; import com.swyp.catsgotogedog.content.domain.response.ContentImageResponse; import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.repository.*; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; import jakarta.persistence.EntityNotFoundException; import jakarta.transaction.Transactional; import org.springframework.data.domain.PageRequest; @@ -83,6 +82,30 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ return PlaceDetailResponse.from(content,avg,wishData,wishCnt,visited,totalView,detailImage); } + public boolean checkWish(String userId, int contentId){ + if (userId == null || userId.isBlank()|| userId.equals("anonymousUser")) { + return false; + } + + validateUser(userId); + + Content content = contentRepository.findByContentId(contentId); + + boolean isWished = isWished(userId, contentId); + + if(isWished){ + contentWishRepository.deleteByUserIdAndContent(Integer.parseInt(userId),content); + return false; + }else{ + ContentWish cw = ContentWish.builder() + .userId(Integer.parseInt(userId)) + .content(content) + .build(); + contentWishRepository.save(cw); + return true; + } + } + public void recordView(String userId, int contentId){ // if (userId != null) { @@ -140,4 +163,16 @@ public List getDetailImage(int contentId){ )) .toList(); } + + public boolean isWished(String userId, int contentId) { + if (userId == null || userId.isBlank()) { + return false; + } + return contentWishRepository.existsByUserIdAndContent_ContentId(Integer.parseInt(userId), contentId); + } + + private User validateUser(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } } From b62dd314f4a12a647513635f839bc32e4c951ca6 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Fri, 8 Aug 2025 01:43:44 +0900 Subject: [PATCH 154/191] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=88=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 충돌 수정 과정에서 괄호 누락 --- .../swyp/catsgotogedog/content/controller/ContentController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 3241b4f..88b3ec7 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -81,6 +81,7 @@ public ResponseEntity checkWish( ) { boolean checkWish = contentService.checkWish(userId, contentId); return ResponseEntity.ok(Map.of("checkWish", checkWish)); + } @GetMapping("/visited-check") public ResponseEntity checkVisited( From a0692dd7433b9c01dae4fa17c6a86b8ae14c93ca Mon Sep 17 00:00:00 2001 From: yhs99 Date: Fri, 8 Aug 2025 07:40:08 +0900 Subject: [PATCH 155/191] =?UTF-8?q?feat/=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EC=9E=A5=EC=86=8C=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API,=20=ED=95=B4=EC=8B=9C=ED=83=9C=EA=B7=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최근 1주일 조회수 기준 최대 20개까지 컨텐츠 반환 - 해시태그 시스템 프롬프트를 일정한 응답이 오도록 수정 - 수동으로 호출 가능하고, 비동기로 동작하도록 수정 --- .../CatsgotogedogApplication.java | 2 + .../common/config/SecurityConfig.java | 3 +- .../filter/OAuth2AutoLoginFilter.java | 1 - .../controller/ContentRankController.java | 27 +++++++ .../ContentRankControllerSwagger.java | 32 ++++++++ .../content/domain/entity/ViewLog.java | 6 ++ .../domain/response/ContentRankResponse.java | 19 +++++ .../content/repository/ContentRepository.java | 2 + .../content/repository/HashtagRepository.java | 3 + .../content/repository/ViewLogRepository.java | 17 ++++- .../content/service/ContentRankService.java | 75 +++++++++++++++++++ .../content/service/HashtagServcie.java | 67 ++++++++++++----- .../global/BatchGeneratorController.java | 25 +++++++ .../global/HashtagGenerator.java | 4 +- 14 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java create mode 100644 src/main/java/com/swyp/catsgotogedog/global/BatchGeneratorController.java diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index eedddf1..f19db57 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -10,12 +10,14 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +@EnableAsync @EnableJpaAuditing @SpringBootApplication(scanBasePackages = { "com.swyp", "com.batch" }) @EnableScheduling diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java index c8fd753..dc12f30 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -52,7 +52,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/content/**", // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 "/api/review/content/**", - "/api/code/**" + "/api/code/**", + "/api/batch/**" ).permitAll() .anyRequest().authenticated()) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java index 88e321a..319087c 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java @@ -28,7 +28,6 @@ public class OAuth2AutoLoginFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - log.info("frontend_base_url :: {}", frontend_base_url); if(request.getRequestURI().contains("/oauth2/authorization/")) { String autoLoginParam = request.getParameter(AUTO_LOGIN_PARAM); diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java new file mode 100644 index 0000000..0c4fd41 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java @@ -0,0 +1,27 @@ +package com.swyp.catsgotogedog.content.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.content.service.ContentRankService; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/content") +public class ContentRankController implements ContentRankControllerSwagger { + + private final ContentRankService contentRankService; + + @Override + @GetMapping("/rank") + public ResponseEntity> fetchContentRank() { + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("조회 성공", contentRankService.fetchContentRank()) + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java new file mode 100644 index 0000000..c7dfcd5 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java @@ -0,0 +1,32 @@ +package com.swyp.catsgotogedog.content.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestParam; + +import com.swyp.catsgotogedog.content.domain.response.ContentRankResponse; +import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") +public interface ContentRankControllerSwagger { + + @Operation( + summary = "인기 장소 조회", + description = "최근 일주일간 조회수 통계 20개를 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = ContentRankResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + ResponseEntity> fetchContentRank(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java index f77d8af..902ec1d 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java @@ -2,8 +2,12 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; + import org.springframework.data.annotation.CreatedDate; import java.time.LocalDateTime; @@ -11,6 +15,8 @@ @Entity @Getter @Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class ViewLog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java new file mode 100644 index 0000000..a565c29 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java @@ -0,0 +1,19 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ContentRankResponse { + private int contentId; + private String title; + private String image; + private String thumbImage; + private int contentTypeId; + private double mapx; + private double mapy; + private List hashtags; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java index c6bfea2..292fc01 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -16,4 +16,6 @@ public interface ContentRepository extends JpaRepository { + "LEFT JOIN hashtag h ON c.content_id = h.content_id " + "WHERE h.content_id IS NULL", nativeQuery = true) List findContentsWithoutHashtags(); + + List findAllByContentIdIn(List contentIds); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java index 352dd66..0fe1209 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.Collection; import java.util.List; public interface HashtagRepository extends JpaRepository { @@ -13,4 +14,6 @@ public interface HashtagRepository extends JpaRepository { boolean existsByContentId(int contentId); List findByContentId(int contentId); + + List findByContentIdIn(Collection contentIds); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java index 72e05c8..c1901af 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java @@ -2,19 +2,34 @@ import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ViewLog; + 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 java.time.LocalDateTime; import java.util.List; public interface ViewLogRepository extends JpaRepository { @Query(""" SELECT v.content FROM ViewLog v - WHERE v.user.id = :userId + WHERE v.user.userId = :userId GROUP BY v.content ORDER BY MAX(v.viewedAt) DESC """) List findRecentContentByUser(int userId, Pageable pageable); + + @Query(""" + SELECT v.content.contentId + FROM ViewLog v + WHERE v.viewedAt >= :startDate + GROUP BY v.content.contentId + ORDER BY COUNT(v.content.contentTypeId) DESC + """) + List findTopContentViews( + @Param("startDate") LocalDateTime startDate, + Pageable pageable + ); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java new file mode 100644 index 0000000..0a233c3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java @@ -0,0 +1,75 @@ +package com.swyp.catsgotogedog.content.service; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.Hashtag; +import com.swyp.catsgotogedog.content.domain.response.ContentRankResponse; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.content.repository.ViewLogRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ContentRankService { + + private final ViewLogRepository viewLogRepository; + private final ContentRepository contentRepository; + private final HashtagRepository hashtagRepository; + + @Transactional(readOnly = true) + public List fetchContentRank() { + LocalDateTime startDate = LocalDateTime.now().minusWeeks(1); + + Pageable top20 = PageRequest.of(0, 20); + + List topContentIds = viewLogRepository.findTopContentViews(startDate, top20); + + if(topContentIds == null || topContentIds.isEmpty()) { + return Collections.emptyList(); + } + + Map contentMap = contentRepository.findAllById(topContentIds).stream() + .collect(Collectors.toMap(Content::getContentId, Function.identity())); + + List hashtags = hashtagRepository.findByContentIdIn(topContentIds); + + Map> hashtagsByContentId = hashtags.stream() + .collect(Collectors.groupingBy( + Hashtag::getContentId, + Collectors.mapping(Hashtag::getContent, Collectors.toList()) + )); + + return topContentIds.stream() + .map(contentId -> { + Content content = contentMap.get(contentId); + List contentHashtags = hashtagsByContentId.getOrDefault(contentId, Collections.emptyList()); + + return ContentRankResponse.builder() + .contentId(content.getContentId()) + .title(content.getTitle()) + .image(content.getImage()) + .thumbImage(content.getThumbImage()) + .contentTypeId(content.getContentTypeId()) + .mapx(content.getMapx()) + .mapy(content.getMapy()) + .hashtags(contentHashtags) + .build(); + }) + .toList(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java b/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java index 7859071..5078e15 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java @@ -1,6 +1,6 @@ package com.swyp.catsgotogedog.content.service; -import java.util.Arrays; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -43,18 +43,42 @@ public class HashtagServcie { private final HashtagRepository hashtagRepository; private static final String SYSTEM_PROMPT = """ + #ROLE 당신은 관광지 정보를 분석하여 효과적인 해시태그를 생성하는 전문 AI입니다. + #INSTRUCTION 제공된 관광지의 제목과 내용을 분석하여 검색성과 마케팅 효과를 높이는 관련성 높은 해시태그를 생성합니다. - 형식: - 띄어쓰기 없이 연결하여 작성 - 기호 포함하여 출력 - 출력형식: + + #CONDITIONS + 1. 생성할 해시태그는 최소 5개, 최대 10개여야 합니다. + 2. 각 해시태그는 '#'으로 시작해야 합니다. + 3. 각 해시태그는 공백을 포함해서는 안 됩니다. + 4. 결과는 반드시 아래 에 명시된 JSON 형식이어야 하며, 다른 어떤 텍스트도 추가해서는 안 됩니다. + + #OUTPUT_FORMAT + { + "hashtags": [ + "#해시태그1", + "#해시태그2", + "...", + "#해시태그10", + ] + } + # EXAMPLE + - 입력: + - 제목: 제주의 숨겨진 보물, 비양도 + - 내용: 제주 한림항에서 배를 타고 15분이면 도착하는 작은 섬 비양도. 아름다운 해안 산책로와 함께 여유로운 시간을 보낼 수 있는 곳입니다. 특히 일몰이 아름답기로 유명하며, 백패킹과 캠핑을 즐기는 사람들에게도 인기가 많습니다. + + - 출력: { "hashtags": [ - "#해시태그1", - "#해시태그2", - "...", - "#해시태그10" + "#비양도", + "#제주도여행", + "#제주가볼만한곳", + "#섬여행", + "#제주숨은명소", + "#한림항", + "#제주일몰", + "#백패킹성지" ] } """; @@ -72,7 +96,7 @@ public void generateAndSaveHashTags(int contentId) { log.info("생성된 해시태그 :: {}", hashtags); if(hashtags != null && !hashtags.isEmpty()) { - if(hashtags.size() > 5) { + if(hashtags.size() >= 5) { saveHashtags(contentId, hashtags); } } @@ -149,21 +173,26 @@ private List parseHashtags(String response) { String jsonData = dataLine.substring(5); JsonNode rootNode = objectMapper.readTree(jsonData); - String contentJson = rootNode + String content = rootNode .path("message") .path("content").asText(); - JsonNode contentNode = objectMapper.readTree(contentJson); - JsonNode hashtagArr = contentNode.path("hashtags"); + content = content.replaceAll("```json\\s*", "") + .replaceAll("```\\s*", "") + .trim(); - if(hashtagArr.isArray() && hashtagArr.size() > 0) { - String hashtagString = hashtagArr.get(0).asText(); + JsonNode contentNode = objectMapper.readTree(content); + JsonNode hashtagArr = contentNode.path("hashtags"); - return Arrays.stream(hashtagString.split(" ")) - .filter(tag -> tag.startsWith("#")) - .limit(10) - .toList(); + if(hashtagArr.isArray() && !hashtagArr.isEmpty()) { + List hashtags = new ArrayList<>(); + for(JsonNode hashtag : hashtagArr) { + hashtags.add(hashtag.asText()); + } + return hashtags; } + + return Collections.emptyList(); } } } diff --git a/src/main/java/com/swyp/catsgotogedog/global/BatchGeneratorController.java b/src/main/java/com/swyp/catsgotogedog/global/BatchGeneratorController.java new file mode 100644 index 0000000..8972560 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/BatchGeneratorController.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.global; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/batch") +public class BatchGeneratorController { + + private final HashtagGenerator hashtagGenerator; + + @Operation(summary = "해시태그 배치 수동 조작", + description = "호출시 서버 배치가 동작하므로 주의 필요한 경우 제외 호출 금지") + @GetMapping("/hashtag") + public ResponseEntity generateHashtags() { + hashtagGenerator.generateHashtags(); + return ResponseEntity.ok("시작"); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java b/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java index c56230d..106ad06 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java +++ b/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -21,7 +22,8 @@ public class HashtagGenerator { private final HashtagRepository hashtagRepository; private final ContentRepository contentRepository; private final HashtagServcie hashtagServcie; - + + @Async @Scheduled(cron = "0 0 3 * * ?") public void generateHashtags() { List contentIds = contentRepository.findAll().stream() From ddbee2c545ae42ea0a4591b0d403f2746a3ccffd Mon Sep 17 00:00:00 2001 From: yhs99 Date: Fri, 8 Aug 2025 21:18:48 +0900 Subject: [PATCH 156/191] =?UTF-8?q?feat/=20recommendedNumber=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9,=20=ED=8A=B9=EC=A0=95=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰의 좋아요, 좋아요 취소시 recommendedNumber가 적용되도록 수정 리뷰 정보 조회 api 추가 --- .../global/exception/ErrorCode.java | 1 + .../review/controller/ReviewController.java | 15 ++++++++++++++ .../controller/ReviewControllerSwagger.java | 19 ++++++++++++++++++ .../review/repository/ReviewRepository.java | 2 +- .../service/ReviewRecommendService.java | 20 +++++++++++-------- .../review/service/ReviewService.java | 12 +++++++++++ 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 3f558fc..d02a104 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -17,6 +17,7 @@ public enum ErrorCode { // 403 Forbidden FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + REVIEW_FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN.value(), "리뷰 접근 권한이 없습니다."), // 404 Notfound MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 회원입니다."), diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index abe8b6b..437f61a 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -22,8 +22,11 @@ import org.springframework.web.multipart.MultipartFile; import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; import com.swyp.catsgotogedog.review.domain.response.ContentReviewPageResponse; +import com.swyp.catsgotogedog.review.repository.ReviewRepository; import com.swyp.catsgotogedog.review.service.ReviewRecommendService; import com.swyp.catsgotogedog.review.service.ReviewService; @@ -40,6 +43,7 @@ public class ReviewController implements ReviewControllerSwagger { private final ReviewService reviewService; private final ReviewRecommendService reviewRecommendService; + private final ReviewRepository reviewRepository; // 리뷰 작성 @Override @@ -138,6 +142,7 @@ public ResponseEntity> fetchReviewsByUserId( ); } + // 리뷰 좋아요 @Override @PostMapping("/recommend/{reviewId}") @@ -160,4 +165,14 @@ public ResponseEntity> cancelRecommendReview( return ResponseEntity.ok(CatsgotogedogApiResponse.success("리뷰 좋아요 취소 완료", null)); } + // 자신의 특정 리뷰 조회 + @Override + @GetMapping("/{reviewId}") + public ResponseEntity> fetchReviewInformation( + @AuthenticationPrincipal String userId, + @PathVariable int reviewId) { + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 조회 성공", reviewService.fetchReviewById(reviewId, userId))); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java index 3b84c17..0425a43 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -13,6 +13,7 @@ import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.MyReviewResponse; import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; import io.jsonwebtoken.io.IOException; @@ -224,4 +225,22 @@ ResponseEntity> cancelRecommendReview( @AuthenticationPrincipal String userId, @Parameter(description = "리뷰 ID", required = true) @PathVariable int reviewId); + + @Operation( + summary = "특정 리뷰 정보 조회.", + description = "사용자 인증을 통해 특정 리뷰 정보를 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 조회 성공" + , content = @Content(schema = @Schema(implementation = MyReviewResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음, 이미 좋아요된 리뷰입니다." + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchReviewInformation( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @PathVariable int reviewId); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java index c8a9b0c..7707a9e 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -21,7 +21,7 @@ public interface ReviewRepository extends JpaRepository { * @return Optional */ @Query("SELECT r FROM Review r WHERE r.reviewId = :reviewId AND r.userId = :userId") - Optional findByIdAndUserId(@Param("reviewId") int reviewId, String userId); + Optional findByIdAndUserId(@Param("reviewId") int reviewId, @Param("userId") String userId); /** * contentId와 pageable 요소를 통한 페이징 Review 목록 diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java index d31e3ac..822e536 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java @@ -1,6 +1,7 @@ package com.swyp.catsgotogedog.review.service; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; @@ -24,24 +25,26 @@ public class ReviewRecommendService { private final ReviewRecommendHistoryRepository reviewRecommendHistoryRepository; // 좋아요 처리 + @Transactional public void recommendReview(int reviewId, String strUserId) { int userId = Integer.parseInt(strUserId); User user = validateUser(userId); Review targetReview = validateReview(reviewId); reviewRecommendHistoryRepository.findReviewRecommendHistoryByReviewAndUserId(targetReview, userId) - .ifPresentOrElse(reviewRecommendHistory -> { + .ifPresent(reviewRecommendHistory -> { throw new CatsgotogedogException(ErrorCode.ALREADY_RECOMMENDED); - }, - () -> reviewRecommendHistoryRepository.save(ReviewRecommendHistory.builder() - .userId(user.getUserId()) - .review(targetReview) - .build() - ) - ); + }); + reviewRecommendHistoryRepository.save(ReviewRecommendHistory.builder() + .userId(user.getUserId()) + .review(targetReview) + .build()); + + targetReview.setRecommendedNumber(targetReview.getRecommendedNumber() + 1); } // 좋아요 해제 처리 + @Transactional public void cancelRecommendReview(int reviewId, String strUserId) { int userId = Integer.parseInt(strUserId); User user = validateUser(userId); @@ -53,6 +56,7 @@ public void cancelRecommendReview(int reviewId, String strUserId) { throw new CatsgotogedogException(ErrorCode.NOT_RECOMMENDED_REVIEW); } ); + targetReview.setRecommendedNumber(targetReview.getRecommendedNumber() - 1); } private User validateUser(int userId) { diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index a74c9cb..fa6c5c3 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -222,6 +222,18 @@ public MyReviewPageResponse fetchReviewsByUserId(String userId, Pageable pageabl ); } + // 유저의 특정 리뷰 데이터 조회 + @Transactional(readOnly = true) + public MyReviewResponse fetchReviewById(int reviewId, String userId) { + validateUser(userId); + validateReview(reviewId); + + Review review = reviewRepository.findByIdAndUserId(reviewId, userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_FORBIDDEN_ACCESS)); + + return toReviewResponse(review); + } + private User validateUser(String userId) { return userRepository.findById(Integer.parseInt(userId)) From ab67582d4e0e14ea933741469e46133968a95d7e Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Fri, 8 Aug 2025 22:02:28 +0900 Subject: [PATCH 157/191] =?UTF-8?q?fix:=20mapping=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9C=A0=ED=98=95=20=EB=B3=80=EA=B2=BD=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/controller/ContentController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 36d37e6..da93f8e 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -74,7 +74,7 @@ public ResponseEntity> getRecentViews(@Authenticat return ResponseEntity.ok().body(recent); } - @GetMapping("/visited-check") + @PostMapping("/visited-check") public ResponseEntity checkVisited( @AuthenticationPrincipal String userId, @RequestParam int contentId From 42c4c2757599fca0008394427fdc7e40ab04dc0f Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Fri, 8 Aug 2025 23:03:21 +0900 Subject: [PATCH 158/191] =?UTF-8?q?feat:=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=B6=94=EA=B0=80=20=EC=A0=80=EC=9E=A5=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/swyp/catsgotogedog/content/service/ContentService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 49bb30e..76837eb 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -22,6 +22,8 @@ import java.util.List; +import static java.time.LocalDateTime.now; + @Service @RequiredArgsConstructor @Slf4j @@ -105,6 +107,7 @@ public void recordView(String userId, int contentId){ ViewLog.builder() .user(user) .content(content) + .viewedAt(now()) .build() ); } From 92f7f8df6cd433f1697773b01bf0067d733cbaeb Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Fri, 8 Aug 2025 23:23:38 +0900 Subject: [PATCH 159/191] =?UTF-8?q?fix:=20=EA=B8=B0=EB=B3=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80=20#86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/domain/entity/ContentWish.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java index 3ef829f..c004d7e 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java @@ -7,12 +7,13 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import lombok.Builder; -import lombok.Getter; +import lombok.*; @Entity @Getter @Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor public class ContentWish { @Id From 88e362e619e993c41bed3735f55255ea9d3eb8e8 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:07:25 +0900 Subject: [PATCH 160/191] =?UTF-8?q?feat:=20=EC=83=81=EC=84=B8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EB=82=B4=20=EB=B0=98=EB=A0=A4?= =?UTF-8?q?=EB=8F=99=EB=AC=BC=20=EA=B4=80=EB=A0=A8=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/response/PlaceDetailResponse.java | 9 ++++-- .../content/service/ContentService.java | 17 +++++++++- .../pet/domain/response/.gitkeep | 0 .../pet/domain/response/PetGuideResponse.java | 31 +++++++++++++++++++ .../pet/repository/PetGuideRepository.java | 5 +++ 5 files changed, 59 insertions(+), 3 deletions(-) delete mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/response/.gitkeep create mode 100644 src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetGuideResponse.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java index 75a7592..3dba8ee 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -1,6 +1,8 @@ package com.swyp.catsgotogedog.content.domain.response; import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import com.swyp.catsgotogedog.pet.domain.response.PetGuideResponse; import lombok.Builder; import java.util.List; @@ -27,7 +29,8 @@ public record PlaceDetailResponse( boolean visited, int totalView, String overview, - List detailImage) { + List detailImage, + PetGuide petGuide) { public static PlaceDetailResponse from( Content c, @@ -36,7 +39,8 @@ public static PlaceDetailResponse from( int wishCnt, boolean visited, int totalView, - List detailImage){ + List detailImage, + PetGuide petGuide){ return PlaceDetailResponse.builder() .contentId(c.getContentId()) @@ -60,6 +64,7 @@ public static PlaceDetailResponse from( .totalView(totalView) .overview(c.getOverview()) .detailImage(detailImage) + .petGuide(petGuide) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 76837eb..6810677 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -11,6 +11,8 @@ import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.repository.*; +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import com.swyp.catsgotogedog.pet.repository.PetGuideRepository; import jakarta.persistence.EntityNotFoundException; import jakarta.transaction.Transactional; import org.springframework.data.domain.PageRequest; @@ -21,6 +23,7 @@ import lombok.extern.slf4j.Slf4j; import java.util.List; +import java.util.Optional; import static java.time.LocalDateTime.now; @@ -37,6 +40,7 @@ public class ContentService { private final UserRepository userRepository; private final ViewLogRepository viewLogRepository; private final VisitHistoryRepository visitHistoryRepository; + private final PetGuideRepository petGuideRepository; private final ContentSearchService contentSearchService; @@ -83,7 +87,10 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ List detailImage = getDetailImage(contentId); - return PlaceDetailResponse.from(content,avg,wishData,wishCnt,visited,totalView,detailImage); + PetGuide petGuide = getPetGuide(contentId) + .orElse(null); + + return PlaceDetailResponse.from(content,avg,wishData,wishCnt,visited,totalView,detailImage,petGuide); } public void recordView(String userId, int contentId){ @@ -144,4 +151,12 @@ public List getDetailImage(int contentId){ )) .toList(); } + + public Optional getPetGuide(int contentId) { + if (petGuideRepository.existsByContent_ContentId(contentId)) { + return petGuideRepository.findByContent_ContentId(contentId); + } + return Optional.empty(); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/response/.gitkeep b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetGuideResponse.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetGuideResponse.java new file mode 100644 index 0000000..3f97adb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetGuideResponse.java @@ -0,0 +1,31 @@ +package com.swyp.catsgotogedog.pet.domain.response; + +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import lombok.Builder; + +@Builder +public class PetGuideResponse { + private String accidentPrep; + private String availableFacility; + private String providedItem; + private String etcInfo; + private String purchasableItem; + private String allowedPetType; + private String rentItem; + private String petPrep; + private String withPet; + + public static PetGuideResponse from(PetGuide e) { + return PetGuideResponse.builder() + .accidentPrep(e.getAccidentPrep()) + .availableFacility(e.getAvailableFacility()) + .providedItem(e.getProvidedItem()) + .etcInfo(e.getEtcInfo()) + .purchasableItem(e.getPurchasableItem()) + .allowedPetType(e.getAllowedPetType()) + .rentItem(e.getRentItem()) + .petPrep(e.getPetPrep()) + .withPet(e.getWithPet()) + .build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java index 5a9d6ad..8ccfc67 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java @@ -4,5 +4,10 @@ import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import java.util.Optional; + public interface PetGuideRepository extends JpaRepository { + boolean existsByContent_ContentId(int contentId); + + Optional findByContent_ContentId(int contentId); } From 3da525fab6ea9a216a974b4509b9efb78ff27fdd Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:24:16 +0900 Subject: [PATCH 161/191] =?UTF-8?q?refactore:=20sigungu=20=EA=B0=92=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 2 +- .../controller/ContentControllerSwagger.java | 2 +- .../content/service/ContentSearchService.java | 18 +++++++++++------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 681d2c5..e46fc7d 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -27,7 +27,7 @@ public class ContentController implements ContentControllerSwagger{ public ResponseEntity> search( @RequestParam(required = false) String title, @RequestParam(required = false) String sido, - @RequestParam(required = false) String sigungu, + @RequestParam(required = false) List sigungu, @RequestParam(required = false) Integer contentTypeId, @AuthenticationPrincipal String principal) { diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index 57ab52a..fced70c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -40,7 +40,7 @@ ResponseEntity> search( @RequestParam(required = false) String sido, @Parameter(description = "시/군/구 코드", required = false) - @RequestParam(required = false) String sigungu, + @RequestParam(required = false) List sigunguCode, @Parameter(description = "컨텐츠 유형 ID", required = false) @RequestParam(required = false) Integer contentTypeId, diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 7eda237..fec2d0b 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -1,5 +1,6 @@ package com.swyp.catsgotogedog.content.service; +import co.elastic.clients.elasticsearch._types.FieldValue; import co.elastic.clients.elasticsearch._types.query_dsl.Query; import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; import com.swyp.catsgotogedog.User.domain.entity.User; @@ -50,7 +51,7 @@ public List searchByKeyword(String keyword){ public List search(String title, String sidoCode, - String sigunguCode, + List sigunguCode, Integer contentTypeId, String userId) { @@ -60,12 +61,9 @@ public List search(String title, boolean noTitle = (title == null || title.isBlank()); boolean noSidoCode = (sidoCode == null || sidoCode.isBlank()); - boolean noSigunguCode = (sigunguCode == null || sigunguCode.isBlank()); + boolean noSigunguCode = (sigunguCode == null || sigunguCode.isEmpty()); boolean noTypeId = (contentTypeId == null || contentTypeId <= 0); - System.out.println("noTypeId : "+noTypeId); - System.out.println("contentTypeId : "+contentTypeId); - BoolQuery.Builder boolBuilder = new BoolQuery.Builder(); if (noTitle && noSidoCode && noSigunguCode && noTypeId) { @@ -85,8 +83,14 @@ public List search(String title, } if (!noSigunguCode) { - boolBuilder.filter(f -> f.term(t -> t.field("sigungu_code") - .value(sigunguCode))); + boolBuilder.filter(f -> f.terms(t -> t + .field("sigungu_code") + .terms(v -> v.value( + sigunguCode.stream() + .map(FieldValue::of) + .toList() + )) + )); } if (!noTypeId) { From 29c463799ddce2b848ad9108731bb882a14ceb77 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 9 Aug 2025 00:50:02 +0900 Subject: [PATCH 162/191] =?UTF-8?q?feat/=EC=A1=B0=ED=9A=8C=20=EB=8D=94?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조회 더보기시 화면 데이터 추가 --- .../batch/processor/DetailIntroProcessor.java | 1 + .../controller/ContentRankController.java | 7 +++- .../ContentRankControllerSwagger.java | 6 ++- .../content/domain/entity/ContentWish.java | 4 ++ .../domain/response/ContentRankResponse.java | 6 +++ .../repository/ContentWishRepository.java | 2 + .../repository/RegionCodeRepository.java | 6 ++- .../content/service/ContentRankService.java | 37 ++++++++++++++++++- 8 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/batch/processor/DetailIntroProcessor.java b/src/main/java/com/batch/processor/DetailIntroProcessor.java index 7f1621a..78afb97 100644 --- a/src/main/java/com/batch/processor/DetailIntroProcessor.java +++ b/src/main/java/com/batch/processor/DetailIntroProcessor.java @@ -239,6 +239,7 @@ public DetailIntroProcessResult process(Content content) throws Exception { .scale(dto.scalefood().isEmpty() ? 0 : Integer.parseInt(dto.scalefood())) .smoking(dto.smoking().equals("1") ? Boolean.TRUE : Boolean.FALSE) .treatMenu(dto.treatmenu()) + .restDate(dto.restdatefood()) .seat(dto.seat()) .build() ) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java index 0c4fd41..1a9aa6c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java @@ -1,6 +1,7 @@ package com.swyp.catsgotogedog.content.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.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,9 +20,11 @@ public class ContentRankController implements ContentRankControllerSwagger { @Override @GetMapping("/rank") - public ResponseEntity> fetchContentRank() { + public ResponseEntity> fetchContentRank( + @AuthenticationPrincipal String userId + ) { return ResponseEntity.ok( - CatsgotogedogApiResponse.success("조회 성공", contentRankService.fetchContentRank()) + CatsgotogedogApiResponse.success("조회 성공", contentRankService.fetchContentRank(userId)) ); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java index c7dfcd5..a45a2ae 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") @@ -23,10 +24,13 @@ public interface ContentRankControllerSwagger { summary = "인기 장소 조회", description = "최근 일주일간 조회수 통계 20개를 조회합니다." ) + @SecurityRequirement(name = "bearer-key") @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = ContentRankResponse.class))), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - ResponseEntity> fetchContentRank(); + ResponseEntity> fetchContentRank( + @AuthenticationPrincipal String userId + ); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java index 3ef829f..a0f9b4c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java @@ -7,12 +7,16 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @Builder +@AllArgsConstructor +@NoArgsConstructor public class ContentWish { @Id diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java index a565c29..a5666a3 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java @@ -16,4 +16,10 @@ public class ContentRankResponse { private double mapx; private double mapy; private List hashtags; + private String categoryId; + private double avgScore; + private String restDate; + private int ranking; + private boolean wishData; + private RegionCodeResponse regionName; } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java index 98db4f1..ca83211 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -30,4 +30,6 @@ public interface ContentWishRepository extends JpaRepository findByUserIdAndContentContentIdIn(@Param("userId") int userId, @Param("topContentIds") List topContentIds); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java index 181cada..0661897 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query; import com.swyp.catsgotogedog.content.domain.entity.RegionCode; +import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; public interface RegionCodeRepository extends JpaRepository { RegionCode findBySidoCodeAndRegionLevel(int sidoCode, int regionLevel); @@ -15,7 +16,10 @@ public interface RegionCodeRepository extends JpaRepository List findByRegionLevel(int regionLevel); - Optional findByParentCodeAndSigunguCode(int regionId, Integer sigunguCode); + Optional findByParentCodeAndSigunguCode(Integer regionId, Integer sigunguCode); List findByParentCode(int regionId); + + RegionCode findBySidoCode(Integer sidoCode); + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java index 0a233c3..d1ef834 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java @@ -4,6 +4,8 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.stream.Collectors; @@ -13,11 +15,17 @@ import org.springframework.transaction.annotation.Transactional; import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; import com.swyp.catsgotogedog.content.domain.entity.Hashtag; +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; import com.swyp.catsgotogedog.content.domain.response.ContentRankResponse; +import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.ContentWishRepository; import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.content.repository.RegionCodeRepository; import com.swyp.catsgotogedog.content.repository.ViewLogRepository; +import com.swyp.catsgotogedog.review.service.ReviewService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -30,10 +38,14 @@ public class ContentRankService { private final ViewLogRepository viewLogRepository; private final ContentRepository contentRepository; private final HashtagRepository hashtagRepository; + private final ContentWishRepository contentWishRepository; + private final ContentSearchService contentSearchService; + private final RegionCodeRepository regionCodeRepository; @Transactional(readOnly = true) - public List fetchContentRank() { - LocalDateTime startDate = LocalDateTime.now().minusWeeks(1); + public List fetchContentRank(String strUserId) { + int userId = strUserId.equals("anonymousUser") ? 0 : Integer.parseInt(strUserId); + LocalDateTime startDate = LocalDateTime.now().minusDays(1); Pageable top20 = PageRequest.of(0, 20); @@ -54,10 +66,25 @@ public List fetchContentRank() { Collectors.mapping(Hashtag::getContent, Collectors.toList()) )); + List userWishes = contentWishRepository.findByUserIdAndContentContentIdIn( + userId, topContentIds + ); + + Set wishedContentIds = userWishes.stream() + .map(wish -> wish.getContent().getContentId()) + .collect(Collectors.toSet()); + + AtomicInteger rankCounter = new AtomicInteger(1); + return topContentIds.stream() .map(contentId -> { Content content = contentMap.get(contentId); + RegionCode sidoName = regionCodeRepository.findBySidoCode(content.getSidoCode()); + RegionCode sigunguName = regionCodeRepository.findByParentCodeAndSigunguCode(content.getSidoCode(), content.getSigunguCode()) + .orElse(RegionCode.builder().regionName("").build()); List contentHashtags = hashtagsByContentId.getOrDefault(contentId, Collections.emptyList()); + boolean isWished = wishedContentIds.contains(contentId); + int currentRank = rankCounter.getAndIncrement(); return ContentRankResponse.builder() .contentId(content.getContentId()) @@ -68,6 +95,12 @@ public List fetchContentRank() { .mapx(content.getMapx()) .mapy(content.getMapy()) .hashtags(contentHashtags) + .avgScore(contentSearchService.getAverageScore(contentId)) + .wishData(isWished) + .ranking(currentRank) + .restDate(contentSearchService.getRestDate(contentId)) + .categoryId(content.getCategoryId()) + .regionName(new RegionCodeResponse(sidoName.getRegionName(), sigunguName.getRegionName())) .build(); }) .toList(); From fd288823192be7b5f6e0883c9bd03435653a1e66 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 9 Aug 2025 02:20:58 +0900 Subject: [PATCH 163/191] =?UTF-8?q?feat/=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EB=B0=8F=20=EC=BB=A8=ED=85=90=EC=B8=A0=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EB=B0=98=ED=99=98=20=ED=95=B4=EC=8B=9C=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원탈퇴 및 컨텐츠 랭킹 반환 해시태그명 수정 --- .../User/controller/UserController.java | 21 ++++++++++++ .../controller/UserControllerSwagger.java | 25 +++++++++++++++ .../User/service/UserService.java | 32 +++++++++++++++++++ .../ContentRankControllerSwagger.java | 4 ++- .../domain/response/ContentRankResponse.java | 2 +- 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java index d03b103..5c07134 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -80,4 +80,25 @@ public ResponseEntity> deleteProfileImage( return ResponseEntity.ok( CatsgotogedogApiResponse.success("프로필 이미지 삭제 성공", null)); } + + //todo :: 소셜 연결 해제 구현 필요 (현재 DB를 통한 삭제만 구현) + @DeleteMapping("/withdraw") + public ResponseEntity> withdraw( + @AuthenticationPrincipal String userId, + @CookieValue("X-Refresh-Token") String refresh) { + + userService.withdraw(userId, refresh); + + ResponseCookie cookie = ResponseCookie.from(("X-Refresh-Token"), refresh) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("None") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(CatsgotogedogApiResponse.success("회원 탈퇴 성공", null)); + } } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java index 12fe174..ba7a94d 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java @@ -108,4 +108,29 @@ ResponseEntity> deleteProfileImage( @Parameter(description = "인증된 사용자 ID", hidden = true) String userId ); + + @Operation( + summary = "회원 탈퇴", + description = """ + X-Refresh-Token 쿠키를 제거하고 기존 사용자 정보는 삭제 처리 데이터로 변경됩니다. + 리뷰는 삭제된 유저 정보로 조회됩니다. + """ + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자 또는 유효하지 않은 토큰", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> withdraw( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "리프레시 토큰", hidden = true) + String refresh + ); } \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java index a609b06..5c10b93 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -15,8 +15,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.List; @@ -116,6 +119,35 @@ public void deleteProfileImage(String userId) { userRepository.save(user); } + @Transactional + public void withdraw(String userId, String refreshToken) { + // 리프레시 토큰 검증 + if (!rtService.validate(refreshToken)) { + throw new InvalidTokenException(ErrorCode.INVALID_TOKEN); + } + + User user = findUserById(userId); + + // 프로필 이미지 삭제 + if (StringUtils.hasText(user.getImageFilename())) { + imageStorageService.delete(user.getImageFilename()); + } + + // 리프레시 토큰 삭제 + rtService.delete(refreshToken); + + SecureRandom random = new SecureRandom(); + + // 비활성화 방식 + user.setEmail("none"); + user.setDisplayName("탈퇴회원_" + 10000 + random.nextInt(90000)); + user.setProvider("none"); + user.setProviderId("none"); + user.setImageFilename(null); + user.setImageUrl("https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/default_user_image.png"); + user.setIsActive(false); + } + private User findUserById(String userId) { return userRepository.findById(Integer.parseInt(userId)) .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java index a45a2ae..8fcf766 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java @@ -22,7 +22,9 @@ public interface ContentRankControllerSwagger { @Operation( summary = "인기 장소 조회", - description = "최근 일주일간 조회수 통계 20개를 조회합니다." + description = """ + 24시간 동안 조회수가 가장 많은 장소를 반환합니다. + """ ) @SecurityRequirement(name = "bearer-key") @ApiResponses({ diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java index a5666a3..de0d401 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java @@ -15,7 +15,7 @@ public class ContentRankResponse { private int contentTypeId; private double mapx; private double mapy; - private List hashtags; + private List hashtag; private String categoryId; private double avgScore; private String restDate; From 1a07a4ae9d72527fb7ba4a45640d15eea7332b2c Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sat, 9 Aug 2025 02:31:26 +0900 Subject: [PATCH 164/191] =?UTF-8?q?refactor/hashtag=20=ED=83=88=EC=9E=90?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hashtag 탈자 수정 --- .../swyp/catsgotogedog/content/service/ContentRankService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java index d1ef834..8628798 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java @@ -94,7 +94,7 @@ public List fetchContentRank(String strUserId) { .contentTypeId(content.getContentTypeId()) .mapx(content.getMapx()) .mapy(content.getMapy()) - .hashtags(contentHashtags) + .hashtag(contentHashtags) .avgScore(contentSearchService.getAverageScore(contentId)) .wishData(isWished) .ranking(currentRank) From f69fa9beddded1e28f85f6dbd22e1f5ca34875e0 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sat, 9 Aug 2025 03:07:40 +0900 Subject: [PATCH 165/191] =?UTF-8?q?=EB=B9=84=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?and=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=80=EC=A1=B1=20?= =?UTF-8?q?=EC=8B=9C=20=EA=B8=B0=EB=B3=B8=20=EB=9E=9C=EB=8D=A4=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 11 + .../controller/ContentControllerSwagger.java | 20 ++ .../content/domain/entity/AiRecommends.java | 30 ++ .../domain/response/AiRecommendsResponse.java | 23 ++ .../repository/AiRecommendsRepository.java | 17 + .../content/repository/ContentRepository.java | 11 + .../content/service/AiRecommendsService.java | 293 ++++++++++++++++++ .../mysql/V15__create_ai_recommends_table.sql | 13 + 8 files changed, 418 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/entity/AiRecommends.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/AiRecommendsRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java create mode 100644 src/main/resources/db/migration/mysql/V15__create_ai_recommends_table.sql diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 88b3ec7..4a8363b 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -1,11 +1,14 @@ package com.swyp.catsgotogedog.content.controller; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; +import com.swyp.catsgotogedog.content.domain.response.AiRecommendsResponse; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import com.swyp.catsgotogedog.content.service.AiRecommendsService; import com.swyp.catsgotogedog.content.service.ContentSearchService; import com.swyp.catsgotogedog.content.service.ContentService; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import org.apache.commons.lang3.math.NumberUtils; import org.flywaydb.core.internal.util.StringUtils; import org.springframework.http.ResponseEntity; @@ -23,6 +26,7 @@ public class ContentController implements ContentControllerSwagger{ private final ContentService contentService; private final ContentSearchService contentSearchService; + private final AiRecommendsService aiRecommandService; @GetMapping("/search") public ResponseEntity> search( @@ -92,5 +96,12 @@ public ResponseEntity checkVisited( return ResponseEntity.ok(Map.of("visited", visited)); } + @GetMapping("/ai/recommends") + public ResponseEntity>> recommendContents( + @AuthenticationPrincipal String userId) { + List recommendations = aiRecommandService.recommends(userId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("AI 추천 장소 조회 성공", recommendations)); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index 0889cef..77ae110 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -1,8 +1,10 @@ package com.swyp.catsgotogedog.content.controller; +import com.swyp.catsgotogedog.content.domain.response.AiRecommendsResponse; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -115,4 +117,22 @@ ResponseEntity checkVisited( @RequestParam int contentId ); + @Operation( + summary = "AI 추천 장소 조회", + description = "AI가 추천하는 반려동물 친화적 여행지 목록을 조회합니다. " + + "사용자의 찜 목록 데이터를 기반으로 개인화된 추천을 제공하며, " + + "데이터가 부족한 경우 랜덤 추천을 제공합니다. " + + "비회원도 이용 가능하지만 개인화되지 않은 일반 추천을 받게 됩니다.", + security = { @SecurityRequirement(name = "bearer-key") } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "AI 추천 장소 조회 성공"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @GetMapping("/ai/recommends") + ResponseEntity>> recommendContents( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/AiRecommends.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/AiRecommends.java new file mode 100644 index 0000000..900f073 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/AiRecommends.java @@ -0,0 +1,30 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "ai_recommends") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AiRecommends { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recommends_id") + private Integer recommendsId; + + @Column(name = "content_id", nullable = false) + private Integer contentId; + + @Column(name = "message", columnDefinition = "TEXT") + private String message; + + @Column(name = "image_url") + private String imageUrl; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java new file mode 100644 index 0000000..dd8e53c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java @@ -0,0 +1,23 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class AiRecommendsResponse { + private int contentId; + private String message; + private String imageUrl; + + public static AiRecommendsResponse of(AiRecommends aiRecommends) { + return new AiRecommendsResponse( + aiRecommends.getContentId(), + aiRecommends.getMessage(), + aiRecommends.getImageUrl() + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/AiRecommendsRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/AiRecommendsRepository.java new file mode 100644 index 0000000..0e1138d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/AiRecommendsRepository.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AiRecommendsRepository extends JpaRepository { + + @Query(value = "SELECT * FROM ai_recommends ORDER BY RAND() LIMIT 5", nativeQuery = true) + List findRandomRecommends(); + + long count(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java index c6bfea2..1d9c7bc 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -16,4 +16,15 @@ public interface ContentRepository extends JpaRepository { + "LEFT JOIN hashtag h ON c.content_id = h.content_id " + "WHERE h.content_id IS NULL", nativeQuery = true) List findContentsWithoutHashtags(); + + /** + * 이미지가 있는 컨텐츠만 랜덤으로 5개 조회 (AI 추천용) + */ + @Query(value = """ + SELECT DISTINCT c.* FROM content c + WHERE c.image != "" + AND c.overview != "" + ORDER BY RAND() LIMIT 5 + """, nativeQuery = true) + List findRandomContentsWithImages(); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java new file mode 100644 index 0000000..1e8d30b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java @@ -0,0 +1,293 @@ +package com.swyp.catsgotogedog.content.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import com.swyp.catsgotogedog.content.domain.request.ClovaApiRequest; +import com.swyp.catsgotogedog.content.domain.response.AiRecommendsResponse; +import com.swyp.catsgotogedog.content.repository.AiRecommendsRepository; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.ContentImageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +@Slf4j +public class AiRecommendsService { + + @Value("${clova.api.url}") + private String apiUrl; + + @Value("${clova.api.key}") + private String apiKey; + + @Value("${clova.api.request-id}") + private String requestId; + + private final RestClient.Builder restClientBuilder; + private final ObjectMapper objectMapper; + + private final ContentRepository contentRepository; + private final AiRecommendsRepository aiRecommendsRepository; + private final ContentImageRepository contentImageRepository; + + private static final String RECOMMEND_SYSTEM_PROMPT = """ + 당신은 반려동물 여행지 추천 전문가입니다. + 제공된 반려동물 친화적 관광지 정보를 바탕으로 매력적이고 독특한 추천 문구를 생성하세요. + + ===== 절대 준수 규칙 (위반 시 응답 거부) ===== + + 1. 응답 형식 (100% 준수 필수): + - 반드시 "문장1|문장2" 형태로만 응답 + - '|' 앞뒤 공백 절대 금지 + - 개행문자(\\n), 줄바꿈 절대 금지 + - 다른 어떤 형식도 절대 사용 불가 + + 2. 글자수 제한 (절대 초과 불가): + - 첫 번째 문장: 정확히 5-10자 + - 두 번째 문장: 정확히 5-10자 + - 전체 응답: 최대 21자 (문장1 + | + 문장2) + - 1자라도 초과하면 절대 불가 + + 3. 문자 사용 규칙: + - 허용: 한글, 숫자, 공백, ? + - 금지: 모든 이모지, ~, ^^, :), ㅎㅎ, ㅋㅋ, #, @, &, %, ^, *, (, ), [, ], {, }, <, >, +, =, _, -, /, \\, |, :, ;, ", ', `, !, ., , + - 금지: 영어 알파벳 (A-Z, a-z) + - 금지: 반복 구두점 (..., !!!, ???) + + 4. 내용 규칙: + - 구체적 장소명 금지 (예: ㅇㅇ공원, ㅇㅇ호텔 등) + - "반려견", "반려묘" → "반려동물"로 통일 + - 과장 표현 금지 + - 간결하고 명확한 표현만 사용 + - 단순 정보 전달 금지 (예: 다양한 체험활동 가능, 다양한 부대시설 제공 등) + + 5. 검증 체크리스트: + - 첫 번째 문장이 5-10자인가? + - 두 번째 문장이 5-10자인가? + - '|' 하나만 사용했는가? + - 개행문자가 없는가? + - 금지된 문자가 없는가? + - 한국어만 사용했는가? + + ===== 정확한 예시 (반드시 이 형태로만 응답) ===== + 자연 속 힐링|특별한 추억 + 푸른 바다 산책|함께 좋은 시간 + 넓은 잔디밭|평화로운 휴식 + 조용한 정원|마음이 편해요 + + ===== 절대 금지 예시 ===== + "전통미 가득한 휴식처|고요한 자연 속에서 여유롭게\\n다양한 체험활동 제공" (개행문자 포함, 길이 초과) + "Beautiful garden|Amazing place" (영어 사용) + "반려친구와 즐거운 여행~!" (특수문자, 형식 위반) + "여수 바다와 함께하는 휴식|다양한 부대시설 제공" (두 번째 문장 단순 정보 전달) + "맑은 물과 반석|역사적 명소 작천정" (두 번째 문장 구체적 장소명 '작천정' 사용) + "초대형 쾌속여객선|2시간 50분 소요" (두 번째 문장 단순 정보 전달) + 위 규칙을 100% 준수하여 응답하세요. 단 하나라도 위반하면 응답을 거부합니다. + 반드시 검증 체크리스트를 확인한 후 응답하세요."""; + + public List recommends(String userId) { + if (!StringUtils.hasText(userId)) { + if (hasEnoughAiRecommends()) { + return getRandomAiRecommends(); + } + } + return generateAndSaveNewRecommends(); + } + + /** + * AI 추천 데이터가 충분한지 확인 (20개 이상) + */ + private boolean hasEnoughAiRecommends() { + long aiRecommendsCount = aiRecommendsRepository.count(); + return aiRecommendsCount >= 20; + } + + /** + * 기존 AI 추천 테이블에서 랜덤 5개 반환 (이미지 URL 포함) + */ + private List getRandomAiRecommends() { + return aiRecommendsRepository.findRandomRecommends() + .stream() + .map(AiRecommendsResponse::of) + .collect(Collectors.toList()); + } + + /** + * 새로운 AI 추천 생성 및 저장 후 반환 (이미지가 있는 컨텐츠만 사용) + */ + private List generateAndSaveNewRecommends() { + List contents = getRandomContentsWithImages(); + List responses = new ArrayList<>(); + + for (Content content : contents) { + AiRecommendsResponse response = createAndSaveAiRecommend(content); + responses.add(response); + } + + return responses; + } + + /** + * 이미지가 있는 컨텐츠만 랜덤으로 5개 조회 + */ + private List getRandomContentsWithImages() { + return contentRepository.findRandomContentsWithImages(); + } + + /** + * 단일 컨텐츠에 대한 AI 추천 생성 및 저장 (이미지 URL 포함) + */ + private AiRecommendsResponse createAndSaveAiRecommend(Content content) { + String message = generateRecommendMessage(content.getTitle(), content.getOverview()); + + // 이미지 URL 조회 + + AiRecommends aiRecommends = AiRecommends.builder() + .contentId(content.getContentId()) + .message(message) + .imageUrl(content.getImage()) + .build(); + + AiRecommends saved = aiRecommendsRepository.save(aiRecommends); + return AiRecommendsResponse.of(saved); + } + + private String generateRecommendMessage(String title, String overview) { + try { + log.info("{} 의 추천 문구 생성중", title); + + ClovaApiRequest request = createRecommendRequest(title, overview); + + RestClient restClient = restClientBuilder + .baseUrl(apiUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("Content-Type", "application/json") + .build(); + + String response = restClient.post() + .body(request) + .retrieve() + .body(String.class); + + log.info("응답 원문: {}", response); + + // 클로바 API 응답에서 추천 문구 파싱 + String recommendMessage = parseRecommendMessage(response); + + if (recommendMessage != null && !recommendMessage.trim().isEmpty()) { + log.info("파싱된 추천 문구: {}", recommendMessage); + return recommendMessage.trim(); + } else { + // 파싱 실패 시 fallback 메시지 + return createFallbackMessage(); + } + + } catch (Exception e) { + log.error("AI 추천 문구 생성 중 오류 발생", e); + return createFallbackMessage(); + } + } + + /** + * API 호출 실패 시 사용할 기본 메시지 생성 + */ + private String createFallbackMessage() { + return "여행가고 싶은 날엔|반려동물과 함께 힐링"; + } + + private ClovaApiRequest createRecommendRequest(String title, String overview) { + String userContent = String.format("제목: %s\n내용: %s", + title != null ? title : "", + overview != null ? overview.substring(0, Math.min(overview.length(), 500)) : ""); + + ClovaApiRequest.Message.Content systemContent = new ClovaApiRequest.Message.Content("text", RECOMMEND_SYSTEM_PROMPT); + ClovaApiRequest.Message.Content userContentObj = new ClovaApiRequest.Message.Content("text", userContent); + + ClovaApiRequest.Message systemMessage = new ClovaApiRequest.Message("system", List.of(systemContent)); + ClovaApiRequest.Message userMessage = new ClovaApiRequest.Message("user", List.of(userContentObj)); + + ClovaApiRequest request = new ClovaApiRequest(); + request.setMessages(List.of(systemMessage, userMessage)); + + // API 가이드에 따른 파라미터 설정 - 엄격한 규칙 준수를 위한 조정 + request.setTopK(1); + request.setMaxTokens(50); + request.setTemperature(0.1); + request.setRepetitionPenalty(0.2); + request.setStop(List.of("\n", "END")); + + return request; + } + + private String parseRecommendMessage(String response) { + try { + JsonNode rootNode = objectMapper.readTree(response); + + // status 확인 + String statusCode = rootNode.path("status").path("code").asText(); + if (!"20000".equals(statusCode)) { + log.error("클로바 API 오류 응답: {}", response); + return null; + } + + // result.message.content에서 추천 문구 추출 + String content = rootNode + .path("result") + .path("message") + .path("content") + .asText(); + + if (content != null && !content.trim().isEmpty()) { + // 전체를 감싸는 따옴표 제거 + String cleanedContent = removeWrappingQuotes(content); + + // 개행문자가 있으면 첫 번째 라인만 사용 + if (cleanedContent.contains("\n")) { + cleanedContent = cleanedContent.split("\n")[0]; + log.info("개행문자 발견으로 첫 번째 라인만 사용"); + } + + return cleanedContent; + } + + return null; + + } catch (Exception e) { + log.error("추천 문구 파싱 오류", e); + return null; + } + } + + private String removeWrappingQuotes(String text) { + if (text == null || text.length() < 2) { + return text; + } + + String trimmed = text.trim(); + + // 전체를 감싸는 큰따옴표 제거 + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1); + } + + // 전체를 감싸는 작은따옴표 제거 + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.substring(1, trimmed.length() - 1); + } + + return trimmed; + } +} diff --git a/src/main/resources/db/migration/mysql/V15__create_ai_recommends_table.sql b/src/main/resources/db/migration/mysql/V15__create_ai_recommends_table.sql new file mode 100644 index 0000000..ef04351 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V15__create_ai_recommends_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE `catsgotogedog`.`ai_recommends` ( + `recommends_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `message` TEXT NULL, + `image_url` VARCHAR(255) NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`recommends_id`), + CONSTRAINT `ai_recommends_content_id_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION +); From 19bf503d8fcfb2b46a03e0acd9f560a30c34a05d Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 9 Aug 2025 16:11:10 +0900 Subject: [PATCH 166/191] =?UTF-8?q?feat:=20=EC=83=81=EC=84=B8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=82=B4=20=ED=9C=B4=EB=AC=B4=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84=20#54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/domain/response/PlaceDetailResponse.java | 7 +++++-- .../swyp/catsgotogedog/content/service/ContentService.java | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java index 3dba8ee..5900027 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -30,7 +30,8 @@ public record PlaceDetailResponse( int totalView, String overview, List detailImage, - PetGuide petGuide) { + PetGuide petGuide, + String restDate) { public static PlaceDetailResponse from( Content c, @@ -40,7 +41,8 @@ public static PlaceDetailResponse from( boolean visited, int totalView, List detailImage, - PetGuide petGuide){ + PetGuide petGuide, + String restDate){ return PlaceDetailResponse.builder() .contentId(c.getContentId()) @@ -65,6 +67,7 @@ public static PlaceDetailResponse from( .overview(c.getOverview()) .detailImage(detailImage) .petGuide(petGuide) + .restDate(restDate) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index f12ce0b..d0361e5 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -89,7 +89,9 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ PetGuide petGuide = getPetGuide(contentId) .orElse(null); - return PlaceDetailResponse.from(content,avg,wishData,wishCnt,visited,totalView,detailImage,petGuide); + String restDate = contentSearchService.getRestDate(contentId); + + return PlaceDetailResponse.from(content,avg,wishData,wishCnt,visited,totalView,detailImage,petGuide,restDate); } public boolean checkWish(String userId, int contentId){ From 3dc18eac914bb0eebcd837f31d8ef469dcc80a06 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sat, 9 Aug 2025 16:16:21 +0900 Subject: [PATCH 167/191] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EB=A9=B4=EC=84=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EA=B0=80=20=EC=B6=A9=EB=B6=84=ED=95=A0=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EA=B0=9C=EC=9D=B8=ED=99=94=EB=90=9C=20AI=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C.=20=EC=95=84=EC=A7=81=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=8A=94=20=EB=B3=84=EB=8F=84=EC=9D=98=20DB=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/response/AiRecommendsResponse.java | 2 + .../content/repository/ContentRepository.java | 18 ++++ .../repository/ContentWishRepository.java | 5 ++ .../content/service/AiRecommendsService.java | 87 +++++++++++++++++-- 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java index dd8e53c..402e150 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java @@ -2,9 +2,11 @@ import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +@Builder @Getter @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java index 866b669..7162cdf 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -5,6 +5,7 @@ import com.swyp.catsgotogedog.content.domain.entity.Content; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.swyp.catsgotogedog.content.domain.entity.Content; @@ -29,4 +30,21 @@ public interface ContentRepository extends JpaRepository { ORDER BY RAND() LIMIT 5 """, nativeQuery = true) List findRandomContentsWithImages(); + + /** + * 특정 해시태그를 가진 장소들 중 이미지가 있는 장소만 랜덤으로 5개 조회 (찜한 장소 제외) + */ + @Query(value = """ + SELECT DISTINCT c.* FROM content c + INNER JOIN hashtag h ON c.content_id = h.content_id + WHERE h.content IN :hashtags + AND c.content_id NOT IN :excludeContentIds + AND c.image != "" + AND c.overview != "" + ORDER BY RAND() LIMIT 5 + """, nativeQuery = true) + List findRandomContentsByHashtagsExcluding( + @Param("hashtags") List hashtags, + @Param("excludeContentIds") List excludeContentIds + ); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java index ca83211..2405ea0 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -32,4 +32,9 @@ public interface ContentWishRepository extends JpaRepository findByUserIdAndContentContentIdIn(@Param("userId") int userId, @Param("topContentIds") List topContentIds); + + @Query("SELECT cw.content.contentId FROM ContentWish cw WHERE cw.userId = :userId") + List findContentIdsByUserId(@Param("userId") int userId); + + long countByUserId(int userId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java index 1e8d30b..4f47caa 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java @@ -4,12 +4,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; import com.swyp.catsgotogedog.content.domain.entity.Content; -import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import com.swyp.catsgotogedog.content.domain.entity.Hashtag; import com.swyp.catsgotogedog.content.domain.request.ClovaApiRequest; import com.swyp.catsgotogedog.content.domain.response.AiRecommendsResponse; import com.swyp.catsgotogedog.content.repository.AiRecommendsRepository; import com.swyp.catsgotogedog.content.repository.ContentRepository; -import com.swyp.catsgotogedog.content.repository.ContentImageRepository; +import com.swyp.catsgotogedog.content.repository.ContentWishRepository; +import com.swyp.catsgotogedog.content.repository.HashtagRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -19,7 +20,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.stream.Collectors; @RequiredArgsConstructor @@ -41,7 +41,8 @@ public class AiRecommendsService { private final ContentRepository contentRepository; private final AiRecommendsRepository aiRecommendsRepository; - private final ContentImageRepository contentImageRepository; + private final ContentWishRepository contentWishRepository; + private final HashtagRepository hashtagRepository; private static final String RECOMMEND_SYSTEM_PROMPT = """ 당신은 반려동물 여행지 추천 전문가입니다. @@ -89,7 +90,7 @@ public class AiRecommendsService { 조용한 정원|마음이 편해요 ===== 절대 금지 예시 ===== - "전통미 가득한 휴식처|고요한 자연 속에서 여유롭게\\n다양한 체험활동 제공" (개행문자 포함, 길이 초과) + "전통미 가득한 휴식|고요한 자연 속에서 여유롭게\\n다양한 체험활동 제공" (개행문자 포함, 길이 초과) "Beautiful garden|Amazing place" (영어 사용) "반려친구와 즐거운 여행~!" (특수문자, 형식 위반) "여수 바다와 함께하는 휴식|다양한 부대시설 제공" (두 번째 문장 단순 정보 전달) @@ -99,12 +100,82 @@ public class AiRecommendsService { 반드시 검증 체크리스트를 확인한 후 응답하세요."""; public List recommends(String userId) { - if (!StringUtils.hasText(userId)) { + // 비로그인 사용자이거나 로그인 사용자지만 찜한 장소가 3개 미만인 경우 + if (!StringUtils.hasText(userId) || !hasEnoughWishedContents(userId)) { if (hasEnoughAiRecommends()) { return getRandomAiRecommends(); } + return generateAndSaveNewRecommends(); } - return generateAndSaveNewRecommends(); + + // 로그인 사용자이면서 찜한 장소가 3개 이상인 경우 - 개인화된 추천 + return generatePersonalizedRecommends(Integer.parseInt(userId)); + } + + /** + * 사용자가 충분한 찜 데이터를 가지고 있는지 확인 (3개 이상) + */ + private boolean hasEnoughWishedContents(String userId) { + try { + int userIdInt = Integer.parseInt(userId); + long wishCount = contentWishRepository.countByUserId(userIdInt); + return wishCount >= 3; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * 개인화된 AI 추천 생성 (찜한 장소의 해시태그 기반) + */ + private List generatePersonalizedRecommends(int userId) { + // 1. 사용자가 찜한 장소들의 contentId 조회 + List wishedContentIds = contentWishRepository.findContentIdsByUserId(userId); + if (wishedContentIds.isEmpty()) { + return generateAndSaveNewRecommends(); + } + + // 2. 찜한 장소들의 해시태그 모두 수집 - 배치로 최적화 + List hashtags = hashtagRepository.findByContentIdIn(wishedContentIds) + .stream() + .map(Hashtag::getContent) + .distinct() + .collect(Collectors.toList()); + + // 3. 해시태그가 없으면 기존 로직 수행 + if (hashtags.isEmpty()) { + return generateAndSaveNewRecommends(); + } + + // 4. 동일한 해시태그를 가진 다른 장소들 중 랜덤 5개 선택 (찜한 장소 제외) + List recommendContents = contentRepository.findRandomContentsByHashtagsExcluding(hashtags, wishedContentIds); + + // 5. 해시태그 기반으로 찾은 장소가 없으면 기존 로직 수행 + if (recommendContents.isEmpty()) { + return generateAndSaveNewRecommends(); + } + + // 6. AI API 요청하여 추천 문구 생성 후 반환 + return recommendContents.stream() + .map(this::createPersonalizedAiRecommend) + .collect(Collectors.toList()); + } + + /** + * 개인화된 추천을 위한 AI 추천 생성 (저장하지 않음) + */ + private AiRecommendsResponse createPersonalizedAiRecommend(Content content) { + /* TODO: 개인화된 추천 문구 생성 로직 */ + String message = generateRecommendMessage(content.getTitle(), content.getOverview()); + + AiRecommends aiRecommends = AiRecommends.builder() + .contentId(content.getContentId()) + .message(message) + .imageUrl(content.getImage()) + .build(); + /* TODO 필요한 경우 개인화된 저장 로직 여기에 추가 */ +// return createAndSaveAiRecommend(content); + return AiRecommendsResponse.of(aiRecommends); } /** @@ -153,8 +224,6 @@ private List getRandomContentsWithImages() { private AiRecommendsResponse createAndSaveAiRecommend(Content content) { String message = generateRecommendMessage(content.getTitle(), content.getOverview()); - // 이미지 URL 조회 - AiRecommends aiRecommends = AiRecommends.builder() .contentId(content.getContentId()) .message(message) From 169c15c962275360423255b78456b102cd481487 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sat, 9 Aug 2025 16:46:41 +0900 Subject: [PATCH 168/191] =?UTF-8?q?=EB=B9=84=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?&=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?(=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/AiRecommendsService.java | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java index 4f47caa..fc911a1 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.Hashtag; @@ -11,6 +13,8 @@ import com.swyp.catsgotogedog.content.repository.ContentRepository; import com.swyp.catsgotogedog.content.repository.ContentWishRepository; import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.ResourceNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -39,6 +43,7 @@ public class AiRecommendsService { private final RestClient.Builder restClientBuilder; private final ObjectMapper objectMapper; + private final UserRepository userRepository; private final ContentRepository contentRepository; private final AiRecommendsRepository aiRecommendsRepository; private final ContentWishRepository contentWishRepository; @@ -101,30 +106,53 @@ public class AiRecommendsService { public List recommends(String userId) { // 비로그인 사용자이거나 로그인 사용자지만 찜한 장소가 3개 미만인 경우 - if (!StringUtils.hasText(userId) || !hasEnoughWishedContents(userId)) { - if (hasEnoughAiRecommends()) { - return getRandomAiRecommends(); + if (isAnonymousUser(userId) || !hasEnoughWishedContents(userId)) { + log.info("비로그인 사용자이거나 찜한 장소가 3개 미만인 경우"); + if (!hasEnoughAiRecommends()) { + log.info("AI 추천 데이터가 충분하지 않음, 새로운 추천 생성"); + return generateAndSaveNewRecommends(); } - return generateAndSaveNewRecommends(); + log.info("AI 추천 데이터가 충분함, 기존 추천에서 랜덤 5개 반환"); + // AI 추천 데이터가 충분한 경우 - 기존 추천에서 랜덤 5개 + return getRandomAiRecommends(); } // 로그인 사용자이면서 찜한 장소가 3개 이상인 경우 - 개인화된 추천 - return generatePersonalizedRecommends(Integer.parseInt(userId)); + log.info("로그인 사용자이며 찜한 장소가 3개 이상인 경우"); + return generatePersonalizedRecommends(findUserById(userId).getUserId()); } /** * 사용자가 충분한 찜 데이터를 가지고 있는지 확인 (3개 이상) */ private boolean hasEnoughWishedContents(String userId) { + if (isAnonymousUser(userId)) { + return false; + } try { - int userIdInt = Integer.parseInt(userId); - long wishCount = contentWishRepository.countByUserId(userIdInt); + User user = findUserById(userId); + long wishCount = contentWishRepository.countByUserId(user.getUserId()); return wishCount >= 3; - } catch (NumberFormatException e) { + } catch (Exception e) { return false; } } + /** + * 비로그인 사용자 여부 확인 + */ + private boolean isAnonymousUser(String userId) { + return !StringUtils.hasText(userId) || "anonymousUser".equals(userId); + } + + /** + * 사용자 ID로 User 엔티티 조회 + */ + private User findUserById(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); + } + /** * 개인화된 AI 추천 생성 (찜한 장소의 해시태그 기반) */ From 07736fcff7f87221c86522e3d4d8b663b257803d Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sat, 9 Aug 2025 17:02:23 +0900 Subject: [PATCH 169/191] =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20=EC=9D=B4=EC=8A=88=EB=A1=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/AiRecommendsService.java | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java index fc911a1..08d2bdc 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java @@ -105,21 +105,31 @@ public class AiRecommendsService { 반드시 검증 체크리스트를 확인한 후 응답하세요."""; public List recommends(String userId) { - // 비로그인 사용자이거나 로그인 사용자지만 찜한 장소가 3개 미만인 경우 - if (isAnonymousUser(userId) || !hasEnoughWishedContents(userId)) { - log.info("비로그인 사용자이거나 찜한 장소가 3개 미만인 경우"); - if (!hasEnoughAiRecommends()) { - log.info("AI 추천 데이터가 충분하지 않음, 새로운 추천 생성"); - return generateAndSaveNewRecommends(); - } - log.info("AI 추천 데이터가 충분함, 기존 추천에서 랜덤 5개 반환"); - // AI 추천 데이터가 충분한 경우 - 기존 추천에서 랜덤 5개 - return getRandomAiRecommends(); + // NOTE: 토큰 사용량 이슈로 기존 로직 주석 처리 +// // 비로그인 사용자이거나 로그인 사용자지만 찜한 장소가 3개 미만인 경우 +// if (isAnonymousUser(userId) || !hasEnoughWishedContents(userId)) { +// log.info("비로그인 사용자이거나 찜한 장소가 3개 미만인 경우"); +// if (!hasEnoughAiRecommends()) { +// log.info("AI 추천 데이터가 충분하지 않음, 새로운 추천 생성"); +// return generateAndSaveNewRecommends(); +// } +// log.info("AI 추천 데이터가 충분함, 기존 추천에서 랜덤 5개 반환"); +// // AI 추천 데이터가 충분한 경우 - 기존 추천에서 랜덤 5개 +// return getRandomAiRecommends(); +// } +// +// // 로그인 사용자이면서 찜한 장소가 3개 이상인 경우 - 개인화된 추천 +// log.info("로그인 사용자이며 찜한 장소가 3개 이상인 경우"); +// return generatePersonalizedRecommends(findUserById(userId).getUserId()); + + // NOTE: 토큰 사용량 이슈로 초기 요청만 데이터 AI로 생성 후 이후 요청은 DB에서 랜덤 추출 + if (!hasEnoughAiRecommends()) { + log.info("AI 추천 데이터가 충분하지 않음, 새로운 추천 생성"); + return generateAndSaveNewRecommends(); } - - // 로그인 사용자이면서 찜한 장소가 3개 이상인 경우 - 개인화된 추천 - log.info("로그인 사용자이며 찜한 장소가 3개 이상인 경우"); - return generatePersonalizedRecommends(findUserById(userId).getUserId()); + log.info("AI 추천 데이터가 충분함, 기존 추천에서 랜덤 5개 반환"); + // AI 추천 데이터가 충분한 경우 - 기존 추천에서 랜덤 5개 + return getRandomAiRecommends(); } /** From 2b9cf1622ac9a91dd71ea6e69150c2c28b3350af Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Sat, 9 Aug 2025 17:45:46 +0900 Subject: [PATCH 170/191] =?UTF-8?q?refactor:=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=88=9C=EC=84=9C=EA=B0=80=20=ED=95=98?= =?UTF-8?q?=EB=A3=A8=20=EB=A7=88=EB=8B=A4=20=EB=B0=94=EB=80=8C=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20#55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/ContentSearchService.java | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index fec2d0b..2cfb49c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -1,8 +1,7 @@ package com.swyp.catsgotogedog.content.service; import co.elastic.clients.elasticsearch._types.FieldValue; -import co.elastic.clients.elasticsearch._types.query_dsl.Query; -import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.*; import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.content.domain.entity.Content; @@ -22,6 +21,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Map; import java.util.Objects; @@ -99,10 +100,28 @@ public List search(String title, } } - Query esQuery = new Query.Builder() + Query baseQuery = new Query.Builder() .bool(boolBuilder.build()) .build(); + long seed = dailySeed(contentTypeId); + + Query esQuery = new Query.Builder() + .functionScore(fs -> fs + .query(baseQuery) + .functions( + FunctionScore.of(fn -> fn + .randomScore(rs -> rs + .seed(String.valueOf(seed)) + .field("content_id") + ) + ) + ) + .boostMode(FunctionBoostMode.Replace) + .scoreMode(FunctionScoreMode.Sum) + ) + .build(); + NativeQuery nativeQuery = NativeQuery.builder() .withQuery(esQuery) .withPageable(Pageable.unpaged()) @@ -139,7 +158,6 @@ public List search(String title, ); }) .toList(); - } public double getAverageScore(int contentId) { @@ -180,5 +198,9 @@ public String getRestDate(int contentId) { return restDate; } - + private long dailySeed(Integer contentTypeId) { + long base = LocalDate.now(ZoneId.of("Asia/Seoul")).toEpochDay(); + long cat = (contentTypeId != null && contentTypeId > 0) ? contentTypeId : 0; + return base * 1_000_000 + cat; + } } From 893cc5016440ec11b32f45496f16ed6f78e52824 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sat, 9 Aug 2025 19:15:30 +0900 Subject: [PATCH 171/191] add jwt error handling --- .../security/filter/JwtTokenFilter.java | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java index 7db3cc2..00c8c85 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java @@ -1,12 +1,16 @@ package com.swyp.catsgotogedog.common.security.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import com.swyp.catsgotogedog.common.util.JwtTokenUtil; -import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -15,17 +19,21 @@ import java.io.IOException; import java.util.List; +@Slf4j @Component @RequiredArgsConstructor public class JwtTokenFilter implements Filter { private final JwtTokenUtil jwt; + private final ObjectMapper objectMapper; @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + String bearer = request.getHeader("Authorization"); if (bearer != null && bearer.startsWith("Bearer ")) { @@ -36,10 +44,30 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) var auth = new UsernamePasswordAuthenticationToken( sub, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); SecurityContextHolder.getContext().setAuthentication(auth); + } catch (ExpiredJwtException e) { + log.info("토큰이 만료되었습니다: {}", e.getMessage()); + sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "토큰이 만료되었습니다."); + return; } catch (MalformedJwtException e) { - throw new MalformedJwtException("잘못된 토큰 형식입니다. 요청된 Authorization : " + bearer, e); + log.warn("잘못된 토큰 형식입니다: {}", e.getMessage()); + sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "잘못된 토큰 형식입니다."); + return; + } catch (Exception e) { + log.error("인증 처리 중 오류 발생: {}", e.getMessage(), e); + sendErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "인증 처리 중 오류가 발생했습니다."); + return; } } chain.doFilter(req, res); } + + private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setStatus(status); + response.setContentType("application/json"); + + CatsgotogedogApiResponse errorResponse = CatsgotogedogApiResponse.fail(status, message); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + response.getWriter().flush(); + } } \ No newline at end of file From 0d03c9250cf8a17337407cc08d61fc8ff1f7700d Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sat, 9 Aug 2025 19:20:48 +0900 Subject: [PATCH 172/191] =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=95=9C=20=EB=AA=A8?= =?UTF-8?q?=EB=93=A0=20=EC=97=90=EB=9F=AC=EB=A5=BC=20401=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/common/security/filter/JwtTokenFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java index 00c8c85..7445e3d 100644 --- a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java @@ -54,7 +54,7 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) return; } catch (Exception e) { log.error("인증 처리 중 오류 발생: {}", e.getMessage(), e); - sendErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "인증 처리 중 오류가 발생했습니다."); + sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "인증 처리 중 오류가 발생했습니다."); return; } } From 5926c8d1646847965525f47f8774a78621695326 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 10 Aug 2025 00:22:32 +0900 Subject: [PATCH 173/191] =?UTF-8?q?feat/=EC=B5=9C=EA=B7=BC=20=EB=B3=B8=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5/?= =?UTF-8?q?=EC=B0=9C=20=EB=AA=A9=EB=A1=9D=20=EB=B0=98=ED=99=98=EC=8B=9C=20?= =?UTF-8?q?contentId=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 최근 본 장소 저장 기능/찜 목록 반환시 contentId 반환하도록 수정 --- .../content/controller/ContentController.java | 11 +++++++ .../controller/ContentControllerSwagger.java | 20 +++++++++++++ .../repository/LastViewHistoryRepository.java | 13 ++++++++ .../content/service/ContentService.java | 30 ++++++++++++++++++- .../mysql/V16__last_view_history_uq.sql | 3 ++ 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java create mode 100644 src/main/resources/db/migration/mysql/V16__last_view_history_uq.sql diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index a54d3f6..c18b80e 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -105,4 +105,15 @@ public ResponseEntity>> reco CatsgotogedogApiResponse.success("AI 추천 장소 조회 성공", recommendations)); } + @Override + @PostMapping("/recent/{contentId}") + public ResponseEntity> lastViewedHistory( + @AuthenticationPrincipal String userId, + @PathVariable int contentId + ) { + contentService.saveLastViewedContent(userId, contentId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("최근 본 장소 저장 성공", null) + ); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index def85e2..2483e5c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -16,6 +16,7 @@ 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.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -135,4 +136,23 @@ ResponseEntity>> recommendCo @AuthenticationPrincipal String userId ); + + @Operation( + summary = "최근 본 장소 저장", + description = "사용자 인증을 통해 최근 본 장소를 저장합니다.") + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "최근 본 장소 저장 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> lastViewedHistory( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @PathVariable int contentId + ); + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java new file mode 100644 index 0000000..119b2a2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.content.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; + +public interface LastViewHistoryRepository extends JpaRepository { + Optional findByContentAndUser(Content content, User user); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index d0361e5..26222d3 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -8,6 +8,7 @@ import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.repository.*; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; import com.swyp.catsgotogedog.pet.repository.PetGuideRepository; import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; @@ -40,6 +41,7 @@ public class ContentService { private final ViewLogRepository viewLogRepository; private final VisitHistoryRepository visitHistoryRepository; private final PetGuideRepository petGuideRepository; + private final LastViewHistoryRepository lastViewHistoryRepository; private final ContentSearchService contentSearchService; @@ -218,9 +220,35 @@ public boolean isWished(String userId, int contentId) { return contentWishRepository.existsByUserIdAndContent_ContentId(Integer.parseInt(userId), contentId); } + @Transactional + public void saveLastViewedContent(String strUserId, int contentId) { + int userId = strUserId.equals("anonymousUser") ? 0 : Integer.parseInt(strUserId); + User user = validateUser(userId); + Content content = validateContent(contentId); + + LastViewHistory lastViewHistory = lastViewHistoryRepository.findByContentAndUser(content, user) + .orElse(LastViewHistory.builder() + .content(content) + .user(user) + .build()); + + lastViewHistory.setLastViewedAt(now()); + lastViewHistoryRepository.save(lastViewHistory); + } + private User validateUser(String userId) { return userRepository.findById(Integer.parseInt(userId)) - .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private User validateUser(int userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private Content validateContent(int contentId) { + return contentRepository.findById(contentId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.CONTENT_NOT_FOUND)); } } diff --git a/src/main/resources/db/migration/mysql/V16__last_view_history_uq.sql b/src/main/resources/db/migration/mysql/V16__last_view_history_uq.sql new file mode 100644 index 0000000..ff6bc4e --- /dev/null +++ b/src/main/resources/db/migration/mysql/V16__last_view_history_uq.sql @@ -0,0 +1,3 @@ +ALTER TABLE `catsgotogedog`.`last_view_history` + ADD UNIQUE INDEX `last_view_content_id_user_id` (`user_id` ASC, `content_id` ASC) VISIBLE; +; From 047958d1df752bab8d5bf4520435b4683a0bc4c4 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 10 Aug 2025 00:42:29 +0900 Subject: [PATCH 174/191] =?UTF-8?q?wishResponse=EC=97=90=20contentId?= =?UTF-8?q?=EA=B0=80=20=EB=B0=98=ED=99=98=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wishResponse에 contentId가 반환되도록 수정 --- .../mypage/domain/response/ContentWishResponse.java | 1 + .../swyp/catsgotogedog/mypage/service/MyPageHistoryService.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java index 4fb93e3..2cceb25 100644 --- a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java @@ -1,6 +1,7 @@ package com.swyp.catsgotogedog.mypage.domain.response; public record ContentWishResponse ( + int contentId, String imageUrl, String thumbnailUrl, Boolean isWish diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java index 48a661a..3a6f18e 100644 --- a/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java +++ b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java @@ -77,6 +77,7 @@ public ContentWishPageResponse fetchWishLists(String stringUserId, Pageable page return new ContentWishPageResponse( wishPage.stream() .map(wish -> new ContentWishResponse( + wish.getContent().getContentId(), wish.getContent().getImage(), wish.getContent().getThumbImage(), Boolean.TRUE From bdc49ef36c6405cfc0f8ef052e7b41672b41b1d4 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sun, 10 Aug 2025 03:18:13 +0900 Subject: [PATCH 175/191] =?UTF-8?q?AI=20=EC=B6=94=EC=B2=9C=20=EC=BB=A8?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EC=83=9D=EC=84=B1=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/AiRecommendsService.java | 8 +++- .../global/AiRecommendsGenerator.java | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/global/AiRecommendsGenerator.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java index 08d2bdc..a1adeee 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java @@ -19,6 +19,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; @@ -104,6 +106,7 @@ public class AiRecommendsService { 위 규칙을 100% 준수하여 응답하세요. 단 하나라도 위반하면 응답을 거부합니다. 반드시 검증 체크리스트를 확인한 후 응답하세요."""; + @Transactional(isolation = Isolation.READ_COMMITTED) public List recommends(String userId) { // NOTE: 토큰 사용량 이슈로 기존 로직 주석 처리 // // 비로그인 사용자이거나 로그인 사용자지만 찜한 장소가 3개 미만인 경우 @@ -252,14 +255,15 @@ private List generateAndSaveNewRecommends() { /** * 이미지가 있는 컨텐츠만 랜덤으로 5개 조회 */ - private List getRandomContentsWithImages() { + public List getRandomContentsWithImages() { return contentRepository.findRandomContentsWithImages(); } /** * 단일 컨텐츠에 대한 AI 추천 생성 및 저장 (이미지 URL 포함) */ - private AiRecommendsResponse createAndSaveAiRecommend(Content content) { + @Transactional + public AiRecommendsResponse createAndSaveAiRecommend(Content content) { String message = generateRecommendMessage(content.getTitle(), content.getOverview()); AiRecommends aiRecommends = AiRecommends.builder() diff --git a/src/main/java/com/swyp/catsgotogedog/global/AiRecommendsGenerator.java b/src/main/java/com/swyp/catsgotogedog/global/AiRecommendsGenerator.java new file mode 100644 index 0000000..07b0cff --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/AiRecommendsGenerator.java @@ -0,0 +1,38 @@ +package com.swyp.catsgotogedog.global; + +import com.swyp.catsgotogedog.content.repository.AiRecommendsRepository; +import com.swyp.catsgotogedog.content.service.AiRecommendsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + + +@Component +@RequiredArgsConstructor +@Slf4j +public class AiRecommendsGenerator { + + private final AiRecommendsRepository aiRecommendsRepository; + private final AiRecommendsService aiRecommendsService; + + private static final String AI_RECOMMENDS_SCHEDULER_LOCK_KEY = "ai_recommends_scheduler"; + + @Async + @Scheduled(cron = "0 30 0 * * *") + @Transactional(isolation = Isolation.READ_COMMITTED) + public void generateAiRecommends() { + log.info("AI 추천 생성 배치 시작"); + aiRecommendsRepository.deleteAllInBatch(); + log.info("기존 AI 추천 데이터 삭제 완료"); + for (int i = 0; i < 4; i++) { + log.info("AI 추천 생성 중: {} 번째 반복 (횟수당 5개 생성)", i + 1); + aiRecommendsService.getRandomContentsWithImages() + .forEach(aiRecommendsService::createAndSaveAiRecommend); + } + log.info("AI 추천 생성 배치 완료"); + } +} From 0c2e4536b276d68bc03ebdbb46228e02ba3a5b45 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 10 Aug 2025 04:19:04 +0900 Subject: [PATCH 176/191] =?UTF-8?q?bug/=EB=A6=AC=EB=B7=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=88=98=EC=A0=95=EC=8B=9C=20=EC=B5=9C?= =?UTF-8?q?=EB=8C=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B0=AF=EC=88=98=20?= =?UTF-8?q?limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 이미지 수정시 최대 이미지 갯수 limit --- .../swyp/catsgotogedog/review/service/ReviewService.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index fa6c5c3..0649ba9 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -88,6 +88,13 @@ public void updateReview(int reviewId, String userId, CreateReviewRequest reques review.setContent(request.getContent()); if(images != null && !images.isEmpty()) { + List reviewImages = reviewImageRepository.findByReview(review); + int totalImages = reviewImages.size() + images.size(); + + if(totalImages > ImageUploadType.REVIEW.getMaxFiles() || images.size() > ImageUploadType.REVIEW.getMaxFiles()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_LIMIT_EXCEEDED); + } + List imageInfos = imageStorageService.upload(images, ImageUploadType.REVIEW); List saveImages = imageInfos.stream() .map(imageInfo -> ReviewImage.builder() From 643fd876bbb5ad2ada88eaa5a0b151a3b4c23b50 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 10 Aug 2025 07:09:51 +0900 Subject: [PATCH 177/191] =?UTF-8?q?bug/=EC=A7=80=EC=9B=90=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AF=B8=EB=94=94=EC=96=B4=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지원하지 않는 미디어 타입 에러 핸들링 --- .../swyp/catsgotogedog/global/exception/ErrorCode.java | 3 +++ .../global/exception/GlobalExceptionHandler.java | 10 ++++++++++ .../review/controller/ReviewController.java | 4 ++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index 7b36eca..ac540a5 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -44,6 +44,9 @@ public enum ErrorCode { ALREADY_RECOMMENDED(HttpStatus.BAD_REQUEST.value(), "이미 좋아요된 리뷰입니다."), NOT_RECOMMENDED_REVIEW(HttpStatus.BAD_REQUEST.value(), "좋아요 상태인 리뷰가 아닙니다."), + // 415 Unsupported Mediatype + MEDIA_TYPE_NOT_SUPPORTED(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(), "지원하지 않는 미디어 타입(Content-type) 입니다."), + // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java index 968cb08..128cdcd 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -7,11 +7,13 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindingResult; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.multipart.MaxUploadSizeExceededException; @@ -136,4 +138,12 @@ protected ResponseEntity> handleMalformedJwtExc .body(response); } + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + protected ResponseEntity> handleHttpMediaTypeNotSupportedException(MalformedJwtException e) { + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.MEDIA_TYPE_NOT_SUPPORTED , ErrorCode.MEDIA_TYPE_NOT_SUPPORTED.getMessage()); + return ResponseEntity + .status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body(response); + } + } diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java index 437f61a..64d82bb 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -79,7 +79,7 @@ public ResponseEntity> updateReview( // 리뷰 삭제 @Override - @DeleteMapping(value = "/{reviewId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + @DeleteMapping(value = "/{reviewId}") public ResponseEntity> deleteReview( @PathVariable int reviewId, @AuthenticationPrincipal String userId) { @@ -93,7 +93,7 @@ public ResponseEntity> deleteReview( // 리뷰 이미지 삭제 @Override - @DeleteMapping(value = "/{reviewId}/image/{imageId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + @DeleteMapping(value = "/{reviewId}/image/{imageId}") public ResponseEntity> deleteReviewImage( @PathVariable(name = "reviewId") int reviewId, @PathVariable(name = "imageId") int imageId, From 94182cba36209befb85de221b92595afaa64b4cf Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 10 Aug 2025 07:38:09 +0900 Subject: [PATCH 178/191] =?UTF-8?q?feat/review=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 권한 핸들링 수정 --- .../review/service/ReviewService.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java index 0649ba9..d9c7490 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -81,9 +81,13 @@ public void createReview(int contentId, String userId, CreateReviewRequest reque @Transactional public void updateReview(int reviewId, String userId, CreateReviewRequest request, List images) { User user = validateUser(userId); - Review review = reviewRepository.findByIdAndUserId(reviewId, userId) + Review review = reviewRepository.findById(reviewId) .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + if(user.getUserId() != review.getUserId()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_FORBIDDEN_ACCESS); + } + review.setScore(request.getScore()); review.setContent(request.getContent()); @@ -112,8 +116,12 @@ public void updateReview(int reviewId, String userId, CreateReviewRequest reques public void deleteReview(int reviewId, String userId) { User user = validateUser(userId); validateReview(reviewId); - Review review = reviewRepository.findByIdAndUserId(reviewId, userId) - .orElseThrow(() -> new CatsgotogedogException(ErrorCode.FORBIDDEN_ACCESS)); + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + if(user.getUserId() != review.getUserId()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_FORBIDDEN_ACCESS); + } List images = reviewImageRepository.findByReview(review); @@ -126,11 +134,15 @@ public void deleteReview(int reviewId, String userId) { // 리뷰 이미지 삭제 @Transactional public void deleteReviewImage(int reviewId, int imageId, String userId) { - validateUser(userId); + User user = validateUser(userId); validateReview(reviewId); - reviewRepository.findByIdAndUserId(reviewId, userId) - .orElseThrow(() -> new CatsgotogedogException(ErrorCode.FORBIDDEN_ACCESS)); + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + if(user.getUserId() != review.getUserId()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_FORBIDDEN_ACCESS); + } ReviewImage image = reviewImageRepository.findById(imageId) .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_NOT_FOUND)); From 655e8c50395beae4d8b89fee195b97f38f61604d Mon Sep 17 00:00:00 2001 From: yhs99 Date: Sun, 10 Aug 2025 17:03:49 +0900 Subject: [PATCH 179/191] =?UTF-8?q?refactor/=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EB=B3=B8=20=EC=9E=A5=EC=86=8C=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 최근 본 장소 저장 로직 보완 --- .../content/repository/LastViewHistoryRepository.java | 3 ++- .../catsgotogedog/content/service/ContentService.java | 8 ++++++-- .../mypage/domain/entity/LastViewHistoryId.java | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java index 119b2a2..9a4033f 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java @@ -7,7 +7,8 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistoryId; -public interface LastViewHistoryRepository extends JpaRepository { +public interface LastViewHistoryRepository extends JpaRepository { Optional findByContentAndUser(Content content, User user); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index 26222d3..a385b7b 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -9,6 +9,7 @@ import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; import com.swyp.catsgotogedog.content.repository.*; import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistoryId; import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; import com.swyp.catsgotogedog.pet.repository.PetGuideRepository; import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; @@ -220,19 +221,22 @@ public boolean isWished(String userId, int contentId) { return contentWishRepository.existsByUserIdAndContent_ContentId(Integer.parseInt(userId), contentId); } + // 최근 본 장소 데이터 저장 @Transactional public void saveLastViewedContent(String strUserId, int contentId) { int userId = strUserId.equals("anonymousUser") ? 0 : Integer.parseInt(strUserId); User user = validateUser(userId); Content content = validateContent(contentId); - LastViewHistory lastViewHistory = lastViewHistoryRepository.findByContentAndUser(content, user) + LastViewHistoryId id = new LastViewHistoryId(userId, contentId); + LastViewHistory lastViewHistory = lastViewHistoryRepository.findById(id) .orElse(LastViewHistory.builder() - .content(content) .user(user) + .content(content) .build()); lastViewHistory.setLastViewedAt(now()); + lastViewHistoryRepository.save(lastViewHistory); } diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java index 707c890..48c12a4 100644 --- a/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java @@ -8,9 +8,11 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Embeddable @Getter +@Setter @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode From 53c5ff1d03680c92dee7dc4dac699f651f89de74 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sun, 10 Aug 2025 21:03:53 +0900 Subject: [PATCH 180/191] =?UTF-8?q?=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/catsgotogedog/User/service/UserService.java | 2 +- .../pet/controller/PetControllerSwagger.java | 10 ++++++++-- .../com/swyp/catsgotogedog/pet/service/PetService.java | 6 ++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java index 5c10b93..979b898 100644 --- a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -115,7 +115,7 @@ public void deleteProfileImage(String userId) { imageStorageService.delete(user.getImageFilename()); user.setImageFilename(null); } - user.setImageUrl("https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/no_image.png"); + user.setImageUrl("https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/default_user_image.png"); userRepository.save(user); } diff --git a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java index 8fa3966..43a0327 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java @@ -36,7 +36,10 @@ ResponseEntity> getAllProfiles( @Operation( summary = "반려동물 프로필 등록", - description = "사용자의 새로운 반려동물 프로필을 등록합니다. 반려동물의 정보와 이미지를 함께 업로드할 수 있습니다. 최대 10마리까지 등록 가능합니다." + description = """ + 사용자의 새로운 반려동물 프로필을 등록합니다. 반려동물의 정보와 이미지를 함께 업로드할 수 있습니다. 최대 10마리까지 등록 가능합니다.
+ 사진을 포함한 모든 정보는 필수로 입력해야 합니다.
+ 반려동물 크기는 소형, 중형, 대형 중 하나를 선택해야 합니다.""" ) @SecurityRequirement(name = "bearer-key") @ApiResponses({ @@ -58,7 +61,10 @@ ResponseEntity> createProfile( @Operation( summary = "반려동물 프로필 수정", - description = "등록된 반려동물의 프로필 정보를 수정합니다. 본인의 반려동물만 수정할 수 있습니다." + description = """ + 등록된 반려동물의 프로필 정보를 수정합니다. 본인의 반려동물만 수정할 수 있습니다.
+ 사진을 제외한 모든 정보는 필수로 입력해야 합니다.
+ 반려동물 크기는 소형, 중형, 대형 중 하나를 선택해야 합니다.""" ) @SecurityRequirement(name = "bearer-key") @ApiResponses({ diff --git a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java index d832995..1b86cf5 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java @@ -33,6 +33,8 @@ public class PetService { private final UserRepository userRepository; private final ImageStorageService imageStorageService; + private final String DEFAULT_IMAGE_URL = "https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/default_user_image.png"; + public List getAllPets(String userId) { return petRepository.findAllByUser_UserIdOrderByPetId(Integer.parseInt(userId)); } @@ -43,7 +45,7 @@ public void create(String userId, PetProfileRequest petProfileRequest) { PetSize petSize = findPetSizeBySize(petProfileRequest.getSize()); - String imageUrl = "https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/no_image.png"; + String imageUrl = DEFAULT_IMAGE_URL; String imageFilename = null; // 이미지 업로드 처리 (이미지가 있는 경우) @@ -146,6 +148,6 @@ private void deleteExistingImageIfExists(Pet pet) { imageStorageService.delete(pet.getImageFilename()); pet.setImageFilename(null); } - pet.setImageUrl("https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/no_image.png"); + pet.setImageUrl(DEFAULT_IMAGE_URL); } } From 5483476b51203b69bc03a428a7bd3f092069cf44 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sun, 10 Aug 2025 22:02:07 +0900 Subject: [PATCH 181/191] =?UTF-8?q?=EB=B0=98=EB=A0=A4=EB=8F=99=EB=AC=BC=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EA=B8=B0=EB=B3=B8=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/swyp/catsgotogedog/pet/service/PetService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java index 1b86cf5..843316f 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java @@ -33,7 +33,7 @@ public class PetService { private final UserRepository userRepository; private final ImageStorageService imageStorageService; - private final String DEFAULT_IMAGE_URL = "https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/default_user_image.png"; + private final String DEFAULT_IMAGE_URL = "https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/default_pet_image.png"; public List getAllPets(String userId) { return petRepository.findAllByUser_UserIdOrderByPetId(Integer.parseInt(userId)); From 7b267bac67a5e24c9115642923864d4249c9357f Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Sun, 10 Aug 2025 22:22:04 +0900 Subject: [PATCH 182/191] =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=ED=91=9C=ED=98=84?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/catsgotogedog/pet/controller/PetControllerSwagger.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java index 43a0327..7d89e33 100644 --- a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java @@ -38,7 +38,7 @@ ResponseEntity> getAllProfiles( summary = "반려동물 프로필 등록", description = """ 사용자의 새로운 반려동물 프로필을 등록합니다. 반려동물의 정보와 이미지를 함께 업로드할 수 있습니다. 최대 10마리까지 등록 가능합니다.
- 사진을 포함한 모든 정보는 필수로 입력해야 합니다.
+ 사진을 제외한 모든 정보는 필수로 입력해야 합니다.
반려동물 크기는 소형, 중형, 대형 중 하나를 선택해야 합니다.""" ) @SecurityRequirement(name = "bearer-key") From 976e995a1655c7a4b348eb06c67c1a12ef061180 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 11 Aug 2025 22:04:20 +0900 Subject: [PATCH 183/191] =?UTF-8?q?refactor:=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?#129?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projection 사용, 배치 쿼리 형식 --- .../repository/ContentWishRepository.java | 10 ++ .../RestaurantInformationRepository.java | 12 +++ .../SightsInformationRepository.java | 11 +++ .../repository/ViewTotalRepository.java | 10 ++ .../projection/RestDateProjection.java | 6 ++ .../projection/ViewTotalProjection.java | 6 ++ .../projection/WishCountProjection.java | 6 ++ .../content/service/ContentSearchService.java | 92 +++++++++++++++---- .../repository/ContentReviewRepository.java | 12 +++ .../projection/AvgScoreProjection.java | 6 ++ 10 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/projection/RestDateProjection.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/projection/ViewTotalProjection.java create mode 100644 src/main/java/com/swyp/catsgotogedog/content/repository/projection/WishCountProjection.java create mode 100644 src/main/java/com/swyp/catsgotogedog/review/repository/projection/AvgScoreProjection.java diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java index 2405ea0..f090762 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -4,6 +4,7 @@ import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.content.domain.entity.ContentWish; +import com.swyp.catsgotogedog.content.repository.projection.WishCountProjection; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -37,4 +38,13 @@ public interface ContentWishRepository extends JpaRepository findContentIdsByUserId(@Param("userId") int userId); long countByUserId(int userId); + + @Query(""" + SELECT cw.content.contentId AS contentId, + COUNT(cw) AS wishCount + FROM ContentWish cw + WHERE cw.content.contentId IN :contentIds + GROUP BY cw.content.contentId + """) + List countByContentIdIn(List contentIds); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java index 445df89..89ffcbe 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java @@ -1,11 +1,23 @@ package com.swyp.catsgotogedog.content.repository; +import com.swyp.catsgotogedog.content.repository.projection.RestDateProjection; import org.springframework.data.jpa.repository.JpaRepository; import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; import org.springframework.data.jpa.repository.Query; +import java.util.List; + public interface RestaurantInformationRepository extends JpaRepository { @Query("select r.restDate from RestaurantInformation r where r.content.contentId = :contentId") String findRestDateByContentId(int contentId); + + @Query(""" + SELECT r.content.contentId AS contentId, + r.restDate AS restDate + FROM RestaurantInformation r + WHERE r.content.contentId IN :contentIds + """) + List findRestDateByContentIdIn(List contentIds); } + diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java index 98bfcf3..044f97e 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java @@ -1,11 +1,22 @@ package com.swyp.catsgotogedog.content.repository; +import com.swyp.catsgotogedog.content.repository.projection.RestDateProjection; import org.springframework.data.jpa.repository.JpaRepository; import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; import org.springframework.data.jpa.repository.Query; +import java.util.List; + public interface SightsInformationRepository extends JpaRepository { @Query("select s.restDate from SightsInformation s where s.content.contentId = :contentId") String findRestDateByContentId(int contentId); + @Query(""" + SELECT s.content.contentId AS contentId, + s.restDate AS restDate + FROM SightsInformation s + WHERE s.content.contentId IN :contentIds + """) + List findRestDateByContentIdIn(List contentIds); + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java index e86cea8..fd82375 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java @@ -1,11 +1,13 @@ package com.swyp.catsgotogedog.content.repository; import com.swyp.catsgotogedog.content.domain.entity.ViewTotal; +import com.swyp.catsgotogedog.content.repository.projection.ViewTotalProjection; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; public interface ViewTotalRepository extends JpaRepository { @@ -26,4 +28,12 @@ INSERT INTO view_total (content_id, total_view, updated_at) WHERE vt.contentId = :contentId """) Optional findTotalViewByContentId(int contentId); + @Query(""" + SELECT vt.contentId AS contentId, + vt.totalView AS totalView + FROM ViewTotal vt + WHERE vt.contentId IN :contentIds + """) + List findTotalViewByContentIdIn(List contentIds); + } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/projection/RestDateProjection.java b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/RestDateProjection.java new file mode 100644 index 0000000..df531eb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/RestDateProjection.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.content.repository.projection; + +public interface RestDateProjection { + int getContentId(); + String getRestDate(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/projection/ViewTotalProjection.java b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/ViewTotalProjection.java new file mode 100644 index 0000000..46e4201 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/ViewTotalProjection.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.content.repository.projection; + +public interface ViewTotalProjection { + int getContentId(); + int getTotalView(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/projection/WishCountProjection.java b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/WishCountProjection.java new file mode 100644 index 0000000..4db9dec --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/WishCountProjection.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.content.repository.projection; + +public interface WishCountProjection { + int getContentId(); + int getWishCount(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 2cfb49c..c5c1f60 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -4,14 +4,15 @@ import co.elastic.clients.elasticsearch._types.query_dsl.*; import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; -import com.swyp.catsgotogedog.content.domain.entity.Content; -import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; -import com.swyp.catsgotogedog.content.domain.entity.ContentImage; -import com.swyp.catsgotogedog.content.domain.entity.RegionCode; +import com.swyp.catsgotogedog.content.domain.entity.*; import com.swyp.catsgotogedog.content.domain.response.ContentResponse; import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; import com.swyp.catsgotogedog.content.repository.*; +import com.swyp.catsgotogedog.content.repository.projection.RestDateProjection; +import com.swyp.catsgotogedog.content.repository.projection.ViewTotalProjection; +import com.swyp.catsgotogedog.content.repository.projection.WishCountProjection; import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; +import com.swyp.catsgotogedog.review.repository.projection.AvgScoreProjection; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -23,10 +24,7 @@ import java.time.LocalDate; import java.time.ZoneId; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; @Service @@ -138,24 +136,80 @@ public List search(String title, Map contentMap = contentRepository.findAllById(ids).stream() .collect(Collectors.toMap(Content::getContentId, c -> c)); + Map avgScoreMap = new HashMap<>(); + List avgRows = contentReviewRepository.findAvgScoreByContentIdIn(ids); + for (AvgScoreProjection row : avgRows) { + Double v = row.getAvgScore(); + avgScoreMap.put(row.getContentId(),v); + } + + Set wishedSet; + if (userId != null && !userId.isBlank()) { + wishedSet = contentWishRepository + .findWishedContentIdsByUserIdAndContentIds(Integer.parseInt(userId), ids); + } else { + wishedSet = Collections.emptySet(); + } + + Map> hashtagMap = new HashMap<>(); + List hashtags = hashtagRepository.findByContentIdIn(ids); + for (Hashtag h : hashtags) { + int cid = h.getContentId(); + List list = hashtagMap.get(cid); + if (list == null) { + list = new ArrayList<>(); + hashtagMap.put(cid, list); + } + list.add(h.getContent()); + } + + Map restDateMap = new HashMap<>(); + List sightRows = sightsInformationRepository.findRestDateByContentIdIn(ids); + for (RestDateProjection r : sightRows) { + String rd = r.getRestDate(); + if (rd != null) { + restDateMap.put(r.getContentId(), rd); + } + } + List restRows = restaurantInformationRepository.findRestDateByContentIdIn(ids); + for (RestDateProjection r : restRows) { + String rd = r.getRestDate(); + if (rd != null) { + restDateMap.putIfAbsent(r.getContentId(), rd); + } + } + + Map totalViewMap = new HashMap<>(); + List viewRows = viewTotalRepository.findTotalViewByContentIdIn(ids); + for (ViewTotalProjection v : viewRows) { + int tv = v.getTotalView(); + totalViewMap.put(v.getContentId(), tv); + } + + Map wishCntMap = new HashMap<>(); + List wishCntRows = contentWishRepository.countByContentIdIn(ids); + for (WishCountProjection w : wishCntRows) { + int cnt = w.getWishCount(); + wishCntMap.put(w.getContentId(), cnt); + } + return ids.stream() .map(contentMap::get) .filter(Objects::nonNull) .filter(c -> c.getSidoCode() != 0 && c.getSigunguCode() != 0) .map(content -> { - int id = content.getContentId(); - double avg = getAverageScore(id); - boolean wishData = (userId != null) ? getWishData(userId, id) : false; + + double avg = avgScoreMap.getOrDefault(id, 0.0); + boolean wishData = wishedSet.contains(id); + List hashtag = hashtagMap.getOrDefault(id, List.of()); + String restDate = restDateMap.get(id); + int totalView = totalViewMap.getOrDefault(id, 0); + int wishCnt = wishCntMap.getOrDefault(id, 0); + RegionCodeResponse regionName = getRegionName(content.getSidoCode(), content.getSigunguCode()); - List hashtag = hashtagRepository.findContentsByContentId(id); - String restDate = getRestDate(id); - int totalView = viewTotalRepository.findTotalViewByContentId(id).orElse(0); - int wishCnt = contentWishRepository.countByContent_ContentId(id); - - return ContentResponse.from( - content, avg, wishData, regionName, hashtag, restDate, totalView, wishCnt - ); + + return ContentResponse.from(content, avg, wishData, regionName, hashtag, restDate, totalView, wishCnt); }) .toList(); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java index 8cb0657..c4a89b4 100644 --- a/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java @@ -1,14 +1,26 @@ package com.swyp.catsgotogedog.review.repository; import com.swyp.catsgotogedog.review.domain.entity.ContentReview; +import com.swyp.catsgotogedog.review.repository.projection.AvgScoreProjection; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.query.Param; +import java.util.List; + public interface ContentReviewRepository extends JpaRepository { ContentReview findByContentId(int contentId); @Query("select avg(cr.score) from ContentReview cr where cr.contentId = :contentId") Double findAvgScoreByContentId(@Param("contentId") int contentId); + + @Query(""" + SELECT cr.contentId AS contentId, + AVG(cr.score) AS avgScore + FROM ContentReview cr + WHERE cr.contentId IN :contentIds + GROUP BY cr.contentId + """) + List findAvgScoreByContentIdIn(@Param("contentIds") List contentIds); } diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/projection/AvgScoreProjection.java b/src/main/java/com/swyp/catsgotogedog/review/repository/projection/AvgScoreProjection.java new file mode 100644 index 0000000..76fad4a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/projection/AvgScoreProjection.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.review.repository.projection; + +public interface AvgScoreProjection { + int getContentId(); + Double getAvgScore(); +} From 11fa1beed2b123830d78e20e895ed5f263f4a2e5 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 11 Aug 2025 22:07:38 +0900 Subject: [PATCH 184/191] =?UTF-8?q?refactor:=20=EB=B3=84=EC=A0=90=20?= =?UTF-8?q?=ED=8F=89=EA=B7=A0=20null=20=EB=A1=9C=EC=A7=81=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20#129?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catsgotogedog/content/service/ContentSearchService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index c5c1f60..3f552c5 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -136,11 +136,12 @@ public List search(String title, Map contentMap = contentRepository.findAllById(ids).stream() .collect(Collectors.toMap(Content::getContentId, c -> c)); + Map avgScoreMap = new HashMap<>(); List avgRows = contentReviewRepository.findAvgScoreByContentIdIn(ids); for (AvgScoreProjection row : avgRows) { Double v = row.getAvgScore(); - avgScoreMap.put(row.getContentId(),v); + avgScoreMap.put(row.getContentId(), (v != null ? v : 0.0)); } Set wishedSet; From 482bf611097bf0e948eae28baf604c56c9e08531 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 11 Aug 2025 22:11:43 +0900 Subject: [PATCH 185/191] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BC=ED=99=94=20#129?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 배치 쿼리 스트림 스타일 적용 --- .../content/service/ContentSearchService.java | 99 ++++++++----------- 1 file changed, 41 insertions(+), 58 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 3f552c5..3ffcb2d 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -136,63 +136,46 @@ public List search(String title, Map contentMap = contentRepository.findAllById(ids).stream() .collect(Collectors.toMap(Content::getContentId, c -> c)); + Map avgScoreMap = contentReviewRepository + .findAvgScoreByContentIdIn(ids).stream() + .collect(Collectors.toMap( + AvgScoreProjection::getContentId, + p -> Optional.ofNullable(p.getAvgScore()).orElse(0.0) + )); - Map avgScoreMap = new HashMap<>(); - List avgRows = contentReviewRepository.findAvgScoreByContentIdIn(ids); - for (AvgScoreProjection row : avgRows) { - Double v = row.getAvgScore(); - avgScoreMap.put(row.getContentId(), (v != null ? v : 0.0)); - } - - Set wishedSet; - if (userId != null && !userId.isBlank()) { - wishedSet = contentWishRepository - .findWishedContentIdsByUserIdAndContentIds(Integer.parseInt(userId), ids); - } else { - wishedSet = Collections.emptySet(); - } - - Map> hashtagMap = new HashMap<>(); - List hashtags = hashtagRepository.findByContentIdIn(ids); - for (Hashtag h : hashtags) { - int cid = h.getContentId(); - List list = hashtagMap.get(cid); - if (list == null) { - list = new ArrayList<>(); - hashtagMap.put(cid, list); - } - list.add(h.getContent()); - } + Set wishedSet = (userId != null && !userId.isBlank()) + ? contentWishRepository.findWishedContentIdsByUserIdAndContentIds(Integer.parseInt(userId), ids) + : Set.of(); - Map restDateMap = new HashMap<>(); - List sightRows = sightsInformationRepository.findRestDateByContentIdIn(ids); - for (RestDateProjection r : sightRows) { - String rd = r.getRestDate(); - if (rd != null) { - restDateMap.put(r.getContentId(), rd); - } - } - List restRows = restaurantInformationRepository.findRestDateByContentIdIn(ids); - for (RestDateProjection r : restRows) { - String rd = r.getRestDate(); - if (rd != null) { - restDateMap.putIfAbsent(r.getContentId(), rd); - } - } + Map> hashtagMap = hashtagRepository.findByContentIdIn(ids).stream() + .collect(Collectors.groupingBy( + Hashtag::getContentId, + Collectors.mapping(Hashtag::getContent, Collectors.toList()) + )); - Map totalViewMap = new HashMap<>(); - List viewRows = viewTotalRepository.findTotalViewByContentIdIn(ids); - for (ViewTotalProjection v : viewRows) { - int tv = v.getTotalView(); - totalViewMap.put(v.getContentId(), tv); - } + Map restDateMap = sightsInformationRepository + .findRestDateByContentIdIn(ids).stream() + .collect(Collectors.toMap( + RestDateProjection::getContentId, + RestDateProjection::getRestDate, + (a, b) -> a + )); + restaurantInformationRepository.findRestDateByContentIdIn(ids) + .forEach(r -> restDateMap.putIfAbsent(r.getContentId(), r.getRestDate())); + + Map totalViewMap = viewTotalRepository + .findTotalViewByContentIdIn(ids).stream() + .collect(Collectors.toMap( + ViewTotalProjection::getContentId, + v -> Optional.ofNullable(v.getTotalView()).orElse(0) + )); - Map wishCntMap = new HashMap<>(); - List wishCntRows = contentWishRepository.countByContentIdIn(ids); - for (WishCountProjection w : wishCntRows) { - int cnt = w.getWishCount(); - wishCntMap.put(w.getContentId(), cnt); - } + Map wishCntMap = contentWishRepository + .countByContentIdIn(ids).stream() + .collect(Collectors.toMap( + WishCountProjection::getContentId, + w -> w.getWishCount() + )); return ids.stream() .map(contentMap::get) @@ -201,16 +184,16 @@ public List search(String title, .map(content -> { int id = content.getContentId(); - double avg = avgScoreMap.getOrDefault(id, 0.0); + double avg = Optional.ofNullable(avgScoreMap.get(id)).orElse(0.0); boolean wishData = wishedSet.contains(id); - List hashtag = hashtagMap.getOrDefault(id, List.of()); + List hashtags = hashtagMap.getOrDefault(id, List.of()); String restDate = restDateMap.get(id); - int totalView = totalViewMap.getOrDefault(id, 0); - int wishCnt = wishCntMap.getOrDefault(id, 0); + int totalView = Optional.ofNullable(totalViewMap.get(id)).orElse(0); + int wishCnt = Optional.ofNullable(wishCntMap.get(id)).orElse(0); RegionCodeResponse regionName = getRegionName(content.getSidoCode(), content.getSigunguCode()); - return ContentResponse.from(content, avg, wishData, regionName, hashtag, restDate, totalView, wishCnt); + return ContentResponse.from(content, avg, wishData, regionName, hashtags, restDate, totalView, wishCnt); }) .toList(); } From 8c93922333773473bd007e3768294338dc7284df Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Mon, 11 Aug 2025 22:28:24 +0900 Subject: [PATCH 186/191] =?UTF-8?q?refactor:=20=EC=A7=80=EC=97=AD=EB=AA=85?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메모이제이션 적용(캐시 방식) --- .../content/service/ContentSearchService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java index 3ffcb2d..e2ff257 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -177,6 +177,8 @@ public List search(String title, w -> w.getWishCount() )); + Map regionCache = new HashMap<>(); + return ids.stream() .map(contentMap::get) .filter(Objects::nonNull) @@ -191,7 +193,10 @@ public List search(String title, int totalView = Optional.ofNullable(totalViewMap.get(id)).orElse(0); int wishCnt = Optional.ofNullable(wishCntMap.get(id)).orElse(0); - RegionCodeResponse regionName = getRegionName(content.getSidoCode(), content.getSigunguCode()); + String key = content.getSidoCode() + "-" + content.getSigunguCode(); + RegionCodeResponse regionName = regionCache.computeIfAbsent(key, k -> + getRegionName(content.getSidoCode(), content.getSigunguCode()) + ); return ContentResponse.from(content, avg, wishData, regionName, hashtags, restDate, totalView, wishCnt); }) From aea9d06c3e6fd37ea62f8bdeb97c55c77c838c15 Mon Sep 17 00:00:00 2001 From: jhhwang <5832120@naver.com> Date: Tue, 12 Aug 2025 09:57:47 +0900 Subject: [PATCH 187/191] =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/mysql/V17__drop_email_unique_index.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/main/resources/db/migration/mysql/V17__drop_email_unique_index.sql diff --git a/src/main/resources/db/migration/mysql/V17__drop_email_unique_index.sql b/src/main/resources/db/migration/mysql/V17__drop_email_unique_index.sql new file mode 100644 index 0000000..f4cdc26 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V17__drop_email_unique_index.sql @@ -0,0 +1 @@ +ALTER TABLE `catsgotogedog`.`user` DROP INDEX `email_UNIQUE`; \ No newline at end of file From 20d4548619f27cf2902685a3a297b05099aed432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=9D=AC=EC=84=B1?= <45925957+yhs99@users.noreply.github.com> Date: Sun, 24 Aug 2025 14:46:04 +0900 Subject: [PATCH 188/191] =?UTF-8?q?CI=20=EC=B6=A9=EB=8F=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a69b6d6..e17d102 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,6 @@ jobs: java-version: '17' distribution: 'temurin' -<<<<<<< develop - name: Start Elasticsearch & Kibana run: | docker compose up -d @@ -42,9 +41,6 @@ jobs: run: docker compose logs --tail=50 elasticsearch || true # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. -======= - # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. ->>>>>>> main # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -57,8 +53,6 @@ jobs: - name: Give execute permission to gradlew run: chmod +x ./gradlew -<<<<<<< develop - - name: Build with Gradle Wrapper run: ./gradlew clean build @@ -68,7 +62,3 @@ jobs: with: name: test-report path: build/reports/tests/test -======= - - name: Build with Gradle Wrapper - run: ./gradlew clean build ->>>>>>> main From 37476e6d5fd5a0a402221c04b64b9e0aa22e1ab5 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Wed, 3 Sep 2025 21:25:16 +0900 Subject: [PATCH 189/191] =?UTF-8?q?feat:=20=EC=83=81=EC=84=B8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=9E=91=EC=97=85=20#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 7 +- .../controller/ContentControllerSwagger.java | 5 +- .../domain/response/PlaceDetailResponse.java | 14 +- .../FestivalInformationRepository.java | 1 + .../LodgeInformationRepository.java | 1 + .../RestaurantInformationRepository.java | 2 + .../SightsInformationRepository.java | 1 + .../content/service/ContentService.java | 174 +++++++++++++++++- 8 files changed, 192 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index c18b80e..4f168e4 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -61,14 +61,17 @@ public ResponseEntity saveList(@RequestBody List requests) { } @GetMapping("/placedetail") - public ResponseEntity getPlaceDetail(@RequestParam int contentId, @AuthenticationPrincipal String principal){ + public ResponseEntity getPlaceDetail( + @RequestParam int contentId, + @AuthenticationPrincipal String principal, + @RequestParam int contentTypeId){ String userId = null; if (StringUtils.hasText(principal) && NumberUtils.isCreatable(principal)) { userId = principal; } - PlaceDetailResponse placeDetailResponse = contentService.getPlaceDetail(contentId,userId); + PlaceDetailResponse placeDetailResponse = contentService.getPlaceDetail(contentId,userId,contentTypeId); return ResponseEntity.ok(placeDetailResponse); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index 2483e5c..a4adc10 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -69,7 +69,10 @@ ResponseEntity getPlaceDetail( @RequestParam int contentId, @Parameter(hidden = true) - @AuthenticationPrincipal String principal + @AuthenticationPrincipal String principal, + + @Parameter(description = "컨텐츠 카테고리 ID", required = true) + @RequestParam int contentTypeId ); @Operation( diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java index 5900027..cd05bc8 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -1,12 +1,17 @@ package com.swyp.catsgotogedog.content.domain.response; +import com.fasterxml.jackson.annotation.JsonInclude; import com.swyp.catsgotogedog.content.domain.entity.Content; import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; import com.swyp.catsgotogedog.pet.domain.response.PetGuideResponse; import lombok.Builder; import java.util.List; +import java.util.Map; +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; + +@JsonInclude(NON_NULL) @Builder public record PlaceDetailResponse( int contentId, @@ -31,7 +36,9 @@ public record PlaceDetailResponse( String overview, List detailImage, PetGuide petGuide, - String restDate) { + String restDate, + Map additionalInformation +) { public static PlaceDetailResponse from( Content c, @@ -42,7 +49,9 @@ public static PlaceDetailResponse from( int totalView, List detailImage, PetGuide petGuide, - String restDate){ + String restDate, + Map additionalInformation + ){ return PlaceDetailResponse.builder() .contentId(c.getContentId()) @@ -68,6 +77,7 @@ public static PlaceDetailResponse from( .detailImage(detailImage) .petGuide(petGuide) .restDate(restDate) + .additionalInformation(additionalInformation) .build(); } } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java index d8aa645..40406cd 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java @@ -5,4 +5,5 @@ import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; public interface FestivalInformationRepository extends JpaRepository { + FestivalInformation findByContent_ContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java index 920e6aa..4232fab 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java @@ -5,4 +5,5 @@ import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; public interface LodgeInformationRepository extends JpaRepository { + LodgeInformation findByContent_ContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java index 89ffcbe..0c35fc9 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java @@ -19,5 +19,7 @@ public interface RestaurantInformationRepository extends JpaRepository findRestDateByContentIdIn(List contentIds); + + RestaurantInformation findByContent_ContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java index 044f97e..3541358 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java @@ -19,4 +19,5 @@ public interface SightsInformationRepository extends JpaRepository findRestDateByContentIdIn(List contentIds); + SightsInformation findByContent_ContentId(int contentId); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index a385b7b..c27b26d 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -3,6 +3,10 @@ import com.swyp.catsgotogedog.User.domain.entity.User; import com.swyp.catsgotogedog.User.repository.UserRepository; import com.swyp.catsgotogedog.content.domain.entity.*; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; import com.swyp.catsgotogedog.content.domain.request.ContentRequest; import com.swyp.catsgotogedog.content.domain.response.ContentImageResponse; import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; @@ -23,8 +27,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import java.util.List; -import java.util.Optional; +import java.util.*; import static java.time.LocalDateTime.now; @@ -43,6 +46,10 @@ public class ContentService { private final VisitHistoryRepository visitHistoryRepository; private final PetGuideRepository petGuideRepository; private final LastViewHistoryRepository lastViewHistoryRepository; + private final LodgeInformationRepository lodgeInformationRepository; + private final RestaurantInformationRepository restaurantInformationRepository; + private final SightsInformationRepository sightsInformationRepository; + private final FestivalInformationRepository festivalInformationRepository; private final ContentSearchService contentSearchService; @@ -66,16 +73,15 @@ public void saveContent(ContentRequest request){ contentElasticRepository.save(ContentDocument.from(content)); } - public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ + public PlaceDetailResponse getPlaceDetail(int contentId, String userId, int contentTypeId){ + // 카테고리 공통 viewTotalRepository.upsertAndIncrease(contentId); if(userId != null){ recordView(userId, contentId); } - Content content = contentRepository.findByContentId(contentId); - double avg = contentSearchService.getAverageScore(contentId); boolean wishData = (userId != null) ? contentSearchService.getWishData(userId, contentId) : false; @@ -87,14 +93,28 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ int totalView = viewTotalRepository.findTotalViewByContentId(contentId) .orElse(0); - List detailImage = getDetailImage(contentId); - PetGuide petGuide = getPetGuide(contentId) .orElse(null); + List detailImage = getDetailImage(contentId); + String restDate = contentSearchService.getRestDate(contentId); - return PlaceDetailResponse.from(content,avg,wishData,wishCnt,visited,totalView,detailImage,petGuide,restDate); + Content content = contentRepository.findByContentId(contentId); + + Map additional = getAdditionalInfo(contentId,contentTypeId); + + return PlaceDetailResponse.from( + content, + avg, + wishData, + wishCnt, + visited, + totalView, + detailImage, + petGuide, + restDate, + additional); } public boolean checkWish(String userId, int contentId){ @@ -255,4 +275,142 @@ private Content validateContent(int contentId) { .orElseThrow(() -> new CatsgotogedogException(ErrorCode.CONTENT_NOT_FOUND)); } + private Map getAdditionalInfo(int contentId, int contentTypeId) { + return switch (contentTypeId) { + case 15 -> getFestivalInfo(contentId); + case 32 -> getLodgeInfo(contentId); + case 39 -> getRestaurantInfo(contentId); + case 12 -> getSightsInfo(contentId); + default -> Map.of(); + }; + } + + private Map getFestivalInfo(int contentId) { + var e = festivalInformationRepository.findByContent_ContentId(contentId); + + if (e == null) return Map.of("type", "FESTIVAL"); + + var m = new HashMap(); + + m.put("type", "FESTIVAL"); + m.put("festivalId", e.getFestivalId()); + m.put("ageLimit", e.getAgeLimit()); + m.put("bookingPlace", e.getBookingPlace()); + m.put("discountInfo", e.getDiscountInfo()); + m.put("eventStartDate", e.getEventStartDate()); + m.put("eventEndDate", e.getEventEndDate()); + m.put("eventHomepage", e.getEventHomepage()); + m.put("eventPlace", e.getEventPlace()); + m.put("placeInfo", e.getPlaceInfo()); + m.put("playTime", e.getPlayTime()); + m.put("program", e.getProgram()); + m.put("spendTime", e.getSpendTime()); + m.put("organizer", e.getOrganizer()); + m.put("organizerTel", e.getOrganizerTel()); + m.put("supervisor", e.getSupervisor()); + m.put("supervisorTel", e.getSupervisorTel()); + m.put("subEvent", e.getSubEvent()); + m.put("feeInfo", e.getFeeInfo()); + + return m; + } + + private Map getLodgeInfo(int contentId) { + var e = lodgeInformationRepository.findByContent_ContentId(contentId); + + if (e == null) return Map.of("type", "LODGE"); + + var m = new HashMap(); + + m.put("type", "LODGE"); + m.put("lodgeId", e.getLodgeId()); + m.put("capacityCount", e.getCapacityCount()); + m.put("goodstay", e.getGoodstay()); + m.put("benikia", e.getBenikia()); + m.put("checkInTime", e.getCheckInTime()); + m.put("checkOutTime", e.getCheckOutTime()); + m.put("cooking", e.getCooking()); + m.put("foodplace", e.getFoodplace()); + m.put("hanok", e.getHanok()); + m.put("information", e.getInformation()); + m.put("parking", e.getParking()); + m.put("pickupService", e.getPickupService()); + m.put("roomCount", e.getRoomCount()); + m.put("reservationInfo", e.getReservationInfo()); + m.put("reservationUrl", e.getReservationUrl()); + m.put("roomType", e.getRoomType()); + m.put("scale", e.getScale()); + m.put("subFacility", e.getSubFacility()); + m.put("barbecue", e.getBarbecue()); + m.put("beauty", e.getBeauty()); + m.put("beverage", e.getBeverage()); + m.put("bicycle", e.getBicycle()); + m.put("campfire", e.getCampfire()); + m.put("fitness", e.getFitness()); + m.put("karaoke", e.getKaraoke()); + m.put("publicBath", e.getPublicBath()); + m.put("publicPcRoom", e.getPublicPcRoom()); + m.put("sauna", e.getSauna()); + m.put("seminar", e.getSeminar()); + m.put("sports", e.getSports()); + m.put("refundRegulation", e.getRefundRegulation()); + + return m; + } + + private Map getRestaurantInfo(int contentId) { + var e = restaurantInformationRepository.findByContent_ContentId(contentId); + + if (e == null) return Map.of("type", "RESTAURANT"); + + var m = new HashMap(); + + m.put("type", "RESTAURANT"); + m.put("restaurantId", e.getRestaurantId()); + m.put("chkCreditcard", e.getChkCreditcard()); + m.put("discountInfo", e.getDiscountInfo()); + m.put("signatureMenu", e.getSignatureMenu()); + m.put("information", e.getInformation()); + m.put("kidsFacility", e.getKidsFacility()); + m.put("openDate", e.getOpenDate()); + m.put("openTime", e.getOpenTime()); + m.put("takeout", e.getTakeout()); + m.put("parking", e.getParking()); + m.put("reservation", e.getReservation()); + m.put("restDate", e.getRestDate()); + m.put("scale", e.getScale()); + m.put("seat", e.getSeat()); + m.put("smoking", e.getSmoking()); + m.put("treatMenu", e.getTreatMenu()); + + return m; + } + + private Map getSightsInfo(int contentId) { + var e = sightsInformationRepository.findByContent_ContentId(contentId); + + if (e == null) return Map.of("type", "SIGHTS"); + + var m = new HashMap(); + + m.put("type", "SIGHTS"); + m.put("sightsId", e.getSightsId()); + m.put("contentTypeId", e.getContentTypeId()); + m.put("accomCount", e.getAccomCount()); + m.put("chkCreditcard", e.getChkCreditcard()); + m.put("expAgeRange", e.getExpAgeRange()); + m.put("expGuide", e.getExpGuide()); + m.put("infoCenter", e.getInfoCenter()); + m.put("openDate", e.getOpenDate()); + m.put("parking", e.getParking()); + m.put("restDate", e.getRestDate()); + m.put("useSeason", e.getUseSeason()); + m.put("useTime", e.getUseTime()); + m.put("heritage1", e.getHeritage1()); + m.put("heritage2", e.getHeritage2()); + m.put("heritage3", e.getHeritage3()); + + return m; + } + } From 6cdfa261838c10e979d91b6e2aedd29f70788299 Mon Sep 17 00:00:00 2001 From: wooodev <142153611+wooodev@users.noreply.github.com> Date: Wed, 3 Sep 2025 22:04:13 +0900 Subject: [PATCH 190/191] =?UTF-8?q?feat:=20contentTypeId=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=A9=EB=B2=95=20=EB=B3=80=EA=B2=BD=20#135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인기목록, ai 추천 장소에서 서버로 contentTypeId 전달 불가로 인한 수정 --- .../content/controller/ContentController.java | 5 ++--- .../content/controller/ContentControllerSwagger.java | 5 +---- .../catsgotogedog/content/service/ContentService.java | 8 +++++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java index 4f168e4..f0629d5 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -63,15 +63,14 @@ public ResponseEntity saveList(@RequestBody List requests) { @GetMapping("/placedetail") public ResponseEntity getPlaceDetail( @RequestParam int contentId, - @AuthenticationPrincipal String principal, - @RequestParam int contentTypeId){ + @AuthenticationPrincipal String principal){ String userId = null; if (StringUtils.hasText(principal) && NumberUtils.isCreatable(principal)) { userId = principal; } - PlaceDetailResponse placeDetailResponse = contentService.getPlaceDetail(contentId,userId,contentTypeId); + PlaceDetailResponse placeDetailResponse = contentService.getPlaceDetail(contentId,userId); return ResponseEntity.ok(placeDetailResponse); } diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java index a4adc10..2483e5c 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -69,10 +69,7 @@ ResponseEntity getPlaceDetail( @RequestParam int contentId, @Parameter(hidden = true) - @AuthenticationPrincipal String principal, - - @Parameter(description = "컨텐츠 카테고리 ID", required = true) - @RequestParam int contentTypeId + @AuthenticationPrincipal String principal ); @Operation( diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java index c27b26d..5813df6 100644 --- a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -73,7 +73,11 @@ public void saveContent(ContentRequest request){ contentElasticRepository.save(ContentDocument.from(content)); } - public PlaceDetailResponse getPlaceDetail(int contentId, String userId, int contentTypeId){ + public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ + + Content content = contentRepository.findByContentId(contentId); + + int contentTypeId = content.getContentTypeId(); // 카테고리 공통 viewTotalRepository.upsertAndIncrease(contentId); @@ -100,8 +104,6 @@ public PlaceDetailResponse getPlaceDetail(int contentId, String userId, int cont String restDate = contentSearchService.getRestDate(contentId); - Content content = contentRepository.findByContentId(contentId); - Map additional = getAdditionalInfo(contentId,contentTypeId); return PlaceDetailResponse.from( From 3f8045d1ffd39c06b9528a52d3c91a5d61a5bb47 Mon Sep 17 00:00:00 2001 From: yhs99 Date: Wed, 3 Sep 2025 22:23:51 +0900 Subject: [PATCH 191/191] =?UTF-8?q?AI=ED=94=8C=EB=9E=98=EB=84=88=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AiplannerController.java | 39 ++ .../AiplannerControllerSwagger.java | 45 ++ .../aiplanner/domain/ContentEmbedding.java | 39 ++ .../aiplanner/domain/TravelDuration.java | 15 + .../ContentEmbeddingRepository.java | 11 + .../aiplanner/request/PlannerRequest.java | 38 ++ .../aiplanner/response/AiplannerResponse.java | 44 ++ .../aiplanner/service/AiPlannerService.java | 414 ++++++++++++++++++ .../global/exception/ErrorCode.java | 1 + .../elasticsearch/content-embed-mapping.json | 16 + .../elasticsearch/content-embed-setting.json | 6 + 11 files changed, 668 insertions(+) create mode 100644 src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerController.java create mode 100644 src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerControllerSwagger.java create mode 100644 src/main/java/com/swyp/catsgotogedog/aiplanner/domain/ContentEmbedding.java create mode 100644 src/main/java/com/swyp/catsgotogedog/aiplanner/domain/TravelDuration.java create mode 100644 src/main/java/com/swyp/catsgotogedog/aiplanner/repository/ContentEmbeddingRepository.java create mode 100644 src/main/java/com/swyp/catsgotogedog/aiplanner/request/PlannerRequest.java create mode 100644 src/main/java/com/swyp/catsgotogedog/aiplanner/response/AiplannerResponse.java create mode 100644 src/main/java/com/swyp/catsgotogedog/aiplanner/service/AiPlannerService.java create mode 100644 src/main/resources/elasticsearch/content-embed-mapping.json create mode 100644 src/main/resources/elasticsearch/content-embed-setting.json diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerController.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerController.java new file mode 100644 index 0000000..62d33a4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerController.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.aiplanner.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.aiplanner.request.PlannerRequest; +import com.swyp.catsgotogedog.aiplanner.service.AiPlannerService; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/planner") +@Slf4j +public class AiplannerController implements AiplannerControllerSwagger { + + private final AiPlannerService aiPlannerService; + + @PostMapping("/initEmbeddingData") + public void initEmbeddingData() { + aiPlannerService.initEmbedContentData(); + } + + @PostMapping("/recommend") + public ResponseEntity> createAiPlanner( + @AuthenticationPrincipal String userId, + @RequestBody PlannerRequest request + ) { + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("플래너 생성 성공", aiPlannerService.createPlan(userId, request)) + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerControllerSwagger.java new file mode 100644 index 0000000..572777c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerControllerSwagger.java @@ -0,0 +1,45 @@ +package com.swyp.catsgotogedog.aiplanner.controller; + +import org.springframework.http.ResponseEntity; + +import com.swyp.catsgotogedog.aiplanner.request.PlannerRequest; +import com.swyp.catsgotogedog.aiplanner.response.AiplannerResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.pet.domain.response.PetProfileResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Ai플래너", description = "AI 플래너 관련 API") +public interface AiplannerControllerSwagger { + + @Operation( + summary = "AI 플래너 생성", + description = "AI를 통한 플랜 생성" + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "플랜 생성 성공", + content = @Content(schema = @Schema(implementation = AiplannerResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> createAiPlanner( + @Parameter(hidden = true) + String userId, + @RequestBody(description = """ + duration은 DAY_TRIP, ONE_NIGHT, TWO_NIGHT 중 하나의 데이터를 입력해야합니다. + mood는 사용자가 입력한 기분 데이터입니다. + """, required = true) + PlannerRequest request + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/ContentEmbedding.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/ContentEmbedding.java new file mode 100644 index 0000000..64d98f4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/ContentEmbedding.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.aiplanner.domain; + +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; +import org.springframework.data.elasticsearch.annotations.Mapping; +import org.springframework.data.elasticsearch.annotations.Setting; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Document(indexName = "content-embedding") +@Setting(settingPath = "elasticsearch/content-embed-setting.json") +@Mapping(mappingPath = "elasticsearch/content-embed-mapping.json") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ContentEmbedding { + + @Id + private Integer contentId; + private int contentTypeId; + private String categoryId; + private int sidoCode; + private int sigunguCode; + private double mapx; + private double mapy; + private String title; + + @Field(type = FieldType.Dense_Vector, dims = 1024) + private List embedding; +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/TravelDuration.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/TravelDuration.java new file mode 100644 index 0000000..be0dfbf --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/TravelDuration.java @@ -0,0 +1,15 @@ +package com.swyp.catsgotogedog.aiplanner.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TravelDuration { + DAY_TRIP(1, "당일치기"), + ONE_NIGHT(2, "1박2일"), + TWO_NIGHT(3, "2박3일"); + + private final int days; + private final String description; +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/repository/ContentEmbeddingRepository.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/repository/ContentEmbeddingRepository.java new file mode 100644 index 0000000..1426848 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/repository/ContentEmbeddingRepository.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.aiplanner.repository; + +import java.util.List; + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import com.swyp.catsgotogedog.aiplanner.domain.ContentEmbedding; + +public interface ContentEmbeddingRepository extends ElasticsearchRepository { + List findByContentIdIn(List contentIds); +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/request/PlannerRequest.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/request/PlannerRequest.java new file mode 100644 index 0000000..189a959 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/request/PlannerRequest.java @@ -0,0 +1,38 @@ +package com.swyp.catsgotogedog.aiplanner.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.swyp.catsgotogedog.aiplanner.domain.TravelDuration; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Valid +public class PlannerRequest { + + @JsonProperty + @NotNull(message = "여행 기간은 필수입니다.") + private TravelDuration duration; + + @JsonProperty + @NotNull(message = "대분류 여행 지역은 필수입니다.") + @Min(value = 1, message = "올바른 시도 코드를 입력해주세요.") + private Integer sidoCode; + + @JsonProperty + @NotBlank(message = "현재 기분은 필수입니다.") + @Size(max = 50, message = "기분 설명은 50자 이하로 입력해주세요") + private String mood; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/response/AiplannerResponse.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/response/AiplannerResponse.java new file mode 100644 index 0000000..7df0ecb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/response/AiplannerResponse.java @@ -0,0 +1,44 @@ +package com.swyp.catsgotogedog.aiplanner.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Setter +@Builder +public class AiplannerResponse { + private List dayPlans; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class DayPlan { + private Integer day; + private List dayContents; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ContentInfo { + private Integer contentId; + private String title; + private String categoryId; + private String addr1; + private String addr2; + private String image; + private String thumbImage; + private double mapx; + private double mapy; + private String rest; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/service/AiPlannerService.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/service/AiPlannerService.java new file mode 100644 index 0000000..2e5806c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/service/AiPlannerService.java @@ -0,0 +1,414 @@ +package com.swyp.catsgotogedog.aiplanner.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.aiplanner.domain.ContentEmbedding; +import com.swyp.catsgotogedog.aiplanner.repository.ContentEmbeddingRepository; +import com.swyp.catsgotogedog.aiplanner.request.PlannerRequest; +import com.swyp.catsgotogedog.aiplanner.response.AiplannerResponse; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.RestaurantInformationRepository; +import com.swyp.catsgotogedog.content.repository.SightsInformationRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.json.JsonData; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class AiPlannerService { + + @Value("${clova.api.embed.url}") + private String clovaUrl; + + @Value("${clova.api.key}") + private String apiKey; + + private RestClient restClient; + private final ContentEmbeddingRepository contentEmbeddingRepository; + private final ContentRepository contentRepository; + private final ElasticsearchClient esClient; + private final ObjectMapper objectMapper; + private final UserRepository userRepository; + private final SightsInformationRepository sightsInformationRepository; + private final RestaurantInformationRepository restaurantInformationRepository; + + public AiPlannerService(ContentEmbeddingRepository contentEmbeddingRepository, ContentRepository contentRepository, ElasticsearchClient esClient, ObjectMapper objectMapper, UserRepository userRepository, SightsInformationRepository sightsInformationRepository + , RestaurantInformationRepository restaurantInformationRepository) { + this.contentEmbeddingRepository = contentEmbeddingRepository; + this.contentRepository = contentRepository; + this.esClient = esClient; + this.objectMapper = objectMapper; + this.userRepository = userRepository; + this.sightsInformationRepository = sightsInformationRepository; + this.restaurantInformationRepository = restaurantInformationRepository; + } + + @PostConstruct + private void initRestClient() { + this.restClient = RestClient.builder() + .baseUrl(clovaUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("X-NCP-CLOVASTUDIO-REQUEST-ID", "f729bb278df149ce8b7e25e3ec8e1a25") + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Transactional + public void initEmbedContentData() { + int processedCount = 0; + int failedCount = 0; + + // Content 테이블 id 조회 + List allContents = contentRepository.findAll(); + + // ContentId 추출 + List allContentIds = allContents.stream() + .map(Content::getContentId) + .toList(); + + // 현재 삽입되어있는 엘라스틱서치 Content 데이터 목록 + List existEmbeddings = contentEmbeddingRepository.findByContentIdIn(allContentIds); + + // 엘라스틱서치에 존재하는 Id 추출 + Set existIds = existEmbeddings.stream() + .map(ContentEmbedding::getContentId) + .collect(Collectors.toSet()); + + // 존재하지 않는 Content 데이터 추출 + List newContents = allContents.stream() + .filter(content -> !existIds.contains(content.getContentId())) + .filter(this::isValidEmbeddingData) + .toList(); + + List contentEmbeddings = new ArrayList<>(); + + for(Content content : newContents) { + try { + if(processedCount > 0) { + Thread.sleep(1000); + } + + // 임베딩 텍스트 생성 + String textToEmbed = createEmbeddingText(content); + // Clova 호출 + List embedding = callClovaEmbedApi(textToEmbed); + + if(!embedding.isEmpty()) { + ContentEmbedding contentEmbedding = ContentEmbedding.builder() + .contentId(content.getContentId()) + .title(content.getTitle()) + .contentTypeId(content.getContentTypeId()) + .categoryId(content.getCategoryId()) + .sidoCode(content.getSidoCode()) + .sigunguCode(content.getSigunguCode()) + .mapx(content.getMapx()) + .mapy(content.getMapy()) + .embedding(embedding) + .build(); + + contentEmbeddings.add(contentEmbedding); + processedCount++; + + if(contentEmbeddings.size() >= 10) { + saveToElasticSearch(contentEmbeddings); + contentEmbeddings.clear(); + } + + if(processedCount % 10 == 0) { + log.info("임베딩 진행 상황 : {}/{}", processedCount, newContents.size()); + } + } else { + failedCount++; + log.warn("ContentId {} - 임베딩 생성 실패", content.getContentId()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("임베딩 처리 중 인터럽트 발생", e); + break; + } catch(Exception e) { + failedCount++; + log.error("ContentId {} 임베딩 처리 중 오류발생 : {}", content.getContentId(), e.getMessage()); + } + } + + + + + log.info("임베딩 데이터 삽입 완료 - 성공 : {}, 실패 : {}", processedCount, failedCount); + } + + public AiplannerResponse createPlan(String userId, PlannerRequest request) { + validateUser(userId); + + List moodEmbed = getMoodEmbed(request.getMood()); + + if(moodEmbed.isEmpty()) { + throw new CatsgotogedogException(ErrorCode.CLOVA_HASHTAG_SERVER_ERROR); + } + + List dayPlans = new ArrayList<>(); + + int days = request.getDuration().getDays(); + Set excludedIds = new HashSet<>(); + + for(int day = 1; day <= days; day++) { + AiplannerResponse.DayPlan dayPlan = createDayPlan(day, request.getSidoCode(), days, moodEmbed, excludedIds); + dayPlans.add(dayPlan); + } + + return new AiplannerResponse(dayPlans); + } + + private AiplannerResponse.DayPlan createDayPlan( + int day, int sido, int totalDays, List moodEmbed, Set excludedIds + ) { + List dayContents = new ArrayList<>(); + + dayContents.add(findBestPlace(sido, moodEmbed, 12, excludedIds)); + dayContents.add(findBestPlace(sido, moodEmbed, 39, excludedIds)); + dayContents.add(findBestPlace(sido, moodEmbed, 12, excludedIds)); + dayContents.add(findBestPlace(sido, moodEmbed, 39, excludedIds)); + + if (totalDays > 1 && day < totalDays) { + dayContents.add(findBestPlace(sido, moodEmbed, 32, excludedIds)); + } + + return new AiplannerResponse.DayPlan(day, dayContents); + } + + private List getMoodEmbed(String mood) { + + Map requestBody = Map.of("text", mood); + + try { + log.info("{} 요청", clovaUrl); + Map responseBody = Objects.requireNonNull(restClient.post() + .body(requestBody) + .retrieve() + .body(Map.class)); + + log.info("responseBody : {}", responseBody); + + Map result = (Map) responseBody.get("result"); + + if(result != null) { + Object embeddingObject = result.get("embedding"); + log.info(embeddingObject.toString()); + + if (embeddingObject instanceof List) { + List embeddingList = (List) embeddingObject; + + return embeddingList.stream() + .map(Number::floatValue) + .toList(); + } + } + + } catch(RestClientResponseException e) { + log.error("Clova Embed Api 호출 오류 :: 상태코드 {} - 응답 :: {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new CatsgotogedogException(ErrorCode.CLOVA_HASHTAG_SERVER_ERROR); + } catch(Exception e) { + log.error("Mood 임베드 Api 호출 중 예상치 못한 오류 발생", e); + throw new CatsgotogedogException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + return Collections.emptyList(); + } + + private AiplannerResponse.ContentInfo findBestPlace( + Integer sidoCode, + List embededMood, + Integer contentTypeId, + Set excludedIds) { + + try { + List queryVector = embededMood.stream() + .map(Float::doubleValue) + .collect(Collectors.toList()); + + SearchRequest searchRequest = SearchRequest.of(s -> s + .index("content-embedding") + .size(100) + .query(q -> q + .scriptScore(ss -> ss + .query(query -> query + .bool(b -> b + .filter(f -> f.term(t -> t.field("sidoCode").value(sidoCode))) + .filter(f -> f.term(t -> t.field("contentTypeId").value(contentTypeId))) + ) + ) + .script(script -> script + .source("cosineSimilarity(params.queryVector, 'embedding') + 1.0") + .params("queryVector", JsonData.of(queryVector)) + ) + ) + ) + ); + + SearchResponse response = esClient.search(searchRequest, Map.class); + + for(Hit hit : response.hits().hits()) { + log.info("검색된 문서 ID: {}, Score: {}", hit.id(), hit.score()); + + Map sourceAsMap = hit.source(); + + if(sourceAsMap == null) { + log.warn("sourceAsMap이 null입니다."); + continue; + } + + log.info("문서 내용: {}", sourceAsMap); + + Integer contentId = (Integer) sourceAsMap.get("contentId"); + log.info("contentId: {}, excludedIds에 포함여부: {}", + contentId, excludedIds.contains(contentId)); + + if(contentId != null && !excludedIds.contains(contentId)) { + excludedIds.add(contentId); + Content content = contentRepository.findByContentId(contentId); + String rest = null; + + if(content.getContentTypeId() == 12) { + rest = sightsInformationRepository.findRestDateByContentId(content.getContentId()); + } else if(content.getContentTypeId() == 39) { + rest = restaurantInformationRepository.findRestDateByContentId(content.getContentId()); + } + + if(rest == null || rest.isEmpty()) { + rest = null; + } + + if(content == null) { + log.warn("contentId {}에 해당하는 Content를 찾을 수 없습니다.", contentId); + continue; + } + + log.info("추천 장소 선택됨: {}", content.getTitle()); + + return AiplannerResponse.ContentInfo.builder() + .contentId(content.getContentId()) + .image(content.getImage()) + .thumbImage(content.getThumbImage()) + .title(content.getTitle()) + .addr1(content.getAddr1()) + .addr2(content.getAddr2()) + .mapx(content.getMapx()) + .mapy(content.getMapy()) + .categoryId(content.getCategoryId()) + .rest(rest) + .build(); + } + } + + } catch (Exception e) { + log.error("Elasticsearch API 호출 오류 : {}", e.getMessage(), e); + throw new CatsgotogedogException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + throw new CatsgotogedogException(ErrorCode.NO_RECOMMEND_PLACES); + } + + private boolean isValidEmbeddingData(Content content) { + boolean hasTitle = content.getTitle() != null && !content.getTitle().trim().isEmpty(); + boolean hasOverview = content.getOverview() != null && !content.getOverview().trim().isEmpty(); + + if (!hasTitle || !hasOverview) { + log.debug("ContentId {} - title 또는 overview가 모두 없어 임베딩 제외", content.getContentId()); + return false; + } + + return true; + } + + private String createEmbeddingText(Content content) { + StringBuilder sb = new StringBuilder(); + if(content.getTitle() != null) { + sb.append("제목 : ").append(content.getTitle()).append(" "); + } + if(content.getOverview() != null) { + sb.append("소개 : ").append(content.getOverview()).append(" "); + } + return sb.toString().trim(); + } + + private List callClovaEmbedApi(String text) { + Map requestBody = Map.of("text", text); + + try { + Map responseBody = Objects.requireNonNull(restClient.post() + .body(requestBody) + .retrieve() + .body(Map.class)); + + Map result = (Map) responseBody.get("result"); + + if(result != null) { + Object embeddingObject = result.get("embedding"); + log.info(embeddingObject.toString()); + + if (embeddingObject instanceof List) { + List embeddingList = (List) embeddingObject; + + return embeddingList.stream() + .map(Number::floatValue) + .toList(); + } + } + + } catch(RestClientResponseException e) { + log.error("Clova Embed Api 호출 오류 :: 상태코드 {} - 응답 :: {}", e.getStatusCode(), e.getResponseBodyAsString()); + } catch(Exception e) { + log.error("{} Api 호출 중 예상치 못한 오류 발생", clovaUrl, e); + } + + return Collections.emptyList(); + } + + private void saveToElasticSearch(List embeddings) { + try { + contentEmbeddingRepository.saveAll(embeddings); + + log.info("{} 개의 ContentEmbedding 데이터 저장 완료", embeddings.size()); + }catch (Exception e) { + log.error("Elasticsearch 데이터 저장중 오류 발생 : ", e); + throw new RuntimeException("Elasticsaerch 저장중 오류 발생", e); + } + } + + private User validateUser(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java index ac540a5..48451ea 100644 --- a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -27,6 +27,7 @@ public enum ErrorCode { REVIEW_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰 이미지입니다."), SIGUNGU_CODE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 지역을 찾을 수 없습니다."), SIDO_CODE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 시/도를 찾을 수 없습니다."), + NO_RECOMMEND_PLACES(HttpStatus.NOT_FOUND.value(), "추천할 관광지가 없어요."), // 405 Method not allowed METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메소드입니다."), diff --git a/src/main/resources/elasticsearch/content-embed-mapping.json b/src/main/resources/elasticsearch/content-embed-mapping.json new file mode 100644 index 0000000..20e5947 --- /dev/null +++ b/src/main/resources/elasticsearch/content-embed-mapping.json @@ -0,0 +1,16 @@ +{ + "properties": { + "contentId": { "type": "integer" }, + "contentTypeId": { "type": "integer" }, + "categoryId": { "type": "keyword" }, + "sidoCode": { "type": "integer" }, + "sigunguCode": { "type": "integer" }, + "mapx": { "type": "double" }, + "mapy": { "type": "double" }, + "title": { "type": "keyword" }, + "embedding": { + "type": "dense_vector", + "dims": 1024 + } + } +} \ No newline at end of file diff --git a/src/main/resources/elasticsearch/content-embed-setting.json b/src/main/resources/elasticsearch/content-embed-setting.json new file mode 100644 index 0000000..9761fcd --- /dev/null +++ b/src/main/resources/elasticsearch/content-embed-setting.json @@ -0,0 +1,6 @@ +{ + "index": { + "number_of_shards": 1, + "number_of_replicas": 1 + } +} \ No newline at end of file