Skip to content

Commit 60d5142

Browse files
Merge branch 'server-dev' into BOM-1110-매일메일-구독-수정-api
2 parents c870456 + 966cb06 commit 60d5142

50 files changed

Lines changed: 2637 additions & 150 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/bom-bom-server/build.gradle.kts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
plugins {
22
java
3-
id("org.springframework.boot") version "3.5.3"
3+
id("org.springframework.boot") version "3.5.14"
44
id("io.spring.dependency-management") version "1.1.7"
55
}
66

@@ -26,6 +26,7 @@ repositories {
2626
dependencyManagement {
2727
imports {
2828
mavenBom("org.testcontainers:testcontainers-bom:2.0.3")
29+
mavenBom("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha:2.26.1-alpha")
2930
}
3031
}
3132

@@ -61,8 +62,8 @@ dependencies {
6162
testImplementation("org.springframework.boot:spring-boot-starter-test")
6263
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
6364
testImplementation("org.testcontainers:testcontainers")
64-
testImplementation("org.testcontainers:mysql")
65-
testImplementation("org.testcontainers:junit-jupiter")
65+
testImplementation("org.testcontainers:testcontainers-mysql")
66+
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
6667
testImplementation("org.springframework.boot:spring-boot-testcontainers")
6768

6869
// db
@@ -87,7 +88,6 @@ dependencies {
8788
implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2")
8889

8990
//otel
90-
implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha:2.26.1-alpha"))
9191
implementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations")
9292
implementation("io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0")
9393

@@ -97,6 +97,10 @@ dependencies {
9797

9898
// Annotations
9999
implementation("jakarta.annotation:jakarta.annotation-api")
100+
101+
// Retry
102+
implementation("org.springframework.retry:spring-retry")
103+
implementation("org.springframework:spring-aspects")
100104
}
101105

102106
// Querydsl 생성된 파일 정리

backend/bom-bom-server/src/main/java/me/bombom/BomBomServerApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
import org.springframework.boot.autoconfigure.SpringBootApplication;
66
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
77
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
8+
import org.springframework.retry.annotation.EnableRetry;
89
import org.springframework.scheduling.annotation.EnableAsync;
910
import org.springframework.scheduling.annotation.EnableScheduling;
1011

1112
@EnableAsync
13+
@EnableRetry
1214
@EnableScheduling
1315
@EnableJpaAuditing
1416
@SpringBootApplication
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package me.bombom.api.v1.challenge.controller;
2+
3+
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.Positive;
5+
import lombok.RequiredArgsConstructor;
6+
import me.bombom.api.v1.challenge.dto.request.CreateChallengeReviewRequest;
7+
import me.bombom.api.v1.challenge.dto.request.UpdateChallengeReviewRequest;
8+
import me.bombom.api.v1.challenge.dto.response.ChallengeReviewResponse;
9+
import me.bombom.api.v1.challenge.dto.response.MyChallengeReviewResponse;
10+
import me.bombom.api.v1.challenge.service.ChallengeReviewService;
11+
import me.bombom.api.v1.common.resolver.LoginMember;
12+
import me.bombom.api.v1.member.domain.Member;
13+
import org.springframework.data.domain.Page;
14+
import org.springframework.data.domain.Pageable;
15+
import org.springframework.data.web.PageableDefault;
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.validation.annotation.Validated;
18+
import org.springframework.web.bind.annotation.GetMapping;
19+
import org.springframework.web.bind.annotation.PathVariable;
20+
import org.springframework.web.bind.annotation.PostMapping;
21+
import org.springframework.web.bind.annotation.PutMapping;
22+
import org.springframework.web.bind.annotation.RequestBody;
23+
import org.springframework.web.bind.annotation.RequestMapping;
24+
import org.springframework.web.bind.annotation.ResponseStatus;
25+
import org.springframework.web.bind.annotation.RestController;
26+
27+
@Validated
28+
@RestController
29+
@RequiredArgsConstructor
30+
@RequestMapping("/api/v1/challenges/{challengeId}/reviews")
31+
public class ChallengeReviewController implements ChallengeReviewControllerApi {
32+
33+
private final ChallengeReviewService challengeReviewService;
34+
35+
@Override
36+
@GetMapping
37+
public Page<ChallengeReviewResponse> getReviews(
38+
@PathVariable @Positive(message = "challengeId는 1 이상의 값이어야 합니다.") Long challengeId,
39+
@LoginMember Member member,
40+
@PageableDefault(size = 20) Pageable pageable
41+
) {
42+
return challengeReviewService.getReviews(challengeId, member.getId(), pageable);
43+
}
44+
45+
@Override
46+
@GetMapping("/me")
47+
public MyChallengeReviewResponse getMyReview(
48+
@PathVariable @Positive(message = "challengeId는 1 이상의 값이어야 합니다.") Long challengeId,
49+
@LoginMember Member member
50+
) {
51+
return challengeReviewService.getMyReview(challengeId, member);
52+
}
53+
54+
@Override
55+
@PostMapping
56+
@ResponseStatus(HttpStatus.CREATED)
57+
public void createReview(
58+
@PathVariable @Positive(message = "challengeId는 1 이상의 값이어야 합니다.") Long challengeId,
59+
@Valid @RequestBody CreateChallengeReviewRequest request,
60+
@LoginMember Member member
61+
) {
62+
challengeReviewService.createReview(challengeId, member, request);
63+
}
64+
65+
@Override
66+
@PutMapping("/{reviewId}")
67+
@ResponseStatus(HttpStatus.NO_CONTENT)
68+
public void updateReview(
69+
@PathVariable @Positive(message = "challengeId는 1 이상의 값이어야 합니다.") Long challengeId,
70+
@PathVariable @Positive(message = "reviewId는 1 이상의 값이어야 합니다.") Long reviewId,
71+
@Valid @RequestBody UpdateChallengeReviewRequest request,
72+
@LoginMember Member member
73+
) {
74+
challengeReviewService.updateReview(challengeId, reviewId, member, request);
75+
}
76+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package me.bombom.api.v1.challenge.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
5+
import io.swagger.v3.oas.annotations.media.Content;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
7+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.Valid;
10+
import jakarta.validation.constraints.Positive;
11+
import me.bombom.api.v1.challenge.dto.request.CreateChallengeReviewRequest;
12+
import me.bombom.api.v1.challenge.dto.request.UpdateChallengeReviewRequest;
13+
import me.bombom.api.v1.challenge.dto.response.ChallengeReviewResponse;
14+
import me.bombom.api.v1.challenge.dto.response.MyChallengeReviewResponse;
15+
import me.bombom.api.v1.common.resolver.LoginMember;
16+
import me.bombom.api.v1.member.domain.Member;
17+
import org.springframework.data.domain.Page;
18+
import org.springframework.data.domain.Pageable;
19+
import org.springframework.web.bind.annotation.PathVariable;
20+
import org.springframework.web.bind.annotation.RequestBody;
21+
22+
@Tag(name = "Challenge Review", description = "챌린지 리뷰 관련 API")
23+
public interface ChallengeReviewControllerApi {
24+
25+
@Operation(
26+
summary = "열람 가능한 리뷰 목록 조회",
27+
description = "로그인한 사용자가 직접 작성한 리뷰(비공개 포함)와 다른 사용자가 작성한 공개 리뷰 목록을 함께 조회합니다. "
28+
+ "정렬은 항상 최신순으로 적용되며, `sort` 파라미터는 무시됩니다. "
29+
+ "각 항목의 `isMyReview` 필드로 로그인 회원 본인 작성 여부를 분기 처리할 수 있습니다. "
30+
)
31+
@ApiResponses({
32+
@ApiResponse(responseCode = "200", description = "리뷰 목록 조회 성공"),
33+
@ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)", content = @Content),
34+
@ApiResponse(responseCode = "404", description = "챌린지를 찾을 수 없음", content = @Content)
35+
})
36+
Page<ChallengeReviewResponse> getReviews(
37+
@PathVariable @Positive(message = "challengeId는 1 이상의 값이어야 합니다.") Long challengeId,
38+
@Parameter(hidden = true) @LoginMember Member member,
39+
@Parameter(description = "페이징 요청 (예: ?page=0&size=20). 정렬은 항상 최신순으로 서버 강제이며 sort 파라미터는 무시됩니다.") Pageable pageable
40+
);
41+
42+
@Operation(
43+
summary = "내가 작성한 리뷰 조회",
44+
description = "로그인한 사용자가 해당 챌린지에 이미 작성한 리뷰가 있는지 확인합니다. "
45+
+ "리뷰가 존재하면 200과 함께 리뷰 본문을 반환하고, 없으면 404를 반환합니다. "
46+
)
47+
@ApiResponses({
48+
@ApiResponse(responseCode = "200", description = "내 리뷰가 존재함"),
49+
@ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)", content = @Content),
50+
@ApiResponse(responseCode = "404", description = "해당 챌린지에 작성한 리뷰가 없음", content = @Content)
51+
})
52+
MyChallengeReviewResponse getMyReview(
53+
@PathVariable @Positive(message = "challengeId는 1 이상의 값이어야 합니다.") Long challengeId,
54+
@Parameter(hidden = true) @LoginMember Member member
55+
);
56+
57+
@Operation(
58+
summary = "리뷰 작성",
59+
description = "로그인한 사용자가 챌린지 리뷰를 작성합니다. 비공개 여부를 함께 지정할 수 있습니다. "
60+
+ "본인이 참여한 챌린지에 대해서만 작성 가능하며, 비참여자 요청은 정보 누설 방지를 위해 404 로 응답합니다. "
61+
+ "리뷰는 챌린지 마지막 날(종료일) 부터 작성 가능합니다. 종료일 당일 작성 시 출석 인정, 종료 후 작성은 리뷰만 저장됩니다."
62+
)
63+
@ApiResponses({
64+
@ApiResponse(responseCode = "201", description = "리뷰 작성 성공"),
65+
@ApiResponse(
66+
responseCode = "400",
67+
description = "잘못된 요청 (유효성 검증 실패 / 이미 작성한 리뷰 존재 / 챌린지 종료일 이전 — 리뷰는 마지막 날부터 작성 가능)",
68+
content = @Content
69+
),
70+
@ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)", content = @Content),
71+
@ApiResponse(responseCode = "404", description = "챌린지를 찾을 수 없음 또는 본인이 참여하지 않은 챌린지", content = @Content)
72+
})
73+
void createReview(
74+
@PathVariable @Positive(message = "challengeId는 1 이상의 값이어야 합니다.") Long challengeId,
75+
@Valid @RequestBody CreateChallengeReviewRequest request,
76+
@Parameter(hidden = true) @LoginMember Member member
77+
);
78+
79+
@Operation(
80+
summary = "리뷰 수정",
81+
description = "로그인한 사용자가 자신의 챌린지 리뷰를 수정합니다. 코멘트와 비공개 여부를 함께 수정할 수 있습니다. "
82+
+ "본인 리뷰만 수정 가능하며, 타인 리뷰에 대한 요청은 정보 누설 방지를 위해 404 로 응답합니다."
83+
)
84+
@ApiResponses({
85+
@ApiResponse(responseCode = "204", description = "리뷰 수정 성공"),
86+
@ApiResponse(responseCode = "400", description = "잘못된 요청 (유효성 검증 실패)", content = @Content),
87+
@ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)", content = @Content),
88+
@ApiResponse(responseCode = "404", description = "리뷰를 찾을 수 없음 (미존재 / 경로의 챌린지 불일치 / 타인 리뷰 — IDOR 방어)", content = @Content)
89+
})
90+
void updateReview(
91+
@PathVariable @Positive(message = "challengeId는 1 이상의 값이어야 합니다.") Long challengeId,
92+
@PathVariable @Positive(message = "reviewId는 1 이상의 값이어야 합니다.") Long reviewId,
93+
@Valid @RequestBody UpdateChallengeReviewRequest request,
94+
@Parameter(hidden = true) @LoginMember Member member
95+
);
96+
}

