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()); + } +} + +