Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String> 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<LoginResponse> callback(@RequestParam String code,
@RequestParam String state,
HttpServletRequest request,
HttpServletResponse response) {
LoginResponse body = oauthLoginFacade.handleKakaoCallback(code, state, request, response);
return ResponseEntity.ok(body);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,32 @@

import java.time.LocalDate;

/**
* 회원 정보 응답 DTO
*/
public record MemberInfoResponse(
Long memberId,
String email,

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, // 파트너 이름
String partnerNickname, // 내가 파트너에게 준 애칭 (partner.nickname)

LocalDate relationshipStartDate
) {
/** 엔티티 값을 그대로 사용 (profileImageUrl에는 S3 key가 들어감) */

/**
* 엔티티 값을 그대로 사용 (profileImageUrl에는 S3 key가 들어감)
*/
public static MemberInfoResponse of(Member me) {
Long partnerId = me.getPartnerId();
Member partner = me.getPartner();
Expand All @@ -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(),
Expand All @@ -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();
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand All @@ -18,21 +19,33 @@
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.UUID;

@Service("KAKAO")
@RequiredArgsConstructor
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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +11,7 @@
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDate;
import java.util.List;

/**
Expand Down Expand Up @@ -64,4 +66,19 @@ public ResponseEntity<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.yeoro.twogether.domain.place.dto.response;

import java.util.List;

public record PlaceByDateResponse(
List<PlaceResponse> mine,
List<PlaceResponse> partner
) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,6 +26,20 @@ public interface PlaceRepository extends JpaRepository<Place, Long> {
@EntityGraph(attributePaths = "tags")
Optional<Place> findByMember_IdAndAddress(Long memberId, String address);

boolean existsByMemberAndAddressAndCreatedAtBetween(
Member member,
String address,
java.time.LocalDateTime startInclusive,
java.time.LocalDateTime endExclusive
);

@EntityGraph(attributePaths = "tags")
List<Place> findAllByMember_IdInAndCreatedAtBetween(
List<Long> memberIds,
LocalDateTime startInclusive,
LocalDateTime endExclusive
);

// ===== 회원 삭제용 =====

/** 회원이 올린 Place 전체 조회 (S3 키 수집용) */
Expand Down
Loading