Skip to content

Commit 8d2ba71

Browse files
authored
#143 [Feat] 마이페이지 리캡 공유 API 추가 (#167)
## #️⃣ 연관된 이슈 - #143 ## 📝 작업 내용 ### ✨ Feat | 내용 | 파일 | |------|------| | 현재 로그인 사용자의 recap 공유 키 발급 API 추가 (`GET /api/v1/share/recap`) | `ShareApiController.java`, `ShareService.java`, `RecapShareKeyResponse.java` | | 공유 키 기반 공개 recap 조회 API 추가 (`GET /api/v1/share/recap/{shareKey}`) | `ShareApiController.java`, `ShareService.java` | | 사용자별 재사용 가능한 `recapShareKey` 저장 필드 및 조회 로직 추가 | `UserProfile.java`, `UserProfileRepository.java` | ### ♻️ Refactor | 내용 | 파일 | |------|------| | 기존 recap 조립 로직을 사용자 ID 기반 공용 메서드로 분리 | `MypageService.java` | | | | ### 🐛 Fix | 내용 | 파일 | |------|------| | 공개 공유 조회 경로가 JWT 필터에서 401 되지 않도록 whitelist 반영 | `JwtFilter.java`, `SecurityConfig.java` | | recap 부재 상황을 공유 API에서 명확히 처리하도록 예외 코드 추가 | `ErrorCode.java` | ## 📌 공유 사항 > 1. `shareUrl`은 제외하고 `shareKey`만 반환하도록 구현했습니다. > 2. 공유 키는 사용자별 1개를 재사용하며, 최초 호출 시에만 생성됩니다. > 3. 공개 조회 응답은 기존 `/api/v1/me/recap`과 동일한 `RecapResponse`를 사용합니다. ## ✅ 체크리스트 - [x] Reviewer에 팀원들을 선택했나요? - [x] Assignees에 본인을 선택했나요? - [x] 컨벤션에 맞는 Type을 선택했나요? - [x] Development에 이슈를 연동했나요? - [x] Merge 하려는 브랜치가 올바르게 설정되어 있나요? - [x] 컨벤션을 지키고 있나요? - [x] 로컬에서 실행했을 때 에러가 발생하지 않나요? - [x] 팀원들에게 PR 링크 공유를 했나요? ## 📸 스크린샷 - 없음 - 테스트 실행: `./gradlew test --tests com.swyp.picke.domain.user.service.MypageServiceTest --tests com.swyp.picke.domain.share.service.ShareServiceTest --tests com.swyp.picke.domain.share.controller.ShareApiIntegrationTest` ## 💬 리뷰 요구사항 > 1. 공유 키를 사용자별 고정 재사용 방식으로 둔 점이 현재 요구사항에 맞는지 확인 부탁드립니다. > 2. 공개 조회 경로를 `/api/v1/share/recap/{shareKey}`로 둔 네이밍이 적절한지 봐주세요.
1 parent 395fbc0 commit 8d2ba71

File tree

12 files changed

+381
-7
lines changed

12 files changed

+381
-7
lines changed

