Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5eefbb7
feat: 순공 시간 및 타이머 DB 스키마 작성
dh2906 Dec 31, 2025
8c8adba
feat: 순공 시간 관련 엔티티 생성
dh2906 Dec 31, 2025
490c410
refactor: 도메인 패키지 명 수정
dh2906 Dec 31, 2025
4d71ef1
feat: 순공 시간 타이머 API 명세 작성
dh2906 Dec 31, 2025
b44c0a4
feat: 순공 시간 타이머 API 구현
dh2906 Dec 31, 2025
d8f9039
feat: 순공 시간 타이머 서비스 로직 구현
dh2906 Dec 31, 2025
821479e
fix: 순공 시간 타이머 PK 구조 변경
dh2906 Dec 31, 2025
3c2c381
fix: 순공 시간 집계 응답 순서 수정
dh2906 Dec 31, 2025
31d2f78
refactor: 스터디 타이머 종료 응답 매핑을 서비스로 이동
dh2906 Dec 31, 2025
8fa352e
fix: 동시 요청에 대한 에러 방지
dh2906 Dec 31, 2025
a349977
fix: 클래스 명, 엔드포인트 수정
dh2906 Dec 31, 2025
f3766f9
fix: 타이머 종료 시 유효성 검증을 위해 누적 시간을 요청으로 받도록 수정
dh2906 Dec 31, 2025
24f17a1
feat: 타이머 종료 누적 시간 DTO 추가
dh2906 Dec 31, 2025
f7b5661
fix: 매직 넘버 상수화
dh2906 Dec 31, 2025
801776d
fix: 순공 시간 타이머 엔드포인트 수정
dh2906 Jan 1, 2026
4d2deec
fix: 타이머 종료 시 시간, 분, 초 각각 요청으로 받도록 수정
dh2906 Jan 1, 2026
a8444d3
fix: StudyTimeAggregate -> StudyTimeSummary 네이밍 수정
dh2906 Jan 1, 2026
eb5943b
fix: 타이머 경과 시간 검증 메소드 명 수정
dh2906 Jan 1, 2026
4383a8d
fix: 공부 시간 반영하는 메소드 명 수정
dh2906 Jan 1, 2026
eb58ad1
refactor: 시간 누적 로직 메소드 분할
dh2906 Jan 2, 2026
caf492e
refactor: accumulateStudyTime 반환형 long으로 변경
dh2906 Jan 2, 2026
4d03665
refactor: 엔티티 생성을 정적 팩토리 메소드 호출하는 방식으로 수정
dh2906 Jan 2, 2026
ccb3cfa
chore: 코드 포맷팅
dh2906 Jan 2, 2026
d7b4df4
chore: 매직넘버 상수화
dh2906 Jan 2, 2026
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,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<Void> start(@UserId Integer userId);

@Operation(summary = "스터디 타이머를 종료한다.", description = """
## 설명
- 실행 중인 타이머를 종료하고 공부 시간을 집계합니다.
- 시간이 자정을 넘기면 날짜별로 분할 집계합니다.
- 일간, 월간, 총 누적 시간을 함께 갱신합니다.

## 에러
- `STUDY_TIMER_TIME_MISMATCH` (400): 클라이언트 누적 시간과 서버 시간 차이가 1분 이상인 경우
- `STUDY_TIMER_NOT_RUNNING` (400): 실행 중인 타이머가 없는 경우
""")
@DeleteMapping("/timers")
ResponseEntity<StudyTimerStopResponse> stop(
@UserId Integer userId,
@RequestBody @Valid StudyTimerStopRequest request
);
}
Original file line number Diff line number Diff line change
@@ -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<Void> start(@UserId Integer userId) {
studyTimerService.start(userId);

return ResponseEntity.ok().build();
}

@DeleteMapping("/timers")
public ResponseEntity<StudyTimerStopResponse> stop(
@UserId Integer userId,
@RequestBody @Valid StudyTimerStopRequest request
) {
StudyTimerStopResponse response = studyTimerService.stop(userId, request);

return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package gg.agit.konect.domain.studytime.model;

public record StudyTimeSummary(
long sessionSeconds,
long dailySeconds,
long monthlySeconds,
long totalSeconds
) {
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading