Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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,168 @@
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 (!user.getDetoxEndTime().isAfter(user.getDetoxStartTime())) {
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 record DetoxPeriod(LocalDateTime start, LocalDateTime end) {

private static DetoxPeriod of(LocalDate targetDate, LocalTime startTime, LocalTime endTime) {
LocalDate endDate = endTime.isAfter(startTime) ? targetDate : targetDate.plusDays(1);
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