-
Notifications
You must be signed in to change notification settings - Fork 0
Feat: [FN-96][FN-97][FN-99][FN-110][FN-123] 그룹 초대 기능 #27
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
Changes from all commits
27fb73c
a1f8858
5734aff
389912f
4f562fb
1c8cf19
a83d967
0de90c6
e26fe13
5d3a0a7
e85cb58
933340d
c725313
442f49a
4c632a5
418215f
0eb2af2
62214bf
f3ae3f3
e50dbe4
48658f3
c1e0018
d67c104
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,7 @@ | ||
| package project.flipnote.common.event; | ||
|
|
||
| public record UserRegisteredEvent( | ||
| Long userId, | ||
| String email | ||
| ) { | ||
| } | ||
dungbik marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package project.flipnote.common.response; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import org.springframework.data.domain.Page; | ||
|
|
||
| public record PageResponse<T>( | ||
| List<T> content, | ||
| int page, | ||
| int size, | ||
| long totalElements, | ||
| int totalPages, | ||
| boolean first, | ||
| boolean last, | ||
| boolean hasNext, | ||
| boolean hasPrevious | ||
| ) { | ||
|
|
||
| public static <T> PageResponse<T> from(Page<T> page) { | ||
| return new PageResponse<>( | ||
| page.getContent(), | ||
| page.getNumber(), | ||
| page.getSize(), | ||
| page.getTotalElements(), | ||
| page.getTotalPages(), | ||
| page.isFirst(), | ||
| page.isLast(), | ||
| page.hasNext(), | ||
| page.hasPrevious() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| package project.flipnote.group.controller; | ||
|
|
||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.DeleteMapping; | ||
| import org.springframework.web.bind.annotation.PatchMapping; | ||
| 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.RestController; | ||
|
|
||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import project.flipnote.common.security.dto.AuthPrinciple; | ||
| import project.flipnote.group.controller.docs.GroupInvitationControllerDocs; | ||
| import project.flipnote.group.model.GroupInvitationCreateRequest; | ||
| import project.flipnote.group.model.GroupInvitationCreateResponse; | ||
| import project.flipnote.group.model.GroupInvitationRespondRequest; | ||
| import project.flipnote.group.service.GroupInvitationService; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @RestController | ||
| @RequestMapping("/v1/groups/{groupId}/invitations") | ||
| public class GroupInvitationController implements GroupInvitationControllerDocs { | ||
|
|
||
| private final GroupInvitationService groupInvitationService; | ||
|
|
||
| @PostMapping | ||
| public ResponseEntity<GroupInvitationCreateResponse> createGroupInvitation( | ||
| @PathVariable("groupId") Long groupId, | ||
| @Valid @RequestBody GroupInvitationCreateRequest req, | ||
| @AuthenticationPrincipal AuthPrinciple authPrinciple | ||
| ) { | ||
| GroupInvitationCreateResponse res = groupInvitationService.createGroupInvitation(authPrinciple, groupId, req); | ||
|
|
||
| return ResponseEntity.status(HttpStatus.CREATED).body(res); | ||
| } | ||
|
|
||
| @DeleteMapping("/{invitationId}") | ||
| public ResponseEntity<Void> deleteGroupInvitation( | ||
| @PathVariable("groupId") Long groupId, | ||
| @PathVariable("invitationId") Long invitationId, | ||
| @AuthenticationPrincipal AuthPrinciple authPrinciple | ||
| ) { | ||
| groupInvitationService.deleteGroupInvitation(authPrinciple.userId(), groupId, invitationId); | ||
|
|
||
| return ResponseEntity.ok().build(); | ||
| } | ||
|
|
||
| @PatchMapping("/{invitationId}") | ||
| public ResponseEntity<Void> respondToGroupInvitation( | ||
| @PathVariable("groupId") Long groupId, | ||
| @PathVariable("invitationId") Long invitationId, | ||
| @Valid @RequestBody GroupInvitationRespondRequest req, | ||
| @AuthenticationPrincipal AuthPrinciple authPrinciple | ||
| ) { | ||
| groupInvitationService.respondToGroupInvitation(authPrinciple.userId(), groupId, invitationId, req); | ||
|
|
||
| return ResponseEntity.ok().build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package project.flipnote.group.controller; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import jakarta.validation.constraints.Min; | ||
| import lombok.RequiredArgsConstructor; | ||
| import project.flipnote.common.response.PageResponse; | ||
| import project.flipnote.common.security.dto.AuthPrinciple; | ||
| import project.flipnote.group.controller.docs.GroupInvitationQueryControllerDocs; | ||
| import project.flipnote.group.model.IncomingGroupInvitationResponse; | ||
| import project.flipnote.group.model.OutgoingGroupInvitationResponse; | ||
| import project.flipnote.group.service.GroupInvitationService; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @RestController | ||
| @RequestMapping("/v1") | ||
| public class GroupInvitationQueryController implements GroupInvitationQueryControllerDocs { | ||
|
|
||
| private final GroupInvitationService groupInvitationService; | ||
|
|
||
| @GetMapping("/groups/{groupId}/invitations") | ||
| public ResponseEntity<PageResponse<OutgoingGroupInvitationResponse>> getOutgoingInvitations( | ||
| @PathVariable("groupId") Long groupId, | ||
| @Min(0) @RequestParam(defaultValue = "0") int page, | ||
| @Min(1) @Min(30) @RequestParam(defaultValue = "20") int size, | ||
| @AuthenticationPrincipal AuthPrinciple authPrinciple | ||
| ) { | ||
| PageResponse<OutgoingGroupInvitationResponse> res | ||
| = groupInvitationService.getOutgoingInvitations(authPrinciple.userId(), groupId, page, size); | ||
|
|
||
| return ResponseEntity.ok(res); | ||
| } | ||
|
|
||
| @GetMapping("/group-invitations") | ||
| public ResponseEntity<PageResponse<IncomingGroupInvitationResponse>> getIncomingInvitations( | ||
| @Min(0) @RequestParam(defaultValue = "0") int page, | ||
| @Min(1) @Min(30) @RequestParam(defaultValue = "20") int size, | ||
| @AuthenticationPrincipal AuthPrinciple authPrinciple | ||
| ) { | ||
| PageResponse<IncomingGroupInvitationResponse> res | ||
| = groupInvitationService.getIncomingInvitations(authPrinciple.userId(), page, size); | ||
|
|
||
| return ResponseEntity.ok(res); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package project.flipnote.group.controller.docs; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
|
|
||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.security.SecurityRequirement; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import project.flipnote.common.security.dto.AuthPrinciple; | ||
| import project.flipnote.group.model.GroupInvitationCreateRequest; | ||
| import project.flipnote.group.model.GroupInvitationCreateResponse; | ||
| import project.flipnote.group.model.GroupInvitationRespondRequest; | ||
|
|
||
| @Tag(name = "Group Invitation", description = "Group Invitation API") | ||
| public interface GroupInvitationControllerDocs { | ||
|
|
||
| @Operation(summary = "그룹 초대", security = {@SecurityRequirement(name = "access-token")}) | ||
| ResponseEntity<GroupInvitationCreateResponse> createGroupInvitation( | ||
| Long groupId, GroupInvitationCreateRequest req, AuthPrinciple authPrinciple | ||
| ); | ||
|
|
||
| @Operation(summary = "그룹 초대 취소", security = {@SecurityRequirement(name = "access-token")}) | ||
| ResponseEntity<Void> deleteGroupInvitation(Long groupId, Long invitationId, AuthPrinciple authPrinciple); | ||
|
|
||
| @Operation(summary = "그룹 초대 응답", security = {@SecurityRequirement(name = "access-token")}) | ||
| ResponseEntity<Void> respondToGroupInvitation( | ||
| Long groupId, Long invitationId, GroupInvitationRespondRequest req, AuthPrinciple authPrinciple | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package project.flipnote.group.controller.docs; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
|
|
||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.security.SecurityRequirement; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import project.flipnote.common.response.PageResponse; | ||
| import project.flipnote.common.security.dto.AuthPrinciple; | ||
| import project.flipnote.group.model.IncomingGroupInvitationResponse; | ||
| import project.flipnote.group.model.OutgoingGroupInvitationResponse; | ||
|
|
||
| @Tag(name = "Group Invitation Query", description = "Group Invitation Query API") | ||
| public interface GroupInvitationQueryControllerDocs { | ||
|
|
||
| @Operation(summary = "그룹 초대 보낸 목록 조회", security = {@SecurityRequirement(name = "access-token")}) | ||
| ResponseEntity<PageResponse<OutgoingGroupInvitationResponse>> getOutgoingInvitations( | ||
| Long groupId, | ||
| int page, | ||
| int size, | ||
| AuthPrinciple authPrinciple | ||
| ); | ||
|
|
||
| @Operation(summary = "그룹 초대 받은 목록 조회", security = {@SecurityRequirement(name = "access-token")}) | ||
| ResponseEntity<PageResponse<IncomingGroupInvitationResponse>> getIncomingInvitations( | ||
| int page, | ||
| int size, | ||
| AuthPrinciple authPrinciple | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -16,6 +16,8 @@ | |||||||||||||||||||||
| import lombok.Getter; | ||||||||||||||||||||||
| import lombok.NoArgsConstructor; | ||||||||||||||||||||||
| import project.flipnote.common.entity.BaseEntity; | ||||||||||||||||||||||
| import project.flipnote.common.exception.BizException; | ||||||||||||||||||||||
| import project.flipnote.group.exception.GroupErrorCode; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @Getter | ||||||||||||||||||||||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||||||||||||||||||||||
|
|
@@ -67,4 +69,10 @@ private Group( | |||||||||||||||||||||
| this.maxMember = maxMember; | ||||||||||||||||||||||
| this.imageUrl = imageUrl; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| public void validateJoinable() { | ||||||||||||||||||||||
| if (maxMember < 1 || maxMember >= 100) { | ||||||||||||||||||||||
| throw new BizException(GroupErrorCode.INVALID_MAX_MEMBER); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+73
to
+77
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. maxMember 유효성 검증 범위 불일치 문제 Line 74에서 관련 코드:
다음 중 하나로 통일해주세요: public void validateJoinable() {
- if (maxMember < 1 || maxMember >= 100) {
+ if (maxMember < 1 || maxMember > 100) {
throw new BizException(GroupErrorCode.INVALID_MAX_MEMBER);
}
}또는 JPA 어노테이션과 GroupService 로직을 99로 통일하세요. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,70 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| package project.flipnote.group.entity; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Column; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Entity; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.EnumType; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Enumerated; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.GeneratedValue; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.GenerationType; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Id; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Index; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.JoinColumn; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.ManyToOne; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Table; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.UniqueConstraint; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.AccessLevel; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.Builder; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.Getter; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.NoArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import project.flipnote.common.entity.BaseEntity; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @Getter | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @Entity | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @Table( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| name = "group_invitation", | ||||||||||||||||||||||||||||||||||||||||||||||||||
| indexes = { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @Index(name = "idx_group_invitee_user", columnList = "group_id, invitee_user_id, status"), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @Index(name = "idx_group_invitee_email", columnList = "group_id, invitee_email, status"), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @Index(name = "idx_invitee_user_status", columnList = "invitee_user_id, status"), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @Index(name = "idx_invitee_email_status", columnList = "invitee_email, status") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| uniqueConstraints = { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @UniqueConstraint(name = "uq_group_invitee_user", columnNames = {"group_id", "invitee_user_id"}), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @UniqueConstraint(name = "uq_group_invitee_email", columnNames = {"group_id", "invitee_email"}) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| public class GroupInvitation extends BaseEntity { | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @Id | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| private Long id; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @ManyToOne | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @JoinColumn(name = "group_id", nullable = false) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| private Group group; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @Column(nullable = false) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| private Long inviterUserId; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| private Long inviteeUserId; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| private String inviteeEmail; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+50
to
+53
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. 🛠️ Refactor suggestion 엔터티 불변식 강화: 회원/비회원 초대 상호 배타성 보장 현재 inviteeUserId, inviteeEmail 둘 다 또는 둘 다 null인 잘못된 상태의 엔터티가 생성될 수 있습니다. 생성 경로를 두 개로 분리(회원/비회원)하거나 팩토리 메서드로 불변식을 강제하세요. Builder 접근을 제한하는 것도 방법입니다. 예시(접근 제한 + 팩토리): -@Builder
-public GroupInvitation(Long groupId, Long inviterUserId, Long inviteeUserId, String inviteeEmail) {
+@Builder(access = AccessLevel.PRIVATE)
+public GroupInvitation(Long groupId, Long inviterUserId, Long inviteeUserId, String inviteeEmail) {
...
}
+
+public static GroupInvitation forUser(Long groupId, Long inviterUserId, Long inviteeUserId, String inviteeEmail) {
+ if (inviteeUserId == null || inviteeEmail == null) throw new IllegalArgumentException("회원 초대는 userId와 email이 모두 필요합니다.");
+ return GroupInvitation.builder()
+ .groupId(groupId).inviterUserId(inviterUserId)
+ .inviteeUserId(inviteeUserId).inviteeEmail(inviteeEmail)
+ .build();
+}
+
+public static GroupInvitation forGuest(Long groupId, Long inviterUserId, String inviteeEmail) {
+ if (inviteeEmail == null || inviteeEmail.isBlank()) throw new IllegalArgumentException("비회원 초대는 email이 필요합니다.");
+ return GroupInvitation.builder()
+ .groupId(groupId).inviterUserId(inviterUserId)
+ .inviteeUserId(null).inviteeEmail(inviteeEmail.toLowerCase())
+ .build();
+}추가로, 이메일은 저장 전 소문자 정규화(toLowerCase) 일관 적용을 추천합니다.
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| @Enumerated(EnumType.STRING) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @Column(nullable = false) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| private GroupInvitationStatus status; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @Builder | ||||||||||||||||||||||||||||||||||||||||||||||||||
| public GroupInvitation(Group group, Long inviterUserId, Long inviteeUserId, String inviteeEmail) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| this.group = group; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| this.inviterUserId = inviterUserId; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| this.inviteeUserId = inviteeUserId; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| this.inviteeEmail = inviteeEmail; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| this.status = GroupInvitationStatus.PENDING; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| public void respond(GroupInvitationStatus status) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| this.status = status; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+67
to
+69
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. 🛠️ Refactor suggestion 상태 전이 제약 추가 (PENDING -> ACCEPTED/REJECTED만 허용) 엔티티 차원에서 불변식(이미 처리된 초대는 재처리 불가, PENDING으로 회귀 불가)을 보장하세요. public void respond(GroupInvitationStatus status) {
- this.status = status;
+ if (this.status != GroupInvitationStatus.PENDING) {
+ throw new IllegalStateException("이미 처리된 초대입니다.");
+ }
+ if (status == GroupInvitationStatus.PENDING) {
+ throw new IllegalArgumentException("PENDING 상태로의 변경은 허용되지 않습니다.");
+ }
+ this.status = status;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+67
to
+69
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. 상태 전이 검증 부족: 이미 처리된 초대 재변경 가능 respond는 모든 상태에서 임의 상태로 변경 가능합니다. PENDING → {ACCEPTED, REJECTED}로만 허용하고, 그 외 전이는 막아야 데이터 무결성이 보장됩니다. - public void respond(GroupInvitationStatus status) {
- this.status = status;
- }
+ public void respond(GroupInvitationStatus status) {
+ if (this.status != GroupInvitationStatus.PENDING) {
+ throw new IllegalStateException("이미 처리된 초대입니다. 현재 상태=" + this.status);
+ }
+ if (status == null || status == GroupInvitationStatus.PENDING) {
+ throw new IllegalArgumentException("유효하지 않은 상태 전이입니다: " + status);
+ }
+ this.status = status;
+ }상태 전이를 엔티티에서 강제하면 서비스 계층의 실수도 방지할 수 있습니다. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package project.flipnote.group.entity; | ||
|
|
||
| public enum GroupInvitationStatus { | ||
| PENDING, ACCEPTED, REJECTED | ||
| } | ||
|
Comment on lines
+3
to
+5
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. 💡 Verification agent 🧩 Analysis chain초대 상태 확장 및 매핑 방식 확인 제안
초대 엔티티에서 문자열 매핑이 적용되어 있는지 확인 스크립트: 🏁 Script executed: #!/bin/bash
rg -n "class GroupInvitation|@Enumerated|GroupInvitationStatus" -A 10 -B 5Length of output: 33404 GroupInvitationStatus enum 확장 제안 및 매핑 적용 확인
🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,7 +11,8 @@ | |||||||||
| @RequiredArgsConstructor | ||||||||||
| public enum GroupErrorCode implements ErrorCode { | ||||||||||
| GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "GROUP_002", "그룹이 존재하지 않습니다."), | ||||||||||
| INVALID_MAX_MEMBER(HttpStatus.BAD_REQUEST, "GROUP_001", "최대 인원 수는 1 이상 100 이하여야 합니다."); | ||||||||||
| INVALID_MAX_MEMBER(HttpStatus.BAD_REQUEST, "GROUP_001", "최대 인원 수는 1 이상 100 이하여야 합니다."), | ||||||||||
| USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다."); | ||||||||||
|
Comment on lines
+14
to
+15
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. 💡 Verification agent 🧩 Analysis chain권한/존재의 의미적 구분에 따른 HTTP 상태 정합성 재검토(403 vs 404)
권장 수정안(diff): - USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다.");
+ USER_NOT_IN_GROUP(HttpStatus.FORBIDDEN, "GROUP_003", "해당 그룹의 멤버가 아닙니다.");본 에러코드 사용부에서 클라이언트 기대 동작과의 일치 여부를 확인하세요: 🏁 Script executed: #!/bin/bash
rg -n "GroupErrorCode\.USER_NOT_IN_GROUP|GROUP_003"Length of output: 326 🏁 Script executed: #!/bin/bash
rg -n "GROUP_MEMBER_NOT_FOUND" -A2 -B2Length of output: 1543 GroupErrorCode.USER_NOT_IN_GROUP의 HTTP 상태 및 메시지 일관성 수정 필요 도메인 관점에서 “그룹에 유저가 존재하지 않습니다”는 권한(Forbidden) 문제로, CardSetErrorCode.GROUP_MEMBER_NOT_FOUND에서도 403을 사용하고 있어 두 코드를 맞추는 것이 좋습니다. 수정 대상:
권장 수정안: - USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다.");
+ USER_NOT_IN_GROUP(HttpStatus.FORBIDDEN, "GROUP_003", "해당 그룹의 멤버가 아닙니다.");📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| private final HttpStatus httpStatus; | ||||||||||
| private final String code; | ||||||||||
|
|
||||||||||
Uh oh!
There was an error while loading. Please reload this page.