From 294f7242b7d5c38ab2d770a062661607f71bcc32 Mon Sep 17 00:00:00 2001 From: gwony Date: Fri, 10 Oct 2025 14:03:55 +0900 Subject: [PATCH] =?UTF-8?q?Bug:=20TD-32=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=90=EB=9F=AC,=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=ED=83=80=20=EC=97=90=EB=9F=AC=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 --- .../controller/KakaoAuthController.java | 108 +++--------------- .../dto/response/MemberInfoResponse.java | 28 +++-- .../service/Impl/KakaoOauthService.java | 15 ++- .../member/service/Impl/OauthLoginFacade.java | 42 +++++++ .../domain/member/service/OauthService.java | 3 +- .../place/controller/PlaceController.java | 17 +++ .../dto/response/PlaceByDateResponse.java | 8 ++ .../twogether/domain/place/entity/Place.java | 3 +- .../place/repository/PlaceRepository.java | 15 +++ .../domain/place/service/PlaceService.java | 3 + .../place/service/impl/PlaceServiceImpl.java | 95 ++++++++++++++- 11 files changed, 229 insertions(+), 108 deletions(-) create mode 100644 src/main/java/com/yeoro/twogether/domain/member/service/Impl/OauthLoginFacade.java create mode 100644 src/main/java/com/yeoro/twogether/domain/place/dto/response/PlaceByDateResponse.java diff --git a/src/main/java/com/yeoro/twogether/domain/member/controller/KakaoAuthController.java b/src/main/java/com/yeoro/twogether/domain/member/controller/KakaoAuthController.java index 6e804e1..6d63b50 100644 --- a/src/main/java/com/yeoro/twogether/domain/member/controller/KakaoAuthController.java +++ b/src/main/java/com/yeoro/twogether/domain/member/controller/KakaoAuthController.java @@ -1,106 +1,34 @@ package com.yeoro.twogether.domain.member.controller; -import com.yeoro.twogether.domain.member.dto.OauthProfile; -import com.yeoro.twogether.domain.member.entity.LoginPlatform; -import com.yeoro.twogether.domain.member.service.OauthService; -import com.yeoro.twogether.domain.member.service.MemberService; -import com.yeoro.twogether.global.store.OtcStore; -import com.yeoro.twogether.global.store.StateStore; +import com.yeoro.twogether.domain.member.dto.response.LoginResponse; +import com.yeoro.twogether.domain.member.service.Impl.OauthLoginFacade; // ← Facade 주입 +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import org.springframework.web.util.UriComponentsBuilder; - -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.UUID; @RestController @RequiredArgsConstructor @RequestMapping("/api/member/oauth/kakao") public class KakaoAuthController { - private final OauthService kakao; - private final OtcStore otcStore; - private final StateStore stateStore; - private final MemberService memberService; + private final OauthLoginFacade oauthLoginFacade; - @Value("${kakao.redirect-uri}") private String redirectUri; - @Value("${custom.site.frontUrl}") private String frontUrl; - - /** 같은 탭에서 로그인 시작 (카카오 인증 페이지로 이동) */ + /** 카카오 로그인 시작: 프론트는 이 URL로 리다이렉트하면 됨 (state 생성 포함) */ @GetMapping("/start") - public void start(@RequestParam(defaultValue = "/") String returnUrl, - HttpServletResponse res) throws IOException { - String state = UUID.randomUUID().toString(); - stateStore.save(state, returnUrl); // TTL 5분 - - String authorize = kakao.buildAuthorizeUrl(redirectUri, state); - res.sendRedirect(authorize); + public ResponseEntity start() { + String authorizeUrl = oauthLoginFacade.buildKakaoAuthorizeUrl(); + return ResponseEntity.ok(authorizeUrl); } - /** 카카오 콜백: code 교환 → 프로필 조회 → 회원 조회/가입 → OTC 발급 → 프론트로 리다이렉트 */ + /** 카카오 콜백: JWT 포함 LoginResponse JSON 반환 */ @GetMapping("/callback") - public void callback(@RequestParam String code, - @RequestParam String state, - HttpServletResponse res) throws IOException { - // state 검증 - String returnUrl = stateStore.consume(state).orElse(null); - if (returnUrl == null) { - String err = UriComponentsBuilder.fromHttpUrl(frontUrl + "/oauth/finish") - .queryParam("error", "invalid_state") - .build(true).toUriString(); - res.sendRedirect(err); - return; - } - - // code → access_token - final String accessToken; - try { - accessToken = kakao.exchangeCodeForAccessToken(code, redirectUri); - } catch (Exception e) { - String err = UriComponentsBuilder.fromHttpUrl(frontUrl + "/oauth/finish") - .queryParam("error", "oauth_exchange_failed") - .queryParam("return", returnUrl) - .build(true).toUriString(); - res.sendRedirect(err); - return; - } - - // access_token → 프로필 - final OauthProfile profile; - try { - profile = kakao.getUserProfile(accessToken); - } catch (Exception e) { - String err = UriComponentsBuilder.fromHttpUrl(frontUrl + "/oauth/finish") - .queryParam("error", "profile_fetch_failed") - .queryParam("return", returnUrl) - .build(true).toUriString(); - res.sendRedirect(err); - return; - } - - // 회원 조회/가입 (이미 회원이면 즉시 기존 ID 반환) - final Long memberId; - try { - String dummyPwEncoded = kakao.encodePassword(UUID.randomUUID().toString()); - memberId = memberService.findOrCreateMember(profile, LoginPlatform.KAKAO, dummyPwEncoded); - } catch (Exception e) { - String err = UriComponentsBuilder.fromHttpUrl(frontUrl + "/oauth/finish") - .queryParam("error", "signup_or_login_failed") - .queryParam("return", returnUrl) - .build(true).toUriString(); - res.sendRedirect(err); - return; - } - - // OTC 발급(60초 1회) → 프론트 finish로 - String otc = otcStore.issue(memberId); - String finish = UriComponentsBuilder.fromHttpUrl(frontUrl + "/oauth/finish") - .queryParam("otc", otc) - .queryParam("return", returnUrl) - .build(true).toUriString(); - - res.sendRedirect(finish); + public ResponseEntity callback(@RequestParam String code, + @RequestParam String state, + HttpServletRequest request, + HttpServletResponse response) { + LoginResponse body = oauthLoginFacade.handleKakaoCallback(code, state, request, response); + return ResponseEntity.ok(body); } -} +} \ No newline at end of file diff --git a/src/main/java/com/yeoro/twogether/domain/member/dto/response/MemberInfoResponse.java b/src/main/java/com/yeoro/twogether/domain/member/dto/response/MemberInfoResponse.java index 4a124ac..848d1e5 100644 --- a/src/main/java/com/yeoro/twogether/domain/member/dto/response/MemberInfoResponse.java +++ b/src/main/java/com/yeoro/twogether/domain/member/dto/response/MemberInfoResponse.java @@ -6,6 +6,9 @@ import java.time.LocalDate; +/** + * 회원 정보 응답 DTO + */ public record MemberInfoResponse( Long memberId, String email, @@ -13,11 +16,11 @@ public record MemberInfoResponse( String name, // 내 이름 String myNickname, // 파트너가 '나'에게 준 애칭 (me.nickname) - String profileImageUrl, // S3 키 or presigned URL - Gender gender, // male/female/unknown - String ageRange, // 예: "20" + String profileImageUrl, // S3 key 또는 presigned URL + Gender gender, + String ageRange, String phoneNumber, - LoginPlatform loginPlatform, // LOCAL/KAKAO 등 + LoginPlatform loginPlatform, Long partnerId, String partnerName, // 파트너 이름 @@ -25,7 +28,10 @@ public record MemberInfoResponse( LocalDate relationshipStartDate ) { - /** 엔티티 값을 그대로 사용 (profileImageUrl에는 S3 key가 들어감) */ + + /** + * 엔티티 값을 그대로 사용 (profileImageUrl에는 S3 key가 들어감) + */ public static MemberInfoResponse of(Member me) { Long partnerId = me.getPartnerId(); Member partner = me.getPartner(); @@ -37,8 +43,8 @@ public static MemberInfoResponse of(Member me) { me.getId(), me.getEmail(), me.getName(), - me.getNickname(), // myNickname - me.getProfileImageUrl(), // S3 key 그대로 + me.getNickname(), // myNickname + me.getProfileImageUrl(), me.getGender(), me.getAgeRange(), me.getPhoneNumber(), @@ -50,7 +56,9 @@ public static MemberInfoResponse of(Member me) { ); } - /** presigned URL을 주입해 응답 (profileImageUrl에 presigned URL이 들어감) */ + /** + * presigned URL을 주입해 응답 (profileImageUrl에 presigned URL이 들어감) + */ public static MemberInfoResponse ofResolved(Member me, String resolvedProfileUrl) { Long partnerId = me.getPartnerId(); Member partner = me.getPartner(); @@ -62,8 +70,8 @@ public static MemberInfoResponse ofResolved(Member me, String resolvedProfileUrl me.getId(), me.getEmail(), me.getName(), - me.getNickname(), // myNickname - resolvedProfileUrl, // presigned URL + me.getNickname(), // myNickname + resolvedProfileUrl, me.getGender(), me.getAgeRange(), me.getPhoneNumber(), diff --git a/src/main/java/com/yeoro/twogether/domain/member/service/Impl/KakaoOauthService.java b/src/main/java/com/yeoro/twogether/domain/member/service/Impl/KakaoOauthService.java index 06c52f1..5494913 100644 --- a/src/main/java/com/yeoro/twogether/domain/member/service/Impl/KakaoOauthService.java +++ b/src/main/java/com/yeoro/twogether/domain/member/service/Impl/KakaoOauthService.java @@ -8,6 +8,7 @@ import com.yeoro.twogether.domain.member.service.OauthService; import com.yeoro.twogether.global.exception.ErrorCode; import com.yeoro.twogether.global.exception.ServiceException; +import com.yeoro.twogether.global.store.StateStore; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; @@ -18,6 +19,8 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import java.util.UUID; + @Service("KAKAO") @RequiredArgsConstructor public class KakaoOauthService implements OauthService { @@ -25,14 +28,24 @@ public class KakaoOauthService implements OauthService { private final PasswordEncoder passwordEncoder; private final ObjectMapper objectMapper; private final RestTemplate restTemplate; + private final StateStore stateStore; // state 저장/검증 (CSRF) @Value("${kakao.client-id}") private String clientId; @Value("${kakao.client-secret:}") private String clientSecret; + @Value("${kakao.redirect-uri}") private String redirectUri; @Override public LoginPlatform platform() { return LoginPlatform.KAKAO; } - /** 인가 URL */ + /** 프론트에 전달할 인가 URL 생성 (state 발급/저장 포함) */ + @Override + public String buildAuthorizeUrl() { + String state = UUID.randomUUID().toString(); + stateStore.save(state, "1"); // TTL은 StateStore 구현에서 관리 + return buildAuthorizeUrl(redirectUri, state); + } + + /** 인가 URL (직접 state/redirectUri 주입 가능) */ @Override public String buildAuthorizeUrl(String redirectUri, String state) { return UriComponentsBuilder.fromHttpUrl("https://kauth.kakao.com/oauth/authorize") diff --git a/src/main/java/com/yeoro/twogether/domain/member/service/Impl/OauthLoginFacade.java b/src/main/java/com/yeoro/twogether/domain/member/service/Impl/OauthLoginFacade.java new file mode 100644 index 0000000..c8b1128 --- /dev/null +++ b/src/main/java/com/yeoro/twogether/domain/member/service/Impl/OauthLoginFacade.java @@ -0,0 +1,42 @@ +package com.yeoro.twogether.domain.member.service.Impl; + +import com.yeoro.twogether.domain.member.dto.response.LoginResponse; +import com.yeoro.twogether.domain.member.service.MemberService; +import com.yeoro.twogether.domain.member.service.OauthService; +import com.yeoro.twogether.global.exception.ErrorCode; +import com.yeoro.twogether.global.exception.ServiceException; +import com.yeoro.twogether.global.store.StateStore; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OauthLoginFacade { + + // 카카오 구현체 주입 (KakaoOauthService가 @Service("KAKAO") 임) + @Qualifier("KAKAO") + private final OauthService kakao; + + private final MemberService memberService; + private final StateStore stateStore; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + public LoginResponse handleKakaoCallback(String code, String state, + HttpServletRequest request, HttpServletResponse response) { + if (stateStore.consume(state).isEmpty()) { + throw new ServiceException(ErrorCode.TOKEN_INVALID); + } + String kakaoAccessToken = kakao.exchangeCodeForAccessToken(code, redirectUri); + return memberService.kakaoLogin(kakaoAccessToken, request, response); + } + + public String buildKakaoAuthorizeUrl() { + return kakao.buildAuthorizeUrl(); + } +} \ No newline at end of file diff --git a/src/main/java/com/yeoro/twogether/domain/member/service/OauthService.java b/src/main/java/com/yeoro/twogether/domain/member/service/OauthService.java index c645f4e..76bbf8f 100644 --- a/src/main/java/com/yeoro/twogether/domain/member/service/OauthService.java +++ b/src/main/java/com/yeoro/twogether/domain/member/service/OauthService.java @@ -4,8 +4,9 @@ import com.yeoro.twogether.domain.member.entity.LoginPlatform; public interface OauthService { - public LoginPlatform platform(); + LoginPlatform platform(); String buildAuthorizeUrl(String redirectUri, String state); + String buildAuthorizeUrl(); // 구현체 내부에서 redirectUri/state를 처리 String exchangeCodeForAccessToken(String code, String redirectUri); OauthProfile getUserProfile(String accessToken); String encodePassword(String rawPassword); diff --git a/src/main/java/com/yeoro/twogether/domain/place/controller/PlaceController.java b/src/main/java/com/yeoro/twogether/domain/place/controller/PlaceController.java index 7c320bf..7532359 100644 --- a/src/main/java/com/yeoro/twogether/domain/place/controller/PlaceController.java +++ b/src/main/java/com/yeoro/twogether/domain/place/controller/PlaceController.java @@ -1,5 +1,6 @@ package com.yeoro.twogether.domain.place.controller; +import com.yeoro.twogether.domain.place.dto.response.PlaceByDateResponse; import com.yeoro.twogether.domain.place.dto.response.PlaceCreateResponse; import com.yeoro.twogether.domain.place.dto.response.PlaceResponse; import com.yeoro.twogether.domain.place.service.PlaceService; @@ -10,6 +11,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; import java.util.List; /** @@ -64,4 +66,19 @@ public ResponseEntity deletePlace(@Login Long memberId, placeService.deletePlace(memberId, placeId); return ResponseEntity.ok("삭제되었습니다."); } + + /** 특정 날짜(KST)의 커플 하이라이트 조회 (나 + 연인) + * - date 형식: YYYY-MM-DD (예: 2025-10-10) + * - date 파라미터 없으면 오늘(KST) + */ + @GetMapping("/by-date") + public PlaceByDateResponse getPlacesByDate( + @Login Long memberId, + @RequestParam(required = false) String date + ) { + LocalDate d = (date == null || date.isBlank()) + ? null + : LocalDate.parse(date); // 형식 오류 시 글로벌 예외 핸들러에서 400 처리 권장 + return placeService.getPlacesByDate(memberId, d); + } } diff --git a/src/main/java/com/yeoro/twogether/domain/place/dto/response/PlaceByDateResponse.java b/src/main/java/com/yeoro/twogether/domain/place/dto/response/PlaceByDateResponse.java new file mode 100644 index 0000000..3223369 --- /dev/null +++ b/src/main/java/com/yeoro/twogether/domain/place/dto/response/PlaceByDateResponse.java @@ -0,0 +1,8 @@ +package com.yeoro.twogether.domain.place.dto.response; + +import java.util.List; + +public record PlaceByDateResponse( + List mine, + List partner +) {} \ No newline at end of file diff --git a/src/main/java/com/yeoro/twogether/domain/place/entity/Place.java b/src/main/java/com/yeoro/twogether/domain/place/entity/Place.java index d146425..21d6fdb 100644 --- a/src/main/java/com/yeoro/twogether/domain/place/entity/Place.java +++ b/src/main/java/com/yeoro/twogether/domain/place/entity/Place.java @@ -1,6 +1,7 @@ package com.yeoro.twogether.domain.place.entity; import com.yeoro.twogether.domain.member.entity.Member; +import com.yeoro.twogether.global.entity.BaseTime; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -12,7 +13,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Place { +public class Place extends BaseTime { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "place_id") diff --git a/src/main/java/com/yeoro/twogether/domain/place/repository/PlaceRepository.java b/src/main/java/com/yeoro/twogether/domain/place/repository/PlaceRepository.java index ecde286..6852166 100644 --- a/src/main/java/com/yeoro/twogether/domain/place/repository/PlaceRepository.java +++ b/src/main/java/com/yeoro/twogether/domain/place/repository/PlaceRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -25,6 +26,20 @@ public interface PlaceRepository extends JpaRepository { @EntityGraph(attributePaths = "tags") Optional findByMember_IdAndAddress(Long memberId, String address); + boolean existsByMemberAndAddressAndCreatedAtBetween( + Member member, + String address, + java.time.LocalDateTime startInclusive, + java.time.LocalDateTime endExclusive + ); + + @EntityGraph(attributePaths = "tags") + List findAllByMember_IdInAndCreatedAtBetween( + List memberIds, + LocalDateTime startInclusive, + LocalDateTime endExclusive + ); + // ===== 회원 삭제용 ===== /** 회원이 올린 Place 전체 조회 (S3 키 수집용) */ diff --git a/src/main/java/com/yeoro/twogether/domain/place/service/PlaceService.java b/src/main/java/com/yeoro/twogether/domain/place/service/PlaceService.java index f522bb4..4929ec2 100644 --- a/src/main/java/com/yeoro/twogether/domain/place/service/PlaceService.java +++ b/src/main/java/com/yeoro/twogether/domain/place/service/PlaceService.java @@ -1,9 +1,11 @@ package com.yeoro.twogether.domain.place.service; +import com.yeoro.twogether.domain.place.dto.response.PlaceByDateResponse; import com.yeoro.twogether.domain.place.dto.response.PlaceCreateResponse; import com.yeoro.twogether.domain.place.dto.response.PlaceResponse; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; import java.util.List; public interface PlaceService { @@ -13,4 +15,5 @@ public interface PlaceService { void deletePlace(Long memberId, Long placeId); PlaceResponse getOnePlace(Long memberId, Long placeId); PlaceResponse updatePlace(Long memberId, Long placeId, String metaJson, MultipartFile image); + PlaceByDateResponse getPlacesByDate(Long memberId, LocalDate dateKst); } \ No newline at end of file diff --git a/src/main/java/com/yeoro/twogether/domain/place/service/impl/PlaceServiceImpl.java b/src/main/java/com/yeoro/twogether/domain/place/service/impl/PlaceServiceImpl.java index 870fd2b..91cc97e 100644 --- a/src/main/java/com/yeoro/twogether/domain/place/service/impl/PlaceServiceImpl.java +++ b/src/main/java/com/yeoro/twogether/domain/place/service/impl/PlaceServiceImpl.java @@ -5,6 +5,7 @@ import com.yeoro.twogether.domain.member.service.MemberService; import com.yeoro.twogether.domain.place.dto.request.PlaceCreateRequest; import com.yeoro.twogether.domain.place.dto.request.PlaceUpdateRequest; +import com.yeoro.twogether.domain.place.dto.response.PlaceByDateResponse; import com.yeoro.twogether.domain.place.dto.response.PlaceCreateResponse; import com.yeoro.twogether.domain.place.dto.response.PlaceResponse; import com.yeoro.twogether.domain.place.entity.Place; @@ -25,6 +26,9 @@ import java.io.IOException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Optional; @@ -44,23 +48,39 @@ public class PlaceServiceImpl implements PlaceService { @Override @Transactional public PlaceCreateResponse createPlace(Long memberId, String metaJson, MultipartFile image) { + // 현재 로그인한 사용자 정보 조회 Member member = memberService.getCurrentMember(memberId); + + // metaJson → PlaceCreateRequest DTO로 변환 PlaceCreateRequest meta = parseCreateMeta(metaJson); - if (placeRepository.existsByMemberAndAddress(member, meta.address())) { + // 오늘(한국시간 KST) 기준으로 "동일 주소" 하이라이트 존재 여부 검사 + // 오늘 자정(00:00) ~ 내일 자정(00:00) 범위 안에서 같은 주소가 존재하면 예외 발생 + LocalDateTime[] todayRangeKST = todayRangeKST(); + boolean alreadyToday = placeRepository.existsByMemberAndAddressAndCreatedAtBetween( + member, + meta.address(), + todayRangeKST[0], + todayRangeKST[1] + ); + if (alreadyToday) { + // 동일한 장소에 대해 오늘 이미 업로드했다면 다시 올릴 수 없음 throw new ServiceException(ErrorCode.PLACE_ADDRESS_EXISTS); } + // 태그 검증 (공백 제거, 중복 제거, 최대 5개 제한) List tags = validateTags(meta.tags()); + // 이미지 유효성 검사 if (image == null || image.isEmpty()) { throw new ServiceException(ErrorCode.PLACE_CREATION_FAILED); } - // 업로드 키를 미리 받아두고, 롤백 시 정리 + // 이미지 업로드 및 롤백 대비 등록 HighlightS3Service.UploadResult up = uploadImage(memberId, image); - registerRollbackDelete(up.key()); + registerRollbackDelete(up.key()); // 트랜잭션 롤백 시 업로드된 이미지 삭제 + // Place 엔티티 생성 및 저장 Place place = Place.builder() .member(member) .imageUrl(up.key()) @@ -72,11 +92,13 @@ public PlaceCreateResponse createPlace(Long memberId, String metaJson, Multipart placeRepository.save(place); + // Presigned URL 발급 및 응답 반환 String presigned = highlightS3Service.presignedGetUrl(up.key()); return PlaceCreateResponse.fromWithResolvedUrl(place, presigned); } + /** * 주소로 Place 목록 조회 */ @@ -162,6 +184,56 @@ public void afterCommit() { } } + /** + * 날짜 기준 Place 조회 (본인 + 연인이 올린 하이라이트 조회) + */ + @Override + @Transactional(readOnly = true) + public PlaceByDateResponse getPlacesByDate(Long memberId, LocalDate dateKst) { + // 본인/파트너 식별 + Member me = memberService.getCurrentMember(memberId); + + Long partnerId = memberService.getPartnerId(memberId); + + // 날짜(KST) 범위 계산: 해당 날짜 00:00 ~ 다음날 00:00 + ZoneId KST = ZoneId.of("Asia/Seoul"); + LocalDate d = (dateKst != null) ? dateKst : LocalDate.now(KST); + LocalDateTime start = d.atStartOfDay(); + LocalDateTime end = d.plusDays(1).atStartOfDay(); + + // 쿼리용 대상 memberIds + List memberIds = (partnerId != null) + ? List.of(memberId, partnerId) + : List.of(memberId); + + // 조회 + List all = placeRepository.findAllByMember_IdInAndCreatedAtBetween(memberIds, start, end); + + // presigned URL 변환 + var mine = all.stream() + .filter(p -> p.getMember().getId().equals(memberId)) + .map(p -> { + String key = p.getImageUrl(); + String url = (key == null || key.isBlank()) ? null : highlightS3Service.presignedGetUrl(key); + return PlaceResponse.fromWithResolvedUrl(p, url); + }) + .toList(); + + var partner = (partnerId == null) ? List.of() + : all.stream() + .filter(p -> p.getMember().getId().equals(partnerId)) + .map(p -> { + String key = p.getImageUrl(); + String url = (key == null || key.isBlank()) ? null : highlightS3Service.presignedGetUrl(key); + return PlaceResponse.fromWithResolvedUrl(p, url); + }) + .toList(); + + return new PlaceByDateResponse(mine, partner); + } + + // --------------------------------------------------------------------------------------------------------------- + /** @@ -187,15 +259,18 @@ private PlaceUpdateRequest parseUpdateMeta(String metaJson) { } /** - * 태그 검증 (최대 2개) + * 태그 검증 (최대 5개) */ private List validateTags(List tags) { List safe = Optional.ofNullable(tags).orElse(List.of()).stream() .filter(t -> t != null && !t.isBlank()) .map(String::trim) .distinct() + .limit(5) // 과다 요청 방지 .toList(); - if (safe.size() > 2) throw new ServiceException(ErrorCode.PLACE_TAG_LIMIT_EXCEEDED); + if (safe.size() > 5) { + throw new ServiceException(ErrorCode.PLACE_TAG_LIMIT_EXCEEDED); + } return safe; } @@ -301,4 +376,14 @@ public void afterCompletion(int status) { } }); } + + + + private LocalDateTime[] todayRangeKST() { + ZoneId KST = ZoneId.of("Asia/Seoul"); // 한국 시간 기준 + LocalDate today = LocalDate.now(KST); + LocalDateTime start = today.atStartOfDay(); + LocalDateTime end = today.plusDays(1).atStartOfDay(); + return new LocalDateTime[]{ start, end }; + } } \ No newline at end of file