Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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);
Comment on lines +22 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

[LEVEL: high] 공개 write API에 남용 방지 장치가 없습니다.
문제: @PublicApi POST 엔드포인트에 요청 빈도 제어가 없어 반복 호출로 등록 데이터와 Slack 알림이 과도하게 생성될 수 있습니다. 영향: 봇/스크립트 유입 시 운영 채널 노이즈와 저장소 부하로 장애 가능성이 커집니다. 제안: 기존 인프라 패턴에 맞춰 IP·UA 기준 rate limit(예: Bucket4j/게이트웨이 제한) 또는 최소한의 중복 제출 쿨다운을 본 PR에서 함께 적용해 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java`
around lines 22 - 24, The public POST endpoint submitClubRegistrationRequest in
ClubRegistrationRequestApi lacks rate-limiting or duplicate-submission
protection; add a protective layer by integrating an IP/UA-based rate limiter
(e.g., Bucket4j or gateway-backed limiter) or implement a lightweight
duplicate-submission cooldown: validate and reject repeated requests within a
short window based on client fingerprint (IP + User-Agent) or request signature
(hash of ClubRegistrationRequest fields) before processing and sending Slack
notifications; wire this check into the controller/handler that implements
submitClubRegistrationRequest so requests exceeding the limit return an
appropriate 429/409 response and do not persist data or trigger Slack.

}
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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

[LEVEL: medium] 생성 요청 성공 응답 코드가 200으로 고정되어 있습니다.
문제: 신규 리소스 생성 성격의 POST에서 200 OK를 반환하면 생성/갱신 의미가 구분되지 않습니다. 영향: 클라이언트가 상태코드 기반으로 후속 처리(재시도/추적)를 할 때 계약 해석이 흔들릴 수 있습니다. 제안: ResponseEntity.status(HttpStatus.CREATED).build()201 Created를 반환해 API 의미를 명확히 맞춰 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java`
at line 21, In ClubRegistrationRequestController update the POST handler's
response to return HTTP 201 instead of the fixed 200: replace the current
ResponseEntity.ok().build() return in the controller method that handles club
registration (the POST endpoint in ClubRegistrationRequestController) with a
ResponseEntity.status(HttpStatus.CREATED).build() so newly created resources
correctly return 201 Created.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ public record ClubCreateRequest(
@Schema(description = "동아리 주제", example = "코딩", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "동아리 주제는 필수 입력입니다.")
@Size(max = 20, message = "동아리 주제는 20자 이하여야 합니다.")
String topic
String topic,

@Schema(description = "동아리 텍스트 이모지", example = "💻")
@Size(max = 20, message = "동아리 이모지는 20자 이하여야 합니다.")
String emoji
) {
public Club toEntity(University university) {
return Club.builder()
Expand All @@ -58,6 +62,7 @@ public Club toEntity(University university) {
.location(location)
.clubCategory(clubCategory)
.topic(topic)
.emoji(emoji)
.university(university)
.isRecruitmentEnabled(false)
.isApplicationEnabled(true)
Expand Down
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()
);
}
}
5 changes: 5 additions & 0 deletions src/main/java/gg/agit/konect/domain/club/model/Club.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ public class Club extends BaseEntity {
@Column(name = "topic", length = 20, nullable = false)
private String topic;

@Column(name = "emoji", length = 20)
private String emoji;

@Column(name = "description", length = 20, nullable = false)
private String description;

Expand Down Expand Up @@ -109,6 +112,7 @@ private Club(
University university,
String name,
String topic,
String emoji,
String description,
String introduce,
String imageUrl,
Expand All @@ -127,6 +131,7 @@ private Club(
this.university = university;
this.name = name;
this.topic = topic;
this.emoji = emoji;
this.description = description;
this.introduce = introduce;
this.imageUrl = imageUrl;
Expand Down
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))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

[LEVEL: low] import로 대체 가능한 FQCN 사용이 남아 있습니다.
문제: java.util.Comparator.comparing(...)를 직접 사용해 같은 파일 내 import 규칙과 일관성이 깨집니다. 영향: 코드 가독성이 떨어지고 팀 컨벤션 위반이 누적됩니다. 제안: import java.util.Comparator;를 추가하고 Comparator.comparing(...)로 변경해 주세요. As per coding guidelines **/*.java: Java 코드에서 import로 해결할 수 있는 경우 FQCN(Full Qualified Class Name)을 사용하지 않도록 지적한다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestEntity.java`
at line 103, The file uses a fully-qualified class name
java.util.Comparator.comparing in the stream sort call; add an import for
java.util.Comparator and replace java.util.Comparator.comparing(...) with
Comparator.comparing(...) in the ClubRegistrationRequestEntity stream that sorts
ClubRegistrationRequestMedia by getDisplayOrder to match project import
conventions and improve readability.

.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> {
}
Loading
Loading