diff --git a/src/main/java/gg/agit/konect/domain/studytime/controller/StudyTimeApi.java b/src/main/java/gg/agit/konect/domain/studytime/controller/StudyTimeApi.java new file mode 100644 index 00000000..019e029f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/controller/StudyTimeApi.java @@ -0,0 +1,46 @@ +package gg.agit.konect.domain.studytime.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; +import gg.agit.konect.domain.studytime.dto.StudyTimerStopResponse; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Study Time: 순공 시간", description = "순공 시간 API") +@RequestMapping("/studytimes") +public interface StudyTimeApi { + + @Operation(summary = "스터디 타이머를 시작한다.", description = """ + ## 설명 + - 스터디 타이머를 시작합니다. + - 사용자당 동시에 1개의 타이머만 허용됩니다. + + ## 에러 + - `ALREADY_RUNNING_STUDY_TIMER` (409): 이미 실행 중인 타이머가 있는 경우 + """) + @PostMapping("/timers") + ResponseEntity start(@UserId Integer userId); + + @Operation(summary = "스터디 타이머를 종료한다.", description = """ + ## 설명 + - 실행 중인 타이머를 종료하고 공부 시간을 집계합니다. + - 시간이 자정을 넘기면 날짜별로 분할 집계합니다. + - 일간, 월간, 총 누적 시간을 함께 갱신합니다. + + ## 에러 + - `STUDY_TIMER_TIME_MISMATCH` (400): 클라이언트 누적 시간과 서버 시간 차이가 1분 이상인 경우 + - `STUDY_TIMER_NOT_RUNNING` (400): 실행 중인 타이머가 없는 경우 + """) + @DeleteMapping("/timers") + ResponseEntity stop( + @UserId Integer userId, + @RequestBody @Valid StudyTimerStopRequest request + ); +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/controller/StudyTimeController.java b/src/main/java/gg/agit/konect/domain/studytime/controller/StudyTimeController.java new file mode 100644 index 00000000..a8c6d097 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/controller/StudyTimeController.java @@ -0,0 +1,40 @@ +package gg.agit.konect.domain.studytime.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RestController; + +import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; +import gg.agit.konect.domain.studytime.dto.StudyTimerStopResponse; +import gg.agit.konect.domain.studytime.service.StudyTimerService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/studytimes") +public class StudyTimeController implements StudyTimeApi { + + private final StudyTimerService studyTimerService; + + @PostMapping("/timers") + public ResponseEntity start(@UserId Integer userId) { + studyTimerService.start(userId); + + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/timers") + public ResponseEntity stop( + @UserId Integer userId, + @RequestBody @Valid StudyTimerStopRequest request + ) { + StudyTimerStopResponse response = studyTimerService.stop(userId, request); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/dto/StudyTimerStopRequest.java b/src/main/java/gg/agit/konect/domain/studytime/dto/StudyTimerStopRequest.java new file mode 100644 index 00000000..839c92be --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/dto/StudyTimerStopRequest.java @@ -0,0 +1,35 @@ +package gg.agit.konect.domain.studytime.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record StudyTimerStopRequest( + @NotNull(message = "시간(hour)은 필수 입력입니다.") + @Min(value = 0, message = "시간은 0 이상이어야 합니다.") + @Schema(description = "타이머 시간 - 시간", example = "1", requiredMode = REQUIRED) + Integer hour, + + @NotNull(message = "분(minute)은 필수 입력입니다.") + @Min(value = 0, message = "분은 0 이상이어야 합니다.") + @Max(value = 59, message = "분은 59 이하여야 합니다.") + @Schema(description = "타이머 시간 - 분", example = "30", requiredMode = REQUIRED) + Integer minute, + + @NotNull(message = "초(second)는 필수 입력입니다.") + @Min(value = 0, message = "초는 0 이상이어야 합니다.") + @Max(value = 59, message = "초는 59 이하여야 합니다.") + @Schema(description = "타이머 시간 - 초", example = "15", requiredMode = REQUIRED) + Integer second +) { + + private static final long SECONDS_PER_MINUTE = 60L; + private static final long SECONDS_PER_HOUR = 3600L; + + public long toTotalSeconds() { + return hour * SECONDS_PER_HOUR + minute * SECONDS_PER_MINUTE + second; + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/dto/StudyTimerStopResponse.java b/src/main/java/gg/agit/konect/domain/studytime/dto/StudyTimerStopResponse.java new file mode 100644 index 00000000..eed11bb4 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/dto/StudyTimerStopResponse.java @@ -0,0 +1,29 @@ +package gg.agit.konect.domain.studytime.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import gg.agit.konect.domain.studytime.model.StudyTimeSummary; +import io.swagger.v3.oas.annotations.media.Schema; + +public record StudyTimerStopResponse( + @Schema(description = "이번 세션 공부 시간(초)", example = "3600", requiredMode = REQUIRED) + Long sessionSeconds, + + @Schema(description = "오늘 누적 공부 시간(초)", example = "7200", requiredMode = REQUIRED) + Long dailySeconds, + + @Schema(description = "이번 달 누적 공부 시간(초)", example = "120000", requiredMode = REQUIRED) + Long monthlySeconds, + + @Schema(description = "총 누적 공부 시간(초)", example = "360000", requiredMode = REQUIRED) + Long totalSeconds +) { + public static StudyTimerStopResponse from(StudyTimeSummary summary) { + return new StudyTimerStopResponse( + summary.sessionSeconds(), + summary.dailySeconds(), + summary.monthlySeconds(), + summary.totalSeconds() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeDaily.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeDaily.java new file mode 100644 index 00000000..fa420320 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeDaily.java @@ -0,0 +1,70 @@ +package gg.agit.konect.domain.studytime.model; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDate; + +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "study_time_daily", + uniqueConstraints = { + @UniqueConstraint(name = "uq_study_time_daily_user_date", columnNames = {"user_id", "study_date"}) + } +) +@NoArgsConstructor(access = PROTECTED) +public class StudyTimeDaily extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false, updatable = false) + private User user; + + @NotNull + @Column(name = "study_date", nullable = false) + private LocalDate studyDate; + + @NotNull + @Column(name = "total_seconds", nullable = false) + private Long totalSeconds; + + @Builder + private StudyTimeDaily(User user, LocalDate studyDate, Long totalSeconds) { + this.user = user; + this.studyDate = studyDate; + this.totalSeconds = totalSeconds; + } + + public static StudyTimeDaily of(User user, LocalDate studyDate, Long totalSeconds) { + return StudyTimeDaily.builder() + .user(user) + .studyDate(studyDate) + .totalSeconds(totalSeconds) + .build(); + } + + public void addSeconds(long seconds) { + this.totalSeconds += seconds; + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java new file mode 100644 index 00000000..58c37ba5 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java @@ -0,0 +1,70 @@ +package gg.agit.konect.domain.studytime.model; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDate; + +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "study_time_monthly", + uniqueConstraints = { + @UniqueConstraint(name = "uq_study_time_monthly_user_month", columnNames = {"user_id", "study_month"}) + } +) +@NoArgsConstructor(access = PROTECTED) +public class StudyTimeMonthly extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false, updatable = false) + private User user; + + @NotNull + @Column(name = "study_month", nullable = false) + private LocalDate studyMonth; + + @NotNull + @Column(name = "total_seconds", nullable = false) + private Long totalSeconds; + + @Builder + private StudyTimeMonthly(User user, LocalDate studyMonth, Long totalSeconds) { + this.user = user; + this.studyMonth = studyMonth; + this.totalSeconds = totalSeconds; + } + + public static StudyTimeMonthly of(User user, LocalDate studyMonth, Long totalSeconds) { + return StudyTimeMonthly.builder() + .user(user) + .studyMonth(studyMonth) + .totalSeconds(totalSeconds) + .build(); + } + + public void addSeconds(long seconds) { + this.totalSeconds += seconds; + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeSummary.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeSummary.java new file mode 100644 index 00000000..a3687540 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeSummary.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.studytime.model; + +public record StudyTimeSummary( + long sessionSeconds, + long dailySeconds, + long monthlySeconds, + long totalSeconds +) { +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java new file mode 100644 index 00000000..b7914123 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java @@ -0,0 +1,62 @@ +package gg.agit.konect.domain.studytime.model; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "study_time_total", + uniqueConstraints = { + @UniqueConstraint(name = "uq_study_time_total_user", columnNames = {"user_id"}) + } +) +@NoArgsConstructor(access = PROTECTED) +public class StudyTimeTotal extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @NotNull + @Column(name = "total_seconds", nullable = false) + private Long totalSeconds; + + @Builder + private StudyTimeTotal(User user, Long totalSeconds) { + this.user = user; + this.totalSeconds = totalSeconds; + } + + public static StudyTimeTotal of(User user, Long totalSeconds) { + return StudyTimeTotal.builder() + .user(user) + .totalSeconds(totalSeconds) + .build(); + } + + public void addSeconds(long seconds) { + this.totalSeconds += seconds; + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimer.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimer.java new file mode 100644 index 00000000..ad8d87f5 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimer.java @@ -0,0 +1,54 @@ +package gg.agit.konect.domain.studytime.model; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "study_timer") +@NoArgsConstructor(access = PROTECTED) +public class StudyTimer extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @NotNull + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Builder + private StudyTimer(User user, LocalDateTime startedAt) { + this.user = user; + this.startedAt = startedAt; + } + + public static StudyTimer of(User user, LocalDateTime startedAt) { + return StudyTimer.builder() + .user(user) + .startedAt(startedAt) + .build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java new file mode 100644 index 00000000..05d63992 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java @@ -0,0 +1,15 @@ +package gg.agit.konect.domain.studytime.repository; + +import java.time.LocalDate; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import gg.agit.konect.domain.studytime.model.StudyTimeDaily; + +public interface StudyTimeDailyRepository extends Repository { + + Optional findByUserIdAndStudyDate(Integer userId, LocalDate studyDate); + + StudyTimeDaily save(StudyTimeDaily studyTimeDaily); +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java new file mode 100644 index 00000000..34e007ac --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java @@ -0,0 +1,15 @@ +package gg.agit.konect.domain.studytime.repository; + +import java.time.LocalDate; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; + +public interface StudyTimeMonthlyRepository extends Repository { + + Optional findByUserIdAndStudyMonth(Integer userId, LocalDate studyMonth); + + StudyTimeMonthly save(StudyTimeMonthly studyTimeMonthly); +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java new file mode 100644 index 00000000..2ca6aae9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.studytime.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import gg.agit.konect.domain.studytime.model.StudyTimeTotal; + +public interface StudyTimeTotalRepository extends Repository { + + Optional findByUserId(Integer userId); + + StudyTimeTotal save(StudyTimeTotal studyTimeTotal); +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimerRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimerRepository.java new file mode 100644 index 00000000..8a3b7050 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimerRepository.java @@ -0,0 +1,26 @@ +package gg.agit.konect.domain.studytime.repository; + +import static gg.agit.konect.global.code.ApiResponseCode.STUDY_TIMER_NOT_RUNNING; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.global.exception.CustomException; + +public interface StudyTimerRepository extends Repository { + + Optional findByUserId(Integer userId); + + default StudyTimer getByUserId(Integer userId) { + return findByUserId(userId) + .orElseThrow(() -> CustomException.of(STUDY_TIMER_NOT_RUNNING)); + } + + Boolean existsByUserId(Integer userId); + + StudyTimer save(StudyTimer studyTimer); + + void delete(StudyTimer studyTimer); +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java new file mode 100644 index 00000000..5a7f5df2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java @@ -0,0 +1,184 @@ +package gg.agit.konect.domain.studytime.service; + +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_RUNNING_STUDY_TIMER; +import static gg.agit.konect.global.code.ApiResponseCode.STUDY_TIMER_TIME_MISMATCH; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; +import gg.agit.konect.domain.studytime.dto.StudyTimerStopResponse; +import gg.agit.konect.domain.studytime.model.StudyTimeDaily; +import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; +import gg.agit.konect.domain.studytime.model.StudyTimeSummary; +import gg.agit.konect.domain.studytime.model.StudyTimeTotal; +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudyTimerService { + + private static final long TIMER_MISMATCH_THRESHOLD_SECONDS = 60L; + + private final StudyTimerRepository studyTimerRepository; + private final StudyTimeDailyRepository studyTimeDailyRepository; + private final StudyTimeMonthlyRepository studyTimeMonthlyRepository; + private final StudyTimeTotalRepository studyTimeTotalRepository; + private final UserRepository userRepository; + private final EntityManager entityManager; + + @Transactional + public void start(Integer userId) { + if (studyTimerRepository.existsByUserId(userId)) { + throw CustomException.of(ALREADY_RUNNING_STUDY_TIMER); + } + + User user = userRepository.getById(userId); + LocalDateTime startedAt = LocalDateTime.now(); + + try { + studyTimerRepository.save(StudyTimer.of(user, startedAt)); + entityManager.flush(); + } catch (DataIntegrityViolationException e) { + throw CustomException.of(ALREADY_RUNNING_STUDY_TIMER); + } + } + + @Transactional(noRollbackFor = CustomException.class) + public StudyTimerStopResponse stop(Integer userId, StudyTimerStopRequest request) { + StudyTimer studyTimer = studyTimerRepository.getByUserId(userId); + + LocalDateTime endedAt = LocalDateTime.now(); + LocalDateTime startedAt = studyTimer.getStartedAt(); + long serverSeconds = Duration.between(startedAt, endedAt).getSeconds(); + long clientSeconds = request.toTotalSeconds(); + + if (isElapsedTimeInvalid(serverSeconds, clientSeconds)) { + studyTimerRepository.delete(studyTimer); + throw CustomException.of(STUDY_TIMER_TIME_MISMATCH); + } + + long sessionSeconds = accumulateStudyTime(studyTimer.getUser(), startedAt, endedAt); + studyTimerRepository.delete(studyTimer); + StudyTimeSummary summary = buildSummary(userId, endedAt.toLocalDate(), sessionSeconds); + + return StudyTimerStopResponse.from(summary); + } + + private long accumulateStudyTime(User user, LocalDateTime startedAt, LocalDateTime endedAt) { + long sessionSeconds = accumulateDailyAndMonthlySeconds(user, startedAt, endedAt); + updateTotalSecondsIfNeeded(user, sessionSeconds); + + return sessionSeconds; + } + + private long accumulateDailyAndMonthlySeconds(User user, LocalDateTime startedAt, LocalDateTime endedAt) { + LocalDateTime cursor = startedAt; + long sessionSeconds = 0L; + LocalDate endDate = endedAt.toLocalDate(); + + while (cursor.isBefore(endedAt)) { + LocalDateTime segmentEnd; + + if (cursor.toLocalDate().isBefore(endDate)) { + segmentEnd = cursor.toLocalDate().plusDays(1).atStartOfDay(); + } else { + segmentEnd = endedAt; + } + + sessionSeconds += accumulateDailyAndMonthlySegment(user, cursor, segmentEnd); + cursor = segmentEnd; + } + + return sessionSeconds; + } + + private long accumulateDailyAndMonthlySegment(User user, LocalDateTime segmentStart, LocalDateTime segmentEnd) { + if (!segmentStart.isBefore(segmentEnd)) { + return 0L; + } + + long seconds = Duration.between(segmentStart, segmentEnd).getSeconds(); + + if (seconds <= 0) { + return 0L; + } + + LocalDate date = segmentStart.toLocalDate(); + addDailySegment(user, date, seconds); + addMonthlySegment(user, date, seconds); + + return seconds; + } + + private void addDailySegment(User user, LocalDate date, long seconds) { + StudyTimeDaily daily = studyTimeDailyRepository + .findByUserIdAndStudyDate(user.getId(), date) + .orElseGet(() -> StudyTimeDaily.of(user, date, 0L)); + + daily.addSeconds(seconds); + studyTimeDailyRepository.save(daily); + } + + private void addMonthlySegment(User user, LocalDate date, long seconds) { + LocalDate month = date.withDayOfMonth(1); + + StudyTimeMonthly monthly = studyTimeMonthlyRepository + .findByUserIdAndStudyMonth(user.getId(), month) + .orElseGet(() -> StudyTimeMonthly.of(user, month, 0L)); + + monthly.addSeconds(seconds); + studyTimeMonthlyRepository.save(monthly); + } + + private void updateTotalSecondsIfNeeded(User user, long sessionSeconds) { + if (sessionSeconds > 0) { + addTotalSeconds(user, sessionSeconds); + } + } + + private void addTotalSeconds(User user, long seconds) { + StudyTimeTotal total = studyTimeTotalRepository.findByUserId(user.getId()) + .orElseGet(() -> StudyTimeTotal.of(user, 0L)); + + total.addSeconds(seconds); + studyTimeTotalRepository.save(total); + } + + private StudyTimeSummary buildSummary(Integer userId, LocalDate endDate, long sessionSeconds) { + LocalDate month = endDate.withDayOfMonth(1); + + long dailySeconds = studyTimeDailyRepository.findByUserIdAndStudyDate(userId, endDate) + .map(StudyTimeDaily::getTotalSeconds) + .orElse(0L); + + long monthlySeconds = studyTimeMonthlyRepository.findByUserIdAndStudyMonth(userId, month) + .map(StudyTimeMonthly::getTotalSeconds) + .orElse(0L); + + long totalSeconds = studyTimeTotalRepository.findByUserId(userId) + .map(StudyTimeTotal::getTotalSeconds) + .orElse(0L); + + return new StudyTimeSummary(sessionSeconds, dailySeconds, monthlySeconds, totalSeconds); + } + + private boolean isElapsedTimeInvalid(long serverSeconds, long clientSeconds) { + return Math.abs(serverSeconds - clientSeconds) >= TIMER_MISMATCH_THRESHOLD_SECONDS; + } +} diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 1ec0918a..74f4b096 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -22,6 +22,8 @@ public enum ApiResponseCode { CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), REQUIRED_CLUB_APPLY_ANSWER_MISSING(HttpStatus.BAD_REQUEST, "필수 가입 답변이 누락되었습니다."), CANNOT_DELETE_CLUB_PRESIDENT(HttpStatus.BAD_REQUEST, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), + STUDY_TIMER_NOT_RUNNING(HttpStatus.BAD_REQUEST, "실행 중인 스터디 타이머가 없습니다."), + STUDY_TIMER_TIME_MISMATCH(HttpStatus.BAD_REQUEST, "스터디 타이머 시간이 유효하지 않습니다."), // 401 Unauthorized INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."), @@ -56,6 +58,7 @@ public enum ApiResponseCode { DUPLICATE_PHONE_NUMBER(HttpStatus.CONFLICT, "이미 사용 중인 전화번호입니다."), ALREADY_APPLIED_CLUB(HttpStatus.CONFLICT, "이미 동아리에 가입 신청을 완료했습니다."), DUPLICATE_CLUB_APPLY_QUESTION(HttpStatus.CONFLICT, "중복된 가입 문항이 포함되어 있습니다."), + ALREADY_RUNNING_STUDY_TIMER(HttpStatus.CONFLICT, "이미 실행 중인 스터디 타이머가 있습니다."), // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6fef261a..7b2ae738 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -255,3 +255,57 @@ CREATE TABLE chat_message FOREIGN KEY (chat_room_id) REFERENCES chat_room (id) ON DELETE CASCADE, FOREIGN KEY (sender_id) REFERENCES users (id) ON DELETE SET NULL ); + +CREATE TABLE study_timer +( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + started_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + UNIQUE (user_id), + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE study_time_daily +( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + study_date DATE NOT NULL, + total_seconds BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + UNIQUE (user_id, study_date), + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE study_time_monthly +( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + study_month DATE NOT NULL, + total_seconds BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + UNIQUE (user_id, study_month), + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE study_time_total +( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + total_seconds BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + UNIQUE (user_id), + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +);