diff --git a/docker-compose.yml b/docker-compose.yml index a2901e5..dc72910 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,6 @@ services: - dbdata:/var/lib/mysql - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql - ./docker/mysql/mysql-healthcheck.sh:/usr/local/bin/mysql-healthcheck.sh - networks: - retrip-net restart: always @@ -66,7 +65,10 @@ services: image: prom/prometheus container_name: prometheus volumes: + - ./data/prometheus:/prometheus - ./prometheus.yml:/etc/prometheus/prometheus.yml + depends_on: + - retrip-app ports: - "9090:9090" networks: @@ -83,6 +85,7 @@ services: ports: - "3000:3000" volumes: + - ./data/grafana:/var/lib/grafana - grafana-storage:/var/lib/grafana depends_on: - prometheus @@ -131,6 +134,7 @@ volumes: networks: retrip-net: + name: retrip-net driver: bridge ipam: config: diff --git a/nginx/nginx-prod.conf b/nginx/nginx-prod.conf index 31a7a3e..0929608 100644 --- a/nginx/nginx-prod.conf +++ b/nginx/nginx-prod.conf @@ -86,17 +86,10 @@ server { add_header X-Frame-Options DENY always; add_header X-XSS-Protection "1; mode=block" always; - # 내부 네트워크 허용 - allow 192.168.0.0/16; - allow 172.16.0.0/12; - allow 127.0.0.1; - - # IP 화이트리스트 - include /etc/nginx/conf.d/allowed_ips.conf; - # 백엔드 API 프록시 location / { - # OPTIONS 요청 처리 + + # OPTIONS 요청 처리 if ($request_method = 'OPTIONS') { add_header Content-Length 0; add_header Content-Type text/plain; @@ -139,12 +132,12 @@ server { add_header X-Frame-Options SAMEORIGIN always; add_header X-XSS-Protection "1; mode=block" always; - # IP 화이트리스트 설정 - include /etc/nginx/conf.d/allowed_ips.conf; + # IP 화이트리스트 + include /etc/nginx/conf.d/allowed_ips.rules; - # Grafana 프록시 location / { - proxy_pass http://grafana:3000; + + proxy_pass http://grafana:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -183,12 +176,13 @@ server { add_header X-Frame-Options DENY always; add_header X-XSS-Protection "1; mode=block" always; - # IP 화이트리스트 설정 - include /etc/nginx/conf.d/allowed_ips.conf; + # IP 화이트리스트 + include /etc/nginx/conf.d/allowed_ips.rules; # Prometheus 프록시 location / { - proxy_pass http://prometheus:9090; + + proxy_pass http://prometheus:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6f29cd5..4ec82e7 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -16,7 +16,7 @@ MAIN_DOMAIN="retrip.kr" CERT_FILE_PATH="./data/certbot/conf/live/$MAIN_DOMAIN/fullchain.pem" NGINX_CONF_DIR="./nginx/conf.d" NGINX_CONTAINER_NAME="nginx" -WHITELIST_FILE="$NGINX_CONF_DIR/allowed_ips.conf" +WHITELIST_FILE="$NGINX_CONF_DIR/allowed_ips.rules" if command -v docker-compose &> /dev/null; then DOCKER_COMPOSE="docker-compose" @@ -41,7 +41,8 @@ setup_whitelist() { echo "모든 IP에서 접근이 허용됩니다." # 기본 설정 (모든 IP 허용) - cat > "$WHITELIST_FILE" << EOF + sudo tee "$WHITELIST_FILE" > /dev/null << EOF + EOF return 0 fi @@ -49,7 +50,7 @@ EOF echo "화이트리스트가 설정되었습니다: $WHITELIST_IPS" # 화이트리스트 파일 생성 - cat > "$WHITELIST_FILE" << EOF + sudo tee "$WHITELIST_FILE" > /dev/null << EOF EOF # 쉼표로 구분된 IP들을 처리 @@ -60,7 +61,7 @@ EOF # IP 형식 검증 if [[ $ip =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}(/[0-9]{1,2})?$ ]]; then - echo "allow $ip;" >> "$WHITELIST_FILE" + echo "allow $ip;" | sudo tee -a "$WHITELIST_FILE" > /dev/null echo " - 허용된 IP: $ip" else echo "WARNING: 잘못된 IP 형식입니다: $ip" @@ -68,7 +69,7 @@ EOF done # 마지막에 deny all 추가 - echo "deny all;" >> "$WHITELIST_FILE" + echo "deny all;" | sudo tee -a "$WHITELIST_FILE" > /dev/null echo "화이트리스트 설정이 완료되었습니다." echo "설정된 내용:" @@ -171,6 +172,9 @@ echo "최종 운영 설정을 적용하고 모든 서비스를 시작합니다." echo "운영용 Nginx 설정을 적용합니다." sudo cp ./nginx-prod.conf $NGINX_CONF_DIR/default.conf +echo "기존 컨테이너를 종료합니다..." +$DOCKER_COMPOSE down + echo "새로운 Docker 이미지를 pull 합니다" $DOCKER_COMPOSE pull retrip-app diff --git a/src/main/java/ssafy/retrip/api/controller/email/EmailController.java b/src/main/java/ssafy/retrip/api/controller/email/EmailController.java deleted file mode 100644 index 459d452..0000000 --- a/src/main/java/ssafy/retrip/api/controller/email/EmailController.java +++ /dev/null @@ -1,35 +0,0 @@ -package ssafy.retrip.api.controller.email; - - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import ssafy.retrip.api.controller.email.request.EmailRequest; -import ssafy.retrip.api.controller.email.request.EmailVerificationRequest; -import ssafy.retrip.api.service.email.EmailService; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/email") -public class EmailController { - - private final EmailService emailService; - - @PostMapping - public ResponseEntity sendSignUpEmailCode(@Valid @RequestBody EmailRequest request) { - emailService.joinEmail(request.getEmail()); - return ResponseEntity.ok("success"); - } - - @PostMapping("/verify") - public ResponseEntity verifySignUpEmailCode( - @Valid @RequestBody EmailVerificationRequest request) { - - emailService.verifyEmailCode(request.toServiceRequest()); - return ResponseEntity.ok("success"); - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/controller/email/request/EmailRequest.java b/src/main/java/ssafy/retrip/api/controller/email/request/EmailRequest.java deleted file mode 100644 index 8ca5b0d..0000000 --- a/src/main/java/ssafy/retrip/api/controller/email/request/EmailRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package ssafy.retrip.api.controller.email.request; - -import jakarta.validation.constraints.NotNull; -import lombok.Getter; - -@Getter -public class EmailRequest { - - @NotNull(message = "이메일은 필수 입력값입니다.") - private String email; -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/controller/email/request/EmailVerificationRequest.java b/src/main/java/ssafy/retrip/api/controller/email/request/EmailVerificationRequest.java deleted file mode 100644 index aacf785..0000000 --- a/src/main/java/ssafy/retrip/api/controller/email/request/EmailVerificationRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package ssafy.retrip.api.controller.email.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.Getter; -import ssafy.retrip.api.service.email.request.EmailVerificationServiceRequest; - -@Getter -public class EmailVerificationRequest { - - @NotNull(message = "이메일은 필수 입력값입니다.") - private String email; - - @NotNull(message = "코드는 필수 입력값입니다.") - @Size(min = 6, max = 6, message = "코드는 6자리여야 합니다.") - private String code; - - public EmailVerificationServiceRequest toServiceRequest() { - return EmailVerificationServiceRequest.builder() - .email(email) - .code(code).build(); - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/controller/member/MemberController.java b/src/main/java/ssafy/retrip/api/controller/member/MemberController.java deleted file mode 100644 index d05262a..0000000 --- a/src/main/java/ssafy/retrip/api/controller/member/MemberController.java +++ /dev/null @@ -1,84 +0,0 @@ -package ssafy.retrip.api.controller.member; - -import jakarta.validation.Valid; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -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 ssafy.retrip.api.controller.email.request.EmailRequest; -import ssafy.retrip.api.controller.email.request.EmailVerificationRequest; -import ssafy.retrip.api.controller.member.request.MemberSignInRequest; -import ssafy.retrip.api.controller.member.request.MemberSignUpRequest; -import ssafy.retrip.api.controller.member.request.PasswordFindRequest; -import ssafy.retrip.api.controller.member.request.PasswordResetRequest; -import ssafy.retrip.api.service.email.EmailService; -import ssafy.retrip.api.service.member.MemberService; -import ssafy.retrip.api.service.retrip.response.ImageUrlResponse; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/members") -public class MemberController { - - public final EmailService emailService; - public final MemberService memberService; - - @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody MemberSignUpRequest request) { - memberService.signup(request.toServiceRequest()); - return ResponseEntity.ok("success"); - } - - @PostMapping("/signin") - public ResponseEntity signin(@Valid @RequestBody MemberSignInRequest request) { - memberService.signIn(request.toServiceRequest()); - return ResponseEntity.ok("로그인 성공"); - } - - @GetMapping("/validate/nickname") - public ResponseEntity validateNickname( - @RequestParam(value = "nickname") String nickname) { - memberService.validateNickname(nickname); - return ResponseEntity.ok("사용 가능한 아이디입니다."); - } - - @PostMapping("/send-verification-code") - public ResponseEntity sendVerificationCode(@Valid @RequestBody EmailRequest request) { - memberService.sendVerificationCode(request.getEmail()); - return ResponseEntity.ok("success"); - } - - @PostMapping("/find-id") - public ResponseEntity findUserId(@Valid @RequestBody EmailVerificationRequest request) { - emailService.verifyEmailCode(request.toServiceRequest()); - String forgotUserId = memberService.getForgotUserId(request.getEmail()); - return ResponseEntity.ok(forgotUserId); - } - - @PostMapping("/password/verify-credentials") - public ResponseEntity verifyPasswordResetCredentials( - @Valid @RequestBody PasswordFindRequest request) { - memberService.verifyPasswordResetCredentials(request.toServiceRequest()); - return ResponseEntity.ok("success"); - } - - @PostMapping("/password/reset") - public ResponseEntity resetPassword(@Valid @RequestBody PasswordResetRequest request) { - memberService.resetPassword(request.toServiceRequest()); - return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); - } - - @GetMapping("/history") - public ResponseEntity> getRetripHistoryByMemberId() { - - String memberId = "4277332119"; - List responses = memberService.getRetripHistoryByMemberId(memberId); - - return ResponseEntity.ok(responses); - } -} diff --git a/src/main/java/ssafy/retrip/api/controller/member/request/MemberSignInRequest.java b/src/main/java/ssafy/retrip/api/controller/member/request/MemberSignInRequest.java deleted file mode 100644 index d8662e5..0000000 --- a/src/main/java/ssafy/retrip/api/controller/member/request/MemberSignInRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package ssafy.retrip.api.controller.member.request; - -import lombok.Getter; -import ssafy.retrip.api.service.member.request.MemberSignInServiceRequest; - -@Getter -public class MemberSignInRequest { - - private String userId; - private String password; - - public MemberSignInServiceRequest toServiceRequest() { - return MemberSignInServiceRequest.builder() - .userId(userId) - .password(password) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/controller/member/request/MemberSignUpRequest.java b/src/main/java/ssafy/retrip/api/controller/member/request/MemberSignUpRequest.java deleted file mode 100644 index 3dec9b0..0000000 --- a/src/main/java/ssafy/retrip/api/controller/member/request/MemberSignUpRequest.java +++ /dev/null @@ -1,38 +0,0 @@ -package ssafy.retrip.api.controller.member.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import ssafy.retrip.api.service.member.request.MemberSignUpServiceRequest; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class MemberSignUpRequest { - - @NotNull(message = "아이디는 필수 입력값입니다.") - private String userId; - - @NotNull(message = "비밀번호는 필수 입력값입니다.") - @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.") - private String password; - - @NotNull(message = "이메일은 필수 입력값입니다.") - private String email; - - @Builder - private MemberSignUpRequest(String userId, String password, String email) { - this.userId = userId; - this.password = password; - this.email = email; - } - - public MemberSignUpServiceRequest toServiceRequest() { - return MemberSignUpServiceRequest.builder() - .userId(userId) - .password(password) - .email(email).build(); - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/controller/member/request/PasswordFindRequest.java b/src/main/java/ssafy/retrip/api/controller/member/request/PasswordFindRequest.java deleted file mode 100644 index 48775b7..0000000 --- a/src/main/java/ssafy/retrip/api/controller/member/request/PasswordFindRequest.java +++ /dev/null @@ -1,31 +0,0 @@ -package ssafy.retrip.api.controller.member.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import ssafy.retrip.api.service.member.request.PasswordFindServiceRequest; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PasswordFindRequest { - - @NotNull(message = "아이디는 필수 입력값입니다.") - private String userId; // 회원 아이디 - - @NotNull(message = "이메일은 필수 입력값입니다.") - private String email; - - @NotNull(message = "코드는 필수 입력값입니다.") - @Size(min = 6, max = 6, message = "코드는 6자리여야 합니다.") - private String code; - - public PasswordFindServiceRequest toServiceRequest() { - return PasswordFindServiceRequest.builder() - .userId(userId) - .email(email) - .code(code) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/controller/member/request/PasswordResetRequest.java b/src/main/java/ssafy/retrip/api/controller/member/request/PasswordResetRequest.java deleted file mode 100644 index c7968a2..0000000 --- a/src/main/java/ssafy/retrip/api/controller/member/request/PasswordResetRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -package ssafy.retrip.api.controller.member.request; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import ssafy.retrip.api.service.member.request.PasswordResetServiceRequest; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PasswordResetRequest { - - private String email; - private String newPassword; - - public PasswordResetServiceRequest toServiceRequest() { - return PasswordResetServiceRequest.builder() - .email(email) - .newPassword(newPassword) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java b/src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java index 1e8152f..c6838da 100644 --- a/src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java +++ b/src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java @@ -13,7 +13,6 @@ import org.springframework.web.multipart.MultipartFile; import ssafy.retrip.api.controller.retrip.response.TravelAnalysisResponseDto; import ssafy.retrip.api.service.retrip.RetripService; -import ssafy.retrip.domain.member.MemberRepository; @RestController @RequiredArgsConstructor @@ -25,18 +24,12 @@ public class RetripController { @PostMapping("/uploads") public ResponseEntity uploadMultipleImages(HttpServletRequest request, @RequestParam("images") List images) throws IOException { - // 비회원 사용자를 가정하여 memberId를 null로 설정합니다. - String memberId = null; try { - return ResponseEntity.ok(retripService.createRetripFromImages(images, memberId)); + return ResponseEntity.ok(retripService.createRetripFromImages(images)); } catch (Exception e) { e.printStackTrace(); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } - - // TO-DO : retrip 히스토리 조회 + 회원가입 기능 - - } diff --git a/src/main/java/ssafy/retrip/api/controller/test/TestController.java b/src/main/java/ssafy/retrip/api/controller/test/TestController.java deleted file mode 100644 index ecca56b..0000000 --- a/src/main/java/ssafy/retrip/api/controller/test/TestController.java +++ /dev/null @@ -1,13 +0,0 @@ -package ssafy.retrip.api.controller.test; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class TestController { - - @GetMapping("/test") - public String test() { - return "CI Test Success!"; - } -} diff --git a/src/main/java/ssafy/retrip/api/service/email/EmailService.java b/src/main/java/ssafy/retrip/api/service/email/EmailService.java deleted file mode 100644 index c9b30e3..0000000 --- a/src/main/java/ssafy/retrip/api/service/email/EmailService.java +++ /dev/null @@ -1,86 +0,0 @@ -package ssafy.retrip.api.service.email; - -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; -import java.util.Random; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Service; -import ssafy.retrip.api.service.email.request.EmailVerificationServiceRequest; - -@Service -@RequiredArgsConstructor -public class EmailService { - - private static final int CODE_LENGTH = 6; - - private final JavaMailSender mailSender; - private final RedisTemplate redisTemplate; - - @Value("${spring.mail.username}") - private String senderEmail; - - public void joinEmail(String userEmail) { - String code = createRandomCode(); - String title = "[ReTrip] 회원가입 이메일 인증번호 발송"; - String content = "

ReTrip 회원가입을 위한 인증번호입니다.

" - + "

" + code + "

" - + "

인증번호는 3분간 유효합니다.

" - + "

감사합니다.

"; - sendEmailCode(userEmail, title, content, code); - } - - public void findForgotUserId(String userEmail) { - String code = createRandomCode(); - String title = "[ReTrip] 아이디 찾기 이메일 인증번호 발송"; - String content = "

ReTrip 아이디 찾기를 위한 인증번호입니다.

" - + "

" + code + "

" - + "

인증번호는 3분간 유효합니다.

" - + "

감사합니다.

"; - sendEmailCode(userEmail, title, content, code); - } - - private void sendEmailCode(String userEmail, String title, String content, String code) { - - MimeMessage message = mailSender.createMimeMessage(); - try { - MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); - helper.setFrom(senderEmail); - helper.setTo(userEmail); - helper.setSubject(title); - helper.setText(content, true); - mailSender.send(message); - } catch (MessagingException e) { - return; - } - - ValueOperations valOperations = redisTemplate.opsForValue(); - valOperations.set(userEmail, code, 180, TimeUnit.SECONDS); - } - - public void verifyEmailCode(EmailVerificationServiceRequest request) { - ValueOperations valOperations = redisTemplate.opsForValue(); - String code = valOperations.get(request.getEmail()); - if (!StringUtils.equals(code, request.getCode())) { - throw new IllegalArgumentException("인증번호가 일치하지 않습니다."); - } - - redisTemplate.delete(request.getEmail()); - } - - private String createRandomCode() { - String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - StringBuilder sb = new StringBuilder(CODE_LENGTH); - Random random = new Random(); - for (int i = 0; i < CODE_LENGTH; i++) { - sb.append(chars.charAt(random.nextInt(chars.length()))); - } - return sb.toString(); - } -} diff --git a/src/main/java/ssafy/retrip/api/service/email/request/EmailVerificationServiceRequest.java b/src/main/java/ssafy/retrip/api/service/email/request/EmailVerificationServiceRequest.java deleted file mode 100644 index f4bd253..0000000 --- a/src/main/java/ssafy/retrip/api/service/email/request/EmailVerificationServiceRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package ssafy.retrip.api.service.email.request; - -import lombok.Builder; -import lombok.Getter; - -@Getter -public class EmailVerificationServiceRequest { - - private String email; - private String code; - - @Builder - private EmailVerificationServiceRequest(String email, String code) { - this.email = email; - this.code = code; - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/service/member/MemberService.java b/src/main/java/ssafy/retrip/api/service/member/MemberService.java deleted file mode 100644 index 18ab87c..0000000 --- a/src/main/java/ssafy/retrip/api/service/member/MemberService.java +++ /dev/null @@ -1,110 +0,0 @@ -package ssafy.retrip.api.service.member; - -import static ssafy.retrip.domain.member.LoginType.NORMAL; - -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import ssafy.retrip.api.service.email.EmailService; -import ssafy.retrip.api.service.email.request.EmailVerificationServiceRequest; -import ssafy.retrip.api.service.member.request.MemberSignInServiceRequest; -import ssafy.retrip.api.service.member.request.MemberSignUpServiceRequest; -import ssafy.retrip.api.service.member.request.PasswordFindServiceRequest; -import ssafy.retrip.api.service.member.request.PasswordResetServiceRequest; -import ssafy.retrip.api.service.retrip.response.ImageUrlResponse; -import ssafy.retrip.domain.member.Member; -import ssafy.retrip.domain.member.MemberRepository; -import ssafy.retrip.domain.retripReport.RetripReport; -import ssafy.retrip.domain.retripReport.RetripReportRepository; -import ssafy.retrip.global.exception.NicknameAlreadyExistsException; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class MemberService { - - private final EmailService emailService; - private final MemberRepository memberRepository; - private final BCryptPasswordEncoder passwordEncoder; - private final RetripReportRepository retripReportRepository; - - @Transactional - public void signup(MemberSignUpServiceRequest request) { - - String encodedPassword = passwordEncoder.encode(request.getPassword()); - Member member = Member.builder() - .userId(request.getUserId()) - .password(encodedPassword) - .email(request.getEmail()) - .loginType(NORMAL).build(); - - memberRepository.save(member); - } - - public void validateNickname(String nickname) { - if (memberRepository.existsByUserId(nickname)) { - throw new NicknameAlreadyExistsException("이미 사용 중인 닉네임입니다."); - } - } - - public void signIn(MemberSignInServiceRequest request) { - - Member member = memberRepository.findByUserId(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - - if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); - } - } - - public void sendVerificationCode(String email) { - emailService.findForgotUserId(email); - } - - public String getForgotUserId(String email) { - Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - - return member.getUserId(); - } - - public void verifyPasswordResetCredentials(PasswordFindServiceRequest request) { - - emailService.verifyEmailCode(EmailVerificationServiceRequest.builder() - .email(request.getEmail()) - .code(request.getCode()).build()); - - Member member = memberRepository.findByEmail(request.getEmail()).orElseThrow( - () -> new IllegalArgumentException("회원 정보가 일치하지 않습니다.") - ); - - if (!StringUtils.equals(member.getUserId(), request.getUserId())) { - throw new IllegalArgumentException("회원 정보가 일치하지 않습니다."); - } - } - - @Transactional - public void resetPassword(PasswordResetServiceRequest request) { - Member member = memberRepository.findByEmail(request.getEmail()).orElseThrow( - () -> new IllegalArgumentException("존재하지 않는 회원입니다.") - ); - - String encodedPassword = passwordEncoder.encode(request.getNewPassword()); - member.updatePassword(encodedPassword); - } - - public List getRetripHistoryByMemberId(String memberId) { - - List retripReports = retripReportRepository.findByMemberId(memberId).orElseThrow( - () -> new IllegalStateException("해당 회원의 Retrip 히스토리가 없습니다.") - ); - - return retripReports.stream() - .map(report -> ImageUrlResponse.builder() - .imageUrl(report.getImageUrl()) - .build()).toList(); - } -} diff --git a/src/main/java/ssafy/retrip/api/service/member/request/MemberSignInServiceRequest.java b/src/main/java/ssafy/retrip/api/service/member/request/MemberSignInServiceRequest.java deleted file mode 100644 index 17d8901..0000000 --- a/src/main/java/ssafy/retrip/api/service/member/request/MemberSignInServiceRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package ssafy.retrip.api.service.member.request; - -import lombok.Builder; -import lombok.Getter; - -@Getter -public class MemberSignInServiceRequest { - - private String userId; - private String password; - - @Builder - private MemberSignInServiceRequest(String userId, String password) { - this.userId = userId; - this.password = password; - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/service/member/request/MemberSignUpServiceRequest.java b/src/main/java/ssafy/retrip/api/service/member/request/MemberSignUpServiceRequest.java deleted file mode 100644 index d557577..0000000 --- a/src/main/java/ssafy/retrip/api/service/member/request/MemberSignUpServiceRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package ssafy.retrip.api.service.member.request; - -import lombok.Builder; -import lombok.Getter; - -@Getter -public class MemberSignUpServiceRequest { - - - private String userId; - - private String password; - - private String email; - - @Builder - private MemberSignUpServiceRequest(String userId, String password, String email) { - this.userId = userId; - this.password = password; - this.email = email; - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/service/member/request/PasswordFindServiceRequest.java b/src/main/java/ssafy/retrip/api/service/member/request/PasswordFindServiceRequest.java deleted file mode 100644 index 8c202e2..0000000 --- a/src/main/java/ssafy/retrip/api/service/member/request/PasswordFindServiceRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package ssafy.retrip.api.service.member.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.Builder; -import lombok.Getter; - -@Getter -public class PasswordFindServiceRequest { - - @NotNull(message = "아이디는 필수 입력값입니다.") - private String userId; // 회원 아이디 - - @NotNull(message = "이메일은 필수 입력값입니다.") - private String email; - - @NotNull(message = "코드는 필수 입력값입니다.") - @Size(min = 6, max = 6, message = "코드는 6자리여야 합니다.") - private String code; - - @Builder - private PasswordFindServiceRequest(String userId, String email, String code) { - this.userId = userId; - this.email = email; - this.code = code; - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/service/member/request/PasswordResetServiceRequest.java b/src/main/java/ssafy/retrip/api/service/member/request/PasswordResetServiceRequest.java deleted file mode 100644 index 8f90ae4..0000000 --- a/src/main/java/ssafy/retrip/api/service/member/request/PasswordResetServiceRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package ssafy.retrip.api.service.member.request; - -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PasswordResetServiceRequest { - - private String email; - private String newPassword; - - @Builder - private PasswordResetServiceRequest(String email, String newPassword) { - this.email = email; - this.newPassword = newPassword; - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/service/oauth/CustomOAuth2UserService.java b/src/main/java/ssafy/retrip/api/service/oauth/CustomOAuth2UserService.java deleted file mode 100644 index 10c0bbd..0000000 --- a/src/main/java/ssafy/retrip/api/service/oauth/CustomOAuth2UserService.java +++ /dev/null @@ -1,53 +0,0 @@ -package ssafy.retrip.api.service.oauth; - - -import jakarta.transaction.Transactional; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClient; -import ssafy.retrip.api.service.oauth.info.KakaoUserInfo; -import ssafy.retrip.api.service.oauth.request.KakaoUserCreateServiceRequest; - -@Service -@Transactional -@RequiredArgsConstructor -public class CustomOAuth2UserService extends OidcUserService { - - private final RestClient restClient; - - private final String USER_INFO_URL = "https://kapi.kakao.com/v1/oidc/userinfo"; - private final String HEADER_NAME = "Authorization"; - private final String HEADER_VALUE = "Bearer "; - - @Override - public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { - - OidcUser oidcUser = super.loadUser(userRequest); - - KakaoUserInfo info = getKakaoUserInfo(userRequest); - - return CustomOidcUser.builder() - .oidcUser(oidcUser) - .info(info).build(); - } - - private KakaoUserInfo getKakaoUserInfo(OidcUserRequest userRequest) { - - String accessToken = userRequest.getAccessToken().getTokenValue(); - - KakaoUserCreateServiceRequest request = restClient.get() - .uri(USER_INFO_URL) - .header(HEADER_NAME, HEADER_VALUE + accessToken) - .retrieve() - .body(KakaoUserCreateServiceRequest.class); - - return Optional.ofNullable(request) - .map(KakaoUserCreateServiceRequest::toKakaoUserInfo) - .orElseThrow(IllegalArgumentException::new); - } -} diff --git a/src/main/java/ssafy/retrip/api/service/oauth/CustomOidcUser.java b/src/main/java/ssafy/retrip/api/service/oauth/CustomOidcUser.java deleted file mode 100644 index 0e8d72d..0000000 --- a/src/main/java/ssafy/retrip/api/service/oauth/CustomOidcUser.java +++ /dev/null @@ -1,54 +0,0 @@ -package ssafy.retrip.api.service.oauth; - -import java.util.Collection; -import java.util.Map; -import lombok.Builder; -import lombok.Getter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.core.oidc.OidcIdToken; -import org.springframework.security.oauth2.core.oidc.OidcUserInfo; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import ssafy.retrip.api.service.oauth.info.KakaoUserInfo; - -@Getter -public class CustomOidcUser implements OidcUser { - - private final OidcUser oidcUser; - private final KakaoUserInfo info; - - @Builder - private CustomOidcUser(OidcUser oidcUser, KakaoUserInfo info) { - this.oidcUser = oidcUser; - this.info = info; - } - - @Override - public String getName() { - return oidcUser.getName(); - } - - @Override - public OidcIdToken getIdToken() { - return oidcUser.getIdToken(); - } - - @Override - public OidcUserInfo getUserInfo() { - return oidcUser.getUserInfo(); - } - - @Override - public Map getClaims() { - return oidcUser.getClaims(); - } - - @Override - public Map getAttributes() { - return oidcUser.getAttributes(); - } - - @Override - public Collection getAuthorities() { - return oidcUser.getAuthorities(); - } -} diff --git a/src/main/java/ssafy/retrip/api/service/oauth/info/KakaoUserInfo.java b/src/main/java/ssafy/retrip/api/service/oauth/info/KakaoUserInfo.java deleted file mode 100644 index a27bac3..0000000 --- a/src/main/java/ssafy/retrip/api/service/oauth/info/KakaoUserInfo.java +++ /dev/null @@ -1,25 +0,0 @@ -package ssafy.retrip.api.service.oauth.info; - -import static lombok.AccessLevel.PROTECTED; - -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = PROTECTED) -public class KakaoUserInfo { - - private String sub; - - private String email; - - private String nickname; - - @Builder - public KakaoUserInfo(String sub, String email, String nickname) { - this.sub = sub; - this.email = email; - this.nickname = nickname; - } -} diff --git a/src/main/java/ssafy/retrip/api/service/oauth/request/KakaoUserCreateServiceRequest.java b/src/main/java/ssafy/retrip/api/service/oauth/request/KakaoUserCreateServiceRequest.java deleted file mode 100644 index 7c294da..0000000 --- a/src/main/java/ssafy/retrip/api/service/oauth/request/KakaoUserCreateServiceRequest.java +++ /dev/null @@ -1,25 +0,0 @@ -package ssafy.retrip.api.service.oauth.request; - -import static lombok.AccessLevel.PROTECTED; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import ssafy.retrip.api.service.oauth.info.KakaoUserInfo; - -@Getter -@NoArgsConstructor(access = PROTECTED) -public class KakaoUserCreateServiceRequest { - - private String sub; - - private String email; - - private String nickname; - - public KakaoUserInfo toKakaoUserInfo() { - return KakaoUserInfo.builder() - .sub(sub) - .email(email) - .nickname(nickname).build(); - } -} diff --git a/src/main/java/ssafy/retrip/api/service/retrip/ImageConverter.java b/src/main/java/ssafy/retrip/api/service/retrip/ImageConverter.java index 85b22b7..dda0257 100644 --- a/src/main/java/ssafy/retrip/api/service/retrip/ImageConverter.java +++ b/src/main/java/ssafy/retrip/api/service/retrip/ImageConverter.java @@ -13,10 +13,9 @@ public class ImageConverter { private static final int MAX_EDGE = 1080; + public static final String JPEG_FORMAT = "jpg"; + public static final double JPEG_OUTPUT_QUALITY = 0.9; - /** - * MultipartFile을 리사이징하고 JPEG byte[]로 변환합니다. (In-Memory) HEIC 포맷을 감지하여 처리합니다. - */ public byte[] convertAndResizeToJpeg(MultipartFile file) throws IOException { BufferedImage src = ImageIO.read(file.getInputStream()); if (src == null) { @@ -26,16 +25,13 @@ public byte[] convertAndResizeToJpeg(MultipartFile file) throws IOException { try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { Thumbnails.of(src) .size(MAX_EDGE, MAX_EDGE) - .outputFormat("jpg") - .outputQuality(0.9) + .outputFormat(JPEG_FORMAT) + .outputQuality(JPEG_OUTPUT_QUALITY) .toOutputStream(out); return out.toByteArray(); } } - /** - * 이미지 byte[]를 Base64 Data URL로 변환합니다. (GPT 전송용) - */ public String toDataUrl(byte[] imageBytes) { String b64 = Base64.getEncoder().encodeToString(imageBytes); return "data:image/jpeg;base64," + b64; diff --git a/src/main/java/ssafy/retrip/api/service/retrip/RetripService.java b/src/main/java/ssafy/retrip/api/service/retrip/RetripService.java index a6ccb44..d9cf96b 100644 --- a/src/main/java/ssafy/retrip/api/service/retrip/RetripService.java +++ b/src/main/java/ssafy/retrip/api/service/retrip/RetripService.java @@ -1,17 +1,20 @@ package ssafy.retrip.api.service.retrip; -import com.drew.imaging.ImageMetadataReader; -import com.drew.lang.GeoLocation; -import com.drew.metadata.Metadata; -import com.drew.metadata.exif.ExifSubIFDDirectory; -import com.drew.metadata.exif.GpsDirectory; -import java.io.InputStream; +import static ssafy.retrip.domain.retrip.TimeSlot.*; +import static ssafy.retrip.utils.CoordinateUtil.analyzeMainLocation; +import static ssafy.retrip.utils.CoordinateUtil.calculateAverageCoordinates; +import static ssafy.retrip.utils.DateUtil.findEarliestTakenDate; +import static ssafy.retrip.utils.DateUtil.findLatestTakenDate; +import static ssafy.retrip.utils.DistanceUtil.calculateTotalDistance; +import static ssafy.retrip.utils.ImageMetaDataUtil.extractMetadata; + import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; -import lombok.AllArgsConstructor; -import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -22,9 +25,9 @@ import ssafy.retrip.api.service.openai.GptImageAnalysisService; import ssafy.retrip.api.service.openai.response.AnalysisResponse; import ssafy.retrip.api.service.openai.response.AnalysisResponse.Recommendation; -import ssafy.retrip.domain.member.Member; -import ssafy.retrip.domain.member.MemberRepository; -import ssafy.retrip.domain.retrip.RecommendationPlace; +import ssafy.retrip.api.service.retrip.info.GpsCoordinate; +import ssafy.retrip.api.service.retrip.info.ImageMetaData; +import ssafy.retrip.domain.place.RecommendationPlace; import ssafy.retrip.domain.retrip.Retrip; import ssafy.retrip.domain.retrip.RetripRepository; import ssafy.retrip.domain.retrip.TimeSlot; @@ -34,31 +37,60 @@ @RequiredArgsConstructor public class RetripService { - private final MemberRepository memberRepository; + public static final String DEFAULT_USERNAME = "여행자님"; + private final RetripRepository retripRepository; private final ImageConverter imageConverter; private final GptImageAnalysisService gptImageAnalysisService; @Value("${retrip.image.min-count}") - private int minImages; + private int minImageCount; + @Value("${retrip.image.max-count}") - private int maxImages; + private int maxImageCount; - /** - * 여러 장의 이미지를 받아 여행 기록(Retrip)을 생성하고 분석 결과를 반환합니다. 이미지 처리, 메타데이터 추출, GPT 분석, DB 저장을 총괄합니다. - * - * @param images 사용자가 업로드한 이미지 파일 리스트 - * @param memberId 요청을 보낸 회원의 ID (비회원일 경우 null) - * @return 생성된 여행 기록 정보와 GPT 분석 결과를 담은 DTO - */ @Transactional - public TravelAnalysisResponseDto createRetripFromImages(List images, - String memberId) { - validateImageCount(images); + public TravelAnalysisResponseDto createRetripFromImages(List images) { + validateMinimumImageCount(images); + + images = adjustImageCountToMaximum(images); List imageDataUrlsForGpt = new ArrayList<>(); - List allMetadata = new ArrayList<>(); + List allMetadata = new ArrayList<>(); + + prepareImageForProcessing(images, allMetadata, imageDataUrlsForGpt); + + GpsCoordinate averageCoords = calculateAverageCoordinates(allMetadata); + AnalysisResponse analysisResponse = gptImageAnalysisService + .analyze(imageDataUrlsForGpt, averageCoords.latitude(), averageCoords.longitude()); + + try { + Retrip retrip = buildRetripFromAnalysis(analysisResponse); + updateRetripDetailsFromMetadata(retrip, allMetadata); + Retrip savedRetrip = retripRepository.save(retrip); + + return buildTravelAnalysisResponseDto(savedRetrip, analysisResponse); + } catch (Exception e) { + log.error("GPT 분석 결과 처리 및 Retrip 생성 중 오류 발생", e); + throw new IllegalStateException("여행 기록 생성 중 오류가 발생했습니다.", e); + } + } + + private void validateMinimumImageCount(List images) { + if (images == null || images.size() < minImageCount) { + throw new IllegalArgumentException("이미지는 최소 " + minImageCount + "장 이상 업로드해야 합니다."); + } + } + + private List adjustImageCountToMaximum(List images) { + return images.size() <= maxImageCount ? images : images.subList(0, maxImageCount); + } + private void prepareImageForProcessing( + List images, + List allMetadata, + List imageDataUrlsForGpt + ) { for (MultipartFile image : images) { if (image.isEmpty()) { continue; @@ -72,42 +104,12 @@ public TravelAnalysisResponseDto createRetripFromImages(List imag } } - if (allMetadata.size() < minImages) { - throw new IllegalStateException("유효한 이미지가 " + minImages + "장 미만입니다."); - } - - GpsCoordinates averageCoords = calculateAverageCoordinates(allMetadata); - AnalysisResponse analysisResponse = gptImageAnalysisService.analyze( - imageDataUrlsForGpt, averageCoords.getLatitude(), averageCoords.getLongitude()); - - try { - Member member = null; - // memberId가 제공된 경우에만 회원 정보를 조회합니다. - if (memberId != null && !memberId.trim().isEmpty()) { - member = memberRepository.findByKakaoId(memberId) - .orElseThrow( - () -> new IllegalArgumentException("해당 ID를 가진 회원이 존재하지 않습니다: " + memberId)); - } - - Retrip retrip = buildRetripFromAnalysis(member, analysisResponse); - updateRetripDetailsFromMetadata(retrip, allMetadata); - Retrip savedRetrip = retripRepository.save(retrip); - - return buildTravelAnalysisResponseDto(savedRetrip, analysisResponse); - } catch (Exception e) { - log.error("GPT 분석 결과 처리 및 Retrip 생성 중 오류 발생", e); - throw new IllegalStateException("여행 기록 생성 중 오류가 발생했습니다.", e); + if (allMetadata.size() < minImageCount) { + throw new IllegalStateException("유효한 이미지가 " + minImageCount + "장 미만입니다."); } } - /** - * GPT 분석 결과를 바탕으로 Retrip 엔티티의 기본 정보를 빌드합니다. - * - * @param member Retrip의 소유자 - * @param analysis GPT로부터 받은 분석 결과 객체 - * @return GPT 분석 정보가 채워진 Retrip 엔티티 - */ - private Retrip buildRetripFromAnalysis(Member member, AnalysisResponse analysis) { + private Retrip buildRetripFromAnalysis(AnalysisResponse analysis) { AnalysisResponse.User user = analysis.getUser(); AnalysisResponse.TripSummary summary = analysis.getTripSummary(); AnalysisResponse.PhotoStats stats = analysis.getPhotoStats(); @@ -116,14 +118,12 @@ private Retrip buildRetripFromAnalysis(Member member, AnalysisResponse analysis) throw new IllegalStateException("GPT 분석 결과의 세부 정보(사용자, 요약, 통계)가 누락되었습니다."); } - // 에겐/테토 정보 추출 AnalysisResponse.EgenTeto egenTeto = user.getEgenTeto(); String egenTetoType = egenTeto != null ? egenTeto.getType() : null; String egenTetoSubtype = egenTeto != null ? egenTeto.getSubtype() : null; String egenTetoHashtag = egenTeto != null ? egenTeto.getHashtag() : null; Retrip retrip = Retrip.builder() - .member(member) .countryCode(user.getCountryCode()) .mbti(user.getMbti()) .egenTetoType(egenTetoType) @@ -143,283 +143,59 @@ private Retrip buildRetripFromAnalysis(Member member, AnalysisResponse analysis) .emoji(dto.getEmoji()) .place(dto.getPlace()) .description(dto.getDescription()) - .build()); + .build() + ); } } - return retrip; - } - /** - * 이미지 메타데이터 리스트를 분석하여 Retrip 엔티티의 통계 정보를 업데이트합니다. (여행 시작/종료일, 총 이동 거리, 주요 시간대, 주요 위치 좌표 등) - * - * @param retrip 정보를 업데이트할 Retrip 엔티티 - * @param metadataList 이미지에서 추출한 메타데이터 리스트 - */ - private void updateRetripDetailsFromMetadata(Retrip retrip, List metadataList) { - if (metadataList == null || metadataList.isEmpty()) { - return; - } - metadataList.sort(Comparator.comparing(ImageMetadata::getTakenDate, - Comparator.nullsLast(Comparator.naturalOrder()))); - metadataList.stream().map(ImageMetadata::getTakenDate).filter(Objects::nonNull).findFirst() - .ifPresent(retrip::setStartDate); - metadataList.stream().map(ImageMetadata::getTakenDate).filter(Objects::nonNull) - .reduce((first, second) -> second) - .ifPresent(retrip::setEndDate); - retrip.setTotalDistance(calculateTotalDistance(metadataList)); - retrip.setMainTimeSlot(analyzeMainTimeSlot(metadataList)); - Map mainLocationInfo = analyzeMainLocation(metadataList); - retrip.setMainLocationLat((Double) mainLocationInfo.get("latitude")); - retrip.setMainLocationLng((Double) mainLocationInfo.get("longitude")); - retrip.setImageCount(metadataList.size()); + return retrip; } - /** - * 저장된 Retrip 엔티티와 GPT 분석 결과를 조합하여 최종적으로 클라이언트에게 반환할 DTO를 생성합니다. 회원이 없는 경우를 처리합니다. - * - * @param retrip DB에 저장된 Retrip 엔티티 - * @param analysis GPT로부터 받은 분석 결과 객체 - * @return 클라이언트에게 전달될 최종 응답 DTO - */ - private TravelAnalysisResponseDto buildTravelAnalysisResponseDto(Retrip retrip, - AnalysisResponse analysis) { - // 회원이 존재하면 회원의 닉네임을, 없으면 "비회원"를 사용합니다. - String username = - (retrip.getMember() != null) ? retrip.getMember().getNickname() : "여행자님"; - + private TravelAnalysisResponseDto buildTravelAnalysisResponseDto( + Retrip retrip, + AnalysisResponse analysis + ) { return TravelAnalysisResponseDto.from( retrip.getId(), analysis, retrip, - username + DEFAULT_USERNAME ); } - /** - * 업로드된 이미지의 개수가 유효한 범위(최소/최대) 내에 있는지 검증합니다. - * - * @param images 업로드된 이미지 파일 리스트 - * @throws IllegalArgumentException 이미지 개수가 최소 요구량보다 적을 경우 - */ - private void validateImageCount(List images) { - if (images == null || images.size() < minImages) { - throw new IllegalArgumentException("이미지는 최소 " + minImages + "장 이상 업로드해야 합니다."); - } - if (images.size() > maxImages) { - images = images.subList(0, maxImages); - } - } - - /** - * 이미지 메타데이터 리스트로부터 평균 GPS 좌표를 계산합니다. - * - * @param metadataList 이미지 메타데이터 리스트 - * @return 평균 위도와 경도를 담은 GpsCoordinates 객체 - */ - private GpsCoordinates calculateAverageCoordinates(List metadataList) { - List validCoords = metadataList.stream() - .filter(m -> m.latitude != null && m.longitude != null) - .map(m -> new GpsCoordinates(m.latitude, m.longitude)) - .collect(Collectors.toList()); - if (validCoords.isEmpty()) { - log.warn("유효한 GPS 좌표가 없어 기본값(0,0)을 사용합니다."); - return new GpsCoordinates(0.0, 0.0); - } - double avgLat = validCoords.stream().mapToDouble(GpsCoordinates::getLatitude).average() - .orElse(0.0); - double avgLng = validCoords.stream().mapToDouble(GpsCoordinates::getLongitude).average() - .orElse(0.0); - return new GpsCoordinates(avgLat, avgLng); - } - - /** - * 이미지 파일의 InputStream에서 촬영 시간, GPS 정보 등의 메타데이터를 추출합니다. - * - * @param inputStream 이미지 파일의 InputStream - * @return 추출된 메타데이터를 담은 ImageMetadata 객체 - */ - private ImageMetadata extractMetadata(InputStream inputStream) { - ImageMetadata metadata = new ImageMetadata(); - try { - Metadata rawMetadata = ImageMetadataReader.readMetadata(inputStream); - extractDateTimeInfo(rawMetadata, metadata); - extractGpsInfo(rawMetadata, metadata); - } catch (Exception e) { - log.error("메타데이터 추출 오류", e); - } - return metadata; - } - - /** - * EXIF 데이터에서 원본 촬영 시간을 추출하여 ImageMetadata 객체에 설정합니다. - * - * @param rawMetadata 원본 메타데이터 객체 - * @param metadata 정보를 저장할 ImageMetadata 객체 - */ - private void extractDateTimeInfo(Metadata rawMetadata, ImageMetadata metadata) { - ExifSubIFDDirectory exifDir = rawMetadata.getFirstDirectoryOfType( - ExifSubIFDDirectory.class); - if (exifDir != null) { - Date date = exifDir.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); - if (date != null) { - metadata.takenDate = LocalDateTime.ofInstant(date.toInstant(), - ZoneId.systemDefault()); - } - } - } - - /** - * EXIF 데이터에서 GPS 좌표를 추출하여 ImageMetadata 객체에 설정합니다. - * - * @param rawMetadata 원본 메타데이터 객체 - * @param metadata 정보를 저장할 ImageMetadata 객체 - */ - private void extractGpsInfo(Metadata rawMetadata, ImageMetadata metadata) { - GpsDirectory gpsDir = rawMetadata.getFirstDirectoryOfType(GpsDirectory.class); - if (gpsDir != null) { - GeoLocation geoLocation = gpsDir.getGeoLocation(); - if (geoLocation != null && !geoLocation.isZero()) { - metadata.latitude = geoLocation.getLatitude(); - metadata.longitude = geoLocation.getLongitude(); - } - } - } - - /** - * GPS 좌표가 있는 사진들의 촬영 순서에 따라 총 이동 거리를 계산합니다. (단위: km) - * - * @param metadataList 이미지 메타데이터 리스트 - * @return 계산된 총 이동 거리 (km) - */ - private double calculateTotalDistance(List metadataList) { - double totalDistance = 0.0; - List locData = metadataList.stream() - .filter(m -> m.latitude != null && m.longitude != null) - .collect(Collectors.toList()); - for (int i = 0; i < locData.size() - 1; i++) { - ImageMetadata current = locData.get(i); - ImageMetadata next = locData.get(i + 1); - totalDistance += calculateDistance(current.getLatitude(), current.getLongitude(), - next.getLatitude(), next.getLongitude()); - } - return totalDistance; - } - - /** - * 두 GPS 좌표 간의 거리를 Haversine 공식을 사용하여 계산합니다. (단위: km) - * - * @param lat1 첫 번째 지점의 위도 - * @param lon1 첫 번째 지점의 경도 - * @param lat2 두 번째 지점의 위도 - * @param lon2 두 번째 지점의 경도 - * @return 두 지점 간의 거리 (km) - */ - private double calculateDistance(double lat1, double lon1, double lat2, double lon2) { - final int R = 6371; // 지구의 반지름 (km) - double dLat = Math.toRadians(lat2 - lat1); - double dLon = Math.toRadians(lon2 - lon1); - double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; - } - - /** - * 사진들의 촬영 시간을 분석하여 가장 많이 촬영된 시간대(아침, 오후 등)를 결정합니다. - * - * @param metadataList 이미지 메타데이터 리스트 - * @return 가장 빈도가 높은 TimeSlot - */ - private TimeSlot analyzeMainTimeSlot(List metadataList) { + private TimeSlot analyzeMainTimeSlot(List metadataList) { if (metadataList.stream().allMatch(m -> m.getTakenDate() == null)) { - return TimeSlot.AFTERNOON; // 기본값 + return AFTERNOON; } return metadataList.stream() - .map(ImageMetadata::getTakenDate) + .map(ImageMetaData::getTakenDate) .filter(Objects::nonNull) .collect(Collectors.groupingBy(TimeSlot::from, Collectors.counting())) .entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) - .orElse(TimeSlot.AFTERNOON); + .orElse(AFTERNOON); } - /** - * 사진들의 GPS 정보를 클러스터링하여 가장 큰 클러스터의 평균 좌표를 계산합니다. 이는 여행의 주요 활동 지역을 나타냅니다. - * - * @param metadataList 이미지 메타데이터 리스트 - * @return 주요 위치의 위도와 경도를 담은 Map - */ - private Map analyzeMainLocation(List metadataList) { - List locData = metadataList.stream() - .filter(m -> m.latitude != null && m.longitude != null) - .collect(Collectors.toList()); - if (locData.isEmpty()) { - return Map.of("latitude", 0.0, "longitude", 0.0); - } - List largestCluster = findLargestCluster(locData, 0.1); // 100m 반경 - double avgLat = largestCluster.stream().mapToDouble(ImageMetadata::getLatitude).average() - .orElse(0.0); - double avgLng = largestCluster.stream().mapToDouble(ImageMetadata::getLongitude).average() - .orElse(0.0); - return Map.of("latitude", avgLat, "longitude", avgLng); - } - - /** - * 간단한 클러스터링 알고리즘을 사용하여 주어진 임계값 내에서 가장 큰 사진 그룹을 찾습니다. - * - * @param metadataList GPS 정보가 있는 메타데이터 리스트 - * @param thresholdKm 같은 클러스터로 간주할 거리 임계값 (km) - * @return 가장 큰 클러스터에 속하는 메타데이터 리스트 - */ - private List findLargestCluster(List metadataList, - double thresholdKm) { - if (metadataList.isEmpty()) { - return Collections.emptyList(); - } - List> clusters = new ArrayList<>(); - for (ImageMetadata meta : metadataList) { - boolean foundCluster = false; - for (List cluster : clusters) { - // 클러스터의 첫 번째 요소와 거리를 비교하여 클러스터에 추가할지 결정 - double dist = calculateDistance(meta.getLatitude(), meta.getLongitude(), - cluster.get(0).getLatitude(), cluster.get(0).getLongitude()); - if (dist <= thresholdKm) { - cluster.add(meta); - foundCluster = true; - break; - } - } - if (!foundCluster) { - // 어떤 클러스터에도 속하지 않으면 새로운 클러스터 생성 - clusters.add(new ArrayList<>(Collections.singletonList(meta))); - } + public void updateRetripDetailsFromMetadata(Retrip retrip, List metadataList) { + if (metadataList == null || metadataList.isEmpty()) { + return; } - // 가장 크기가 큰 클러스터 반환 - return clusters.stream().max(Comparator.comparingInt(List::size)) - .orElse(Collections.emptyList()); - } - - /** - * GPS 좌표(위도, 경도)를 저장하기 위한 내부 정적 클래스입니다. - */ - @Data - @AllArgsConstructor - private static class GpsCoordinates { - - double latitude; - double longitude; - } + metadataList.sort(Comparator.comparing(ImageMetaData::getTakenDate, + Comparator.nullsLast(Comparator.naturalOrder()))); - /** - * 이미지에서 추출한 주요 메타데이터(촬영 시간, GPS)를 저장하기 위한 내부 정적 클래스입니다. - */ - @Data - private static class ImageMetadata { + LocalDateTime startDate = findEarliestTakenDate(metadataList); + LocalDateTime endDate = findLatestTakenDate(metadataList); + Map mainLocationInfo = analyzeMainLocation(metadataList); - LocalDateTime takenDate; - Double latitude; - Double longitude; + retrip.updateRetripDetailsData( + startDate, + endDate, + metadataList.size(), + calculateTotalDistance(metadataList), + analyzeMainTimeSlot(metadataList), + (Double) mainLocationInfo.get("latitude"), + (Double) mainLocationInfo.get("longitude") + ); } } \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/service/retrip/info/GpsCoordinate.java b/src/main/java/ssafy/retrip/api/service/retrip/info/GpsCoordinate.java new file mode 100644 index 0000000..a83f1d4 --- /dev/null +++ b/src/main/java/ssafy/retrip/api/service/retrip/info/GpsCoordinate.java @@ -0,0 +1,8 @@ +package ssafy.retrip.api.service.retrip.info; + +public record GpsCoordinate( + double latitude, + double longitude +) { + +} diff --git a/src/main/java/ssafy/retrip/api/service/retrip/info/ImageMetaData.java b/src/main/java/ssafy/retrip/api/service/retrip/info/ImageMetaData.java new file mode 100644 index 0000000..0c9238a --- /dev/null +++ b/src/main/java/ssafy/retrip/api/service/retrip/info/ImageMetaData.java @@ -0,0 +1,23 @@ +package ssafy.retrip.api.service.retrip.info; + +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ImageMetaData { + + private LocalDateTime takenDate; + private Double latitude; + private Double longitude; + + public void updateTakenDate(LocalDateTime takenDate) { + this.takenDate = takenDate; + } + + public void updateGeoLocation(Double latitude, Double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } +} diff --git a/src/main/java/ssafy/retrip/api/service/retripReport/RetripReportService.java b/src/main/java/ssafy/retrip/api/service/retripReport/RetripReportService.java deleted file mode 100644 index 2d90c67..0000000 --- a/src/main/java/ssafy/retrip/api/service/retripReport/RetripReportService.java +++ /dev/null @@ -1,25 +0,0 @@ -package ssafy.retrip.api.service.retripReport; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import ssafy.retrip.domain.retripReport.RetripReport; -import ssafy.retrip.domain.retripReport.RetripReportRepository; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class RetripReportService { - - private final RetripReportRepository retripReportRepository; - - @Transactional - public void saveReportImage(String memberId, String imageUrl) { - - RetripReport report = RetripReport.builder() - .memberId(memberId) - .imageUrl(imageUrl).build(); - - retripReportRepository.save(report); - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/aws/S3Uploader.java b/src/main/java/ssafy/retrip/aws/S3Uploader.java deleted file mode 100644 index 257cdae..0000000 --- a/src/main/java/ssafy/retrip/aws/S3Uploader.java +++ /dev/null @@ -1,119 +0,0 @@ -package ssafy.retrip.aws; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.Optional; -import java.util.UUID; - -@Component -public class S3Uploader { - - private final S3Client s3Client; - private final String bucket; - private final String dirName; - - public S3Uploader( - @Value("${spring.cloud.aws.credentials.access-key}") String accessKey, - @Value("${spring.cloud.aws.credentials.secret-key}") String secretKey, - @Value("${spring.cloud.aws.region.static}") String region, - @Value("${spring.cloud.aws.s3.bucket}") String bucket, - @Value("${spring.cloud.aws.s3.dir-name}") String dirName) { - - this.bucket = bucket; - this.dirName = dirName; - - // 명시적으로 UrlConnectionHttpClient 지정 - this.s3Client = S3Client.builder() - .httpClient(UrlConnectionHttpClient.builder().build()) - .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey))) - .build(); - } - - public String upload(MultipartFile multipartFile, String dirName, String storedFileName) throws IOException { - File uploadFile = convert(multipartFile) - .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 변환 실패")); - - return upload(uploadFile, dirName, storedFileName); - } - - private String upload(File uploadFile, String dirName, String storedFileName) { - String uploadImageUrl = putS3(uploadFile, dirName, storedFileName); - - // 로컬에 생성된 파일 삭제 - removeNewFile(uploadFile); - - return uploadImageUrl; - } - - private String putS3(File uploadFile, String dirName, String fileName) { - // 폴더 구조 생성 (dirName/fileName 형태) - String key = dirName + "/" + fileName; - - s3Client.putObject(PutObjectRequest.builder() - .bucket(bucket) - .key(key) - .build(), - RequestBody.fromFile(uploadFile)); - - return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(key)).toString(); - } - - private void removeNewFile(File targetFile) { - if (targetFile.delete()) { - System.out.println("파일이 삭제되었습니다."); - } else { - System.out.println("파일이 삭제되지 못했습니다."); - } - } - - // 테스트를 위해 protected로 변경 - public Optional convert(MultipartFile file) throws IOException { - if (file == null || file.isEmpty()) { - return Optional.empty(); - } - - // 1. 원본 파일명 가져오기 - String originalFilename = file.getOriginalFilename(); - if (originalFilename == null || originalFilename.isBlank()) { - originalFilename = "unnamed-file"; - } - - // 2. 안전한 임시 파일 생성 (UUID 사용으로 중복 방지) - String fileExtension = originalFilename.contains(".") - ? originalFilename.substring(originalFilename.lastIndexOf(".")) - : ""; - - File tempFile = File.createTempFile( - UUID.randomUUID().toString(), - fileExtension, - new File(System.getProperty("java.io.tmpdir")) - ); - - // 3. 파일 내용 쓰기 - try (FileOutputStream fos = new FileOutputStream(tempFile)) { - fos.write(file.getBytes()); - } catch (IOException e) { - // 오류 발생 시 파일 삭제 시도 - if (!tempFile.delete()) { - tempFile.deleteOnExit(); - } - throw new IOException("파일 변환 중 오류 발생: " + e.getMessage(), e); - } - - return Optional.of(tempFile); - } -} diff --git a/src/main/java/ssafy/retrip/config/SecurityConfig.java b/src/main/java/ssafy/retrip/config/SecurityConfig.java index e905fc3..7980c77 100644 --- a/src/main/java/ssafy/retrip/config/SecurityConfig.java +++ b/src/main/java/ssafy/retrip/config/SecurityConfig.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; 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.CsrfConfigurer; @@ -14,20 +13,14 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import ssafy.retrip.api.service.oauth.CustomOAuth2UserService; import ssafy.retrip.filter.SessionAuthenticationFilter; -import ssafy.retrip.handler.OAuth2AuthenticationFailureHandler; -import ssafy.retrip.handler.OAuth2AuthenticationSuccessHandler; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final CustomOAuth2UserService customOAuth2UserService; private final SessionAuthenticationFilter sessionAuthenticationFilter; - private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; - private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler; @Bean public BCryptPasswordEncoder passwordEncoder() { @@ -48,16 +41,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .anyRequest().permitAll() ); - http - .oauth2Client(Customizer.withDefaults()) - .oauth2Login(oauth2 -> oauth2 - .successHandler(oAuth2AuthenticationSuccessHandler) - .failureHandler(oAuth2AuthenticationFailureHandler) - .userInfoEndpoint(userInfo -> userInfo - .oidcUserService(customOAuth2UserService) - ) - ); - http .addFilterBefore(sessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -67,7 +50,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1) .maxSessionsPreventsLogin(true) - .expiredUrl("/login?expired=true") ); return http.build(); diff --git a/src/main/java/ssafy/retrip/domain/member/LoginType.java b/src/main/java/ssafy/retrip/domain/member/LoginType.java deleted file mode 100644 index 529d9d2..0000000 --- a/src/main/java/ssafy/retrip/domain/member/LoginType.java +++ /dev/null @@ -1,17 +0,0 @@ -package ssafy.retrip.domain.member; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum LoginType { - - KAKAO("KAKAO"), - GOOGLE("GOOGLE"), - NAVER("NAVER"), - APPLE("APPLE"), - NORMAL("NORMAL"); - - private final String type; -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/domain/member/Member.java b/src/main/java/ssafy/retrip/domain/member/Member.java deleted file mode 100644 index 0562e3e..0000000 --- a/src/main/java/ssafy/retrip/domain/member/Member.java +++ /dev/null @@ -1,83 +0,0 @@ -package ssafy.retrip.domain.member; - -import jakarta.persistence.*; -import java.io.Serial; -import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; - -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import ssafy.retrip.domain.BaseEntity; -import ssafy.retrip.domain.retrip.Retrip; - -@Getter -@Entity -@Table(name = "members") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Member extends BaseEntity implements Serializable { - - @Serial - private static final long serialVersionUID = 1L; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(unique = true) - private String userId; - - private String password; - - @Column(unique = true) - private String kakaoId; - - private String nickname; - - @Column(unique = true) - private String email; - - @Enumerated(EnumType.STRING) - private LoginType loginType; - - @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) - private List retrips = new ArrayList<>(); - - @Builder - private Member(String userId, String password, String kakaoId, String nickname, String email, - LoginType loginType) { - this.userId = userId; - this.password = password; - this.kakaoId = kakaoId; - this.email = email; - this.nickname = nickname; - this.loginType = loginType; - this.retrips = new ArrayList<>(); - } - - /** - * Retrip 추가 메서드 (양방향 관계 처리) - */ - public void addRetrip(Retrip retrip) { - if (retrip != null && !this.retrips.contains(retrip)) { - this.retrips.add(retrip); - if (retrip.getMember() != this) { - retrip.setMember(this); - } - } - } - - public boolean isNormalMember() { - return this.loginType == LoginType.NORMAL; - } - - public boolean isKakaoMember() { - return this.loginType == LoginType.KAKAO; - } - - public void updatePassword(String newPassword) { - this.password = newPassword; - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/domain/member/MemberRepository.java b/src/main/java/ssafy/retrip/domain/member/MemberRepository.java deleted file mode 100644 index d0fe8e2..0000000 --- a/src/main/java/ssafy/retrip/domain/member/MemberRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package ssafy.retrip.domain.member; - -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface MemberRepository extends JpaRepository { - - Optional findByKakaoId(String kakaoId); - - Optional findByUserId(String userId); - - Optional findByEmail(String email); - - boolean existsByUserId(String userId); -} diff --git a/src/main/java/ssafy/retrip/domain/place/RecommendationPlace.java b/src/main/java/ssafy/retrip/domain/place/RecommendationPlace.java new file mode 100644 index 0000000..b48695b --- /dev/null +++ b/src/main/java/ssafy/retrip/domain/place/RecommendationPlace.java @@ -0,0 +1,45 @@ +package ssafy.retrip.domain.place; + +import static lombok.AccessLevel.*; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.*; +import lombok.*; +import ssafy.retrip.domain.BaseEntity; +import ssafy.retrip.domain.retrip.Retrip; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@Table(name = "recommendation_places") +public class RecommendationPlace extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String emoji; + + @Column(nullable = false) + private String place; + + @Column(columnDefinition = "TEXT", nullable = false) + private String description; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "retrip_id") + @JsonBackReference + private Retrip retrip; + + @Builder + private RecommendationPlace(String emoji, String place, String description) { + this.emoji = emoji; + this.place = place; + this.description = description; + } + + public void updateRetrip(Retrip retrip) { + this.retrip = retrip; + } +} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/domain/retrip/RecommendationPlace.java b/src/main/java/ssafy/retrip/domain/retrip/RecommendationPlace.java deleted file mode 100644 index 2324d0c..0000000 --- a/src/main/java/ssafy/retrip/domain/retrip/RecommendationPlace.java +++ /dev/null @@ -1,35 +0,0 @@ -package ssafy.retrip.domain.retrip; - -import com.fasterxml.jackson.annotation.JsonBackReference; -import jakarta.persistence.*; -import lombok.*; -import ssafy.retrip.domain.BaseEntity; - -@Entity -@Getter -@Setter // 서비스 로직에서 연관관계 설정을 위해 추가 -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Table(name = "recommendation_places") -public class RecommendationPlace extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String emoji; - - @Column(nullable = false) - private String place; - - @Column(columnDefinition = "TEXT", nullable = false) - private String description; - - // Retrip과의 N:1 연관관계 - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "retrip_id") - @JsonBackReference // 순환 참조 방지 - private Retrip retrip; -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/domain/retrip/Retrip.java b/src/main/java/ssafy/retrip/domain/retrip/Retrip.java index 430eeb5..277e6b1 100644 --- a/src/main/java/ssafy/retrip/domain/retrip/Retrip.java +++ b/src/main/java/ssafy/retrip/domain/retrip/Retrip.java @@ -1,64 +1,85 @@ package ssafy.retrip.domain.retrip; import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.*; -import lombok.*; -import ssafy.retrip.domain.BaseEntity; -import ssafy.retrip.domain.member.Member; - +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import ssafy.retrip.domain.BaseEntity; +import ssafy.retrip.domain.place.RecommendationPlace; @Entity @Getter -@Setter +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder @Table(name = "retrips") public class Retrip extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - // --- GPT 분석 결과 --- - private String countryCode; - private String mbti; - private String egenTetoType; // EGEN 또는 TETO - private String egenTetoSubtype; // 세부 유형 (예: "귀족의 피가 흐르는 에겐녀") - private String egenTetoHashtag; // #에겐 또는 #테토 - private String summaryLine; - private String hashtag; - private String favoriteSubjects; // 쉼표로 구분된 문자열 - private String favoritePhotoSpot; // GPT가 추정한 주요 촬영 장소명 - @Column(columnDefinition = "TEXT") - private String keywords; // 쉼표로 구분된 문자열 + // --- GPT 분석 결과 --- + private String countryCode; + private String mbti; + private String egenTetoType; + private String egenTetoSubtype; + private String egenTetoHashtag; + private String summaryLine; + private String hashtag; + private String favoriteSubjects; + private String favoritePhotoSpot; + @Column(columnDefinition = "TEXT") + private String keywords; - // --- 이미지 메타데이터 기반 통계 --- - private LocalDateTime startDate; - private LocalDateTime endDate; - private Integer imageCount; - private Double totalDistance; - @Enumerated(EnumType.STRING) - private TimeSlot mainTimeSlot; - private Double mainLocationLat; // 주로 촬영된 위치의 위도 - private Double mainLocationLng; // 주로 촬영된 위치의 경도 + // --- 이미지 메타데이터 기반 통계 --- + private LocalDateTime startDate; + private LocalDateTime endDate; + private Integer imageCount; + private Double totalDistance; + @Enumerated(EnumType.STRING) + private TimeSlot mainTimeSlot; + private Double mainLocationLat; + private Double mainLocationLng; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id") - private Member member; + @OneToMany(mappedBy = "retrip", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference + private final List recommendations = new ArrayList<>(); - // 추천 장소와의 1:N 연관관계 - @OneToMany(mappedBy = "retrip", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - @JsonManagedReference - private List recommendations = new ArrayList<>(); + public void addRecommendation(RecommendationPlace recommendation) { + recommendations.add(recommendation); + recommendation.updateRetrip(this); + } - // 연관관계 편의 메서드 - public void addRecommendation(RecommendationPlace recommendation) { - recommendations.add(recommendation); - recommendation.setRetrip(this); - } + public void updateRetripDetailsData( + LocalDateTime startDate, + LocalDateTime endDate, + Integer imageCount, + Double totalDistance, + TimeSlot mainTimeSlot, + Double mainLocationLat, + Double mainLocationLng + ) { + this.startDate = startDate; + this.endDate = endDate; + this.imageCount = imageCount; + this.totalDistance = totalDistance; + this.mainTimeSlot = mainTimeSlot; + this.mainLocationLat = mainLocationLat; + this.mainLocationLng = mainLocationLng; + } } \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/domain/retrip/RetripRepository.java b/src/main/java/ssafy/retrip/domain/retrip/RetripRepository.java index 6a4a360..99f8ea8 100644 --- a/src/main/java/ssafy/retrip/domain/retrip/RetripRepository.java +++ b/src/main/java/ssafy/retrip/domain/retrip/RetripRepository.java @@ -5,6 +5,5 @@ @Repository public interface RetripRepository extends JpaRepository { - // 기본 CRUD 메서드는 JpaRepository에서 제공됨 - // 필요한 추가 쿼리 메서드는 여기에 정의 + } diff --git a/src/main/java/ssafy/retrip/domain/retrip/TimeSlot.java b/src/main/java/ssafy/retrip/domain/retrip/TimeSlot.java index 73e5b08..6fe303f 100644 --- a/src/main/java/ssafy/retrip/domain/retrip/TimeSlot.java +++ b/src/main/java/ssafy/retrip/domain/retrip/TimeSlot.java @@ -5,73 +5,44 @@ @Slf4j public enum TimeSlot { - DAWN(0, 5, "새벽(00:00-06:00)"), - MORNING(6, 11, "오전(06:00-12:00)"), - AFTERNOON(12, 17, "오후(12:00-18:00)"), - NIGHT(18, 23, "밤(18:00-24:00)"); + DAWN(0, 5, "새벽(00:00-06:00)"), + MORNING(6, 11, "오전(06:00-12:00)"), + AFTERNOON(12, 17, "오후(12:00-18:00)"), + NIGHT(18, 23, "밤(18:00-24:00)"); - private final int startHour; - private final int endHour; - private final String description; + private final int startHour; + private final int endHour; + private final String description; - TimeSlot(int startHour, int endHour, String description) { - this.startHour = startHour; - this.endHour = endHour; - this.description = description; - } + TimeSlot(int startHour, int endHour, String description) { + this.startHour = startHour; + this.endHour = endHour; + this.description = description; + } - /** - * 주어진 시간이 이 시간대에 속하는지 확인 - */ - public boolean contains(LocalDateTime dateTime) { - int hour = dateTime.getHour(); - return hour >= startHour && hour <= endHour; + public static TimeSlot from(LocalDateTime dateTime) { + if (dateTime == null) { + log.warn("시간 정보가 없습니다. 기본 시간대(AFTERNOON)를 사용합니다."); + return AFTERNOON; } - /** - * 주어진 시간에 해당하는 시간대 반환 - */ - public static TimeSlot from(LocalDateTime dateTime) { - if (dateTime == null) { - log.warn("시간 정보가 없습니다. 기본 시간대(AFTERNOON)를 사용합니다."); - return AFTERNOON; - } - - int hour = dateTime.getHour(); - return fromHour(hour); - } - - /** - * 시간값(0-23)을 기준으로 TimeSlot 반환 - */ - private static TimeSlot fromHour(int hour) { - if (hour < 0 || hour > 23) { - log.warn("비정상적인 시간값: {}. 범위(0-23)를 벗어났습니다. 기본값 AFTERNOON 사용.", hour); - return AFTERNOON; - } - - for (TimeSlot slot : values()) { - if (hour >= slot.startHour && hour <= slot.endHour) { - return slot; - } - } - - // 기본값으로 AFTERNOON 반환 (비정상 케이스) - log.error("시간대 결정 로직 오류. 시간값 {}에 해당하는 시간대를 찾지 못했습니다.", hour); - return AFTERNOON; - } + int hour = dateTime.getHour(); + return fromHour(hour); + } - /** - * 시간대에 대한 설명 반환 - */ - public String getDescription() { - return description; + private static TimeSlot fromHour(int hour) { + if (hour < 0 || hour > 23) { + log.warn("비정상적인 시간값: {}. 범위(0-23)를 벗어났습니다. 기본값 AFTERNOON 사용.", hour); + return AFTERNOON; } - - /** - * 디버깅용 시간 정보 출력 - */ - public String getTimeRange() { - return String.format("%02d:00-%02d:59", startHour, endHour); + + for (TimeSlot slot : values()) { + if (hour >= slot.startHour && hour <= slot.endHour) { + return slot; + } } + + log.error("시간대 결정 로직 오류. 시간값 {}에 해당하는 시간대를 찾지 못했습니다.", hour); + return AFTERNOON; + } } diff --git a/src/main/java/ssafy/retrip/domain/retripReport/RetripReport.java b/src/main/java/ssafy/retrip/domain/retripReport/RetripReport.java deleted file mode 100644 index 7d5d03c..0000000 --- a/src/main/java/ssafy/retrip/domain/retripReport/RetripReport.java +++ /dev/null @@ -1,34 +0,0 @@ -package ssafy.retrip.domain.retripReport; - -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.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import ssafy.retrip.domain.BaseEntity; - -@Getter -@Entity -@Table(name = "retrip_reports") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class RetripReport extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String memberId; - - private String imageUrl; - - - @Builder - private RetripReport(String memberId, String imageUrl) { - this.memberId = memberId; - this.imageUrl = imageUrl; - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/domain/retripReport/RetripReportRepository.java b/src/main/java/ssafy/retrip/domain/retripReport/RetripReportRepository.java deleted file mode 100644 index b006c2a..0000000 --- a/src/main/java/ssafy/retrip/domain/retripReport/RetripReportRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package ssafy.retrip.domain.retripReport; - -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.stereotype.Repository; - -@Repository -public interface RetripReportRepository extends JpaRepository { - - @Query(nativeQuery = true, - value = "SELECT * " - + "FROM retrip_reports " - + "WHERE member_id = :memberId" - ) - Optional> findByMemberId(String memberId); - -} diff --git a/src/main/java/ssafy/retrip/filter/SessionAuthenticationFilter.java b/src/main/java/ssafy/retrip/filter/SessionAuthenticationFilter.java index b0463d7..81ec5f3 100644 --- a/src/main/java/ssafy/retrip/filter/SessionAuthenticationFilter.java +++ b/src/main/java/ssafy/retrip/filter/SessionAuthenticationFilter.java @@ -4,7 +4,6 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -17,15 +16,6 @@ public class SessionAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - HttpSession session = request.getSession(); -// String kakaoId = (String) session.getAttribute("member"); -// -// if (isEmpty(kakaoId)) { -// response.sendRedirect("/login"); -// return; -// } - filterChain.doFilter(request, response); } } diff --git a/src/main/java/ssafy/retrip/global/exception/GlobalExceptionHandler.java b/src/main/java/ssafy/retrip/global/exception/GlobalExceptionHandler.java index d20d02d..d8891f6 100644 --- a/src/main/java/ssafy/retrip/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/ssafy/retrip/global/exception/GlobalExceptionHandler.java @@ -44,13 +44,4 @@ public ResponseEntity> handleGenericException(Exception e) { errorResponse.put("message", e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } - - @ExceptionHandler(NicknameAlreadyExistsException.class) - public ResponseEntity> handleNicknameAlreadyExistsException( - NicknameAlreadyExistsException e) { - Map errorResponse = new HashMap<>(); - errorResponse.put("error", "중복된 아이디"); - errorResponse.put("message", e.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } } diff --git a/src/main/java/ssafy/retrip/global/exception/NicknameAlreadyExistsException.java b/src/main/java/ssafy/retrip/global/exception/NicknameAlreadyExistsException.java deleted file mode 100644 index 101ba87..0000000 --- a/src/main/java/ssafy/retrip/global/exception/NicknameAlreadyExistsException.java +++ /dev/null @@ -1,11 +0,0 @@ -package ssafy.retrip.global.exception; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.CONFLICT) -public class NicknameAlreadyExistsException extends RuntimeException { - public NicknameAlreadyExistsException(String message) { - super(message); - } -} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/handler/OAuth2AuthenticationFailureHandler.java b/src/main/java/ssafy/retrip/handler/OAuth2AuthenticationFailureHandler.java deleted file mode 100644 index 368e480..0000000 --- a/src/main/java/ssafy/retrip/handler/OAuth2AuthenticationFailureHandler.java +++ /dev/null @@ -1,41 +0,0 @@ -package ssafy.retrip.handler; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.RedirectStrategy; -import org.springframework.security.web.authentication.AuthenticationFailureHandler; -import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; - -@Component -@RequiredArgsConstructor -public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler { - - private final RedirectStrategy redirectStrategy; - - private static final String REDIRECT_URL = "/login/callback"; - private static final String FRONT_SERVER = "http://localhost:5173"; - - @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, - AuthenticationException exception) throws IOException, ServletException { - - String errorMessage = URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8); - String targetUrl = getTargetUrl(errorMessage); - - redirectStrategy.sendRedirect(request, response, targetUrl); - } - - private String getTargetUrl(String errorMessage) { - return UriComponentsBuilder - .fromUriString(FRONT_SERVER + REDIRECT_URL) - .queryParam("error", errorMessage) - .build().toUriString(); - } -} diff --git a/src/main/java/ssafy/retrip/handler/OAuth2AuthenticationSuccessHandler.java b/src/main/java/ssafy/retrip/handler/OAuth2AuthenticationSuccessHandler.java deleted file mode 100644 index 9570624..0000000 --- a/src/main/java/ssafy/retrip/handler/OAuth2AuthenticationSuccessHandler.java +++ /dev/null @@ -1,59 +0,0 @@ -package ssafy.retrip.handler; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import java.io.IOException; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.web.RedirectStrategy; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.stereotype.Component; -import ssafy.retrip.domain.member.LoginType; -import ssafy.retrip.domain.member.Member; -import ssafy.retrip.domain.member.MemberRepository; - -@Component -@RequiredArgsConstructor -public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler { - - private final MemberRepository memberRepository; - private final RedirectStrategy redirectStrategy; - - private static final String REDIRECT_URL = "http://localhost:5173/photo"; - private static final String SESSION_MEMBER_KEY = "member"; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, - Authentication authentication) throws IOException, ServletException { - - OidcUser oidcUser = (OidcUser) authentication.getPrincipal(); - - Member member = findMember(oidcUser); - - HttpSession session = request.getSession(); - session.setAttribute(SESSION_MEMBER_KEY, member.getKakaoId()); - - redirectStrategy.sendRedirect(request, response, REDIRECT_URL); - } - - private Member findMember(OidcUser oidcUser) { - - String kakaoId = oidcUser.getSubject(); - String email = oidcUser.getEmail(); - String nickname = oidcUser.getAttribute("nickname"); - - return memberRepository.findByKakaoId(kakaoId).orElseGet( - () -> { - Member m = Member.builder() - .kakaoId(kakaoId) - .email(email) - .nickname(nickname) - .loginType(LoginType.KAKAO).build(); - - return memberRepository.save(m); - }); - } -} diff --git a/src/main/java/ssafy/retrip/utils/CoordinateUtil.java b/src/main/java/ssafy/retrip/utils/CoordinateUtil.java new file mode 100644 index 0000000..e3cf619 --- /dev/null +++ b/src/main/java/ssafy/retrip/utils/CoordinateUtil.java @@ -0,0 +1,98 @@ +package ssafy.retrip.utils; + +import static lombok.AccessLevel.PRIVATE; +import static ssafy.retrip.utils.DistanceUtil.calculateDistance; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import ssafy.retrip.api.service.retrip.info.GpsCoordinate; +import ssafy.retrip.api.service.retrip.info.ImageMetaData; + +@Slf4j +@NoArgsConstructor(access = PRIVATE) +public class CoordinateUtil { + + public static final double SEARCH_RADIUS_KM = 0.1; + public static final double DEFAULT_LATITUDE = 0.0; + public static final double DEFAULT_LONGITUDE = 0.0; + + public static GpsCoordinate calculateAverageCoordinates(List metadataList) { + + List validCoords = metadataList.stream() + .filter(m -> m.getLatitude() != null && m.getLongitude() != null) + .map(m -> new GpsCoordinate(m.getLatitude(), m.getLongitude())) + .toList(); + + if (validCoords.isEmpty()) { + log.warn("유효한 GPS 좌표가 없어 기본값(0, 0)을 사용합니다."); + return new GpsCoordinate(DEFAULT_LATITUDE, DEFAULT_LONGITUDE); + } + + double avgLat = validCoords.stream() + .mapToDouble(GpsCoordinate::latitude) + .average() + .orElse(DEFAULT_LATITUDE); + + double avgLng = validCoords.stream() + .mapToDouble(GpsCoordinate::longitude) + .average() + .orElse(DEFAULT_LONGITUDE); + + return new GpsCoordinate(avgLat, avgLng); + } + + public static Map analyzeMainLocation(List metadataList) { + + List locData = metadataList.stream() + .filter(m -> m.getLatitude() != null && m.getLongitude() != null) + .toList(); + + if (locData.isEmpty()) { + return Map.of("latitude", DEFAULT_LATITUDE, "longitude", DEFAULT_LONGITUDE); + } + + List largestCluster = findLargestCluster(locData); + double avgLat = largestCluster.stream() + .mapToDouble(ImageMetaData::getLatitude) + .average() + .orElse(DEFAULT_LATITUDE); + + double avgLng = largestCluster.stream() + .mapToDouble(ImageMetaData::getLongitude) + .average() + .orElse(DEFAULT_LONGITUDE); + + return Map.of("latitude", avgLat, "longitude", avgLng); + } + + private static List findLargestCluster(List metadataList) { + if (metadataList.isEmpty()) { + return Collections.emptyList(); + } + List> clusters = new ArrayList<>(); + for (ImageMetaData meta : metadataList) { + boolean foundCluster = false; + for (List cluster : clusters) { + double dist = calculateDistance(meta.getLatitude(), meta.getLongitude(), + cluster.get(0).getLatitude(), cluster.get(0).getLongitude()); + if (dist <= SEARCH_RADIUS_KM) { + cluster.add(meta); + foundCluster = true; + break; + } + } + if (!foundCluster) { + clusters.add(new ArrayList<>(Collections.singletonList(meta))); + } + } + + return clusters.stream() + .max(Comparator.comparingInt(List::size)) + .orElse(Collections.emptyList()); + } +} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/utils/DateUtil.java b/src/main/java/ssafy/retrip/utils/DateUtil.java new file mode 100644 index 0000000..3a28585 --- /dev/null +++ b/src/main/java/ssafy/retrip/utils/DateUtil.java @@ -0,0 +1,29 @@ +package ssafy.retrip.utils; + +import static lombok.AccessLevel.PRIVATE; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import lombok.NoArgsConstructor; +import ssafy.retrip.api.service.retrip.info.ImageMetaData; + +@NoArgsConstructor(access = PRIVATE) +public class DateUtil { + + public static LocalDateTime findLatestTakenDate(List metadataList) { + return metadataList.stream() + .map(ImageMetaData::getTakenDate) + .filter(Objects::nonNull) + .reduce((first, second) -> second) + .orElse(LocalDateTime.now()); + } + + public static LocalDateTime findEarliestTakenDate(List metadataList) { + return metadataList.stream() + .map(ImageMetaData::getTakenDate) + .filter(Objects::nonNull) + .findFirst() + .orElse(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/utils/DistanceUtil.java b/src/main/java/ssafy/retrip/utils/DistanceUtil.java new file mode 100644 index 0000000..827a35e --- /dev/null +++ b/src/main/java/ssafy/retrip/utils/DistanceUtil.java @@ -0,0 +1,40 @@ +package ssafy.retrip.utils; + +import static lombok.AccessLevel.PRIVATE; + +import java.util.List; +import lombok.NoArgsConstructor; +import ssafy.retrip.api.service.retrip.info.ImageMetaData; + +@NoArgsConstructor(access = PRIVATE) +public class DistanceUtil { + + public static final int EARTH_RADIUS_KM = 6371; + + public static double calculateDistance(double lat1, double lon1, double lat2, double lon2) { + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_KM * c; + } + + public static double calculateTotalDistance(List metadataList) { + + double totalDistance = 0.0; + List locData = metadataList.stream() + .filter(m -> m.getLatitude() != null && m.getLongitude() != null) + .toList(); + + for (int i = 0; i < locData.size() - 1; i++) { + ImageMetaData current = locData.get(i); + ImageMetaData next = locData.get(i + 1); + totalDistance += calculateDistance(current.getLatitude(), current.getLongitude(), + next.getLatitude(), next.getLongitude()); + } + + return totalDistance; + } +} diff --git a/src/main/java/ssafy/retrip/utils/ImageMetaDataUtil.java b/src/main/java/ssafy/retrip/utils/ImageMetaDataUtil.java new file mode 100644 index 0000000..4acc26d --- /dev/null +++ b/src/main/java/ssafy/retrip/utils/ImageMetaDataUtil.java @@ -0,0 +1,54 @@ +package ssafy.retrip.utils; + +import static com.drew.metadata.exif.ExifSubIFDDirectory.*; +import static lombok.AccessLevel.PRIVATE; + +import com.drew.imaging.ImageMetadataReader; +import com.drew.lang.GeoLocation; +import com.drew.metadata.Metadata; +import com.drew.metadata.exif.ExifSubIFDDirectory; +import com.drew.metadata.exif.GpsDirectory; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import ssafy.retrip.api.service.retrip.info.ImageMetaData; + +@Slf4j +@NoArgsConstructor(access = PRIVATE) +public class ImageMetaDataUtil { + + public static ImageMetaData extractMetadata(InputStream inputStream) { + ImageMetaData metadata = new ImageMetaData(); + try { + Metadata rawMetadata = ImageMetadataReader.readMetadata(inputStream); + extractDateTimeInfo(rawMetadata, metadata); + extractGpsInfo(rawMetadata, metadata); + } catch (Exception e) { + log.error("메타데이터 추출 오류", e); + } + return metadata; + } + + private static void extractDateTimeInfo(Metadata rawMetadata, ImageMetaData metadata) { + ExifSubIFDDirectory exifDir = rawMetadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); + if (exifDir != null) { + Date date = exifDir.getDate(TAG_DATETIME_ORIGINAL); + if (date != null) { + metadata.updateTakenDate(LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault())); + } + } + } + + private static void extractGpsInfo(Metadata rawMetadata, ImageMetaData metadata) { + GpsDirectory gpsDir = rawMetadata.getFirstDirectoryOfType(GpsDirectory.class); + if (gpsDir != null) { + GeoLocation geoLocation = gpsDir.getGeoLocation(); + if (geoLocation != null && !geoLocation.isZero()) { + metadata.updateGeoLocation(geoLocation.getLatitude(), geoLocation.getLongitude()); + } + } + } +} \ No newline at end of file