Skip to content

Commit 710dda2

Browse files
authored
feat: 프로필 수정 API 구현
1 parent cf194df commit 710dda2

File tree

5 files changed

+375
-2
lines changed

5 files changed

+375
-2
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package targeter.aim.domain.file.repository;
2+
3+
import org.springframework.data.jpa.repository.JpaRepository;
4+
import targeter.aim.domain.file.entity.ProfileImage;
5+
6+
public interface ProfileImageRepository extends JpaRepository<ProfileImage, String> {
7+
}

src/main/java/targeter/aim/domain/label/repository/FieldRepository.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import org.springframework.data.jpa.repository.JpaRepository;
44
import targeter.aim.domain.label.entity.Field;
55

6+
import java.util.Collection;
67
import java.util.List;
78
import java.util.Optional;
89

910
public interface FieldRepository extends JpaRepository<Field, Long> {
1011

1112
Optional<Field> findByName(String name);
1213

13-
List<Field> findAllByNameIn(List<String> names);
14-
}
14+
List<Field> findAllByNameIn(Collection<String> names);
15+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package targeter.aim.domain.user.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.tags.Tag;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.http.MediaType;
7+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
8+
import org.springframework.web.bind.annotation.*;
9+
import targeter.aim.domain.user.dto.ProfileDto;
10+
import targeter.aim.domain.user.service.ProfileService;
11+
import targeter.aim.system.exception.model.ErrorCode;
12+
import targeter.aim.system.exception.model.RestException;
13+
import targeter.aim.system.security.model.UserDetails;
14+
15+
@RestController
16+
@RequiredArgsConstructor
17+
@RequestMapping("/api/users")
18+
@Tag(name = "Profile", description = "프로필 조회/수정 API")
19+
public class ProfileController {
20+
21+
private final ProfileService profileService;
22+
23+
@GetMapping("/me/profile")
24+
@Operation(summary = "내 프로필 조회", description = "내 프로필(유저 정보 + 통계 + 관심사/분야)을 조회합니다.")
25+
public ProfileDto.ProfileResponse getMyProfile(
26+
@AuthenticationPrincipal UserDetails userDetails
27+
) {
28+
if (userDetails == null) {
29+
throw new RestException(ErrorCode.AUTH_LOGIN_REQUIRED);
30+
}
31+
32+
return profileService.getProfile(userDetails.getUser().getId(), userDetails);
33+
}
34+
35+
@GetMapping("/{userId}/profile")
36+
@Operation(summary = "유저 프로필 조회", description = "특정 유저의 프로필(유저 정보 + 통계 + 관심사/분야)을 조회합니다.")
37+
public ProfileDto.ProfileResponse getUserProfile(
38+
@PathVariable Long userId,
39+
@AuthenticationPrincipal UserDetails userDetails
40+
) {
41+
return profileService.getProfile(userId, userDetails);
42+
}
43+
44+
@PatchMapping(value = "/me/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
45+
@Operation(summary = "내 프로필 수정", description = "내 프로필(닉네임/관심사/관심분야/프로필사진)을 수정합니다.")
46+
public ProfileDto.ProfileResponse updateMyProfile(
47+
@ModelAttribute ProfileDto.ProfileUpdateRequest request,
48+
@AuthenticationPrincipal UserDetails userDetails
49+
) {
50+
if (userDetails == null) {
51+
throw new RestException(ErrorCode.AUTH_LOGIN_REQUIRED);
52+
}
53+
54+
return profileService.updateMyProfile(request, userDetails);
55+
}
56+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package targeter.aim.domain.user.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.*;
5+
import org.springframework.web.multipart.MultipartFile;
6+
import targeter.aim.domain.file.dto.FileDto;
7+
8+
import java.util.List;
9+
10+
public class ProfileDto {
11+
12+
@Data
13+
@Builder
14+
@NoArgsConstructor
15+
@AllArgsConstructor
16+
@Schema(description = "프로필 조회 응답")
17+
public static class ProfileResponse {
18+
19+
@Schema(description = "유저 ID")
20+
private Long userId;
21+
22+
@Schema(description = "아이디(loginId)")
23+
private String loginId;
24+
25+
@Schema(description = "닉네임")
26+
private String nickname;
27+
28+
@Schema(description = "뱃지/티어 정보")
29+
private TierResponse tier;
30+
31+
@Schema(description = "레벨")
32+
private Integer level;
33+
34+
@Schema(description = "프로필 이미지")
35+
private FileDto.FileResponse profileImage;
36+
37+
@Schema(description = "관심사(태그)")
38+
private List<String> interests;
39+
40+
@Schema(description = "관심 분야")
41+
private List<String> fields;
42+
43+
@Schema(description = "전체 챌린지 기록")
44+
private ChallengeRecord overall;
45+
46+
@Schema(description = "SOLO 기록")
47+
private ChallengeRecord solo;
48+
49+
@Schema(description = "VS 기록")
50+
private ChallengeRecord vs;
51+
52+
@Schema(description = "본인 프로필 여부")
53+
private Boolean isMine;
54+
}
55+
56+
@Data
57+
@Builder
58+
@NoArgsConstructor
59+
@AllArgsConstructor
60+
public static class TierResponse {
61+
private String name;
62+
}
63+
64+
@Data
65+
@Builder
66+
@NoArgsConstructor
67+
@AllArgsConstructor
68+
@Schema(description = "챌린지 기록(시도/성공/실패/성공률)")
69+
public static class ChallengeRecord {
70+
private long attemptCount;
71+
private long successCount;
72+
private long failCount;
73+
74+
@Schema(description = "성공률(0~100)")
75+
private int successRate;
76+
}
77+
78+
@Data
79+
@Builder
80+
@NoArgsConstructor
81+
@AllArgsConstructor
82+
@Schema(description = "프로필 수정 요청")
83+
public static class ProfileUpdateRequest {
84+
85+
@Schema(description = "닉네임(10자 이하)")
86+
private String nickname;
87+
88+
@Schema(description = "관심사(태그) - 문자열 목록")
89+
private List<String> interests;
90+
91+
@Schema(description = "관심 분야 - 문자열 목록")
92+
private List<String> fields;
93+
94+
@Schema(description = "프로필 이미지(<=10MB, jpg/png/pdf)")
95+
private MultipartFile profileImage;
96+
}
97+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package targeter.aim.domain.user.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.stereotype.Service;
5+
import org.springframework.transaction.annotation.Transactional;
6+
import org.springframework.web.multipart.MultipartFile;
7+
import targeter.aim.domain.file.dto.FileDto;
8+
import targeter.aim.domain.file.entity.ProfileImage;
9+
import targeter.aim.domain.file.handler.FileHandler;
10+
import targeter.aim.domain.label.entity.Field;
11+
import targeter.aim.domain.label.entity.Tag;
12+
import targeter.aim.domain.label.repository.FieldRepository;
13+
import targeter.aim.domain.label.repository.TagRepository;
14+
import targeter.aim.domain.user.dto.ProfileDto;
15+
import targeter.aim.domain.user.entity.User;
16+
import targeter.aim.domain.user.repository.ProfileQueryRepository;
17+
import targeter.aim.domain.user.repository.UserRepository;
18+
import targeter.aim.system.exception.model.ErrorCode;
19+
import targeter.aim.system.exception.model.RestException;
20+
import targeter.aim.system.security.model.UserDetails;
21+
22+
import java.util.*;
23+
import java.util.stream.Collectors;
24+
25+
@Service
26+
@RequiredArgsConstructor
27+
public class ProfileService {
28+
29+
private final UserRepository userRepository;
30+
private final ProfileQueryRepository profileQueryRepository;
31+
32+
private final TagRepository tagRepository;
33+
private final FieldRepository fieldRepository;
34+
35+
private final FileHandler fileHandler;
36+
37+
@Transactional(readOnly = true)
38+
public ProfileDto.ProfileResponse getProfile(Long targetUserId, UserDetails viewer) {
39+
40+
User target = userRepository.findById(targetUserId)
41+
.orElseThrow(() -> new RestException(ErrorCode.USER_NOT_FOUND));
42+
43+
// 관심사 / 관심 분야
44+
List<String> interests = profileQueryRepository.findUserTagNames(targetUserId);
45+
List<String> fields = profileQueryRepository.findUserFieldNames(targetUserId);
46+
47+
// 챌린지 기록
48+
ProfileQueryRepository.Record overall = profileQueryRepository.calcOverallRecord(targetUserId);
49+
ProfileQueryRepository.Record solo = profileQueryRepository.calcSoloRecord(targetUserId);
50+
ProfileQueryRepository.Record vs = profileQueryRepository.calcVsRecord(targetUserId);
51+
52+
boolean isMine = viewer != null && viewer.getUser().getId().equals(targetUserId);
53+
54+
return ProfileDto.ProfileResponse.builder()
55+
.userId(target.getId())
56+
.loginId(target.getLoginId())
57+
.nickname(target.getNickname())
58+
.tier(ProfileDto.TierResponse.builder()
59+
.name(target.getTier().getName())
60+
.build())
61+
.level(target.getLevel())
62+
.profileImage(
63+
target.getProfileImage() == null
64+
? null
65+
: FileDto.FileResponse.from(target.getProfileImage())
66+
)
67+
.interests(interests)
68+
.fields(fields)
69+
.overall(toRecordDto(overall))
70+
.solo(toRecordDto(solo))
71+
.vs(toRecordDto(vs))
72+
.isMine(isMine)
73+
.build();
74+
}
75+
76+
@Transactional
77+
public ProfileDto.ProfileResponse updateMyProfile(ProfileDto.ProfileUpdateRequest request, UserDetails viewer) {
78+
if (viewer == null) {
79+
throw new RestException(ErrorCode.AUTH_LOGIN_REQUIRED);
80+
}
81+
82+
User me = userRepository.findById(viewer.getUser().getId())
83+
.orElseThrow(() -> new RestException(ErrorCode.USER_NOT_FOUND));
84+
85+
if (request.getNickname() != null) {
86+
String nickname = request.getNickname().trim();
87+
if (nickname.isBlank() || nickname.length() > 10) {
88+
throw new RestException(ErrorCode.GLOBAL_BAD_REQUEST, "닉네임은 1~10글자여야 합니다.");
89+
}
90+
if (!nickname.equals(me.getNickname()) && userRepository.existsByNickname(nickname)) {
91+
throw new RestException(ErrorCode.GLOBAL_BAD_REQUEST, "이미 사용 중인 닉네임입니다.");
92+
}
93+
me.setNickname(nickname);
94+
}
95+
96+
if (request.getInterests() != null) {
97+
Set<Tag> nextTags = upsertTags(request.getInterests());
98+
me.getTags().clear();
99+
me.getTags().addAll(nextTags);
100+
}
101+
102+
if (request.getFields() != null) {
103+
Set<Field> nextFields = resolveFields(request.getFields());
104+
me.getFields().clear();
105+
me.getFields().addAll(nextFields);
106+
}
107+
108+
if (request.getProfileImage() != null && !request.getProfileImage().isEmpty()) {
109+
replaceProfileImage(me, request.getProfileImage());
110+
}
111+
112+
return getProfile(me.getId(), viewer);
113+
}
114+
115+
private void replaceProfileImage(User user, MultipartFile file) {
116+
if (file.getSize() > 10L * 1024 * 1024) {
117+
throw new RestException(ErrorCode.GLOBAL_BAD_REQUEST, "프로필 이미지는 10MB 이하만 업로드 가능합니다.");
118+
}
119+
120+
ProfileImage prev = user.getProfileImage();
121+
if (prev != null) {
122+
fileHandler.deleteIfExists(prev);
123+
user.setProfileImage(null);
124+
}
125+
126+
ProfileImage next = ProfileImage.from(file);
127+
next.setUser(user);
128+
user.setProfileImage(next);
129+
fileHandler.saveFile(file, next);
130+
}
131+
132+
private Set<Tag> upsertTags(List<String> rawNames) {
133+
List<String> names = normalizeNames(rawNames, 30);
134+
135+
if (names.isEmpty()) {
136+
return new HashSet<>();
137+
}
138+
139+
List<Tag> existing = tagRepository.findAllByNameIn(names);
140+
Map<String, Tag> byName = existing.stream()
141+
.collect(Collectors.toMap(t -> t.getName().toLowerCase(), t -> t));
142+
143+
List<Tag> toSave = new ArrayList<>();
144+
for (String n : names) {
145+
String key = n.toLowerCase();
146+
if (!byName.containsKey(key)) {
147+
Tag newTag = Tag.builder().name(n).build();
148+
toSave.add(newTag);
149+
}
150+
}
151+
152+
if (!toSave.isEmpty()) {
153+
List<Tag> saved = tagRepository.saveAll(toSave);
154+
saved.forEach(t -> byName.put(t.getName().toLowerCase(), t));
155+
}
156+
157+
return names.stream()
158+
.map(n -> byName.get(n.toLowerCase()))
159+
.filter(Objects::nonNull)
160+
.collect(Collectors.toCollection(LinkedHashSet::new));
161+
}
162+
163+
private Set<Field> resolveFields(List<String> rawNames) {
164+
List<String> names = normalizeNames(rawNames, 30);
165+
166+
if (names.isEmpty()) {
167+
return new HashSet<>();
168+
}
169+
170+
List<Field> foundFields = fieldRepository.findAllByNameIn(names);
171+
172+
Set<String> found = foundFields.stream()
173+
.map(f -> f.getName().toLowerCase())
174+
.collect(Collectors.toSet());
175+
176+
for (String n : names) {
177+
if (!found.contains(n.toLowerCase())) {
178+
throw new RestException(ErrorCode.GLOBAL_BAD_REQUEST, "존재하지 않는 관심 분야입니다: " + n);
179+
}
180+
}
181+
182+
return new LinkedHashSet<>(foundFields);
183+
}
184+
185+
private List<String> normalizeNames(List<String> raw, int maxLen) {
186+
if (raw == null) return List.of();
187+
188+
return raw.stream()
189+
.filter(Objects::nonNull)
190+
.map(String::trim)
191+
.filter(s -> !s.isBlank())
192+
.map(s -> s.length() > maxLen ? s.substring(0, maxLen) : s)
193+
.distinct()
194+
.toList();
195+
}
196+
197+
private ProfileDto.ChallengeRecord toRecordDto(ProfileQueryRepository.Record record) {
198+
long attempt = record.attempt();
199+
long success = record.success();
200+
long fail = attempt - success;
201+
int successRate = attempt == 0
202+
? 0
203+
: (int) Math.round((success * 100.0) / attempt);
204+
205+
return ProfileDto.ChallengeRecord.builder()
206+
.attemptCount(attempt)
207+
.successCount(success)
208+
.failCount(fail)
209+
.successRate(successRate)
210+
.build();
211+
}
212+
}

0 commit comments

Comments
 (0)