Skip to content

Commit

Permalink
Feat: 러닝 결과 저장 V2 API 구현(러닝 경로 추가) (#324)
Browse files Browse the repository at this point in the history
* Feat: 좌표값이 null Island(0,0,0) 좌표인지 확인하는 함수 추가

* Feat: (러닝 결과 저장 V2) 챌린지, 목표 각각의 모드에서 관련 엔티티 유효성 ErrorType 추가

- 챌린지 모드에서는 챌린지 관련 값이 필수
- 목표 모드에서는 목표 관련 값이 필수

* Feat: 러닝 결과 저장 V2 requestDTO 추가

- requestV2 추가
- 러닝 경로 공통 응답, 요쳥 형식 DTO 추가

* Feat: 러닝 결과 저장 V2(서비스) 리턴 DTO 추가

* Feat: 러닝 결과 저장 V2 서비스단 구현

* Test: 러닝 결과 저장 V2 서비스단 구현 테스트(러닝 경로 확인 테스트)

* add-running-new

* Feat: 러닝 결과 저장 V2 구현(Controller)

* Fix: altitude Nan값으로 저장 애러 수정(현재 고도값은 저장하지 않음으로, 기본 값을 0으로 설정)

* Test: 러닝 결과 추가에 대한(V2) request mapping, response 확인 테스트 코드
  • Loading branch information
hee9841 authored Dec 25, 2024
1 parent 65bdadf commit fc345c1
Show file tree
Hide file tree
Showing 10 changed files with 635 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dnd.runus.application.running;

import com.dnd.runus.application.running.dto.RunningResultDto;
import com.dnd.runus.application.running.event.RunningRecordAddedEvent;
import com.dnd.runus.domain.challenge.Challenge;
import com.dnd.runus.domain.challenge.ChallengeRepository;
Expand All @@ -23,6 +24,9 @@
import com.dnd.runus.presentation.v1.running.dto.request.RunningRecordRequestV1;
import com.dnd.runus.presentation.v1.running.dto.request.RunningRecordWeeklySummaryType;
import com.dnd.runus.presentation.v1.running.dto.response.*;
import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2;
import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2.ChallengeAchievedDto;
import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2.GoalAchievedDto;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
Expand All @@ -35,6 +39,7 @@
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.dnd.runus.global.constant.MetricsConversionFactor.METERS_IN_A_KILOMETER;
import static com.dnd.runus.global.constant.MetricsConversionFactor.SECONDS_PER_HOUR;
Expand Down Expand Up @@ -180,6 +185,71 @@ public RunningRecordMonthlySummaryResponse getMonthlyRunningSummery(long memberI
.build();
}

@Transactional
public RunningResultDto addRunningRecordV2(long memberId, RunningRecordRequestV2 request) {
Member member =
memberRepository.findById(memberId).orElseThrow(() -> new NotFoundException(Member.class, memberId));

List<CoordinatePoint> route = request.runningData().route().stream()
.flatMap(point -> Stream.of(
new CoordinatePoint(
point.start().longitude(), point.start().latitude()),
new CoordinatePoint(point.end().longitude(), point.end().latitude())))
.collect(Collectors.toList());

// 러닝 record 저장
RunningRecord record = runningRecordRepository.save(RunningRecord.builder()
.member(member)
.startAt(request.startAt().atZone(defaultZoneOffset))
.endAt(request.endAt().atZone(defaultZoneOffset))
.emoji(request.emotion())
.startLocation(request.startLocation())
.endLocation(request.endLocation())
.distanceMeter(request.runningData().distanceMeter())
.duration(request.runningData().runningTime())
.calorie(request.runningData().calorie())
.averagePace(Pace.from(
request.runningData().distanceMeter(),
request.runningData().runningTime()))
.route(route)
.build());

OffsetDateTime now = OffsetDateTime.now();
int totalDistance = runningRecordRepository.findTotalDistanceMeterByMemberId(memberId);
Duration totalDuration = runningRecordRepository.findTotalDurationByMemberId(memberId, BASE_TIME, now);

// 멤버 레벨, 뱃지, 지구한바퀴 저장(update) 이벤트 발생
eventPublisher.publishEvent(new RunningRecordAddedEvent(member, record, totalDistance, totalDuration));

switch (request.achievementMode()) {
case CHALLENGE -> {
ChallengeAchievedDto challengeAchievedForAdd = request.challengeValues();
Challenge challenge = challengeRepository
.findById(challengeAchievedForAdd.challengeId())
.orElseThrow(
() -> new NotFoundException(Challenge.class, challengeAchievedForAdd.challengeId()));

ChallengeAchievement challengeAchievement = challengeAchievementRepository.save(
new ChallengeAchievement(challenge, record, challengeAchievedForAdd.isSuccess()));

return RunningResultDto.of(record, challengeAchievement);
}
case GOAL -> {
GoalAchievedDto goalAchievedForAdd = request.goalValues();
GoalAchievement goalAchievement = goalAchievementRepository.save(new GoalAchievement(
record,
(goalAchievedForAdd.goalDistance() != null) ? GoalMetricType.DISTANCE : GoalMetricType.TIME,
(goalAchievedForAdd.goalDistance() != null)
? goalAchievedForAdd.goalDistance()
: goalAchievedForAdd.goalTime(),
goalAchievedForAdd.isSuccess()));

return RunningResultDto.of(record, goalAchievement);
}
}
return RunningResultDto.from(record);
}

@Transactional
public RunningRecordAddResultResponseV1 addRunningRecordV1(long memberId, RunningRecordRequestV1 request) {
Member member =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.dnd.runus.application.running.dto;


import com.dnd.runus.domain.challenge.achievement.ChallengeAchievement;
import com.dnd.runus.domain.goalAchievement.GoalAchievement;
import com.dnd.runus.domain.running.RunningRecord;

public record RunningResultDto(
RunningRecord runningRecord,
com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode runningAchievementMode,
ChallengeAchievement challengeAchievement,
GoalAchievement goalAchievement
) {
public static RunningResultDto from(RunningRecord runningRecord) {
return new RunningResultDto(
runningRecord,
com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.NORMAL,
null,
null
);
}

public static RunningResultDto of(RunningRecord runningRecord,
ChallengeAchievement challengeAchievement) {
return new RunningResultDto(
runningRecord,
com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.CHALLENGE,
challengeAchievement,
null
);
}

public static RunningResultDto of(RunningRecord runningRecord, GoalAchievement goalAchievement) {
return new RunningResultDto(
runningRecord,
com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.GOAL,
null,
goalAchievement
);
}
}
14 changes: 11 additions & 3 deletions src/main/java/com/dnd/runus/domain/common/CoordinatePoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@

/**
* @param longitude 경도
* @param latitude 위도
* @param altitude 고도
* @param latitude 위도
* @param altitude 고도
*/
public record CoordinatePoint(double longitude, double latitude, double altitude) {

public CoordinatePoint(double longitude, double latitude) {
this(longitude, latitude, Double.NaN);
this(longitude, latitude, 0);
}

/**
* null Island(longitude : 0, latitude: 0, altitude:0 인지점)을 확인
*/
public boolean isNullIsland() {
return (this.longitude == 0 && this.latitude == 0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public enum ErrorType {
CHALLENGE_MODE_WITH_PERSONAL_GOAL(BAD_REQUEST, DEBUG, "RUNNING_002", "챌린지 모드에서는 개인 목표를 설정할 수 없습니다"),
GOAL_MODE_WITH_CHALLENGE_ID(BAD_REQUEST, DEBUG, "RUNNING_003", "개인 목표 모드에서는 챌린지 ID를 설정할 수 없습니다"),
GOAL_TIME_AND_DISTANCE_BOTH_EXIST(BAD_REQUEST, DEBUG, "RUNNING_004", "개인 목표 시간과 거리 중 하나만 설정해야 합니다"),
GOAL_VALUES_REQUIRED_IN_GOAL_MODE(BAD_REQUEST, DEBUG, "RUNNING_005", "개인 목표 모드에서, 개인 목표 달성값은 필수 잆니다."),
CHALLENGE_VALUES_REQUIRED_IN_CHALLENGE_MODE(BAD_REQUEST, DEBUG, "RUNNING_006", "챌린지 모드에서, 챌린지 달성값은 필수 입니다."),

// WeatherErrorType
WEATHER_API_ERROR(SERVICE_UNAVAILABLE, WARN, "WEATHER_001", "날씨 API 호출 중 오류가 발생했습니다"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@

import com.dnd.runus.application.running.RunningRecordService;
import com.dnd.runus.application.running.RunningRecordServiceV2;
import com.dnd.runus.global.exception.type.ApiErrorType;
import com.dnd.runus.global.exception.type.ErrorType;
import com.dnd.runus.presentation.annotation.MemberId;
import com.dnd.runus.presentation.v1.running.dto.response.RunningRecordMonthlySummaryResponse;
import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2;
import com.dnd.runus.presentation.v2.running.dto.response.RunningRecordMonthlySummaryResponseV2;
import com.dnd.runus.presentation.v2.running.dto.response.RunningRecordResultResponseV2;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -33,4 +40,30 @@ public RunningRecordMonthlySummaryResponseV2 getMonthlyRunningSummary(@MemberId
monthlyRunningSummery.monthlyTotalMeter(),
runningRecordService2.getPercentageValues(memberId));
}

@Operation(
summary = "러닝 기록 추가 API V2",
description =
"""
러닝 기록을 추가합니다.<br>
러닝 기록은 시작 시간, 종료 시간, 러닝 평가(emotion), 러닝 데이터 등으로 구성됩니다. <br>
챌린지 모드가 normal : challengeValues, goalValues 둘다 null <br>
챌린지 모드가 challenge : challengeValues 필수 값 <br>
챌린지 모드가 goal : goalValues 필수 값 <br>
러닝 데이터는 위치, 거리, 시간, 칼로리, 평균 페이스, 러닝 경로로 구성됩니다. <br>
러닝 기록 추가에 성공하면 러닝 기록 ID, 기록 정보를 반환합니다. <br>
""")
@ApiErrorType({
ErrorType.START_AFTER_END,
ErrorType.CHALLENGE_VALUES_REQUIRED_IN_CHALLENGE_MODE,
ErrorType.GOAL_VALUES_REQUIRED_IN_GOAL_MODE,
ErrorType.GOAL_TIME_AND_DISTANCE_BOTH_EXIST,
ErrorType.ROUTE_MUST_HAVE_AT_LEAST_TWO_COORDINATES
})
@PostMapping
@ResponseStatus(HttpStatus.OK)
public RunningRecordResultResponseV2 addRunningRecord(
@MemberId long memberId, @Valid @RequestBody RunningRecordRequestV2 request) {
return RunningRecordResultResponseV2.from(runningRecordService.addRunningRecordV2(memberId, request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.dnd.runus.presentation.v2.running.dto;


import com.dnd.runus.domain.common.CoordinatePoint;

/**
* 클라이언트와의 러닝 경로 요청/응답 형식
* @param start 시작 위치
* @param end 종료 위치
*/
public record RouteDtoV2(
Point start,
Point end
) {
public record Point(double longitude, double latitude) {
public static Point from(CoordinatePoint point) {
return new Point(point.longitude(), point.longitude());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.dnd.runus.presentation.v2.running.dto.request;


import com.dnd.runus.global.constant.RunningEmoji;
import com.dnd.runus.global.exception.BusinessException;
import com.dnd.runus.global.exception.type.ErrorType;
import com.dnd.runus.presentation.v2.running.dto.RouteDtoV2;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;

public record RunningRecordRequestV2(
@NotNull
LocalDateTime startAt,
@NotNull
LocalDateTime endAt,
@NotBlank
@Schema(description = "시작 위치", example = "서울시 강남구")
String startLocation,
@NotBlank
@Schema(description = "종료 위치", example = "서울시 송파구")
String endLocation,
@NotNull
@Schema(description = "감정 표현, very-good: 최고, good: 좋음, soso: 보통, bad: 나쁨, very-bad: 최악")
RunningEmoji emotion,
@NotNull
@Schema(description = "목표 달성 모드, normal: 목표 설정X, challenge: 챌린지, goal: 개인 목표")
com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode achievementMode,
@Schema(description = "챌린지 데이터, 챌린지를 하지 않은 경우 null이나 필드 없이 보내주세요")
ChallengeAchievedDto challengeValues,
@Schema(description = "목표 데이터, 목표를 설정하지 않은 경우 null이나 필드 없이 보내주세요")
GoalAchievedDto goalValues,
@NotNull
RunningRecordMetrics runningData
) {
public RunningRecordRequestV2 {
//request valid check
//시작, 종료 시간 유효값 확인
if (startAt.isAfter(endAt)) {
throw new BusinessException(ErrorType.START_AFTER_END, startAt + " ~ " + endAt);
}

// 러닝 모드 유요성 확인
if (achievementMode == com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.CHALLENGE && challengeValues == null) {
throw new BusinessException(ErrorType.CHALLENGE_VALUES_REQUIRED_IN_CHALLENGE_MODE);
}

if (achievementMode == com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.GOAL) {
if(goalValues == null) {
throw new BusinessException(ErrorType.GOAL_VALUES_REQUIRED_IN_GOAL_MODE);
}
if (goalValues.goalDistance() == null && goalValues.goalTime() == null) {
throw new BusinessException(ErrorType.GOAL_TIME_AND_DISTANCE_BOTH_EXIST);
}
}

//러닝 경로 유요성 확인
if(runningData.route() == null || runningData.route().size() < 2) {
throw new BusinessException(ErrorType.ROUTE_MUST_HAVE_AT_LEAST_TWO_COORDINATES);
}
}

public record ChallengeAchievedDto(
@Schema(description = "챌린지 ID", example = "1")
long challengeId,
boolean isSuccess
) {
}

public record GoalAchievedDto(
@Schema(description = "개인 목표 거리 (미터), 거리 목표가 아닌 경우, null이나 필드 없이 보내주세요", example = "5000")
Integer goalDistance,
@Schema(description = "개인 목표 시간 (초), 시간 목표가 아닌 경우, null이나 필드 없이 보내주세요", example = "1800")
Integer goalTime,
boolean isSuccess
) {
}

@Schema(name = "RunningRecordMetrics for Add V2")
public record RunningRecordMetrics(
@NotNull
@Schema(description = "멈춘 시간을 제외한 실제로 달린 시간", example = "123:45:56", format = "HH:mm:ss")
Duration runningTime,
@Schema(description = "달린 거리(m)", example = "1000")
@NotNull
int distanceMeter,
@Schema(description = "소모 칼로리(kcal)", example = "100")
@NotNull
double calorie,
@NotNull
@Schema(description = "러닝 경로, 최소, 경로는 최소 2개의 좌표를 가져야합니다.")
List<RouteDtoV2> route
) {
}
}
Loading

0 comments on commit fc345c1

Please sign in to comment.