diff --git a/backend/build.gradle b/backend/build.gradle
index f29a8d5d6..d07cb9834 100644
--- a/backend/build.gradle
+++ b/backend/build.gradle
@@ -54,6 +54,7 @@ dependencies {
// S3
implementation 'software.amazon.awssdk:s3:2.26.0'
implementation 'software.amazon.awssdk:auth:2.26.0'
+ implementation 'software.amazon.awssdk:url-connection-client:2.26.0'
// resize tool
implementation 'net.coobird:thumbnailator:0.4.14'
diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java
index ed38de60a..d3784936d 100644
--- a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java
+++ b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java
@@ -3,6 +3,7 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
@@ -69,6 +70,16 @@ public ResponseEntity> getClubApplications(@CurrentUser CustomUserDetails user
return Response.ok(clubApplyAdminService.getClubApplicationForms(user));
}
+ @DeleteMapping("/application/{applicationFormId}")
+ @Operation(summary = "클럽 지원서 양식 삭제", description = "클럽의 지원서 양식을 삭제합니다")
+ @PreAuthorize("isAuthenticated()")
+ @SecurityRequirement(name = "BearerAuth")
+ public ResponseEntity> deleteClubApplicationForm(@PathVariable String applicationFormId,
+ @CurrentUser CustomUserDetails user) {
+ clubApplyAdminService.deleteClubApplicationForm(applicationFormId, user);
+ return Response.ok("success delete application");
+ }
+
@GetMapping("/apply/info/{applicationFormId}")
@Operation(summary = "클럽 지원자 현황", description = "클럽 지원자 현황을 불러옵니다")
@PreAuthorize("isAuthenticated()")
@@ -105,13 +116,16 @@ public ResponseEntity> removeApplicant(@PathVariable String applicationFormId,
return Response.ok("success delete applicant");
}
- @GetMapping(value = "/applicant/{applicationFormId}/events",produces = "text/event-stream")
- @Operation(summary = "지원자 상태 변경 실시간 이벤트",
- description = "지원자의 상태 변경을 실시간으로 받아볼 수 있는 SSE 엔드포인트입니다.")
+ @GetMapping(value = "/applicant/{applicationFormId}/events", produces = "text/event-stream")
+ @Operation(summary = "지원자 상태 변경 실시간 이벤트",
+ description = "지원자의 상태 변경을 실시간으로 받아볼 수 있는 SSE 엔드포인트입니다.")
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "BearerAuth")
- public SseEmitter getApplicantStatusEvents(@PathVariable String applicationFormId,
+ public SseEmitter getApplicantStatusEvents(HttpServletResponse response,
+ @PathVariable String applicationFormId,
@CurrentUser CustomUserDetails user) {
+ response.addHeader("X-Accel-Buffering", "no");
+ response.addHeader("Cache-Control", "no-cache");
return clubApplyAdminService.createSseConnection(applicationFormId, user);
}
diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java b/backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java
index 0fcbba2fa..52035d114 100644
--- a/backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java
+++ b/backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java
@@ -34,12 +34,11 @@ public ResponseEntity> applyToClub(@PathVariable String clubId,
return Response.ok("success apply");
}
- @GetMapping("/apply")
+ @GetMapping("/apply")
@Operation(summary = "클럽의 활성화된 지원서 목록 불러오기", description = "클럽의 활성화된 모든 지원서 목록을 불러옵니다")
public ResponseEntity> getActiveApplicationForms(@PathVariable String clubId) {
return Response.ok(clubApplyPublicService.getActiveApplicationForms(clubId));
}
-
}
diff --git a/backend/src/main/java/moadong/club/controller/ClubSearchController.java b/backend/src/main/java/moadong/club/controller/ClubSearchController.java
index 4a9c6a428..3ac3255d9 100644
--- a/backend/src/main/java/moadong/club/controller/ClubSearchController.java
+++ b/backend/src/main/java/moadong/club/controller/ClubSearchController.java
@@ -29,7 +29,7 @@ public class ClubSearchController {
+ "keyword에 빈칸 입력 시 전체 검색
"
+ "recruitmentStatus, category, division에 all 입력 시 전체 검색
"
+ "
"
- + "keyword는 대소문자 구분 없이 자유롭게 검색"
+ + "keyword는 대소문자 구분 없이 자유롭게 검색
"
+ "recruitmentStatus은 모집상태로 ALWAYS(상시모집), OPEN(모집중), CLOSED(모집마감), UPCOMING(모집예정)
"
+ "division은 분과로 중동
"
+ "category는 종류로 봉사, 종교, 취미교양, 학술, 운동, 공연, 기타
")
diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java
index c9df6fd1f..4e81a5333 100644
--- a/backend/src/main/java/moadong/club/entity/Club.java
+++ b/backend/src/main/java/moadong/club/entity/Club.java
@@ -1,14 +1,8 @@
package moadong.club.entity;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
-import com.google.firebase.messaging.Notification;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -25,6 +19,9 @@
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
+import java.util.List;
+import java.util.Map;
+
@Slf4j
@Document("clubs")
@AllArgsConstructor
@@ -49,15 +46,18 @@ public class Club implements Persistable {
@Field("recruitmentInformation")
private ClubRecruitmentInformation clubRecruitmentInformation;
+ @Field("description")
+ private ClubDescription clubDescription;
+
@Version
private Long version;
-
public Club() {
this.name = "";
this.category = "";
this.division = "";
this.state = ClubState.UNAVAILABLE;
this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build();
+ this.clubDescription = ClubDescription.builder().build();
}
public Club(String userId) {
@@ -67,6 +67,7 @@ public Club(String userId) {
this.state = ClubState.UNAVAILABLE;
this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build();
this.userId = userId;
+ this.clubDescription = ClubDescription.builder().build();
}
public Club(String id, String userId) {
@@ -77,15 +78,19 @@ public Club(String id, String userId) {
this.state = ClubState.UNAVAILABLE;
this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build();
this.userId = userId;
+ this.clubDescription = ClubDescription.builder().build();
}
@Builder
public Club(String name, String category, String division,
+ String userId,
ClubRecruitmentInformation clubRecruitmentInformation) {
this.name = name;
this.category = category;
this.division = division;
+ this.userId = userId;
this.clubRecruitmentInformation = clubRecruitmentInformation;
+ this.clubDescription = ClubDescription.builder().build();
}
public void update(ClubInfoRequest request) {
@@ -98,6 +103,7 @@ public void update(ClubInfoRequest request) {
this.state = ClubState.AVAILABLE;
this.socialLinks = request.socialLinks();
this.clubRecruitmentInformation.update(request);
+ this.clubDescription = request.description().toEntity();
}
private void validateTags(List tags) {
diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java
index 32e21a283..0b81a5edc 100644
--- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java
+++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java
@@ -2,26 +2,32 @@
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
-import java.util.ArrayList;
-import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
-import moadong.club.enums.ApplicantStatus;
+import moadong.club.enums.ApplicationFormMode;
import moadong.club.enums.ApplicationFormStatus;
import moadong.club.enums.SemesterTerm;
+import moadong.global.exception.ErrorCode;
+import moadong.global.exception.RestApiException;
import org.springframework.data.annotation.Version;
import org.springframework.data.domain.Persistable;
import org.springframework.data.mongodb.core.mapping.Document;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
@Document("club_application_forms")
@AllArgsConstructor
@Getter
@Builder(toBuilder = true)
public class ClubApplicationForm implements Persistable {
+ private static final String[] externalApplicationUrlAllowed = {"https://forms.gle", "https://docs.google.com/forms", "https://form.naver.com", "https://naver.me"};
@Id
private String id;
@@ -57,9 +63,20 @@ public class ClubApplicationForm implements Persistable {
@Builder.Default
private ApplicationFormStatus status = ApplicationFormStatus.UNPUBLISHED;
+ @NotNull
+ @Builder.Default
+ private ApplicationFormMode formMode = ApplicationFormMode.INTERNAL;
+
+ @Builder.Default
+ private String externalApplicationUrl = "";
+
@Version
private Long version;
+ public ApplicationFormMode getFormMode() {
+ return Optional.ofNullable(this.formMode).orElse(ApplicationFormMode.INTERNAL);
+ }
+
public void updateFormTitle(String title) {
this.title = title;
}
@@ -89,6 +106,21 @@ public void updateFormStatus(boolean activeFlag) {
this.status = ApplicationFormStatus.fromFlag(this.status, activeFlag);
}
+ public void updateFormMode(ApplicationFormMode formMode) {
+ this.formMode = formMode;
+ }
+
+ public void updateExternalApplicationUrl(String externalApplicationUrl) {
+ boolean allowed = Arrays.stream(externalApplicationUrlAllowed)
+ .anyMatch(externalApplicationUrl::startsWith);
+
+ if (!allowed) {
+ throw new RestApiException(ErrorCode.NOT_ALLOWED_EXTERNAL_URL);
+ }
+
+ this.externalApplicationUrl = externalApplicationUrl.trim();
+ }
+
@Override
public boolean isNew() {
return this.version == null;
diff --git a/backend/src/main/java/moadong/club/entity/ClubAward.java b/backend/src/main/java/moadong/club/entity/ClubAward.java
new file mode 100644
index 000000000..99a8c2893
--- /dev/null
+++ b/backend/src/main/java/moadong/club/entity/ClubAward.java
@@ -0,0 +1,19 @@
+package moadong.club.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class ClubAward {
+
+ private String semester;
+
+ private List achievements;
+}
diff --git a/backend/src/main/java/moadong/club/entity/ClubDescription.java b/backend/src/main/java/moadong/club/entity/ClubDescription.java
new file mode 100644
index 000000000..caa22e009
--- /dev/null
+++ b/backend/src/main/java/moadong/club/entity/ClubDescription.java
@@ -0,0 +1,27 @@
+package moadong.club.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class ClubDescription {
+
+ private String introDescription;
+
+ private String activityDescription;
+
+ private List awards;
+
+ private ClubIdealCandidate idealCandidate;
+
+ private String benefits;
+
+ private List faqs;
+}
diff --git a/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java b/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java
new file mode 100644
index 000000000..ba3bfcdf5
--- /dev/null
+++ b/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java
@@ -0,0 +1,19 @@
+package moadong.club.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class ClubIdealCandidate {
+
+ private List tags;
+
+ private String content;
+}
diff --git a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java
index 9de02b81b..819df38e2 100644
--- a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java
+++ b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java
@@ -6,10 +6,6 @@
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
-import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -19,6 +15,12 @@
import moadong.global.RegexConstants;
import org.checkerframework.common.aliasing.qual.Unique;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.List;
+
@AllArgsConstructor
@Getter
@Builder(toBuilder = true)
@@ -38,9 +40,6 @@ public class ClubRecruitmentInformation {
@Column(length = 30)
private String introduction;
- @Column(length = 20000)
- private String description;
-
@Column(length = 5)
private String presidentName;
@@ -48,9 +47,9 @@ public class ClubRecruitmentInformation {
@Column(length = 13)
private String presidentTelephoneNumber;
- private LocalDateTime recruitmentStart;
+ private Instant recruitmentStart;
- private LocalDateTime recruitmentEnd;
+ private Instant recruitmentEnd;
private String recruitmentTarget;
@@ -60,12 +59,12 @@ public class ClubRecruitmentInformation {
private List tags;
- private List faqs;
-
@Enumerated(EnumType.STRING)
@NotNull
private ClubRecruitmentStatus clubRecruitmentStatus;
+ private LocalDateTime lastModifiedDate;
+
public void updateLogo(String logo) {
this.logo = logo;
}
@@ -75,12 +74,10 @@ public void updateRecruitmentStatus(ClubRecruitmentStatus status) {
}
public void updateDescription(ClubRecruitmentInfoUpdateRequest request) {
- this.description = request.description();
this.recruitmentStart = request.recruitmentStart();
this.recruitmentEnd = request.recruitmentEnd();
this.recruitmentTarget = request.recruitmentTarget();
this.externalApplicationUrl = request.externalApplicationUrl();
- this.faqs = request.faqs();
}
public boolean hasRecruitmentPeriod() {
@@ -121,4 +118,12 @@ public void update(ClubInfoRequest request) {
public void updateCover(String cover) {
this.cover = cover;
}
+
+ private void setLastModifiedDate(LocalDateTime lastModifiedDate) {
+ this.lastModifiedDate = lastModifiedDate;
+ }
+
+ public void updateLastModifiedDate() {
+ setLastModifiedDate(LocalDateTime.now());
+ }
}
diff --git a/backend/src/main/java/moadong/club/enums/ApplicationFormMode.java b/backend/src/main/java/moadong/club/enums/ApplicationFormMode.java
new file mode 100644
index 000000000..6a55954b3
--- /dev/null
+++ b/backend/src/main/java/moadong/club/enums/ApplicationFormMode.java
@@ -0,0 +1,6 @@
+package moadong.club.enums;
+
+public enum ApplicationFormMode {
+ INTERNAL,
+ EXTERNAL
+}
diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java
new file mode 100644
index 000000000..ef793e595
--- /dev/null
+++ b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java
@@ -0,0 +1,25 @@
+package moadong.club.payload.dto;
+
+import jakarta.validation.constraints.Size;
+import moadong.club.entity.ClubAward;
+
+import java.util.List;
+
+public record ClubAwardDto(
+ @Size(max = 50)
+ String semester,
+
+ List<@Size(max = 100) String> achievements
+) {
+ public static ClubAwardDto from(ClubAward clubAward) {
+ if (clubAward == null) return null;
+ return new ClubAwardDto(clubAward.getSemester(), clubAward.getAchievements());
+ }
+
+ public ClubAward toEntity() {
+ return ClubAward.builder()
+ .semester(semester)
+ .achievements(achievements)
+ .build();
+ }
+}
diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java
new file mode 100644
index 000000000..9f1b836b3
--- /dev/null
+++ b/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java
@@ -0,0 +1,50 @@
+package moadong.club.payload.dto;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Size;
+import moadong.club.entity.ClubDescription;
+
+import java.util.List;
+
+public record ClubDescriptionDto(
+ @Size(max = 500)
+ String introDescription,
+
+ @Size(max = 1000)
+ String activityDescription,
+
+ @Valid
+ List awards,
+
+ @Valid
+ ClubIdealCandidateDto idealCandidate,
+
+ @Size(max = 1000)
+ String benefits,
+
+ @Valid
+ List faqs
+) {
+ public static ClubDescriptionDto from(ClubDescription description) {
+ if (description == null) return null;
+ return new ClubDescriptionDto(
+ description.getIntroDescription(),
+ description.getActivityDescription(),
+ description.getAwards() == null ? null : description.getAwards().stream().map(ClubAwardDto::from).toList(),
+ ClubIdealCandidateDto.from(description.getIdealCandidate()),
+ description.getBenefits(),
+ description.getFaqs() == null ? null : description.getFaqs().stream().map(FaqDto::from).toList()
+ );
+ }
+
+ public ClubDescription toEntity() {
+ return ClubDescription.builder()
+ .introDescription(introDescription)
+ .activityDescription(activityDescription)
+ .awards(awards == null ? null : awards.stream().map(ClubAwardDto::toEntity).toList())
+ .idealCandidate(idealCandidate == null ? null : idealCandidate.toEntity())
+ .benefits(benefits)
+ .faqs(faqs == null ? null : faqs.stream().map(FaqDto::toEntity).toList())
+ .build();
+ }
+}
diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java
index 37802ac40..5907da911 100644
--- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java
+++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java
@@ -1,12 +1,12 @@
package moadong.club.payload.dto;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-import java.util.Map;
import lombok.Builder;
import moadong.club.entity.Club;
import moadong.club.entity.ClubRecruitmentInformation;
-import moadong.club.entity.Faq;
+
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
@Builder
public record ClubDetailedResult(
@@ -18,27 +18,35 @@ public record ClubDetailedResult(
String state,
List feeds,
String introduction,
- String description,
+ ClubDescriptionDto description,
String presidentName,
String presidentPhoneNumber,
- String recruitmentPeriod,
+ String recruitmentStart,
+ String recruitmentEnd,
String recruitmentTarget,
String recruitmentStatus,
String externalApplicationUrl,
Map socialLinks,
String category,
String division,
- List faqs,
- List recommendClubs
+ String lastModifiedDate
) {
- public static ClubDetailedResult of(Club club, List recommendClubs) {
- String period = "미정";
+ public static ClubDetailedResult of(Club club) {
ClubRecruitmentInformation clubRecruitmentInformation = club.getClubRecruitmentInformation();
+
+ String start = "미정";
+ String end = "미정";
if (clubRecruitmentInformation.hasRecruitmentPeriod()) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
- period = clubRecruitmentInformation.getRecruitmentStart().format(formatter) + " ~ "
- + clubRecruitmentInformation.getRecruitmentEnd().format(formatter);
+ start = clubRecruitmentInformation.getRecruitmentStart().format(formatter);
+ end = clubRecruitmentInformation.getRecruitmentEnd().format(formatter);
+ }
+
+ String lastModifiedDate = "";
+ if (club.getClubRecruitmentInformation().getLastModifiedDate() != null) {
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
+ lastModifiedDate = club.getClubRecruitmentInformation().getLastModifiedDate().format(formatter);
}
return ClubDetailedResult.builder()
.id(club.getId() == null ? "" : club.getId())
@@ -56,25 +64,23 @@ public static ClubDetailedResult of(Club club, List recommendC
.division(club.getDivision() == null ? "" : club.getDivision())
.introduction(clubRecruitmentInformation.getIntroduction() == null ? ""
: clubRecruitmentInformation.getIntroduction())
- .description(clubRecruitmentInformation.getDescription() == null ? ""
- : clubRecruitmentInformation.getDescription())
+ .description(ClubDescriptionDto.from(club.getClubDescription()))
.presidentName(clubRecruitmentInformation.getPresidentName() == null ? ""
: clubRecruitmentInformation.getPresidentName())
.presidentPhoneNumber(
clubRecruitmentInformation.getPresidentTelephoneNumber() == null ? ""
: clubRecruitmentInformation.getPresidentTelephoneNumber())
- .recruitmentPeriod(period)
+ .recruitmentStart(start)
+ .recruitmentEnd(end)
.recruitmentTarget(clubRecruitmentInformation.getRecruitmentTarget() == null ? ""
: clubRecruitmentInformation.getRecruitmentTarget())
.recruitmentStatus(clubRecruitmentInformation.getClubRecruitmentStatus() == null
- ? "" : clubRecruitmentInformation.getClubRecruitmentStatus().getDescription())
+ ? "" : clubRecruitmentInformation.getClubRecruitmentStatus().toString())
.externalApplicationUrl(club.getClubRecruitmentInformation().getExternalApplicationUrl() == null ? "" :
club.getClubRecruitmentInformation().getExternalApplicationUrl())
.socialLinks(club.getSocialLinks() == null ? Map.of()
: club.getSocialLinks())
- .faqs(club.getClubRecruitmentInformation().getFaqs() == null ? List.of()
- : club.getClubRecruitmentInformation().getFaqs())
- .recommendClubs(recommendClubs)
+ .lastModifiedDate(lastModifiedDate)
.build();
}
diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java
new file mode 100644
index 000000000..87e00d6ce
--- /dev/null
+++ b/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java
@@ -0,0 +1,25 @@
+package moadong.club.payload.dto;
+
+import jakarta.validation.constraints.Size;
+import moadong.club.entity.ClubIdealCandidate;
+
+import java.util.List;
+
+public record ClubIdealCandidateDto(
+ List<@Size(max = 10) String> tags,
+
+ @Size(max = 700)
+ String content
+) {
+ public static ClubIdealCandidateDto from(ClubIdealCandidate candidate) {
+ if (candidate == null) return null;
+ return new ClubIdealCandidateDto(candidate.getTags(), candidate.getContent());
+ }
+
+ public ClubIdealCandidate toEntity() {
+ return ClubIdealCandidate.builder()
+ .tags(tags)
+ .content(content)
+ .build();
+ }
+}
diff --git a/backend/src/main/java/moadong/club/payload/dto/FaqDto.java b/backend/src/main/java/moadong/club/payload/dto/FaqDto.java
new file mode 100644
index 000000000..9bec92497
--- /dev/null
+++ b/backend/src/main/java/moadong/club/payload/dto/FaqDto.java
@@ -0,0 +1,21 @@
+package moadong.club.payload.dto;
+
+import jakarta.validation.constraints.Size;
+import moadong.club.entity.Faq;
+
+public record FaqDto(
+ @Size(max = 100)
+ String question,
+
+ @Size(max = 500)
+ String answer
+) {
+ public static FaqDto from(Faq faq) {
+ if (faq == null) return null;
+ return new FaqDto(faq.getQuestion(), faq.getAnswer());
+ }
+
+ public Faq toEntity() {
+ return new Faq(question, answer);
+ }
+}
diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java
index 6b9629286..67094545f 100644
--- a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java
+++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java
@@ -1,27 +1,29 @@
package moadong.club.payload.request;
import jakarta.validation.Valid;
-import jakarta.validation.constraints.Min;
-import jakarta.validation.constraints.Max;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Size;
-import java.util.List;
+import jakarta.validation.constraints.*;
+import moadong.club.enums.ApplicationFormMode;
import moadong.club.enums.SemesterTerm;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
public record ClubApplicationFormCreateRequest(
@NotBlank
@Size(max = 50)
String title,
- @NotBlank
@Size(max = 3000)
String description,
- @NotNull
@Valid
List questions,
+ @NotNull
+ ApplicationFormMode formMode,
+
+ String externalApplicationUrl,
+
@NotNull
@Min(2000)
@Max(2999)
@@ -30,4 +32,25 @@ public record ClubApplicationFormCreateRequest(
@NotNull
SemesterTerm semesterTerm
) {
+
+ @AssertTrue(message = "지원서 양식에 필요한 필드가 누락되었습니다.")
+ private boolean isInternalFormValid() {
+ if (formMode != ApplicationFormMode.INTERNAL) {
+ return true;
+ }
+
+ boolean hasDescription = StringUtils.hasText(description);
+ boolean hasQuestions = questions != null && !questions.isEmpty();
+
+ return hasDescription && hasQuestions;
+ }
+
+ @AssertTrue(message = "외부 링크가 누락되었습니다.")
+ private boolean isExternalFormValid() {
+ if (formMode != ApplicationFormMode.EXTERNAL) {
+ return true;
+ }
+
+ return StringUtils.hasText(externalApplicationUrl);
+ }
}
diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java
index cd8442ec3..63a7249e6 100644
--- a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java
+++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java
@@ -2,6 +2,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
+import moadong.club.enums.ApplicationFormMode;
import moadong.club.enums.SemesterTerm;
import java.util.List;
@@ -18,6 +19,10 @@ public record ClubApplicationFormEditRequest(
@Valid
List questions,
+ String externalApplicationUrl,
+
+ ApplicationFormMode formMode,
+
@Min(2000)
@Max(2999)
Integer semesterYear,
diff --git a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java
index 0f9c75790..24e481886 100644
--- a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java
+++ b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java
@@ -1,12 +1,14 @@
package moadong.club.payload.request;
import jakarta.validation.constraints.NotBlank;
-import java.util.List;
-import java.util.Map;
+import moadong.club.payload.dto.ClubDescriptionDto;
import moadong.club.enums.ClubCategory;
import moadong.club.enums.ClubDivision;
import moadong.global.annotation.PhoneNumber;
+import java.util.List;
+import java.util.Map;
+
public record ClubInfoRequest(
@NotBlank
String name,
@@ -15,6 +17,7 @@ public record ClubInfoRequest(
List tags,
String introduction,
String presidentName,
+ ClubDescriptionDto description,
@PhoneNumber
String presidentPhoneNumber,
Map socialLinks
diff --git a/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java
index 34ad95521..396d46dd1 100644
--- a/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java
+++ b/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java
@@ -1,16 +1,12 @@
package moadong.club.payload.request;
-import java.time.LocalDateTime;
-import java.util.List;
-import moadong.club.entity.Faq;
+import java.time.Instant;
public record ClubRecruitmentInfoUpdateRequest(
- LocalDateTime recruitmentStart,
- LocalDateTime recruitmentEnd,
+ Instant recruitmentStart,
+ Instant recruitmentEnd,
String recruitmentTarget,
- String description,
- String externalApplicationUrl,
- List faqs
+ String externalApplicationUrl
) {
}
diff --git a/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java b/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java
index 72a7dd935..b42387e5b 100644
--- a/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java
+++ b/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java
@@ -2,6 +2,7 @@
import lombok.Builder;
import moadong.club.entity.ClubApplicationFormQuestion;
+import moadong.club.enums.ApplicationFormMode;
import moadong.club.enums.ApplicationFormStatus;
import moadong.club.enums.SemesterTerm;
@@ -12,6 +13,8 @@ public record ClubApplicationFormResponse(
String title,
String description,
List questions,
+ String externalApplicationUrl,
+ ApplicationFormMode formMode,
Integer semesterYear,
SemesterTerm semesterTerm,
ApplicationFormStatus status
diff --git a/backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java b/backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java
index 9d36cc136..616f2cdb6 100644
--- a/backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java
+++ b/backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java
@@ -10,4 +10,6 @@ public interface ClubApplicantsRepository extends MongoRepository findAllByFormId(String questionId);
List findAllByIdInAndFormId(List ids, String formId);
+
+ void deleteAllByFormId(String formId);
}
\ No newline at end of file
diff --git a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java
index 64c5580ca..591c817c1 100644
--- a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java
+++ b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java
@@ -43,8 +43,7 @@ public List searchClubsByKeyword(String keyword, String recrui
if (keyword != null && !keyword.trim().isEmpty()) {
operations.add(Aggregation.match(new Criteria().orOperator(
Criteria.where("name").regex(keyword, "i"),
- Criteria.where("recruitmentInformation.introduction").regex(keyword, "i"),
- Criteria.where("recruitmentInformation.description").regex(keyword, "i"),
+ Criteria.where("category").regex(keyword, "i"),
Criteria.where("recruitmentInformation.tags").regex(keyword, "i")
)));
}
@@ -68,140 +67,6 @@ public List searchClubsByKeyword(String keyword, String recrui
return results.getMappedResults();
}
- public List searchRecommendClubs(String category, String excludeClubId) {
- Set excludeIds = new HashSet<>();
- if (excludeClubId != null) {
- excludeIds.add(excludeClubId);
- }
-
- List result = new ArrayList<>();
-
- // 1. 같은 카테고리 모집중 + (모집마감 포함) 동아리 최대 4개 추출 (모집상태 우선)
- int maxCategoryCount = 4;
- List categoryClubs = findClubsByCategoryAndState(category, excludeIds, true, maxCategoryCount);
- addClubs(result, excludeIds, categoryClubs);
-
- int remainCount = maxCategoryCount - categoryClubs.size();
-
- // 2. 부족하면 마감 동아리로 채우기
- if (remainCount > 0) {
- List categoryClosedClubs = findClubsByCategoryAndState(category, excludeIds, false, remainCount);
- addClubs(result, excludeIds, categoryClosedClubs);
- }
-
- // 3. 나머지 전체 랜덤 2개(모집상태 우선)로 채우기
- int totalNeeded = 6;
- int randomNeeded = totalNeeded - result.size();
-
- if (randomNeeded > 0) {
- List randomPool = findRandomClubs(excludeIds, 10);
-
- List selectedRandomClubs = selectClubsByStatePriority(randomPool, randomNeeded);
- addClubs(result, excludeIds, selectedRandomClubs);
- }
-
- return result.isEmpty() ? Collections.emptyList() : result;
- }
-
- // 같은 카테고리 & 주어진 모집 상태별 랜덤 n개 동아리 조회
- private List findClubsByCategoryAndState(String category, Set excludeIds,
- boolean onlyRecruitAvailable, int limit) {
- List ops = new ArrayList<>();
-
- Criteria criteria = Criteria.where("category").is(category)
- .and("_id").nin(excludeIds);
-
- if (onlyRecruitAvailable) {
- criteria = criteria.and("recruitmentInformation.clubRecruitmentStatus")
- .in(
- ClubRecruitmentStatus.ALWAYS.toString(),
- ClubRecruitmentStatus.OPEN.toString()
- );
- }
-
- ops.add(Aggregation.match(criteria));
- ops.add(Aggregation.sample((long) limit));
-
- // searchClubsByKeyword 와 동일한 project 단계 적용
- ops.add(
- Aggregation.project("name", "state", "category", "division")
- .and("recruitmentInformation.introduction").as("introduction")
- .and("recruitmentInformation.clubRecruitmentStatus").as("recruitmentStatus")
- .and(ConditionalOperators.ifNull("$recruitmentInformation.logo").then(""))
- .as("logo")
- .and(ConditionalOperators.ifNull("$recruitmentInformation.tags").then(Collections.emptyList()))
- .as("tags")
- );
-
- return mongoTemplate.aggregate(Aggregation.newAggregation(ops), "clubs", ClubSearchResult.class)
- .getMappedResults();
- }
-
- // 중복 ID 추적하며 클럽 리스트에 추가
- private void addClubs(List result, Set excludeIds, List clubs) {
- for (ClubSearchResult club : clubs) {
- if (!excludeIds.contains(club.id())) {
- result.add(club);
- excludeIds.add(club.id());
- }
- }
- }
-
- // 전체 랜덤 풀에서 모집중 우선으로 n개, 부족하면 마감 동아리로 채움
- private boolean isRecruiting(ClubSearchResult club) {
- String status = club.recruitmentStatus();
- return ClubRecruitmentStatus.ALWAYS.toString().equals(status) || ClubRecruitmentStatus.OPEN.toString().equals(status);
- }
-
- private List selectClubsByStatePriority(List pool, int maxCount) {
- List selected = new ArrayList<>();
- Set ids = new HashSet<>();
-
- // 모집중 우선 선택
- for (ClubSearchResult club : pool) {
- if (selected.size() >= maxCount) break;
- if (isRecruiting(club) && !ids.contains(club.id())) {
- selected.add(club);
- ids.add(club.id());
- }
- }
-
- // 부족하면 모집 마감 동아리 추가
- if (selected.size() < maxCount) {
- for (ClubSearchResult club : pool) {
- if (selected.size() >= maxCount) break;
- if (!isRecruiting(club) && !ids.contains(club.id())) {
- selected.add(club);
- ids.add(club.id());
- }
- }
- }
-
- return selected;
- }
-
- // 전체 클럽에서 랜덤 n개 뽑기 (중복 제거용 excludeIds는 외부에서 처리)
- private List findRandomClubs(Set excludeIds, int sampleSize) {
- List ops = new ArrayList<>();
- ops.add(Aggregation.match(Criteria.where("_id").nin(excludeIds)));
- ops.add(Aggregation.sample((long) sampleSize));
-
- ops.add(
- Aggregation.project("name", "state", "category", "division")
- .and("recruitmentInformation.introduction").as("introduction")
- .and("recruitmentInformation.clubRecruitmentStatus").as("recruitmentStatus")
- .and(ConditionalOperators.ifNull("$recruitmentInformation.logo").then(""))
- .as("logo")
- .and(ConditionalOperators.ifNull("$recruitmentInformation.tags").then(Collections.emptyList()))
- .as("tags")
- );
-
- return mongoTemplate.aggregate(Aggregation.newAggregation(ops), "clubs", ClubSearchResult.class)
- .getMappedResults();
- }
-
-
-
private Criteria getMatchedCriteria(String recruitmentStatus, String division,
String category) {
List criteriaList = new ArrayList<>();
diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java
index dd9f705f8..b4e485562 100644
--- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java
+++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java
@@ -1,6 +1,5 @@
package moadong.club.service;
-import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import moadong.club.entity.*;
@@ -17,6 +16,7 @@
import moadong.global.util.AESCipher;
import moadong.user.payload.CustomUserDetails;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -119,6 +119,15 @@ public ClubApplicationFormsResponse getClubApplicationForms(CustomUserDetails us
.build();
}
+ @Transactional
+ public void deleteClubApplicationForm(String applicationFormId, CustomUserDetails user) {
+ ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId)
+ .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
+
+ clubApplicantsRepository.deleteAllByFormId(applicationForm.getId());
+ clubApplicationFormsRepository.delete(applicationForm);
+ }
+
public ClubApplyInfoResponse getClubApplyInfo(String applicationFormId, CustomUserDetails user) {
ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId)
.orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
@@ -216,11 +225,15 @@ public void deleteApplicant(String applicationFormId, ClubApplicantDeleteRequest
}
private ClubApplicationForm createApplicationForm(ClubApplicationForm clubApplicationForm, ClubApplicationFormCreateRequest request) {
- clubApplicationForm.updateQuestions(buildClubFormQuestions(request.questions()));
+ if (request.questions() != null)
+ clubApplicationForm.updateQuestions(buildClubFormQuestions(request.questions()));
+ if (request.externalApplicationUrl() != null)
+ clubApplicationForm.updateExternalApplicationUrl(request.externalApplicationUrl());
clubApplicationForm.updateFormTitle(request.title());
clubApplicationForm.updateFormDescription(request.description());
clubApplicationForm.updateSemesterYear(request.semesterYear());
clubApplicationForm.updateSemesterTerm(request.semesterTerm());
+ clubApplicationForm.updateFormMode(request.formMode());
return clubApplicationForm;
}
@@ -234,6 +247,10 @@ private ClubApplicationForm updateApplicationForm(ClubApplicationForm clubApplic
clubApplicationForm.updateFormDescription(request.description());
if (request.active() != null)
clubApplicationForm.updateFormStatus(request.active());
+ if (request.formMode() != null)
+ clubApplicationForm.updateFormMode(request.formMode());
+ if (request.externalApplicationUrl() != null)
+ clubApplicationForm.updateExternalApplicationUrl(request.externalApplicationUrl());
if (request.semesterYear() != null || request.semesterTerm() != null) {
Integer semesterYear = Optional.ofNullable(request.semesterYear()).orElse(clubApplicationForm.getSemesterYear());
@@ -253,6 +270,9 @@ private List buildClubFormQuestions(List items = new ArrayList<>();
+ Set distinctQuestionItemList = new HashSet<>(question.items());
+ if (distinctQuestionItemList.size() != question.items().size()) throw new RestApiException(ErrorCode.DUPLICATE_QUESTIONS_ITEMS);
+
for (var item : question.items()) {
items.add(ClubQuestionItem.builder()
.value(item.value())
diff --git a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java
index ef9f2c937..8853be996 100644
--- a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java
+++ b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java
@@ -6,6 +6,7 @@
import moadong.club.entity.ClubApplicationForm;
import moadong.club.entity.ClubApplicationFormQuestion;
import moadong.club.entity.ClubQuestionAnswer;
+import moadong.club.enums.ApplicationFormMode;
import moadong.club.enums.ClubApplicationQuestionType;
import moadong.club.payload.dto.ClubActiveFormResult;
import moadong.club.payload.dto.ClubActiveFormSlim;
@@ -54,8 +55,16 @@ public ClubActiveFormsResponse getActiveApplicationForms(String clubId) {
public ResponseEntity> getClubApplicationForm(String clubId, String applicationFormId) {
ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
-
- ClubApplicationFormResponse clubApplicationFormResponse = ClubApplicationFormResponse.builder().title(clubApplicationForm.getTitle()).description(Optional.ofNullable(clubApplicationForm.getDescription()).orElse("")).questions(clubApplicationForm.getQuestions()).semesterYear(clubApplicationForm.getSemesterYear()).semesterTerm(clubApplicationForm.getSemesterTerm()).status(clubApplicationForm.getStatus()).build();
+ ClubApplicationFormResponse clubApplicationFormResponse = ClubApplicationFormResponse.builder()
+ .title(clubApplicationForm.getTitle())
+ .description(Optional.ofNullable(clubApplicationForm.getDescription()).orElse(""))
+ .questions(clubApplicationForm.getQuestions())
+ .formMode(clubApplicationForm.getFormMode())
+ .externalApplicationUrl(clubApplicationForm.getExternalApplicationUrl())
+ .semesterYear(clubApplicationForm.getSemesterYear())
+ .semesterTerm(clubApplicationForm.getSemesterTerm())
+ .status(clubApplicationForm.getStatus())
+ .build();
return Response.ok(clubApplicationFormResponse);
}
@@ -64,6 +73,8 @@ public ResponseEntity> getClubApplicationForm(String clubId, String applicatio
public void applyToClub(String clubId, String applicationFormId, ClubApplyRequest request) {
ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
+ if (clubApplicationForm.getFormMode() == ApplicationFormMode.EXTERNAL)
+ throw new RestApiException(ErrorCode.APPLICATION_NOT_FOUND);
validateAnswers(request.questions(), clubApplicationForm);
List answers = new ArrayList<>();
diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java
index 4759a5566..5116ecba9 100644
--- a/backend/src/main/java/moadong/club/service/ClubProfileService.java
+++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java
@@ -1,10 +1,8 @@
package moadong.club.service;
-import java.util.List;
import lombok.AllArgsConstructor;
import moadong.club.entity.Club;
import moadong.club.payload.dto.ClubDetailedResult;
-import moadong.club.payload.dto.ClubSearchResult;
import moadong.club.payload.request.ClubInfoRequest;
import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest;
import moadong.club.payload.response.ClubDetailedResponse;
@@ -35,15 +33,16 @@ public void updateClubInfo(ClubInfoRequest request, CustomUserDetails user) {
}
public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request,
- CustomUserDetails user) {
+ CustomUserDetails user) {
Club club = clubRepository.findClubByUserId(user.getId())
- .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
+ .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
club.update(request);
RecruitmentStateCalculator.calculate(
club,
club.getClubRecruitmentInformation().getRecruitmentStart(),
club.getClubRecruitmentInformation().getRecruitmentEnd()
);
+ club.getClubRecruitmentInformation().updateLastModifiedDate();
clubRepository.save(club);
}
@@ -52,10 +51,8 @@ public ClubDetailedResponse getClubDetail(String clubId) {
Club club = clubRepository.findClubById(objectId)
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
- List clubSearchResults = clubSearchRepository.searchRecommendClubs(club.getCategory(), clubId);
-
ClubDetailedResult clubDetailedResult = ClubDetailedResult.of(
- club,clubSearchResults
+ club
);
return new ClubDetailedResponse(clubDetailedResult);
}
diff --git a/backend/src/main/java/moadong/club/service/ClubSearchService.java b/backend/src/main/java/moadong/club/service/ClubSearchService.java
index 1986c0365..e6fc909dc 100644
--- a/backend/src/main/java/moadong/club/service/ClubSearchService.java
+++ b/backend/src/main/java/moadong/club/service/ClubSearchService.java
@@ -1,6 +1,7 @@
package moadong.club.service;
import java.util.*;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import moadong.club.enums.ClubCategory;
@@ -23,8 +24,10 @@ public ClubSearchResponse searchClubsByKeyword(String keyword,
String division,
String category
) {
+ String quotedKeyword = quotedKeyword(keyword);
+
List result = clubSearchRepository.searchClubsByKeyword(
- keyword,
+ quotedKeyword,
recruitmentStatus,
division,
category
@@ -60,4 +63,8 @@ public ClubSearchResponse searchClubsByKeyword(String keyword,
.totalCount(result.size())
.build();
}
+
+ private String quotedKeyword(String keyword) {
+ return (keyword == null || keyword.trim().isEmpty()) ? keyword : Pattern.quote(keyword.trim());
+ }
}
diff --git a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java
index 289ea8540..8e4b89210 100644
--- a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java
+++ b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java
@@ -32,10 +32,6 @@ public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime rec
return ClubRecruitmentStatus.CLOSED;
}
- if (recruitmentEndDate.getYear() == ALWAYS_RECRUIT_YEAR) {
- return ClubRecruitmentStatus.ALWAYS;
- }
-
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));
if (now.isBefore(recruitmentStartDate)) {
@@ -45,8 +41,10 @@ public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime rec
: ClubRecruitmentStatus.CLOSED;
}
- if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) {
- return ClubRecruitmentStatus.OPEN;
+ if (!now.isBefore(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) {
+ return (recruitmentEndDate.getYear() == ALWAYS_RECRUIT_YEAR)
+ ? ClubRecruitmentStatus.ALWAYS
+ : ClubRecruitmentStatus.OPEN;
}
return ClubRecruitmentStatus.CLOSED;
diff --git a/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java b/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java
index 3a10ff83c..f14e6ee87 100644
--- a/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java
+++ b/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java
@@ -7,4 +7,5 @@
public interface FcmTokenRepository extends MongoRepository {
Optional findFcmTokenByToken(String fcmToken);
+ void deleteFcmTokenByToken(String fcmToken);
}
diff --git a/backend/src/main/java/moadong/global/exception/ErrorCode.java b/backend/src/main/java/moadong/global/exception/ErrorCode.java
index a9355a155..2d31d706d 100644
--- a/backend/src/main/java/moadong/global/exception/ErrorCode.java
+++ b/backend/src/main/java/moadong/global/exception/ErrorCode.java
@@ -6,6 +6,8 @@
@Getter
public enum ErrorCode {
CONCURRENCY_CONFLICT(HttpStatus.CONFLICT, "100-1","다른 사용자가 먼저 수정했습니다. 페이지를 새로고침 후 다시 이용해주세요"),
+
+ // 600xx: Club 관련 오류
CLUB_NOT_FOUND(HttpStatus.NOT_FOUND, "600-1", "동아리가 존재하지 않습니다."),
CLUB_INFORMATION_NOT_FOUND(HttpStatus.NOT_FOUND, "600-2", "동아리 상세 정보가 존재하지 않습니다."),
CLUB_FEED_IMAGES_NOT_FOUND(HttpStatus.NOT_FOUND, "600-3", "동아리 피드가 존재하지 않습니다."),
@@ -17,6 +19,7 @@ public enum ErrorCode {
TOO_LONG_TAG(HttpStatus.BAD_REQUEST, "600-9", "태그는 최대 5글자까지 입력할 수 있습니다."),
TOO_LONG_INTRODUCTION(HttpStatus.BAD_REQUEST, "600-10", "소개는 최대 24글자까지 입력할 수 있습니다."),
+ // 601xx: 파일/미디어 관련 오류
IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "601-1", "이미지 업로드에 실패하였습니다."),
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "601-2", "이미지 파일을 찾을 수 없습니다."),
TOO_MANY_FILES(HttpStatus.PAYLOAD_TOO_LARGE, "601-3", "이미지 파일이 최대치보다 많습니다."),
@@ -24,19 +27,26 @@ public enum ErrorCode {
KOREAN_FILE_NAME(HttpStatus.INTERNAL_SERVER_ERROR, "601-5", "파일명의 한국어를 인코딩할 수 없습니다."),
FILE_TRANSFER_ERROR(HttpStatus.BAD_REQUEST, "601-6", "파일을 올바른 형식으로 변경할 수 없습니다."),
UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "601-7", "파일의 확장자가 올바르지 않습니다."),
+ INVALID_FILE_URL(HttpStatus.BAD_REQUEST, "601-8", "올바르지 않은 파일 URL입니다."),
+ FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "601-9", "파일 삭제에 실패하였습니다."),
+ FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "601-10", "파일 용량이 제한을 초과했습니다."),
+ // 700xx: 사용자/권한 관련 오류
USER_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "700-1", "이미 존재하는 계정입니다."),
USER_NOT_EXIST(HttpStatus.BAD_REQUEST, "700-2", "존재하지 않는 계정입니다."),
USER_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "700-3", "올바르지 않은 유저 형식입니다."),
USER_INVALID_LOGIN(HttpStatus.BAD_REQUEST, "700-4", "올바르지 않은 로그인"),
USER_UNAUTHORIZED(HttpStatus.FORBIDDEN, "700-5", "권한이 없습니다."),
+ // 701xx: 토큰 관련 오류
TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "701-1", "유효하지 않은 토큰입니다."),
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "701-2", "토큰이 만료되었습니다."),
+ // 702xx: 비밀번호 정책
PASSWORD_SAME_AS_USERID(HttpStatus.BAD_REQUEST, "702-1", "아이디와 동일한 비밀번호는 설정할 수 없습니다."),
PASSWORD_SAME_AS_OLD(HttpStatus.BAD_REQUEST,"702-2","이전 비밀번호와 동일한 비밀번호는 설정할 수 없습니다."),
+ // 800xx: 지원서/문항 관련
APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-1", "지원서 양식이 존재하지 않습니다."),
SHORT_EXCEED_LENGTH(HttpStatus.BAD_REQUEST, "800-2", "단답형 최대 글자를 초과하였습니다."),
LONG_EXCEED_LENGTH(HttpStatus.BAD_REQUEST, "800-3", "장문형 최대 글자를 초과하였습니다."),
@@ -44,13 +54,16 @@ public enum ErrorCode {
REQUIRED_QUESTION_MISSING(HttpStatus.BAD_REQUEST, "800-5", "필수 응답 질문이 누락되었습니다."),
ACTIVE_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-6", "활성화된 지원서 양식이 존재하지 않습니다."),
APPLICATION_SEMESTER_INVALID(HttpStatus.BAD_REQUEST, "800-7", "올바르지 않은 학기입니다."),
+ NOT_ALLOWED_EXTERNAL_URL(HttpStatus.BAD_REQUEST, "800-8", "형식에 맞지않은 외부지원서 URL 입니다."),
+ DUPLICATE_QUESTIONS_ITEMS(HttpStatus.BAD_REQUEST, "800-9", "중복된 질문 선택지가 존재합니다."),
+ // 900xx: 기타 시스템 오류
AES_CIPHER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "900-1", "암호화 중 오류가 발생했습니다."),
APPLICANT_NOT_FOUND(HttpStatus.NOT_FOUND, "900-2", "지원서가 존재하지 않습니다."),
+ // 901xx: FCM 관련 오류
FCMTOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "901-1", "존재하지 않는 토큰입니다."),
FCMTOKEN_SUBSCRIBE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "901-2", "동아리 구독중에 오류가 발생 하였습니다.");
- ;
private final HttpStatus httpStatus;
private final String code;
diff --git a/backend/src/main/java/moadong/media/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java
index d8879e7b8..e2f7a0ee9 100644
--- a/backend/src/main/java/moadong/media/controller/ClubImageController.java
+++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java
@@ -5,19 +5,23 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import moadong.global.payload.Response;
import moadong.media.dto.FeedUpdateRequest;
+import moadong.media.dto.PresignedUploadResponse;
+import moadong.media.dto.UploadCompleteRequest;
+import moadong.media.dto.UploadUrlRequest;
import moadong.media.service.ClubImageService;
+import moadong.user.annotation.CurrentUser;
+import moadong.user.payload.CustomUserDetails;
import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
+import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
+import java.util.List;
@RestController
@RequestMapping("/api/club")
@@ -32,46 +36,73 @@ public ClubImageController(@Qualifier("cloudflare") ClubImageService clubImageSe
private final ClubImageService clubImageService;
- @PostMapping(value = "/{clubId}/logo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @Operation(summary = "로고 이미지 업데이트", description = "cloudflare 상에 로고 이미지를 업데이트합니다.")
- public ResponseEntity> uploadLogo(@PathVariable String clubId,
- @RequestPart("logo") MultipartFile file) {
- String fileUrl = clubImageService.uploadLogo(clubId, file);
- return Response.ok(fileUrl);
- }
-
@DeleteMapping(value = "/{clubId}/logo")
- @Operation(summary = "로고 이미지 삭제", description = "cloudflare 상에 로고 이미지를 저장소에서 삭제합니다.")
- public ResponseEntity> deleteLogo(@PathVariable String clubId) {
- clubImageService.deleteLogo(clubId);
+ @Operation(summary = "로고 이미지 삭제", description = "로고 이미지를 저장소에서 삭제합니다.")
+ public ResponseEntity> deleteLogo(@PathVariable String clubId, @CurrentUser CustomUserDetails user) {
+ clubImageService.deleteLogo(clubId, user.getId());
return Response.ok("success delete logo");
}
- @PostMapping(value = "/{clubId}/feed", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @Operation(summary = "피드 이미지 업로드", description = "cloudflare 상에 피드에 사용할 이미지를 업로드하고 주소를 반환받습니다.")
- public ResponseEntity> uploadFeed(@PathVariable String clubId, @RequestPart("feed") MultipartFile file) {
- return Response.ok(clubImageService.uploadFeed(clubId, file));
- }
-
@PostMapping(value = "/{clubId}/feeds")
- @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "cloudflare 상에 피드 이미지의 설정을 업데이트 합니다.")
- public ResponseEntity> putFeeds(@PathVariable String clubId, @RequestBody FeedUpdateRequest feeds) {
- clubImageService.updateFeeds(clubId, feeds.feeds());
+ @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "피드 이미지의 설정을 업데이트 합니다.")
+ public ResponseEntity> putFeeds(@PathVariable String clubId, @RequestBody @Valid FeedUpdateRequest feeds, @CurrentUser CustomUserDetails user) {
+ clubImageService.updateFeeds(clubId, user.getId(), feeds.feeds());
return Response.ok("success put feeds");
}
- @PostMapping(value = "/{clubId}/cover", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @Operation(summary = "커버 이미지 업데이트", description = "cloudflare 상에 커버 이미지를 업데이트합니다.")
- public ResponseEntity> uploadCover(@PathVariable String clubId,
- @RequestPart("cover") MultipartFile file) {
- String fileUrl = clubImageService.uploadCover(clubId, file);
- return Response.ok(fileUrl);
- }
-
@DeleteMapping(value = "/{clubId}/cover")
- @Operation(summary = "커버 이미지 삭제", description = "cloudflare 상에 커버 이미지를 저장소에서 삭제합니다.")
- public ResponseEntity> deleteCover(@PathVariable String clubId) {
- clubImageService.deleteCover(clubId);
+ @Operation(summary = "커버 이미지 삭제", description = "커버 이미지를 저장소에서 삭제합니다.")
+ public ResponseEntity> deleteCover(@PathVariable String clubId, @CurrentUser CustomUserDetails user) {
+ clubImageService.deleteCover(clubId, user.getId());
return Response.ok("success delete cover");
}
+
+ @PostMapping("/{clubId}/logo/upload-url")
+ @Operation(summary = "로고 이미지 업로드 URL 생성", description = "로고 이미지 업로드를 위한 Presigned URL을 생성합니다.")
+ public ResponseEntity> generateLogoUploadUrl(@PathVariable String clubId,
+ @RequestBody @Valid UploadUrlRequest request,
+ @CurrentUser CustomUserDetails user) {
+ PresignedUploadResponse response = clubImageService.generateLogoUploadUrl(
+ clubId, user.getId(), request.fileName(), request.contentType());
+ return Response.ok(response);
+ }
+
+ @PostMapping("/{clubId}/logo/complete")
+ @Operation(summary = "로고 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.")
+ public ResponseEntity> completeLogoUpload(@PathVariable String clubId,
+ @RequestBody @Valid UploadCompleteRequest request,
+ @CurrentUser CustomUserDetails user) {
+ clubImageService.completeLogoUpload(clubId, user.getId(), request.fileUrl());
+ return Response.ok("success upload logo");
+ }
+
+ @PostMapping("/{clubId}/feed/upload-url")
+ @Operation(summary = "피드 이미지 업로드 URL들 생성", description = "피드 이미지 업로드를 위한 Presigned URL을 여러 개 한 번에 생성합니다.")
+ public ResponseEntity> generateFeedUploadUrl(@PathVariable String clubId,
+ @RequestBody @Valid List requests,
+ @CurrentUser CustomUserDetails user) {
+ List results = clubImageService.generateFeedUploadUrls(clubId, user.getId(), requests);
+ return Response.ok(results);
+ }
+
+ // feed complete API는 더 이상 사용하지 않습니다. (검증은 updateFeeds에서 수행)
+
+ @PostMapping("/{clubId}/cover/upload-url")
+ @Operation(summary = "커버 이미지 업로드 URL 생성", description = "커버 이미지 업로드를 위한 Presigned URL을 생성합니다.")
+ public ResponseEntity> generateCoverUploadUrl(@PathVariable String clubId,
+ @RequestBody @Valid UploadUrlRequest request,
+ @CurrentUser CustomUserDetails user) {
+ PresignedUploadResponse response = clubImageService.generateCoverUploadUrl(
+ clubId, user.getId(), request.fileName(), request.contentType());
+ return Response.ok(response);
+ }
+
+ @PostMapping("/{clubId}/cover/complete")
+ @Operation(summary = "커버 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.")
+ public ResponseEntity> completeCoverUpload(@PathVariable String clubId,
+ @RequestBody @Valid UploadCompleteRequest request,
+ @CurrentUser CustomUserDetails user) {
+ clubImageService.completeCoverUpload(clubId, user.getId(), request.fileUrl());
+ return Response.ok("success upload cover");
+ }
}
diff --git a/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java b/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java
index 6a920d24f..e2e1e8b6a 100644
--- a/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java
+++ b/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java
@@ -1,6 +1,10 @@
package moadong.media.dto;
+import jakarta.validation.constraints.NotNull;
import java.util.List;
-public record FeedUpdateRequest(List feeds) {
+public record FeedUpdateRequest(
+ @NotNull
+ List feeds
+) {
}
diff --git a/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java b/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java
new file mode 100644
index 000000000..d40bd4a8a
--- /dev/null
+++ b/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java
@@ -0,0 +1,13 @@
+package moadong.media.dto;
+
+import java.util.Map;
+
+public record PresignedUploadResponse(
+ String presignedUrl,
+ String finalUrl,
+ Map requiredHeaders,
+ boolean success,
+ String failureReason
+) {
+}
+
diff --git a/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java b/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java
new file mode 100644
index 000000000..6348eae93
--- /dev/null
+++ b/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java
@@ -0,0 +1,10 @@
+package moadong.media.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record UploadCompleteRequest(
+ @NotBlank
+ String fileUrl
+) {
+}
+
diff --git a/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java b/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java
new file mode 100644
index 000000000..ee2ae63e5
--- /dev/null
+++ b/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java
@@ -0,0 +1,14 @@
+package moadong.media.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+
+public record UploadUrlRequest(
+ @NotBlank
+ String fileName,
+ @NotBlank
+ @Pattern(regexp = "^image/(jpeg|jpg|png|gif|bmp|webp)$", message = "지원되지 않는 MIME 타입입니다.")
+ String contentType
+) {
+}
+
diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java
index b50592969..363529767 100644
--- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java
+++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java
@@ -1,12 +1,14 @@
package moadong.media.service;
-import static moadong.media.util.ClubImageUtil.containsInvalidChars;
import static moadong.media.util.ClubImageUtil.isImageExtension;
-import static moadong.media.util.ClubImageUtil.resizeImage;
-import java.io.IOException;
+import jakarta.annotation.PostConstruct;
+import java.time.Duration;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import moadong.club.entity.Club;
import moadong.club.repository.ClubRepository;
import moadong.global.exception.ErrorCode;
@@ -14,16 +16,23 @@
import moadong.global.util.ObjectIdConverter;
import moadong.global.util.RandomStringUtil;
import moadong.media.domain.FileType;
+import moadong.media.dto.PresignedUploadResponse;
+import moadong.media.dto.UploadUrlRequest;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
-import org.springframework.web.multipart.MultipartFile;
-import software.amazon.awssdk.core.sync.RequestBody;
+import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
-import software.amazon.awssdk.services.s3.model.ObjectCannedACL;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
+@Slf4j
@Service("cloudflare")
@RequiredArgsConstructor
public class CloudflareImageService implements ClubImageService{
@@ -32,6 +41,8 @@ public class CloudflareImageService implements ClubImageService{
private final S3Client s3Client;
+ private final S3Presigner s3Presigner;
+
@Value("${server.feed.max-count}")
private int MAX_FEED_COUNT;
@Value("${cloud.aws.s3.bucket}")
@@ -39,25 +50,27 @@ public class CloudflareImageService implements ClubImageService{
@Value("${cloud.aws.s3.view-endpoint}")
private String viewEndpoint;
@Value("${server.image.max-size}")
- private long MAX_SIZE;
-
- @Override
- public String uploadLogo(String clubId, MultipartFile file) {
- Club club = getClub(clubId);
-
- if (club.getClubRecruitmentInformation().getLogo() != null) {
- deleteFile(club, club.getClubRecruitmentInformation().getLogo());
+ private long maxImageSizeBytes;
+ @Value("${server.file-url.max-length:200}")
+ private int maxFileUrlLength;
+ @Value("${server.file-url.expiration-time:10}")
+ private int expirationTime;
+ private String normalizedViewEndpoint;
+
+ @PostConstruct
+ private void init() {
+ if (viewEndpoint == null || viewEndpoint.isEmpty()) {
+ throw new IllegalStateException("cloud.aws.s3.view-endpoint must be configured");
}
-
- String filePath = uploadFile(clubId, file, FileType.LOGO);
- club.updateLogo(filePath);
- clubRepository.save(club);
- return filePath;
+ // viewEndpoint 정규화: 후행 슬래시 제거
+ normalizedViewEndpoint = viewEndpoint.replaceAll("/+$", "");
}
@Override
- public void deleteLogo(String clubId) {
- Club club = getClub(clubId);
+ @Transactional
+ public void deleteLogo(String clubId, String userId) {
+ Club club = getAuthorizedClub(clubId, userId);
+ validateClubRecruitmentInformation(club);
if (club.getClubRecruitmentInformation().getLogo() != null) {
deleteFile(club, club.getClubRecruitmentInformation().getLogo());
@@ -67,36 +80,44 @@ public void deleteLogo(String clubId) {
}
@Override
- public String uploadFeed(String clubId, MultipartFile file) {
- int feedImagesCount = getClub(clubId).getClubRecruitmentInformation().getFeedImages().size();
+ @Transactional
+ public void updateFeeds(String clubId, String userId, List newFeedImageList) {
+ Club club = getAuthorizedClub(clubId, userId);
+ validateClubRecruitmentInformation(club);
- if (feedImagesCount + 1 > MAX_FEED_COUNT) {
- throw new RestApiException(ErrorCode.TOO_MANY_FILES);
- }
- return uploadFile(clubId, file, FileType.FEED);
- }
+ if (newFeedImageList == null) {
+ newFeedImageList = java.util.Collections.emptyList();
+ }
- @Override
- public void updateFeeds(String clubId, List newFeedImageList) {
- Club club = getClub(clubId);
+ if (newFeedImageList.size() > MAX_FEED_COUNT) {
+ throw new RestApiException(ErrorCode.TOO_MANY_FILES);
+ }
- if (newFeedImageList.size() > MAX_FEED_COUNT) {
- throw new RestApiException(ErrorCode.TOO_MANY_FILES);
- }
+ //리스트에 대해 URL 제약 검증
+ for (String url : newFeedImageList) {
+ validateFileConstraints(club.getId(), FileType.FEED, url);
+ }
- List feedImages = club.getClubRecruitmentInformation().getFeedImages();
- if (feedImages != null && !feedImages.isEmpty()) {
- deleteFeedImages(club, feedImages, newFeedImageList);
- }
- club.updateFeedImages(newFeedImageList);
- clubRepository.save(club);
+ //검증 통과 후 누락된 기존 파일만 삭제
+ List existingFeedImages = club.getClubRecruitmentInformation().getFeedImages();
+ if (existingFeedImages != null && !existingFeedImages.isEmpty()) {
+ deleteFeedImages(club, existingFeedImages, newFeedImageList);
+ }
+
+ club.updateFeedImages(newFeedImageList);
+ clubRepository.save(club);
}
- private Club getClub(String clubId) {
+ private Club getAuthorizedClub(String clubId, String userId) {
ObjectId objectId = ObjectIdConverter.convertString(clubId);
- return clubRepository.findClubById(objectId)
+ Club club = clubRepository.findClubById(objectId)
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
+
+ if (!club.getUserId().equals(userId)) {
+ throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
+ }
+ return club;
}
private void deleteFeedImages(Club club, List feedImages, List newFeedImages) {
@@ -109,89 +130,221 @@ private void deleteFeedImages(Club club, List feedImages, List n
@Override
public void deleteFile(Club club, String filePath) {
- // https://pub-8655aea549d544239ad12d0385aa98aa.r2.dev/{key} -> {key}
- String key = filePath.substring(viewEndpoint.length()+1);
+ if (filePath == null || filePath.isEmpty()) {
+ log.warn("deleteFile called with null or empty filePath for club: {}", club.getId());
+ return;
+ }
+
+ String key = extractKeyOrNull(filePath);
+ if (key == null) {
+ log.warn("Invalid filePath format for club {}: expected prefix {}", club.getId(), normalizedViewEndpoint + "/");
+ return;
+ }
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
.bucket(bucketName)
.key(key)
.build();
- s3Client.deleteObject(deleteRequest);
+ try {
+ s3Client.deleteObject(deleteRequest);
+ } catch (S3Exception e) {
+ // 파일이 이미 없거나 삭제 권한이 없는 경우 로그만 남기고 계속 진행
+ log.warn("Failed to delete file from S3 for club {}: key={}, error={}", club.getId(), key, e.getMessage());
+ } catch (Exception e) {
+ log.warn("Unexpected error while deleting file from S3 for club {}: key={}, error={}", club.getId(), key, e.getMessage());
+ }
}
@Override
- public String uploadCover(String clubId, MultipartFile file) {
- Club club = getClub(clubId);
+ @Transactional
+ public void deleteCover(String clubId, String userId) {
+ Club club = getAuthorizedClub(clubId, userId);
+ validateClubRecruitmentInformation(club);
if (club.getClubRecruitmentInformation().getCover() != null) {
deleteFile(club, club.getClubRecruitmentInformation().getCover());
}
+ club.updateCover(null);
+ clubRepository.save(club);
+ }
+
+ @Override
+ public PresignedUploadResponse generateLogoUploadUrl(String clubId, String userId, String fileName, String contentType) {
+ getAuthorizedClub(clubId, userId);
+ validateFileName(fileName);
+ return generatePresignedUrl(clubId, fileName, contentType, FileType.LOGO);
+ }
+
+ @Override
+ public List generateFeedUploadUrls(String clubId, String userId, List requests) {
+ Club club = getAuthorizedClub(clubId, userId);
+ validateClubRecruitmentInformation(club);
+ int existingCount = (club.getClubRecruitmentInformation().getFeedImages() == null)
+ ? 0
+ : club.getClubRecruitmentInformation().getFeedImages().size();
+ int remaining = Math.max(0, MAX_FEED_COUNT - existingCount);
+ if (remaining == 0) {
+ return java.util.List.of(errorResponse(ErrorCode.TOO_MANY_FILES));
+ }
+
+ int limit = Math.min(remaining, requests.size());
+ java.util.ArrayList results = new java.util.ArrayList<>(limit + 1);
+ for (int i = 0; i < limit; i++) {
+ UploadUrlRequest req = requests.get(i);
+ try {
+ validateFileName(req.fileName());
+ results.add(generatePresignedUrl(clubId, req.fileName(), req.contentType(), FileType.FEED));
+ } catch (RestApiException e) {
+ results.add(errorResponse(e.getErrorCode()));
+ } catch (Exception e) {
+ log.error("Unexpected error generating presigned URL: clubId={}, fileName={}", clubId, req.fileName(), e);
+ results.add(errorResponse(ErrorCode.IMAGE_UPLOAD_FAILED));
+ }
+ }
+ if (requests.size() > limit) {
+ results.add(errorResponse(ErrorCode.TOO_MANY_FILES));
+ }
+ return results;
+ }
- String filePath = uploadFile(clubId, file, FileType.COVER);
- club.updateCover(filePath);
+ @Override
+ public PresignedUploadResponse generateCoverUploadUrl(String clubId, String userId, String fileName, String contentType) {
+ getAuthorizedClub(clubId, userId);
+ validateFileName(fileName);
+ return generatePresignedUrl(clubId, fileName, contentType, FileType.COVER);
+ }
+
+ @Override
+ @Transactional
+ public void completeLogoUpload(String clubId, String userId, String fileUrl) {
+ validateFileConstraints(clubId, FileType.LOGO, fileUrl);
+ Club club = getAuthorizedClub(clubId, userId);
+ validateClubRecruitmentInformation(club);
+
+ if (club.getClubRecruitmentInformation().getLogo() != null) {
+ deleteFile(club, club.getClubRecruitmentInformation().getLogo());
+ }
+
+ club.updateLogo(fileUrl);
clubRepository.save(club);
- return filePath;
}
@Override
- public void deleteCover(String clubId) {
- Club club = getClub(clubId);
+ @Transactional
+ public void completeCoverUpload(String clubId, String userId, String fileUrl) {
+ validateFileConstraints(clubId, FileType.COVER, fileUrl);
+ Club club = getAuthorizedClub(clubId, userId);
+ validateClubRecruitmentInformation(club);
if (club.getClubRecruitmentInformation().getCover() != null) {
deleteFile(club, club.getClubRecruitmentInformation().getCover());
}
- club.updateCover(null);
+
+ club.updateCover(fileUrl);
clubRepository.save(club);
}
- private String uploadFile(String clubId, MultipartFile file, FileType fileType) {
- if (file == null || file.isEmpty()) {
- throw new RestApiException(ErrorCode.FILE_NOT_FOUND);
- }
-
- // 파일명 처리
- String fileName = file.getOriginalFilename();
- if (!isImageExtension(fileName)) {
- throw new RestApiException(ErrorCode.UNSUPPORTED_FILE_TYPE);
+ private void validateFileConstraints(String clubId, FileType fileType, String fileUrl) {
+ if (fileUrl == null || fileUrl.length() > maxFileUrlLength) {
+ throw new RestApiException(ErrorCode.INVALID_FILE_URL);
}
- if (containsInvalidChars(fileName)) {
- fileName = RandomStringUtil.generateRandomString(10);
+ String key = extractKeyOrNull(fileUrl);
+ if (key == null || !key.startsWith(clubId + "/" + fileType.getPath() + "/")) {
+ throw new RestApiException(ErrorCode.INVALID_FILE_URL);
}
- if (file.getSize() > MAX_SIZE) {
- try {
- file = resizeImage(file, MAX_SIZE);
- } catch (IOException e) {
- throw new RestApiException(ErrorCode.FILE_TRANSFER_ERROR);
+ // R2 HEAD로 사이즈 확인
+ try {
+ HeadObjectRequest headReq = HeadObjectRequest.builder()
+ .bucket(bucketName)
+ .key(key)
+ .build();
+ long contentLength = s3Client.headObject(headReq).contentLength();
+ if (contentLength > maxImageSizeBytes) {
+ // 초과 시 삭제 후 예외
+ try {
+ DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
+ .bucket(bucketName)
+ .key(key)
+ .build();
+ s3Client.deleteObject(deleteRequest);
+ } catch (S3Exception e) {
+ log.warn("Failed to delete oversized object from R2: key={}, error={}", key, e.getMessage());
+ }
+ throw new RestApiException(ErrorCode.FILE_TOO_LARGE);
}
+ } catch (NoSuchKeyException e) {
+ throw new RestApiException(ErrorCode.FILE_NOT_FOUND);
+ } catch (S3Exception e) {
+ throw new RestApiException(ErrorCode.IMAGE_UPLOAD_FAILED);
}
+ }
+
+ /**
+ * viewEndpoint 접두를 검증하고 S3 key를 추출한다.
+ * 접두 불일치나 비정상 URL이면 null을 반환한다(호출자에서 처리).
+ */
+ private String extractKeyOrNull(String fileUrl) {
+ String prefix = normalizedViewEndpoint + "/";
+ if (fileUrl == null || !fileUrl.startsWith(prefix)) {
+ return null;
+ }
+ return fileUrl.substring(prefix.length());
+ }
+
+ private PresignedUploadResponse generatePresignedUrl(String clubId, String fileName, String contentType, FileType fileType) {
+ String extension = "";
+ if (fileName.contains(".")) {
+ extension = fileName.substring(fileName.lastIndexOf("."));
+ }
+ String processedFileName = RandomStringUtil.generateRandomString(10) + extension;
// S3에 저장할 key 경로 생성
- String key = clubId + "/" + fileType + "/" + fileName;
+ String key = clubId + "/" + fileType.getPath() + "/" + processedFileName;
- // S3 업로드 요청
- try {
- PutObjectRequest putRequest = PutObjectRequest.builder()
- .bucket(bucketName)
- .key(key)
- .contentType(file.getContentType())
- .acl(ObjectCannedACL.PUBLIC_READ) // 공개 URL 용도
- .build();
-
- s3Client.putObject(putRequest, RequestBody.fromInputStream(
- file.getInputStream(),
- file.getSize()
- ));
-
- } catch (IOException e) {
- throw new RestApiException(ErrorCode.FILE_TRANSFER_ERROR);
- } catch (Exception e) {
- throw new RestApiException(ErrorCode.IMAGE_UPLOAD_FAILED);
+ // PutObjectRequest 생성
+ PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+ .bucket(bucketName)
+ .key(key)
+ .contentType(contentType)
+ .build();
+
+ // Presigned URL 생성 (10분 유효)
+ PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
+ .signatureDuration(Duration.ofMinutes(expirationTime))
+ .putObjectRequest(putObjectRequest)
+ .build();
+
+ PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
+ String presignedUrl = presignedRequest.url().toString();
+ // 정규화된 viewEndpoint 사용하여 finalUrl 생성
+ String finalUrl = normalizedViewEndpoint + "/" + key;
+
+ // 클라이언트가 파일 업로드 시 필요한 헤더 정보 생성
+ Map requiredHeaders = new HashMap<>();
+ requiredHeaders.put("Content-Type", contentType);
+
+ return new PresignedUploadResponse(presignedUrl, finalUrl, requiredHeaders, true, null);
+ }
+
+ private PresignedUploadResponse errorResponse(ErrorCode code) {
+ return new PresignedUploadResponse(null, null, null, false, code.getMessage());
+ }
+ private void validateFileName(String fileName) {
+ if (fileName == null || fileName.isEmpty()) {
+ throw new RestApiException(ErrorCode.FILE_NOT_FOUND);
}
+ if (!isImageExtension(fileName)) {
+ throw new RestApiException(ErrorCode.UNSUPPORTED_FILE_TYPE);
+ }
+ }
- // 공유 가능한 공개 URL 반환
- return viewEndpoint + "/" + key;
+ private void validateClubRecruitmentInformation(Club club) {
+ if (club.getClubRecruitmentInformation() == null) {
+ log.error("ClubRecruitmentInformation is null for club: {}", club.getId());
+ throw new RestApiException(ErrorCode.CLUB_INFORMATION_NOT_FOUND);
+ }
}
}
diff --git a/backend/src/main/java/moadong/media/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/ClubImageService.java
index a4086c78f..3fa063d4d 100644
--- a/backend/src/main/java/moadong/media/service/ClubImageService.java
+++ b/backend/src/main/java/moadong/media/service/ClubImageService.java
@@ -2,21 +2,27 @@
import java.util.List;
import moadong.club.entity.Club;
-import org.springframework.web.multipart.MultipartFile;
+import moadong.media.dto.PresignedUploadResponse;
+import moadong.media.dto.UploadUrlRequest;
public interface ClubImageService {
- String uploadLogo(String clubId, MultipartFile file);
+ void deleteLogo(String clubId, String userId);
- void deleteLogo(String clubId);
+ void updateFeeds(String clubId, String userId, List newFeedImageList);
- String uploadFeed(String clubId, MultipartFile file);
+ void deleteFile(Club club, String filePath);
- void updateFeeds(String clubId, List newFeedImageList);
+ void deleteCover(String clubId, String userId);
- void deleteFile(Club club, String filePath);
+ // Presigned URL 방식 메서드
+ PresignedUploadResponse generateLogoUploadUrl(String clubId, String userId, String fileName, String contentType);
+
+ List generateFeedUploadUrls(String clubId, String userId, List requests);
+
+ PresignedUploadResponse generateCoverUploadUrl(String clubId, String userId, String fileName, String contentType);
- String uploadCover(String clubId, MultipartFile file);
+ void completeLogoUpload(String clubId, String userId, String fileUrl);
- void deleteCover(String clubId);
+ void completeCoverUpload(String clubId, String userId, String fileUrl);
}
diff --git a/backend/src/main/java/moadong/media/util/S3Config.java b/backend/src/main/java/moadong/media/util/S3Config.java
index 83c2fdbe5..2d222c8a7 100644
--- a/backend/src/main/java/moadong/media/util/S3Config.java
+++ b/backend/src/main/java/moadong/media/util/S3Config.java
@@ -9,6 +9,7 @@
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
@Configuration
public class S3Config {
@@ -24,6 +25,7 @@ public class S3Config {
@Bean
public S3Client s3Client() {
+ validateCredentials();
return S3Client.builder()
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.endpointOverride(URI.create(endpoint))
@@ -34,5 +36,25 @@ public S3Client s3Client() {
.build();
}
+ @Bean(destroyMethod = "close")
+ public S3Presigner s3Presigner() {
+ validateCredentials();
+ return S3Presigner.builder()
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKey, secretKey)))
+ .endpointOverride(URI.create(endpoint))
+ .region(Region.US_EAST_1)
+ .build();
+ }
+
+ private void validateCredentials() {
+ if (accessKey == null || accessKey.isEmpty() || secretKey == null || secretKey.isEmpty()) {
+ throw new IllegalStateException("AWS credentials (accessKey, secretKey) must be configured");
+ }
+ if (endpoint == null || endpoint.isEmpty()) {
+ throw new IllegalStateException("AWS S3 endpoint must be configured");
+ }
+ }
+
}
diff --git a/backend/src/main/java/moadong/user/entity/User.java b/backend/src/main/java/moadong/user/entity/User.java
index 765029574..7dedd80b8 100644
--- a/backend/src/main/java/moadong/user/entity/User.java
+++ b/backend/src/main/java/moadong/user/entity/User.java
@@ -2,8 +2,12 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
+
+import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
+import java.util.List;
+
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -51,8 +55,9 @@ public class User implements UserDetails {
private Date lastLoginAt;
- @Field("refreshToken")
- private RefreshToken refreshToken;
+ @Builder.Default
+ @Field("refreshTokens")
+ private List refreshTokens = new ArrayList<>();
@Field("userInformation")
private UserInformation userInformation;
@@ -92,8 +97,38 @@ public void updateClubId(String clubId) {
this.clubId = clubId;
}
- public void updateRefreshToken(RefreshToken refreshToken) {
- this.refreshToken = refreshToken;
+ public void addRefreshToken(RefreshToken refreshToken) {
+ if (this.refreshTokens == null) {
+ this.refreshTokens = new ArrayList<>();
+ }
+ this.refreshTokens.add(refreshToken);
+ }
+
+ public void replaceRefreshToken(String oldToken, RefreshToken newToken) {
+ if (this.refreshTokens == null) {
+ this.refreshTokens = new ArrayList<>();
+ return;
+ }
+ for (int i = 0; i < this.refreshTokens.size(); i++) {
+ if (this.refreshTokens.get(i).getToken().equals(oldToken)) {
+ this.refreshTokens.set(i, newToken);
+ return;
+ }
+ }
+ }
+
+ public void removeRefreshToken(String refreshToken) {
+ if (this.refreshTokens == null) {
+ return;
+ }
+ this.refreshTokens.removeIf(t -> t.getToken().equals(refreshToken));
}
+ public void removeAllRefreshTokens() {
+ if (this.refreshTokens == null) {
+ this.refreshTokens = new ArrayList<>();
+ return;
+ }
+ this.refreshTokens.clear();
+ }
}
diff --git a/backend/src/main/java/moadong/user/repository/UserRepository.java b/backend/src/main/java/moadong/user/repository/UserRepository.java
index d93320bca..816cf6689 100644
--- a/backend/src/main/java/moadong/user/repository/UserRepository.java
+++ b/backend/src/main/java/moadong/user/repository/UserRepository.java
@@ -9,5 +9,5 @@
public interface UserRepository extends MongoRepository {
Optional findUserByUserId(String userId);
- Optional findUserByRefreshToken_Token(String token);
+ Optional findUserByRefreshTokens_Token(String token);
}
diff --git a/backend/src/main/java/moadong/user/service/UserCommandService.java b/backend/src/main/java/moadong/user/service/UserCommandService.java
index 864dc63db..03321b63e 100644
--- a/backend/src/main/java/moadong/user/service/UserCommandService.java
+++ b/backend/src/main/java/moadong/user/service/UserCommandService.java
@@ -76,7 +76,7 @@ public LoginResponse loginUser(UserLoginRequest userLoginRequest,
ResponseCookie cookie = cookieMaker.makeRefreshTokenCookie(refreshToken.getToken());
response.addHeader("Set-Cookie", cookie.toString());
- user.updateRefreshToken(refreshToken);
+ user.addRefreshToken(refreshToken);
userRepository.save(user);
return new LoginResponse(accessToken, club.getId());
} catch (MongoWriteException e) {
@@ -85,10 +85,10 @@ public LoginResponse loginUser(UserLoginRequest userLoginRequest,
}
public void logoutUser(String refreshToken) {
- User user = userRepository.findUserByRefreshToken_Token(refreshToken)
+ User user = userRepository.findUserByRefreshTokens_Token(refreshToken)
.orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST));
- user.updateRefreshToken(null);
+ user.removeRefreshToken(refreshToken);
userRepository.save(user);
}
@@ -102,14 +102,25 @@ public RefreshResponse refreshAccessToken(String refreshToken,
User user = userRepository.findUserByUserId(userId)
.orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST));
- if (!user.getRefreshToken().getToken().equals(refreshToken)
- || jwtProvider.isTokenExpired(refreshToken)) {
+ boolean hasToken = false;
+ if (user.getRefreshTokens() != null) {
+ for (RefreshToken t : user.getRefreshTokens()) {
+ if (t.getToken().equals(refreshToken)) {
+ hasToken = true;
+ break;
+ }
+ }
+ }
+ if (!hasToken) {
+ throw new RestApiException(ErrorCode.TOKEN_INVALID);
+ }
+ if (jwtProvider.isTokenExpired(refreshToken)) {
throw new RestApiException(ErrorCode.TOKEN_INVALID);
}
String accessToken = jwtProvider.generateAccessToken(userId);
String newRefreshToken = jwtProvider.generateRefreshToken(userId).getToken();
- user.updateRefreshToken(new RefreshToken(newRefreshToken, new Date()));
+ user.replaceRefreshToken(refreshToken, new RefreshToken(newRefreshToken, new Date()));
userRepository.save(user);
ResponseCookie cookie = cookieMaker.makeRefreshTokenCookie(newRefreshToken);
@@ -134,10 +145,13 @@ public void update(String userId,
user.updateUserProfile(userUpdateRequest.encryptPassword(passwordEncoder));
+ user.removeAllRefreshTokens();
+ RefreshToken newRefreshToken = jwtProvider.generateRefreshToken(user.getUsername());
+ user.addRefreshToken(newRefreshToken);
+
userRepository.save(user);
- String newRefreshToken = jwtProvider.generateRefreshToken(user.getUsername()).getToken();
- ResponseCookie cookie = cookieMaker.makeRefreshTokenCookie(newRefreshToken);
+ ResponseCookie cookie = cookieMaker.makeRefreshTokenCookie(newRefreshToken.getToken());
response.addHeader("Set-Cookie", cookie.toString());
}
@@ -152,7 +166,7 @@ public TempPasswordResponse reset(String userId) {
//암호화
user.resetPassword(passwordEncoder.encode(tempPwdResponse.tempPassword()));
- user.updateRefreshToken(null);
+ user.removeAllRefreshTokens();
userRepository.save(user);
return tempPwdResponse;
diff --git a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java
new file mode 100644
index 000000000..5650b77bd
--- /dev/null
+++ b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java
@@ -0,0 +1,75 @@
+package moadong.club.service;
+
+import moadong.club.entity.Club;
+import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest;
+import moadong.club.repository.ClubRepository;
+import moadong.club.repository.ClubSearchRepository;
+import moadong.club.util.RecruitmentStateCalculator;
+import moadong.fixture.ClubRequestFixture;
+import moadong.fixture.UserFixture;
+import moadong.user.payload.CustomUserDetails;
+import moadong.util.annotations.UnitTest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@UnitTest
+public class ClubProfileServiceDateTest {
+
+ @InjectMocks
+ ClubProfileService clubProfileService;
+
+ @Mock
+ ClubRepository clubRepository;
+
+ @Mock
+ ClubSearchRepository clubSearchRepository;
+
+ @DisplayName("모집글 수정 시 최근 업데이트 일자를 보여준다")
+ @Test
+ void 모집글_수정_시_최근_업데이트_일자를_보여줘야한다(){
+ //GIVEN
+ ClubRecruitmentInfoUpdateRequest request = ClubRequestFixture.defaultRequest();
+ CustomUserDetails customUserDetails = UserFixture.createUserDetails("test");
+ Club club = new Club();
+ when(clubRepository.findClubByUserId(any())).thenReturn(Optional.of(club));
+ //updateClubRecruitmentInfo의 RecruitmentStateCalculator 무시
+ try (var mocked = Mockito.mockStatic(RecruitmentStateCalculator.class)) {
+ mocked.when(() ->
+ RecruitmentStateCalculator.calculate(
+ Mockito.any(moadong.club.entity.Club.class),
+ Mockito.any(java.time.ZonedDateTime.class),
+ Mockito.any(java.time.ZonedDateTime.class)
+ )
+ ).thenAnswer(inv -> null);
+
+ //WHEN
+ clubProfileService.updateClubRecruitmentInfo(request, customUserDetails);
+
+ //THEN
+ assertNotNull(club.getClubRecruitmentInformation().getLastModifiedDate());
+ //1초 전후 차이로 살펴보기
+ LocalDateTime now = LocalDateTime.now();
+ assertTrue(club.getClubRecruitmentInformation().
+ getLastModifiedDate().isAfter(now.minusSeconds(1)));
+ assertTrue(club.getClubRecruitmentInformation().
+ getLastModifiedDate().isBefore(now.plusSeconds(1)));
+ }
+ }
+}
diff --git a/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java b/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java
index 93e29f0ee..cecf1e126 100644
--- a/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java
+++ b/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java
@@ -6,6 +6,8 @@
import static org.mockito.Mockito.when;
import java.util.List;
+import java.util.regex.Pattern;
+
import moadong.club.payload.dto.ClubSearchResult;
import moadong.club.payload.response.ClubSearchResponse;
import moadong.club.repository.ClubSearchRepository;
@@ -43,7 +45,7 @@ class ClubSearchServiceTest {
List unsorted = List.of(club1, club2, club3,club4);
- when(clubSearchRepository.searchClubsByKeyword(keyword, recruitmentStatus, division, category))
+ when(clubSearchRepository.searchClubsByKeyword(Pattern.quote(keyword), recruitmentStatus, division, category))
.thenReturn(unsorted);
// when
diff --git a/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java b/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java
index d92d380e8..bb90289cf 100644
--- a/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java
+++ b/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java
@@ -66,6 +66,7 @@ public TaskExecutor taskExecutor() {
@BeforeEach
void setUp() {
+ fcmTokenRepository.deleteFcmTokenByToken("existing_token");
club1 = clubRepository.save(Club.builder().name("club1").build());
club2 = clubRepository.save(Club.builder().name("club2").build());
club3 = clubRepository.save(Club.builder().name("club3").build());
diff --git a/backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java b/backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java
index 9a7d7b9cd..343895117 100644
--- a/backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java
+++ b/backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java
@@ -1,5 +1,6 @@
package moadong.fixture;
+import moadong.club.enums.ApplicationFormMode;
import moadong.club.enums.SemesterTerm;
import moadong.club.payload.request.ClubApplicationFormEditRequest;
import java.util.ArrayList;
@@ -20,6 +21,8 @@ public static ClubApplicationFormEditRequest createClubApplicationEditRequest(){
"테스트 지원서입니다",
false,
new ArrayList<>(),
+ "",
+ ApplicationFormMode.INTERNAL,
2025,
SemesterTerm.SECOND
);
diff --git a/backend/src/test/java/moadong/fixture/ClubFixture.java b/backend/src/test/java/moadong/fixture/ClubFixture.java
index d071b24fd..0049a3bba 100644
--- a/backend/src/test/java/moadong/fixture/ClubFixture.java
+++ b/backend/src/test/java/moadong/fixture/ClubFixture.java
@@ -23,7 +23,6 @@ public static ClubRecruitmentInformation createRecruitmentInfo(
String id,
String logo,
String introduction,
- String description,
String presidentName,
String presidentTelephoneNumber,
LocalDateTime recruitmentStart,
@@ -34,7 +33,6 @@ public static ClubRecruitmentInformation createRecruitmentInfo(
when(clubRecruitmentInfo.getId()).thenReturn(id);
when(clubRecruitmentInfo.getLogo()).thenReturn(logo);
when(clubRecruitmentInfo.getIntroduction()).thenReturn(introduction);
- when(clubRecruitmentInfo.getDescription()).thenReturn(description);
when(clubRecruitmentInfo.getPresidentName()).thenReturn(presidentName);
when(clubRecruitmentInfo.getPresidentTelephoneNumber()).thenReturn(presidentTelephoneNumber);
when(clubRecruitmentInfo.getRecruitmentStart()).thenReturn(ZonedDateTime.from(recruitmentStart));
diff --git a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java
index 8d5feef1a..2761d8866 100644
--- a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java
+++ b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java
@@ -1,9 +1,14 @@
package moadong.fixture;
+import moadong.club.entity.ClubDescription;
import moadong.club.enums.ClubCategory;
import moadong.club.enums.ClubDivision;
+import moadong.club.payload.dto.ClubDescriptionDto;
import moadong.club.payload.request.ClubInfoRequest;
+import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
@@ -16,11 +21,21 @@ public static ClubInfoRequest createValidClubInfoRequest() {
List.of("개발", "스터디"),
"동아리 소개입니다.",
"홍길동",
+ ClubDescriptionDto.from(ClubDescription.builder().build()),
"010-1234-5678",
Map.of("insta", "https://test")
);
}
+
+ public static ClubRecruitmentInfoUpdateRequest defaultRequest() {
+ return new ClubRecruitmentInfoUpdateRequest(
+ Instant.now(),
+ Instant.now().plus(7, ChronoUnit.DAYS),
+ "테스트 대상",
+ "https://fake-url.com"
+ );
+ }
//ToDo: 시간 계산법을 LocalDateTime에서 Instant로 변경 후에 활성화할 것
// public static ClubRecruitmentInfoUpdateRequest createValidRequest() {
// return new ClubRecruitmentInfoUpdateRequest(
diff --git a/backend/src/test/java/moadong/media/service/CloudFlareImageServiceTest.java b/backend/src/test/java/moadong/media/service/CloudFlareImageServiceTest.java
new file mode 100644
index 000000000..3fe1b1e9f
--- /dev/null
+++ b/backend/src/test/java/moadong/media/service/CloudFlareImageServiceTest.java
@@ -0,0 +1,116 @@
+package moadong.media.service;
+
+import moadong.club.entity.Club;
+import moadong.club.repository.ClubRepository;
+import moadong.global.exception.ErrorCode;
+import moadong.global.exception.RestApiException;
+import moadong.util.annotations.UnitTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+@UnitTest
+@ExtendWith(MockitoExtension.class)
+public class CloudFlareImageServiceTest {
+
+ @Spy
+ @InjectMocks
+ CloudflareImageService cloudflareImageService;
+
+ @Mock
+ private ClubRepository clubRepository;
+
+ @Mock
+ private S3Client s3Client;
+
+ @Mock
+ private S3Presigner s3Presigner;
+
+ @BeforeEach
+ void setUp() {
+ ReflectionTestUtils.setField(cloudflareImageService, "viewEndpoint", "https://cdn.example.com/");
+ ReflectionTestUtils.setField(cloudflareImageService, "bucketName", "test-bucket");
+ ReflectionTestUtils.invokeMethod(cloudflareImageService, "init");
+ }
+
+
+ @Test
+ void 잘못된_이름을_넣으면_UNSUPPORTED_FILE_TYPE_을_반환한다(){
+ //given
+ String fileName = "Hello";
+ //when
+ RestApiException exception = assertThrows(RestApiException.class,
+ () -> ReflectionTestUtils.invokeMethod(cloudflareImageService, "validateFileName", fileName));
+ //then
+ assertEquals(ErrorCode.UNSUPPORTED_FILE_TYPE, exception.getErrorCode());
+ }
+
+ @Test
+ void File_이름이_비어있으면_FILE_NOT_FOUND를_반환한다() {
+ //given
+ //이름이 비어있는 파일
+ String emptyFileName = "";
+ //when
+ //이름이 비어있는 파일을 넣었을 때
+ RestApiException exception = assertThrows(RestApiException.class,
+ () -> ReflectionTestUtils.invokeMethod(cloudflareImageService, "validateFileName", emptyFileName));
+ //then
+ //에러코드가 같아야함
+ assertEquals(ErrorCode.FILE_NOT_FOUND, exception.getErrorCode());
+ }
+
+ @Test
+ void 잘못된_filePath_일때_deleteFile_을_실패한다 (){
+ //given
+ Club club = new Club();
+ String filePath = null;
+ //when
+ cloudflareImageService.deleteFile(club, filePath);
+ //then
+ verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
+ }
+
+ @Test
+ void 접두_불일치면_deleteFile_을_실패한다 (){
+ //given
+ Club club = new Club();
+ String wrongFilePath ="https://other.example.com/club123/logo/abc.png";
+
+ //when
+ cloudflareImageService.deleteFile(club, wrongFilePath);
+ //then
+ verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
+ }
+
+ @Test
+ void 정상_url이면_삭제_성공 () {
+ //given
+ Club club = new Club();
+ String filePath = "https://cdn.example.com/club123/logo/abc.png";
+
+ //when
+ cloudflareImageService.deleteFile(club, filePath);
+
+ //then
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectRequest.class);
+ verify(s3Client).deleteObject(captor.capture());
+
+ assertEquals("test-bucket", captor.getValue().bucket());
+ assertEquals("club123/logo/abc.png", captor.getValue().key());
+ }
+}
diff --git a/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceFeedTest.java b/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceFeedTest.java
deleted file mode 100644
index ad67158fe..000000000
--- a/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceFeedTest.java
+++ /dev/null
@@ -1,129 +0,0 @@
-package moadong.media.service;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertIterableEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doNothing;
-import static org.mockito.Mockito.when;
-
-import java.util.Arrays;
-import java.util.List;
-import java.util.Optional;
-import moadong.club.entity.Club;
-import moadong.club.entity.ClubRecruitmentInformation;
-import moadong.club.repository.ClubRepository;
-import moadong.global.exception.ErrorCode;
-import moadong.global.exception.RestApiException;
-import moadong.util.annotations.UnitTest;
-import org.bson.types.ObjectId;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.Spy;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.mock.web.MockMultipartFile;
-import org.springframework.test.util.ReflectionTestUtils;
-import org.springframework.web.multipart.MultipartFile;
-
-@ExtendWith(MockitoExtension.class)
-@UnitTest
-class CloudflareClubImageServiceFeedTest {
-
- @Spy
- @InjectMocks
- private CloudflareImageService clubImageService;
-
- @Mock
- private ClubRepository clubRepository;
-
- private final int MAX_FEED_COUNT = 5;
-
- private Club club;
-
- private ObjectId objectId;
-
- private final MultipartFile mockFile = new MockMultipartFile(
- "testFile", "testFile.jpg", "image/jpeg", "test".getBytes());
-
- @BeforeEach
- void setUp() {
- ClubRecruitmentInformation info = ClubRecruitmentInformation.builder()
- .feedImages(List.of())
- .build();
- club = Club.builder().clubRecruitmentInformation(info).build();
- objectId = new ObjectId();
- ReflectionTestUtils.setField(clubImageService, "MAX_FEED_COUNT", 5);
- }
-
- // uploadFeed
- @Test
- void MAX_FEED_COUNT_이상의_피드를_업로드하면_TOO_MANY_FILES를_반환한다() {
- // given
- List feedImages = Arrays.asList(new String[MAX_FEED_COUNT]);
- ClubRecruitmentInformation info = ClubRecruitmentInformation.builder()
- .feedImages(feedImages)
- .build();
- club = Club.builder().clubRecruitmentInformation(info).build();
- when(clubRepository.findClubById(any())).thenReturn(Optional.of(club));
-
- // when & then
- RestApiException exception = assertThrows(RestApiException.class,
- () -> clubImageService.uploadFeed(objectId.toHexString(), mockFile));
- assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode());
- }
-
- @Test
- void feed를_업로드할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() {
- when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty());
- assertThrows(RestApiException.class, () -> clubImageService.uploadFeed(objectId.toHexString(), mockFile));
- }
-
- // updateFeeds
- @Test
- void 새로운_feed_리스트를_넣으면_정상적으로_저장된다() {
- // given
- List feedImages = List.of("old1.jpg", "old2.jpg");
- ClubRecruitmentInformation info = ClubRecruitmentInformation.builder()
- .feedImages(feedImages)
- .build();
- club = Club.builder().clubRecruitmentInformation(info).build();
- List newList = List.of("new1.jpg");
- when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club));
- doNothing().when(clubImageService).deleteFile(eq(club), any());
-
- // when
- clubImageService.updateFeeds(objectId.toHexString(), newList);
-
- // then
- assertIterableEquals(newList, club.getClubRecruitmentInformation().getFeedImages());
-
- }
-
- @Test
- void feed를_삭제할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() {
- // given
- when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty());
-
- // when & then
- RestApiException exception = assertThrows(RestApiException.class,
- () -> clubImageService.updateFeeds(objectId.toHexString(), List.of()));
- assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode());
- }
-
- @Test
- void 새로운_feed_리스트가_MAX_FEED_COUNT_이상이면_TOO_MANY_FILES를_반환한다() {
- // given
- when(clubRepository.findClubById(any())).thenReturn(Optional.of(club));
- List tooMany = Arrays.asList(new String[MAX_FEED_COUNT + 1]);
-
- // when & then
- RestApiException exception = assertThrows(RestApiException.class,
- () -> clubImageService.updateFeeds(objectId.toHexString(), tooMany));
- assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode());
- }
-
-}
diff --git a/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceLogoTest.java b/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceLogoTest.java
deleted file mode 100644
index 8b0808324..000000000
--- a/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceLogoTest.java
+++ /dev/null
@@ -1,127 +0,0 @@
-package moadong.media.service;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.doNothing;
-import static org.mockito.Mockito.when;
-
-import java.util.Optional;
-import moadong.club.entity.Club;
-import moadong.club.entity.ClubRecruitmentInformation;
-import moadong.club.repository.ClubRepository;
-import moadong.global.exception.ErrorCode;
-import moadong.global.exception.RestApiException;
-import moadong.media.util.ClubImageUtil;
-import moadong.util.annotations.UnitTest;
-import org.bson.types.ObjectId;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.Spy;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.mock.web.MockMultipartFile;
-import org.springframework.web.multipart.MultipartFile;
-
-@ExtendWith(MockitoExtension.class)
-@UnitTest
-class CloudflareClubImageServiceLogoTest {
-
- @Spy
- @InjectMocks
- private CloudflareImageService clubImageService;
-
- @Mock
- private ClubRepository clubRepository;
-
- private Club club;
-
- private ObjectId objectId;
-
- private final MultipartFile mockFile = new MockMultipartFile(
- "testFile", "testFile.jpg", "image/jpeg", "test".getBytes());
-
- @BeforeEach
- void setUp() throws NoSuchMethodException {
- ClubRecruitmentInformation info = ClubRecruitmentInformation.builder()
- .logo(null)
- .build();
- club = Club.builder().clubRecruitmentInformation(info).build();
- objectId = new ObjectId();
- }
-
- @Test
- void logo를_업로드할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() {
- // given
- when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty());
-
- // when & then
- RestApiException exception = assertThrows(RestApiException.class,
- () -> clubImageService.uploadLogo(objectId.toHexString(), mockFile));
- assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode());
- }
-
- @Test
- void 업로드하는_파일이_없다면_FILE_NOT_FOUND를_반환한다() {
- // given
- when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club));
-
- // when & then
- RestApiException exception = assertThrows(RestApiException.class,
- () -> clubImageService.uploadLogo(objectId.toHexString(), null));
- assertEquals(ErrorCode.FILE_NOT_FOUND, exception.getErrorCode());
- }
-
- // deleteLogo
- @Test
- void 로고를_삭제하면_logo값이_null이_된다() {
- // given
- ClubRecruitmentInformation info = ClubRecruitmentInformation.builder()
- .logo("test link")
- .build();
- club = Club.builder().clubRecruitmentInformation(info).build();
- when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club));
- doNothing().when(clubImageService).deleteFile(club, "test link");
-
- // when
- clubImageService.deleteLogo(objectId.toHexString());
-
- // then
- assertNull(club.getClubRecruitmentInformation().getLogo());
- }
-
- @Test
- void logo를_삭제할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() {
- // given
- when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty());
-
- // when & then
- RestApiException exception = assertThrows(RestApiException.class,
- () -> clubImageService.deleteLogo(objectId.toHexString()));
- assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode());
- }
-
- @Test
- void 파일명에_적절하지않은_기호나_한글이_포함되어있으면_true를_반환한다(){
- // 정상 케이스
- assertFalse(ClubImageUtil.containsInvalidChars("normal-file.jpg"));
-
- // 한글 포함
- assertTrue(ClubImageUtil.containsInvalidChars("file 이름.png"));
-
- // 퍼센트 인코딩 포함
- assertTrue(ClubImageUtil.containsInvalidChars("file%20name.png"));
- assertTrue(ClubImageUtil.containsInvalidChars("%3Ahidden.jpg"));
-
- // 공백 포함
- assertTrue(ClubImageUtil.containsInvalidChars("file name.png"));
- assertTrue(ClubImageUtil.containsInvalidChars(" tab\tname.png"));
-
- // 여러 조건 섞인 경우
- assertTrue(ClubImageUtil.containsInvalidChars("%22한글 space.png"));
- }
-}
diff --git a/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java b/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java
new file mode 100644
index 000000000..bea3b781a
--- /dev/null
+++ b/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java
@@ -0,0 +1,78 @@
+package moadong.media.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import moadong.club.entity.Club;
+import moadong.club.entity.ClubRecruitmentInformation;
+import moadong.club.repository.ClubRepository;
+import moadong.global.exception.ErrorCode;
+import moadong.global.exception.RestApiException;
+import moadong.util.annotations.UnitTest;
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+
+@ExtendWith(MockitoExtension.class)
+@UnitTest
+class CloudflareImageServiceUpdateFeedsLimitTest {
+
+ @Spy
+ @InjectMocks
+ private CloudflareImageService cloudflareImageService;
+
+ @Mock
+ private ClubRepository clubRepository;
+
+ @Mock
+ private S3Client s3Client;
+
+ @Mock
+ private S3Presigner s3Presigner;
+
+ private final int MAX_FEED_COUNT = 15;
+
+ private Club club;
+ private ObjectId objectId;
+
+ @BeforeEach
+ void setUp() {
+ ClubRecruitmentInformation info = ClubRecruitmentInformation.builder()
+ .feedImages(List.of())
+ .build();
+ club = Club.builder().userId("").clubRecruitmentInformation(info).build();
+ objectId = new ObjectId();
+ }
+
+ @Test
+ void MAX_FEED_COUNT_이상의_피드를_업로드하면_TOO_MANY_FILES를_반환한다() {
+ // given
+ List tooMany = Arrays.asList(new String[MAX_FEED_COUNT]);
+ ClubRecruitmentInformation info = ClubRecruitmentInformation.builder()
+ .feedImages(tooMany)
+ .build();
+ club = Club.builder().userId("").clubRecruitmentInformation(info).build();
+
+ // when
+ when(clubRepository.findClubById(any())).thenReturn(Optional.of(club));
+
+ // then
+ RestApiException exception = assertThrows(RestApiException.class,
+ () -> cloudflareImageService.updateFeeds(objectId.toHexString(), "", tooMany));
+ assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode());
+ }
+}
+
+