backend/bom-bom-server/src/main/java/me/bombom/api/v1/challenge/domain/Challenge.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ public boolean isLastDay(LocalDate date) {
9191
return date.equals(this.endDate);
9292
}
9393

94+
public boolean isWithinPeriod(LocalDate date) {
95+
return hasStarted(date) && !isEnded(date);
96+
}
97+
98+
public boolean hasReachedEnd(LocalDate date) {
99+
return this.endDate != null && !date.isBefore(this.endDate);
100+
}
101+
94102
public int calculateMaxAllowedAbsences() {
95103
return totalDays - (int) Math.ceil(totalDays * SUCCESS_REQUIRED_RATIO);
96104
}

backend/bom-bom-server/src/main/java/me/bombom/api/v1/challenge/domain/ChallengeDailyResult.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,9 @@ public ChallengeDailyResult(
5151
this.date = date;
5252
this.status = status;
5353
}
54+
55+
public boolean isShieldApplied() {
56+
return this.status == ChallengeDailyStatus.SHIELD
57+
|| this.status == ChallengeDailyStatus.HOLIDAY_SHIELD;
58+
}
5459
}

backend/bom-bom-server/src/main/java/me/bombom/api/v1/challenge/domain/ChallengeDailyStatus.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
public enum ChallengeDailyStatus {
44

55
COMPLETE,
6-
SHIELD
6+
SHIELD,
7+
HOLIDAY_SHIELD
78
}

