-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 신규 동아리 등록 요청 API 추가 #635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package gg.agit.konect.domain.club.controller; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
|
|
||
| import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; | ||
| import gg.agit.konect.global.auth.annotation.PublicApi; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.validation.Valid; | ||
|
|
||
| @Tag(name = "(Normal) Club - Registration Request: 신규 동아리 등록 요청") | ||
| @RequestMapping("/clubs/registration-requests") | ||
| public interface ClubRegistrationRequestApi { | ||
|
|
||
| @Operation(summary = "신규 동아리 등록 요청을 보낸다.", description = """ | ||
| 로그인하지 않은 사용자도 신규 동아리 등록 요청을 보낼 수 있습니다. | ||
| 요청 내용은 가입/탈퇴 알림과 같은 Slack event webhook으로 전달됩니다. | ||
| """) | ||
| @PostMapping | ||
| @PublicApi | ||
| ResponseEntity<Void> submitClubRegistrationRequest(@Valid @RequestBody ClubRegistrationRequest request); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package gg.agit.konect.domain.club.controller; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; | ||
| import gg.agit.konect.domain.club.service.ClubRegistrationRequestService; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/clubs/registration-requests") | ||
| public class ClubRegistrationRequestController implements ClubRegistrationRequestApi { | ||
|
|
||
| private final ClubRegistrationRequestService clubRegistrationRequestService; | ||
|
|
||
| @Override | ||
| public ResponseEntity<Void> submitClubRegistrationRequest(ClubRegistrationRequest request) { | ||
| clubRegistrationRequestService.submitClubRegistrationRequest(request); | ||
| return ResponseEntity.ok().build(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LEVEL: medium] 생성 요청 성공 응답 코드가 200으로 고정되어 있습니다. 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package gg.agit.konect.domain.club.dto; | ||
|
|
||
| import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import gg.agit.konect.domain.club.enums.ClubCategory; | ||
| import io.swagger.v3.oas.annotations.media.ArraySchema; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotEmpty; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| public record ClubRegistrationRequest( | ||
| @NotBlank(message = "대학교 명은 필수 입력입니다.") | ||
| @Size(max = 50, message = "대학교 명은 50자 이하여야 합니다.") | ||
| @Schema(description = "대학교 명", example = "한국기술교육대학교", requiredMode = REQUIRED) | ||
| String universityName, | ||
|
|
||
| @NotBlank(message = "동아리 명은 필수 입력입니다.") | ||
| @Size(max = 50, message = "동아리 명은 50자 이하여야 합니다.") | ||
| @Schema(description = "동아리 명", example = "BCSD Lab", requiredMode = REQUIRED) | ||
| String clubName, | ||
|
|
||
| @NotNull(message = "동아리 분과는 필수 입력입니다.") | ||
| @Schema(description = "동아리 분과", example = "ACADEMIC", requiredMode = REQUIRED) | ||
| ClubCategory clubCategory, | ||
|
|
||
| @NotBlank(message = "동아리 주제는 필수 입력입니다.") | ||
| @Size(max = 20, message = "동아리 주제는 20자 이하여야 합니다.") | ||
| @Schema(description = "동아리 주제", example = "개발", requiredMode = REQUIRED) | ||
| String topic, | ||
|
|
||
| @NotBlank(message = "동아리 이모지는 필수 입력입니다.") | ||
| @Size(max = 20, message = "동아리 이모지는 20자 이하여야 합니다.") | ||
| @Schema(description = "동아리 텍스트 이모지", example = "💻", requiredMode = REQUIRED) | ||
| String emoji, | ||
|
|
||
| @NotBlank(message = "한 줄 소개는 필수 입력입니다.") | ||
| @Size(max = 30, message = "한 줄 소개는 30자 이하여야 합니다.") | ||
| @Schema(description = "한 줄 소개", example = "즐겁게 서비스 만드는 동아리", requiredMode = REQUIRED) | ||
| String description, | ||
|
|
||
| @NotEmpty(message = "사진 및 영상은 필수 입력입니다.") | ||
| @Size(max = 5, message = "사진 및 영상은 5개 이하여야 합니다.") | ||
| @ArraySchema( | ||
| schema = @Schema(description = "사진 및 영상 URL", example = "https://example.com/club-1.png"), | ||
| arraySchema = @Schema(description = "사진 및 영상 URL 목록", requiredMode = REQUIRED) | ||
| ) | ||
| List<@NotBlank(message = "사진 및 영상 URL은 비어 있을 수 없습니다.") | ||
| @Size(max = 255, message = "사진 및 영상 URL은 255자 이하여야 합니다.") String> mediaUrls, | ||
|
|
||
| @NotBlank(message = "동아리 소개는 필수 입력입니다.") | ||
| @Size(max = 2000, message = "동아리 소개는 2000자 이하여야 합니다.") | ||
| @Schema(description = "동아리 소개", example = "BCSD Lab은 IT 서비스 개발 동아리입니다.", requiredMode = REQUIRED) | ||
| String introduce | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package gg.agit.konect.domain.club.event; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; | ||
| import gg.agit.konect.domain.club.enums.ClubCategory; | ||
| import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; | ||
|
|
||
| public record ClubRegistrationRequestedEvent( | ||
| String universityName, | ||
| String clubName, | ||
| ClubCategory clubCategory, | ||
| String topic, | ||
| String emoji, | ||
| String description, | ||
| List<String> mediaUrls, | ||
| String introduce | ||
| ) { | ||
|
|
||
| public static ClubRegistrationRequestedEvent from(ClubRegistrationRequest request) { | ||
| return new ClubRegistrationRequestedEvent( | ||
| request.universityName(), | ||
| request.clubName(), | ||
| request.clubCategory(), | ||
| request.topic(), | ||
| request.emoji(), | ||
| request.description(), | ||
| List.copyOf(request.mediaUrls()), | ||
| request.introduce() | ||
| ); | ||
| } | ||
|
|
||
| public static ClubRegistrationRequestedEvent from(ClubRegistrationRequestEntity request) { | ||
| return new ClubRegistrationRequestedEvent( | ||
| request.getUniversityName(), | ||
| request.getClubName(), | ||
| request.getClubCategory(), | ||
| request.getTopic(), | ||
| request.getEmoji(), | ||
| request.getDescription(), | ||
| request.getMediaUrls(), | ||
| request.getIntroduce() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| package gg.agit.konect.domain.club.model; | ||
|
|
||
| import static jakarta.persistence.CascadeType.ALL; | ||
| import static jakarta.persistence.EnumType.STRING; | ||
| import static jakarta.persistence.GenerationType.IDENTITY; | ||
| import static lombok.AccessLevel.PROTECTED; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; | ||
| import gg.agit.konect.domain.club.enums.ClubCategory; | ||
| import gg.agit.konect.global.model.BaseEntity; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Enumerated; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.OneToMany; | ||
| import jakarta.persistence.Table; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @Entity | ||
| @Table(name = "club_registration_request") | ||
| @NoArgsConstructor(access = PROTECTED) | ||
| public class ClubRegistrationRequestEntity extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = IDENTITY) | ||
| @Column(name = "id", nullable = false, updatable = false, unique = true) | ||
| private Integer id; | ||
|
|
||
| @Column(name = "university_name", length = 50, nullable = false) | ||
| private String universityName; | ||
|
|
||
| @Column(name = "club_name", length = 50, nullable = false) | ||
| private String clubName; | ||
|
|
||
| @NotNull | ||
| @Enumerated(value = STRING) | ||
| @Column(name = "club_category", length = 20, nullable = false) | ||
| private ClubCategory clubCategory; | ||
|
|
||
| @Column(name = "topic", length = 20, nullable = false) | ||
| private String topic; | ||
|
|
||
| @Column(name = "emoji", length = 20, nullable = false) | ||
| private String emoji; | ||
|
|
||
| @Column(name = "description", length = 30, nullable = false) | ||
| private String description; | ||
|
|
||
| @Column(name = "introduce", columnDefinition = "TEXT", nullable = false) | ||
| private String introduce; | ||
|
|
||
| @OneToMany(mappedBy = "clubRegistrationRequest", cascade = ALL, orphanRemoval = true) | ||
| private List<ClubRegistrationRequestMedia> media = new ArrayList<>(); | ||
|
|
||
| @Builder | ||
| private ClubRegistrationRequestEntity( | ||
| Integer id, | ||
| String universityName, | ||
| String clubName, | ||
| ClubCategory clubCategory, | ||
| String topic, | ||
| String emoji, | ||
| String description, | ||
| String introduce | ||
| ) { | ||
| this.id = id; | ||
| this.universityName = universityName; | ||
| this.clubName = clubName; | ||
| this.clubCategory = clubCategory; | ||
| this.topic = topic; | ||
| this.emoji = emoji; | ||
| this.description = description; | ||
| this.introduce = introduce; | ||
| } | ||
|
|
||
| public static ClubRegistrationRequestEntity from(ClubRegistrationRequest request) { | ||
| ClubRegistrationRequestEntity entity = ClubRegistrationRequestEntity.builder() | ||
| .universityName(request.universityName()) | ||
| .clubName(request.clubName()) | ||
| .clubCategory(request.clubCategory()) | ||
| .topic(request.topic()) | ||
| .emoji(request.emoji()) | ||
| .description(request.description()) | ||
| .introduce(request.introduce()) | ||
| .build(); | ||
|
|
||
| for (int index = 0; index < request.mediaUrls().size(); index++) { | ||
| entity.addMedia(request.mediaUrls().get(index), index); | ||
| } | ||
| return entity; | ||
| } | ||
|
|
||
| public List<String> getMediaUrls() { | ||
| return media.stream() | ||
| .sorted(java.util.Comparator.comparing(ClubRegistrationRequestMedia::getDisplayOrder)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LEVEL: low] import로 대체 가능한 FQCN 사용이 남아 있습니다. 🤖 Prompt for AI Agents |
||
| .map(ClubRegistrationRequestMedia::getUrl) | ||
| .toList(); | ||
| } | ||
|
|
||
| private void addMedia(String url, Integer displayOrder) { | ||
| media.add(ClubRegistrationRequestMedia.of(url, displayOrder, this)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package gg.agit.konect.domain.club.model; | ||
|
|
||
| import static jakarta.persistence.FetchType.LAZY; | ||
| import static jakarta.persistence.GenerationType.IDENTITY; | ||
| import static lombok.AccessLevel.PROTECTED; | ||
|
|
||
| import gg.agit.konect.global.model.BaseEntity; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.JoinColumn; | ||
| import jakarta.persistence.ManyToOne; | ||
| import jakarta.persistence.Table; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @Entity | ||
| @Table(name = "club_registration_request_media") | ||
| @NoArgsConstructor(access = PROTECTED) | ||
| public class ClubRegistrationRequestMedia extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = IDENTITY) | ||
| @Column(name = "id", nullable = false, updatable = false, unique = true) | ||
| private Integer id; | ||
|
|
||
| @Column(name = "url", length = 255, nullable = false) | ||
| private String url; | ||
|
|
||
| @Column(name = "display_order", nullable = false) | ||
| private Integer displayOrder; | ||
|
|
||
| @NotNull | ||
| @ManyToOne(fetch = LAZY) | ||
| @JoinColumn(name = "club_registration_request_id", nullable = false) | ||
| private ClubRegistrationRequestEntity clubRegistrationRequest; | ||
|
|
||
| @Builder | ||
|
|
||
| private ClubRegistrationRequestMedia( | ||
| Integer id, | ||
| String url, | ||
| Integer displayOrder, | ||
| ClubRegistrationRequestEntity clubRegistrationRequest | ||
| ) { | ||
| this.id = id; | ||
| this.url = url; | ||
| this.displayOrder = displayOrder; | ||
| this.clubRegistrationRequest = clubRegistrationRequest; | ||
| } | ||
|
|
||
| public static ClubRegistrationRequestMedia of( | ||
| String url, | ||
| Integer displayOrder, | ||
| ClubRegistrationRequestEntity clubRegistrationRequest | ||
| ) { | ||
| return ClubRegistrationRequestMedia.builder() | ||
| .url(url) | ||
| .displayOrder(displayOrder) | ||
| .clubRegistrationRequest(clubRegistrationRequest) | ||
| .build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package gg.agit.konect.domain.club.repository; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; | ||
|
|
||
| public interface ClubRegistrationRequestRepository extends JpaRepository<ClubRegistrationRequestEntity, Integer> { | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[LEVEL: high] 공개 write API에 남용 방지 장치가 없습니다.
문제:
@PublicApiPOST 엔드포인트에 요청 빈도 제어가 없어 반복 호출로 등록 데이터와 Slack 알림이 과도하게 생성될 수 있습니다. 영향: 봇/스크립트 유입 시 운영 채널 노이즈와 저장소 부하로 장애 가능성이 커집니다. 제안: 기존 인프라 패턴에 맞춰 IP·UA 기준 rate limit(예: Bucket4j/게이트웨이 제한) 또는 최소한의 중복 제출 쿨다운을 본 PR에서 함께 적용해 주세요.🤖 Prompt for AI Agents