Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: 러닝 결과 저장 V2 API 구현(러닝 경로 추가) #324

Merged
merged 10 commits into from
Dec 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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