docs/api-specs/user-api.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,62 @@
236236
}
237237
```
238238

239+
### 3.5 `GET /api/v1/share/recap`
240+
241+
현재 로그인한 사용자의 리캡 공유 키 발급.
242+
이미 발급된 키가 있으면 동일 키를 재사용합니다.
243+
244+
응답:
245+
246+
```json
247+
{
248+
"statusCode": 200,
249+
"data": {
250+
"shareKey": "550e8400-e29b-41d4-a716-446655440000"
251+
},
252+
"error": null
253+
}
254+
```
255+
256+
### 3.6 `GET /api/v1/share/recap/{shareKey}`
257+
258+
공유 키로 다른 사용자의 리캡 조회.
259+
인증 없이 호출 가능합니다.
260+
261+
응답:
262+
263+
```json
264+
{
265+
"statusCode": 200,
266+
"data": {
267+
"my_card": {
268+
"philosopher_type": "SOCRATES"
269+
},
270+
"best_match_card": {
271+
"philosopher_type": "PLATO"
272+
},
273+
"worst_match_card": {
274+
"philosopher_type": "MARX"
275+
},
276+
"scores": {
277+
"principle": 88,
278+
"reason": 74,
279+
"individual": 62,
280+
"change": 45,
281+
"inner": 30,
282+
"ideal": 15
283+
},
284+
"preference_report": {
285+
"total_participation": 47,
286+
"opinion_changes": 12,
287+
"battle_win_rate": 68,
288+
"favorite_topics": []
289+
}
290+
},
291+
"error": null
292+
}
293+
```
294+
239295
### 3.5 `GET /api/v1/me/notification-settings`
240296

241297
마이페이지 알림 설정 조회.

src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public class JwtFilter extends OncePerRequestFilter {
4545
"/result", // 공유 링크 리다이렉트
4646
"/report", // 철학자 리포트 딥링크
4747
"/battle", // 배틀 딥링크
48+
"/api/v1/share/recap/", // 공개 리캡 공유 조회
4849
"/.well-known", // Android App Links 인증
4950
"/api/v1/resources" // 이미지, 오디오 파일 (Presigned URL)
5051
);
@@ -129,4 +130,4 @@ private boolean isWhitelisted(String uri) {
129130
// 1. URI가 화이트리스트의 어떤 값으로든 시작하면 true
130131
return WHITELIST.stream().anyMatch(uri::startsWith);
131132
}
132-
}
133+
}

src/main/java/com/swyp/picke/domain/share/controller/ShareApiController.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package com.swyp.picke.domain.share.controller;
22

3+
import com.swyp.picke.domain.share.dto.response.RecapShareKeyResponse;
4+
import com.swyp.picke.domain.share.service.ShareService;
5+
import com.swyp.picke.domain.user.dto.response.RecapResponse;
36
import com.swyp.picke.global.common.response.ApiResponse;
47
import lombok.RequiredArgsConstructor;
58
import org.springframework.beans.factory.annotation.Value;
69
import org.springframework.web.bind.annotation.GetMapping;
10+
import org.springframework.web.bind.annotation.PathVariable;
711
import org.springframework.web.bind.annotation.RequestMapping;
812
import org.springframework.web.bind.annotation.RequestParam;
913
import org.springframework.web.bind.annotation.RestController;
@@ -18,6 +22,8 @@ public class ShareApiController {
1822
@Value("${picke.baseUrl}")
1923
private String baseUrl;
2024

25+
private final ShareService shareService;
26+
2127
@GetMapping("/report")
2228
public ApiResponse<Map<String, String>> getReportShareUrl(@RequestParam Long reportId) {
2329
String shareUrl = baseUrl + "/report/" + reportId;
@@ -29,4 +35,14 @@ public ApiResponse<Map<String, String>> getBattleShareUrl(@RequestParam Long bat
2935
String shareUrl = baseUrl + "/battle/" + battleId;
3036
return ApiResponse.onSuccess(Map.of("shareUrl", shareUrl));
3137
}
38+
39+
@GetMapping("/recap")
40+
public ApiResponse<RecapShareKeyResponse> getRecapShareKey() {
41+
return ApiResponse.onSuccess(shareService.getRecapShareKey());
42+
}
43+
44+
@GetMapping("/recap/{shareKey}")
45+
public ApiResponse<RecapResponse> getSharedRecap(@PathVariable String shareKey) {
46+
return ApiResponse.onSuccess(shareService.getSharedRecap(shareKey));
47+
}
3248
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.swyp.picke.domain.share.dto.response;
2+
3+
public record RecapShareKeyResponse(
4+
String shareKey
5+
) {
6+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.swyp.picke.domain.share.service;
2+
3+
import com.swyp.picke.domain.share.dto.response.RecapShareKeyResponse;
4+
import com.swyp.picke.domain.user.dto.response.RecapResponse;
5+
import com.swyp.picke.domain.user.entity.User;
6+
import com.swyp.picke.domain.user.entity.UserProfile;
7+
import com.swyp.picke.domain.user.repository.UserProfileRepository;
8+
import com.swyp.picke.domain.user.service.MypageService;
9+
import com.swyp.picke.domain.user.service.UserService;
10+
import com.swyp.picke.global.common.exception.CustomException;
11+
import com.swyp.picke.global.common.exception.ErrorCode;
12+
import java.util.UUID;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
@Transactional(readOnly = true)
20+
public class ShareService {
21+
22+
private final UserService userService;
23+
private final UserProfileRepository userProfileRepository;
24+
private final MypageService mypageService;
25+
26+
@Transactional
27+
public RecapShareKeyResponse getRecapShareKey() {
28+
User user = userService.findCurrentUser();
29+
UserProfile profile = userService.findUserProfile(user.getId());
30+
31+
ensureRecapExists(user.getId());
32+
33+
if (profile.getRecapShareKey() == null || profile.getRecapShareKey().isBlank()) {
34+
profile.updateRecapShareKey(UUID.randomUUID().toString());
35+
}
36+
37+
return new RecapShareKeyResponse(profile.getRecapShareKey());
38+
}
39+
40+
public RecapResponse getSharedRecap(String shareKey) {
41+
UserProfile profile = userProfileRepository.findByRecapShareKey(shareKey)
42+
.orElseThrow(() -> new CustomException(ErrorCode.RECAP_NOT_FOUND));
43+
44+
RecapResponse recap = mypageService.findRecapByUserId(profile.getUser().getId());
45+
if (recap == null) {
46+
throw new CustomException(ErrorCode.RECAP_NOT_FOUND);
47+
}
48+
49+
return recap;
50+
}
51+
52+
private void ensureRecapExists(Long userId) {
53+
if (mypageService.findRecapByUserId(userId) == null) {
54+
throw new CustomException(ErrorCode.RECAP_NOT_FOUND);
55+
}
56+
}
57+
}

src/main/java/com/swyp/picke/domain/user/entity/UserProfile.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@ public class UserProfile extends BaseEntity {
3737
@Enumerated(EnumType.STRING)
3838
private PhilosopherType philosopherType;
3939

40+
@Column(name = "recap_share_key", unique = true, length = 36)
41+
private String recapShareKey;
42+
4043
private BigDecimal mannerTemperature;
4144

4245
@Builder
43-
private UserProfile(User user, String nickname, CharacterType characterType, BigDecimal mannerTemperature) {
46+
private UserProfile(User user, String nickname, CharacterType characterType, String recapShareKey, BigDecimal mannerTemperature) {
4447
this.user = user;
4548
this.nickname = nickname;
4649
this.characterType = Objects.requireNonNull(characterType, "characterType must not be null");
50+
this.recapShareKey = recapShareKey;
4751
this.mannerTemperature = mannerTemperature;
4852
}
4953

@@ -59,4 +63,8 @@ public void update(String nickname, CharacterType characterType) {
5963
public void updatePhilosopherType(PhilosopherType philosopherType) {
6064
this.philosopherType = philosopherType;
6165
}
66+
67+
public void updateRecapShareKey(String recapShareKey) {
68+
this.recapShareKey = recapShareKey;
69+
}
6270
}

src/main/java/com/swyp/picke/domain/user/repository/UserProfileRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@
77
public interface UserProfileRepository extends JpaRepository<UserProfile, Long> {
88

99
Optional<UserProfile> findByUserId(Long userId);
10+
11+
Optional<UserProfile> findByRecapShareKey(String recapShareKey);
1012
}

src/main/java/com/swyp/picke/domain/user/service/MypageService.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,11 @@ public MypageResponse getMypage() {
8888

8989
public RecapResponse getRecap() {
9090
User user = userService.findCurrentUser();
91-
UserProfile profile = userService.findUserProfile(user.getId());
91+
return findRecapByUserId(user.getId());
92+
}
9293

94+
public RecapResponse findRecapByUserId(Long userId) {
95+
UserProfile profile = userService.findUserProfile(userId);
9396
PhilosopherType philosopherType = profile.getPhilosopherType();
9497
if (philosopherType == null) {
9598
return null;
@@ -108,7 +111,7 @@ public RecapResponse getRecap() {
108111
philosopherType.getIdeal()
109112
);
110113

111-
RecapResponse.PreferenceReport preferenceReport = buildPreferenceReport(user.getId());
114+
RecapResponse.PreferenceReport preferenceReport = buildPreferenceReport(userId);
112115

113116
return new RecapResponse(myCard, bestMatchCard, worstMatchCard, scores, preferenceReport);
114117
}
@@ -362,4 +365,3 @@ private String resolveCharacterImageUrl(String characterType) {
362365
}
363366

364367

365-

src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ public enum ErrorCode {
109109
REWARD_INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "REWARD_401", "AdMob 서명 검증에 실패했습니다."),
110110

111111
// MyPage
112-
PHILOSOPHER_CALC_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "USER_500_PHIL", "철학자 유형을 계산할 수 없습니다.");
112+
PHILOSOPHER_CALC_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "USER_500_PHIL", "철학자 유형을 계산할 수 없습니다."),
113+
RECAP_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_RECAP", "존재하지 않는 리캡입니다.");
113114

114115
private final HttpStatus httpStatus;
115116
private final String code;

src/main/java/com/swyp/picke/global/config/SecurityConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
5656
.requestMatchers(HttpMethod.GET, "/api/v1/admin/picke/**").permitAll()
5757

5858
// 3. 단순 조회성 및 진행상태 업데이트 REST API
59+
.requestMatchers(HttpMethod.GET, "/api/v1/share/recap/*").permitAll()
5960
.requestMatchers(HttpMethod.GET, "/api/v1/tags", "/api/v1/battles/**").authenticated()
6061
.requestMatchers(HttpMethod.POST, "/api/v1/battles/**").authenticated()
6162

@@ -89,4 +90,4 @@ public CorsConfigurationSource corsConfigurationSource() {
8990
source.registerCorsConfiguration("/**", configuration);
9091
return source;
9192
}
92-
}
93+
}

0 commit comments

Comments
 (0)