backend/bom-bom-server/src/main/java/me/bombom/api/v1/challenge/domain/ChallengeParticipant.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ public boolean useShieldIfAvailable() {
8989
return false;
9090
}
9191

92+
public void applyHolidayShield() {
93+
this.completedDays += 1;
94+
}
95+
9296
public void markAsFailed() {
9397
this.isSurvived = false;
9498
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package me.bombom.api.v1.challenge.domain;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.GeneratedValue;
6+
import jakarta.persistence.GenerationType;
7+
import jakarta.persistence.Id;
8+
import jakarta.persistence.Table;
9+
import jakarta.persistence.UniqueConstraint;
10+
import jakarta.validation.constraints.NotBlank;
11+
import lombok.AccessLevel;
12+
import lombok.Builder;
13+
import lombok.Getter;
14+
import lombok.NoArgsConstructor;
15+
import lombok.NonNull;
16+
import me.bombom.api.v1.common.BaseEntity;
17+
18+
@Entity
19+
@Getter
20+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
21+
@Table(uniqueConstraints = @UniqueConstraint(
22+
name = "uk_challenge_review_challenge_id_member_id",
23+
columnNames = {"challenge_id", "member_id"}
24+
))
25+
public class ChallengeReview extends BaseEntity {
26+
27+
@Id
28+
@GeneratedValue(strategy = GenerationType.IDENTITY)
29+
private Long id;
30+
31+
@Column(nullable = false)
32+
private Long challengeId;
33+
34+
@Column(nullable = false)
35+
private Long memberId;
36+
37+
@NotBlank
38+
@Column(nullable = false, length = 500)
39+
private String comment;
40+
41+
private boolean isPrivate;
42+
43+
@Builder
44+
public ChallengeReview(
45+
Long id,
46+
@NonNull Long memberId,
47+
@NonNull Long challengeId,
48+
@NotBlank String comment,
49+
boolean isPrivate
50+
) {
51+
this.id = id;
52+
this.memberId = memberId;
53+
this.challengeId = challengeId;
54+
this.comment = comment;
55+
this.isPrivate = isPrivate;
56+
}
57+
58+
public boolean isOwnedBy(Long memberId) {
59+
return this.memberId.equals(memberId);
60+
}
61+
62+
public void update(String comment, boolean isPrivate) {
63+
this.comment = comment;
64+
this.isPrivate = isPrivate;
65+
}
66+
}

backend/bom-bom-server/src/main/java/me/bombom/api/v1/challenge/domain/ChallengeTodoType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ public enum ChallengeTodoType {
44

55
READ,
66
COMMENT,
7-
MINDSET
7+
MINDSET,
8+
REVIEW
89
;
910
}

0 commit comments

Comments
 (0)