Skip to content
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
@@ -0,0 +1,29 @@
package com.example.hackathon.domain.detox.controller;

import com.example.hackathon.domain.detox.dto.DetoxProgressResponse;
import com.example.hackathon.domain.detox.service.DetoxProgressService;
import com.example.hackathon.global.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Detox", description = "디지털 디톡스 진행 관련 API")
@RestController
@RequestMapping("/api/detox")
@RequiredArgsConstructor
public class DetoxProgressController {

private final DetoxProgressService detoxProgressService;

@Operation(summary = "디톡스 진행 상태 조회", description = "디톡스 진행 상태와 시간이 겹치는 팀원을 조회합니다.")
@GetMapping("/progress")
public ApiResponse<DetoxProgressResponse> getProgress(
@RequestHeader("X-Device-Id") String deviceId
) {
return ApiResponse.ok("디톡스 진행 상태 조회 성공", detoxProgressService.getProgress(deviceId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.hackathon.domain.detox.dto;

import com.example.hackathon.domain.mission.entity.MissionStatus;

import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;

public record DetoxProgressResponse(
Long missionLogId,
MissionStatus status,
boolean inProgress,
LocalDateTime startDateTime,
LocalDateTime endDateTime,
LocalTime endTime,
long remainingSeconds,
String titleMessage,
String unlockMessage,
int overlappingMemberCount,
List<OverlappingMemberResponse> overlappingMembers
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.example.hackathon.domain.detox.dto;

import java.time.LocalDateTime;
import java.time.LocalTime;

public record OverlappingMemberResponse(
Long userId,
String nickname,
LocalTime detoxStartTime,
LocalTime detoxEndTime,
LocalDateTime overlapStartDateTime,
LocalDateTime overlapEndDateTime
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.example.hackathon.domain.detox.service;

import com.example.hackathon.domain.detox.dto.DetoxProgressResponse;
import com.example.hackathon.domain.detox.dto.OverlappingMemberResponse;
import com.example.hackathon.domain.mission.entity.MissionStatus;
import com.example.hackathon.domain.mission.entity.UserMissionLog;
import com.example.hackathon.domain.mission.repository.UserMissionLogRepository;
import com.example.hackathon.domain.team.repository.TeamMemberRepository;
import com.example.hackathon.domain.user.entity.User;
import com.example.hackathon.domain.user.repository.UserRepository;
import com.example.hackathon.global.exception.BusinessException;
import com.example.hackathon.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DetoxProgressService {

private static final String PROGRESS_TITLE = "폰 내려놓을 시간이에요.";
private static final String BEFORE_START_TITLE = "디지털 디톡스 시작 전이에요.";
private static final String FINISHED_TITLE = "디지털 디톡스가 종료되었어요.";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");

private final UserRepository userRepository;
private final UserMissionLogRepository userMissionLogRepository;
private final TeamMemberRepository teamMemberRepository;
private final Clock clock;

public DetoxProgressResponse getProgress(String deviceId) {
LocalDateTime now = LocalDateTime.now(clock);
User user = findUser(deviceId);
UserMissionLog missionLog = findCurrentMissionLog(user, now.toLocalDate());
validateMissionSuccess(missionLog);

DetoxPeriod userPeriod = DetoxPeriod.of(
missionLog.getTargetDate(), user.getDetoxStartTime(), user.getDetoxEndTime());
boolean inProgress = userPeriod.contains(now);
long remainingSeconds = calculateRemainingSeconds(userPeriod, now, inProgress);
List<OverlappingMemberResponse> members = inProgress
? findOverlappingMembers(user, missionLog.getTargetDate(), userPeriod)
: List.of();

String titleMessage = resolveTitleMessage(userPeriod, now, inProgress);
String unlockMessage = inProgress
? "%s에 해제됩니다.".formatted(user.getDetoxEndTime().format(TIME_FORMATTER))
: null;

return new DetoxProgressResponse(
missionLog.getId(), missionLog.getStatus(), inProgress,
userPeriod.start(), userPeriod.end(), user.getDetoxEndTime(), remainingSeconds,
titleMessage, unlockMessage, members.size(), members
);
}

private User findUser(String deviceId) {
User user = userRepository.findByDeviceId(deviceId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_ERROR_404_NOT_FOUND));
if (user.getDetoxStartTime() == null || user.getDetoxEndTime() == null) {
throw new BusinessException(ErrorCode.MISSION_ERROR_400_DETOX_TIME_NOT_SET);
}
return user;
}

private UserMissionLog findCurrentMissionLog(User user, LocalDate today) {
return userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), today)
.orElseGet(() -> {
if (crossesMidnight(user.getDetoxStartTime(), user.getDetoxEndTime())) {
return userMissionLogRepository
.findByUserIdAndTargetDate(user.getId(), today.minusDays(1))
.orElseThrow(() -> new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND));
}
throw new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND);
});
}

private void validateMissionSuccess(UserMissionLog missionLog) {
if (missionLog.getStatus() != MissionStatus.SUCCESS) {
throw new BusinessException(ErrorCode.MISSION_ERROR_400_NOT_CERTIFIED);
}
}

private long calculateRemainingSeconds(DetoxPeriod period, LocalDateTime now, boolean inProgress) {
return inProgress ? Math.max(Duration.between(now, period.end()).getSeconds(), 0) : 0;
}

private List<OverlappingMemberResponse> findOverlappingMembers(
User user,
LocalDate targetDate,
DetoxPeriod userPeriod
) {
Set<Long> seenUserIds = new HashSet<>();
return teamMemberRepository.findDistinctTeammatesByUserId(user.getId()).stream()
.filter(member -> !member.getId().equals(user.getId()))
.filter(member -> seenUserIds.add(member.getId()))
.filter(member -> member.getDetoxStartTime() != null && member.getDetoxEndTime() != null)
.map(member -> toOverlappingMember(member, targetDate, userPeriod))
.filter(java.util.Objects::nonNull)
.sorted(Comparator.comparing(OverlappingMemberResponse::nickname)
.thenComparing(OverlappingMemberResponse::userId))
.toList();
}

private OverlappingMemberResponse toOverlappingMember(
User member,
LocalDate targetDate,
DetoxPeriod userPeriod
) {
// 모든 팀원의 시작 시각을 현재 미션의 targetDate에 결합하는 정책을 사용한다.
DetoxPeriod memberPeriod = DetoxPeriod.of(
targetDate, member.getDetoxStartTime(), member.getDetoxEndTime());
if (!userPeriod.overlaps(memberPeriod)) {
return null;
}
DetoxPeriod overlap = userPeriod.intersection(memberPeriod);
return new OverlappingMemberResponse(
member.getId(), member.getNickname(),
member.getDetoxStartTime(), member.getDetoxEndTime(),
overlap.start(), overlap.end()
);
}

private String resolveTitleMessage(DetoxPeriod period, LocalDateTime now, boolean inProgress) {
if (inProgress) {
return PROGRESS_TITLE;
}
return now.isBefore(period.start()) ? BEFORE_START_TITLE : FINISHED_TITLE;
}

private static boolean crossesMidnight(LocalTime startTime, LocalTime endTime) {
return !endTime.isAfter(startTime);
}

private record DetoxPeriod(LocalDateTime start, LocalDateTime end) {

private static DetoxPeriod of(LocalDate targetDate, LocalTime startTime, LocalTime endTime) {
LocalDate endDate = crossesMidnight(startTime, endTime) ? targetDate.plusDays(1) : targetDate;
return new DetoxPeriod(
LocalDateTime.of(targetDate, startTime),
LocalDateTime.of(endDate, endTime)
);
}

private boolean contains(LocalDateTime dateTime) {
return !dateTime.isBefore(start) && dateTime.isBefore(end);
}

private boolean overlaps(DetoxPeriod other) {
return start.isBefore(other.end) && other.start.isBefore(end);
}

private DetoxPeriod intersection(DetoxPeriod other) {
return new DetoxPeriod(
start.isAfter(other.start) ? start : other.start,
end.isBefore(other.end) ? end : other.end
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,26 @@ public S3Config(S3Properties properties) {
* access-key/secret-key 가 주입되면 그 자격증명을 쓰고, 비어 있으면
* 기본 자격증명 체인(환경변수·EC2 IAM 역할 등)에 위임한다.
*/
// region 이 비면 서울로 폴백한다. 설정 하나 빠졌다고 앱 전체(와 모든 테스트)가
// 부팅에 실패하는 것을 막는다. presign 은 어차피 실제 발급 시점에만 자격증명을 검증한다.
private static final String DEFAULT_REGION = "ap-northeast-2";

@Bean
public S3Presigner s3Presigner() {
String region = (properties.region() == null || properties.region().isBlank())
? DEFAULT_REGION
: properties.region();
return S3Presigner.builder()
.region(Region.of(region))
.region(resolveRegion())
.credentialsProvider(credentialsProvider())
.build();
}

@Bean
public S3Client s3Client() {
String region = (properties.region() == null || properties.region().isBlank())
? DEFAULT_REGION
: properties.region();
return S3Client.builder()
.region(Region.of(region))
.region(resolveRegion())
.credentialsProvider(credentialsProvider())
.build();
}

private Region resolveRegion() {
return Region.of(properties.resolvedRegion());
}

private AwsCredentialsProvider credentialsProvider() {
S3Properties.S3 s3 = properties.s3();
if (s3.accessKey() != null && !s3.accessKey().isBlank()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ public record S3Properties(
String region,
S3 s3
) {
private static final String DEFAULT_REGION = "ap-northeast-2";

public String resolvedRegion() {
return region == null || region.isBlank() ? DEFAULT_REGION : region;
}

public record S3(
String bucket,
String accessKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ public String uploadMissionImage(Long missionLogId, MultipartFile image) {
.build(),
RequestBody.fromBytes(image.getBytes())
);
return "https://%s.s3.%s.amazonaws.com/%s".formatted(bucket, properties.region(), key);
return "https://%s.s3.%s.amazonaws.com/%s".formatted(bucket, properties.resolvedRegion(), key);
} catch (IOException | RuntimeException exception) {
throw new BusinessException(ErrorCode.IMAGE_ERROR_500_UPLOAD_FAILED);
throw new BusinessException(ErrorCode.IMAGE_ERROR_500_UPLOAD_FAILED, exception);
}
}

Expand All @@ -67,7 +67,11 @@ private String validate(MultipartFile image) {
if (image.getSize() > MAX_IMAGE_SIZE) {
throw new BusinessException(ErrorCode.IMAGE_ERROR_400_SIZE_EXCEEDED);
}
String extension = ALLOWED_CONTENT_TYPES.get(image.getContentType());
String contentType = image.getContentType();
if (contentType == null) {
throw new BusinessException(ErrorCode.IMAGE_ERROR_400_INVALID_CONTENT_TYPE);
}
String extension = ALLOWED_CONTENT_TYPES.get(contentType);
if (extension == null) {
throw new BusinessException(ErrorCode.IMAGE_ERROR_400_INVALID_CONTENT_TYPE);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,19 @@ public class MissionCertificationService {
private final UserMissionLogRepository userMissionLogRepository;
private final StorageService storageService;
private final Clock clock;
private final MissionCertificationTransactionService transactionService;

@Transactional(noRollbackFor = BusinessException.class)
public MissionCertificationResponse certify(String deviceId, MultipartFile image) {
LocalDateTime now = LocalDateTime.now(clock);
User user = findUser(deviceId);
UserMissionLog log = findTodayLogForUpdate(user, now);
LocalDate targetDate = MissionTargetDateResolver.resolve(user, now);
UserMissionLog log = userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), targetDate)
.orElseThrow(() -> new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND));
validateInitialCertification(log, now);

String newImageUrl = storageService.uploadMissionImage(log.getId(), image);
deleteOnRollback(newImageUrl, log.getId());
try {
log.certify(newImageUrl, now);
userMissionLogRepository.saveAndFlush(log);
log = transactionService.certifyUploadedImage(user.getId(), targetDate, newImageUrl, now);
} catch (RuntimeException exception) {
deleteQuietly(newImageUrl, log.getId());
throw exception;
Expand Down Expand Up @@ -77,7 +77,7 @@ public MissionCertificationRetakeResponse retake(String deviceId, MultipartFile
deleteAfterCommit(oldImageUrl, log.getId());

return MissionCertificationRetakeResponse.of(
log, now, user.getDetoxEndTime(), now.isBefore(detoxEnd));
log, log.getUpdatedAt(), user.getDetoxEndTime(), now.isBefore(detoxEnd));
}

private User findUser(String deviceId) {
Expand All @@ -90,7 +90,7 @@ private User findUser(String deviceId) {
}

private UserMissionLog findTodayLogForUpdate(User user, LocalDateTime now) {
LocalDate targetDate = resolveTargetDate(user, now);
LocalDate targetDate = MissionTargetDateResolver.resolve(user, now);
return userMissionLogRepository.findByUserIdAndTargetDateForUpdate(user.getId(), targetDate)
.orElseThrow(() -> new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND));
}
Expand Down Expand Up @@ -125,15 +125,6 @@ private void validateRetake(UserMissionLog log, LocalDateTime now, LocalDateTime
}
}

private LocalDate resolveTargetDate(User user, LocalDateTime now) {
LocalTime startTime = user.getDetoxStartTime();
LocalTime endTime = user.getDetoxEndTime();
if (!endTime.isAfter(startTime) && now.toLocalTime().isBefore(endTime)) {
return now.toLocalDate().minusDays(1);
}
return now.toLocalDate();
}

private LocalDateTime resolveDetoxEndDateTime(
LocalDate targetDate,
LocalTime startTime,
Expand Down
Loading
Loading