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