Skip to content

Commit e50168e

Browse files
authored
AIM-71-프로필-조회 (#70)
* feat: QnA/후기 댓글 작성 및 조회 기능 구현 * feat: 프로필 조회 API 및 챌린지 통계 구현
1 parent 9dc920f commit e50168e

File tree

4 files changed

+300
-0
lines changed

4 files changed

+300
-0
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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.security.core.annotation.AuthenticationPrincipal;
7+
import org.springframework.web.bind.annotation.*;
8+
import targeter.aim.domain.user.dto.ProfileDto;
9+
import targeter.aim.domain.user.service.ProfileService;
10+
import targeter.aim.system.exception.model.ErrorCode;
11+
import targeter.aim.system.security.model.UserDetails;
12+
import targeter.aim.system.exception.model.RestException;
13+
14+
@RestController
15+
@RequiredArgsConstructor
16+
@RequestMapping("/api/users")
17+
@Tag(name = "Profile", description = "프로필 조회 API")
18+
public class ProfileController {
19+
20+
private final ProfileService profileService;
21+
22+
@GetMapping("/me/profile")
23+
@Operation(summary = "내 프로필 조회", description = "내 프로필(유저 정보 + 통계 + 관심사/분야)을 조회합니다.")
24+
public ProfileDto.ProfileResponse getMyProfile(
25+
@AuthenticationPrincipal UserDetails userDetails
26+
) {
27+
if (userDetails == null) {
28+
throw new RestException(ErrorCode.AUTH_LOGIN_REQUIRED);
29+
}
30+
31+
return profileService.getProfile(userDetails.getUser().getId(), userDetails);
32+
}
33+
34+
@GetMapping("/{userId}/profile")
35+
@Operation(summary = "유저 프로필 조회", description = "특정 유저의 프로필(유저 정보 + 통계 + 관심사/분야)을 조회합니다.")
36+
public ProfileDto.ProfileResponse getUserProfile(
37+
@PathVariable Long userId,
38+
@AuthenticationPrincipal UserDetails userDetails
39+
) {
40+
return profileService.getProfile(userId, userDetails);
41+
}
42+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package targeter.aim.domain.user.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.*;
5+
import targeter.aim.domain.file.dto.FileDto;
6+
7+
import java.util.List;
8+
9+
public class ProfileDto {
10+
11+
@Data
12+
@Builder
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
@Schema(description = "프로필 조회 응답")
16+
public static class ProfileResponse {
17+
18+
@Schema(description = "유저 ID")
19+
private Long userId;
20+
21+
@Schema(description = "아이디(loginId)")
22+
private String loginId;
23+
24+
@Schema(description = "닉네임")
25+
private String nickname;
26+
27+
@Schema(description = "뱃지/티어 정보")
28+
private TierResponse tier;
29+
30+
@Schema(description = "레벨")
31+
private Integer level;
32+
33+
@Schema(description = "프로필 이미지")
34+
private FileDto.FileResponse profileImage;
35+
36+
@Schema(description = "관심사(태그)")
37+
private List<String> interests;
38+
39+
@Schema(description = "관심 분야")
40+
private List<String> fields;
41+
42+
@Schema(description = "전체 챌린지 기록")
43+
private ChallengeRecord overall;
44+
45+
@Schema(description = "SOLO 기록")
46+
private ChallengeRecord solo;
47+
48+
@Schema(description = "VS 기록")
49+
private ChallengeRecord vs;
50+
51+
@Schema(description = "본인 프로필 여부")
52+
private Boolean isMine;
53+
}
54+
55+
@Data
56+
@Builder
57+
@NoArgsConstructor
58+
@AllArgsConstructor
59+
public static class TierResponse {
60+
private String name;
61+
}
62+
63+
@Data
64+
@Builder
65+
@NoArgsConstructor
66+
@AllArgsConstructor
67+
@Schema(description = "챌린지 기록(시도/성공/실패/성공률)")
68+
public static class ChallengeRecord {
69+
private long attemptCount;
70+
private long successCount;
71+
private long failCount;
72+
73+
@Schema(description = "성공률(0~100)")
74+
private int successRate;
75+
}
76+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package targeter.aim.domain.user.repository;
2+
3+
import com.querydsl.core.BooleanBuilder;
4+
import com.querydsl.core.types.dsl.CaseBuilder;
5+
import com.querydsl.core.types.dsl.NumberExpression;
6+
import com.querydsl.core.types.dsl.Expressions;
7+
import com.querydsl.jpa.impl.JPAQueryFactory;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.stereotype.Repository;
10+
11+
import java.util.List;
12+
13+
import static targeter.aim.domain.challenge.entity.QChallenge.challenge;
14+
import static targeter.aim.domain.challenge.entity.QWeeklyProgress.weeklyProgress;
15+
import static targeter.aim.domain.label.entity.QField.field;
16+
import static targeter.aim.domain.label.entity.QTag.tag;
17+
import static targeter.aim.domain.user.entity.QUser.user;
18+
19+
import targeter.aim.domain.challenge.entity.ChallengeMode;
20+
21+
@Repository
22+
@RequiredArgsConstructor
23+
public class ProfileQueryRepository {
24+
25+
private final JPAQueryFactory queryFactory;
26+
27+
public record Record(long attempt, long success) {}
28+
29+
public List<String> findUserTagNames(Long userId) {
30+
return queryFactory
31+
.select(tag.name)
32+
.from(user)
33+
.join(user.tags, tag)
34+
.where(user.id.eq(userId))
35+
.orderBy(tag.name.asc())
36+
.fetch();
37+
}
38+
39+
public List<String> findUserFieldNames(Long userId) {
40+
return queryFactory
41+
.select(field.name)
42+
.from(user)
43+
.join(user.fields, field)
44+
.where(user.id.eq(userId))
45+
.orderBy(field.name.asc())
46+
.fetch();
47+
}
48+
49+
public Record calcOverallRecord(Long userId) {
50+
return calcRecordByMode(userId, null);
51+
}
52+
53+
public Record calcSoloRecord(Long userId) {
54+
return calcRecordByMode(userId, ChallengeMode.SOLO);
55+
}
56+
57+
public Record calcVsRecord(Long userId) {
58+
return calcRecordByMode(userId, ChallengeMode.VS);
59+
}
60+
61+
private Record calcRecordByMode(Long userId, ChallengeMode mode) {
62+
63+
BooleanBuilder where = new BooleanBuilder();
64+
where.and(weeklyProgress.user.id.eq(userId));
65+
66+
if (mode != null) {
67+
where.and(challenge.mode.eq(mode));
68+
}
69+
Long attempt = queryFactory
70+
.select(weeklyProgress.challenge.id.countDistinct())
71+
.from(weeklyProgress)
72+
.join(weeklyProgress.challenge, challenge)
73+
.where(where)
74+
.fetchOne();
75+
76+
long attemptCount = attempt == null ? 0L : attempt;
77+
78+
NumberExpression<Integer> completeCnt = new CaseBuilder()
79+
.when(weeklyProgress.isComplete.isTrue()).then(1)
80+
.otherwise(0)
81+
.sum();
82+
83+
NumberExpression<Long> totalCnt = weeklyProgress.id.count();
84+
85+
NumberExpression<Double> ratio = Expressions.numberTemplate(
86+
Double.class,
87+
"({0} * 1.0) / {1}",
88+
completeCnt,
89+
totalCnt
90+
);
91+
List<Long> successChallengeIds = queryFactory
92+
.select(weeklyProgress.challenge.id)
93+
.from(weeklyProgress)
94+
.join(weeklyProgress.challenge, challenge)
95+
.where(where)
96+
.groupBy(weeklyProgress.challenge.id)
97+
.having(ratio.goe(0.7))
98+
.fetch();
99+
100+
long successCount = (successChallengeIds == null) ? 0L : successChallengeIds.size();
101+
102+
return new Record(attemptCount, successCount);
103+
}
104+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 targeter.aim.domain.file.dto.FileDto;
7+
import targeter.aim.domain.user.dto.ProfileDto;
8+
import targeter.aim.domain.user.entity.User;
9+
import targeter.aim.domain.user.repository.ProfileQueryRepository;
10+
import targeter.aim.domain.user.repository.UserRepository;
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+
import java.util.List;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class ProfileService {
20+
21+
private final UserRepository userRepository;
22+
private final ProfileQueryRepository profileQueryRepository;
23+
24+
@Transactional(readOnly = true)
25+
public ProfileDto.ProfileResponse getProfile(Long targetUserId, UserDetails viewer) {
26+
27+
User target = userRepository.findById(targetUserId)
28+
.orElseThrow(() -> new RestException(ErrorCode.USER_NOT_FOUND));
29+
30+
// 관심사 / 관심 분야
31+
List<String> interests = profileQueryRepository.findUserTagNames(targetUserId);
32+
List<String> fields = profileQueryRepository.findUserFieldNames(targetUserId);
33+
34+
// 챌린지 기록
35+
ProfileQueryRepository.Record overall = profileQueryRepository.calcOverallRecord(targetUserId);
36+
ProfileQueryRepository.Record solo = profileQueryRepository.calcSoloRecord(targetUserId);
37+
ProfileQueryRepository.Record vs = profileQueryRepository.calcVsRecord(targetUserId);
38+
39+
boolean isMine = viewer != null && viewer.getUser().getId().equals(targetUserId);
40+
41+
return ProfileDto.ProfileResponse.builder()
42+
.userId(target.getId())
43+
.loginId(target.getLoginId())
44+
.nickname(target.getNickname())
45+
.tier(ProfileDto.TierResponse.builder()
46+
.name(target.getTier().getName())
47+
.build())
48+
.level(target.getLevel())
49+
.profileImage(
50+
target.getProfileImage() == null
51+
? null
52+
: FileDto.FileResponse.from(target.getProfileImage())
53+
)
54+
.interests(interests)
55+
.fields(fields)
56+
.overall(toRecordDto(overall))
57+
.solo(toRecordDto(solo))
58+
.vs(toRecordDto(vs))
59+
.isMine(isMine)
60+
.build();
61+
}
62+
63+
private ProfileDto.ChallengeRecord toRecordDto(ProfileQueryRepository.Record record) {
64+
long attempt = record.attempt();
65+
long success = record.success();
66+
long fail = attempt - success;
67+
int successRate = attempt == 0
68+
? 0
69+
: (int) Math.round((success * 100.0) / attempt);
70+
71+
return ProfileDto.ChallengeRecord.builder()
72+
.attemptCount(attempt)
73+
.successCount(success)
74+
.failCount(fail)
75+
.successRate(successRate)
76+
.build();
77+
}
78+
}

0 commit comments

Comments
 (0)