-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/#27 조직 멤버 초대 및 수락 구현 #42
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
dd971ed
99215bb
8e49ab3
8d0eb8d
2835466
61a343c
f2008fb
de41291
2c48429
efc79ee
08144a9
7a50428
0a26605
1892a75
77408be
cad16a9
474ad2d
9e5bdf9
1a66823
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 | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,16 +12,19 @@ | |||||||||
| import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.Organization; | ||||||||||
| import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository; | ||||||||||
| import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgRepository; | ||||||||||
| import com.whereyouad.WhereYouAd.domains.user.domain.service.EmailService; | ||||||||||
| import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode; | ||||||||||
| import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler; | ||||||||||
| import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User; | ||||||||||
| import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository; | ||||||||||
| import com.whereyouad.WhereYouAd.global.utils.RedisUtil; | ||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||
| import org.springframework.stereotype.Service; | ||||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||||
|
|
||||||||||
| import java.util.List; | ||||||||||
| import java.util.Objects; | ||||||||||
| import java.util.UUID; | ||||||||||
|
|
||||||||||
| @Service | ||||||||||
| @Transactional | ||||||||||
|
|
@@ -32,6 +35,9 @@ public class OrgServiceImpl implements OrgService { | |||||||||
| private final OrgMemberRepository orgMemberRepository; | ||||||||||
| private final UserRepository userRepository; | ||||||||||
|
|
||||||||||
| private final RedisUtil redisUtil; | ||||||||||
| private final EmailService emailService; | ||||||||||
|
|
||||||||||
| // 조직(워크스페이스) 생성 메서드 | ||||||||||
| public OrgResponse.Create createOrganization(Long userId, OrgRequest.Create request) { | ||||||||||
|
|
||||||||||
|
|
@@ -190,4 +196,77 @@ public void removeMemberFromOrg(Long userId, Long orgId, Long memberId) { | |||||||||
| // 5. 중간 테이블에서 해당 멤버 삭제 | ||||||||||
| orgMemberRepository.delete(targetMember); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @Override | ||||||||||
| // 조직 초대 이메일 보내기 | ||||||||||
| public OrgResponse.OrgInvitationResponse sendOrgInvitation(Long userId, Long orgId, String email) { | ||||||||||
| Organization organization = orgRepository.findById(orgId) | ||||||||||
| .orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND)); | ||||||||||
|
|
||||||||||
| // 초대자와 조직 관계 검증 (초대자가 조직의 멤버인지 확인) | ||||||||||
| User sender = userRepository.findById(userId) | ||||||||||
| .orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND)); | ||||||||||
|
|
||||||||||
| if (!orgMemberRepository.existsByUserAndOrganization(sender, organization)) { | ||||||||||
| // 초대자가 조직 멤버가 아님 -> 권한 없음 | ||||||||||
| throw new OrgHandler(OrgErrorCode.ORG_FORBIDDEN); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // 초대 완료 여부 확인, 가입 여부에 상관 없이 이메일 발송 | ||||||||||
| userRepository.findUserByEmail(email).ifPresent(user -> { | ||||||||||
| if (orgMemberRepository.existsByUserAndOrganization(user, organization)) { | ||||||||||
| throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE); | ||||||||||
| } | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| // Redis key = 임의의 UUID 토큰(조직 초대 이메일 내 링크를 구별) | ||||||||||
| String token = UUID.randomUUID().toString(); | ||||||||||
| // Redis value = 조직 아이디와 이메일의 조합 | ||||||||||
| String value = orgId + ":" + email; | ||||||||||
| redisUtil.setDataExpire("INVITE:" + token, value, 3600 * 24L); | ||||||||||
|
|
||||||||||
| emailService.sendEmailForOrgInvitation(token, email, organization.getName()); | ||||||||||
|
|
||||||||||
| return new OrgResponse.OrgInvitationResponse(orgId, "조직 멤버 초대 이메일을 전송하였습니다.", email); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @Override | ||||||||||
| // 조직 초대 수락 (이메일 내 링크 클릭 시) | ||||||||||
| public OrgResponse.OrgInvitationResponse acceptOrgInvitation(Long userId, String token) { | ||||||||||
| // 링크 만료 또는 유효하지 않을 시 | ||||||||||
| if (token == null) | ||||||||||
| throw new OrgHandler(OrgErrorCode.ORG_INVITATION_INVALID); | ||||||||||
|
|
||||||||||
| // Redis 내 UUID(key)에 대한 email(value) 비교 | ||||||||||
| String value = redisUtil.getData("INVITE:" + token); | ||||||||||
| if (value == null) { | ||||||||||
| throw new OrgHandler(OrgErrorCode.ORG_INVITATION_INVALID); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| String[] valueForSplit = value.split(":"); | ||||||||||
| String email = valueForSplit[1]; | ||||||||||
|
Comment on lines
+246
to
+247
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. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# First, find the file and check its structure
find . -type f -name "OrgServiceImpl.java" | head -5Repository: WhereYouAd/WhereYouAd-Backend Length of output: 170 🏁 Script executed: #!/bin/bash
# Once we have the file path, let's examine the relevant sections
# Find lines 200-270 to see the full context of both methods
if [ -f "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java" ]; then
sed -n '200,270p' "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java"
else
find . -name "OrgServiceImpl.java" -exec sed -n '200,270p' {} \;
fiRepository: WhereYouAd/WhereYouAd-Backend Length of output: 3050 🏁 Script executed: #!/bin/bash
# Let's also check where the Redis value is being set (the "orgId:email" format)
if [ -f "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java" ]; then
grep -n "redisTemplate\|setValue\|set(" "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java" | head -20
fiRepository: WhereYouAd/WhereYouAd-Backend Length of output: 55 🏁 Script executed: #!/bin/bash
# Search for Redis operations and INVITE token handling
rg -n "INVITE|redis|set\(" --type=java "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java"Repository: WhereYouAd/WhereYouAd-Backend Length of output: 307
Redis에 예: 더 근본적으로, Redis 값 포맷에 콜론이 추가되거나 변경되는 순간 이 파싱 로직은 깨질 수 있습니다. ✅ 수정 제안- String[] valueForSplit = value.split(":");
+ String[] valueForSplit = value.split(":", 2);
String email = valueForSplit[1];📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| // 로그인한 사용자 검증 | ||||||||||
| User user = userRepository.findById(userId) | ||||||||||
| .orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND)); | ||||||||||
|
|
||||||||||
| // 초대된 이메일과 현재 로그인한 사용자의 이메일이 일치하는지 확인 | ||||||||||
| if (!user.getEmail().equals(email)) { | ||||||||||
| throw new OrgHandler(OrgErrorCode.ORG_INVITATION_FORBIDDEN_USER); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| Organization organization = orgRepository.findById(Long.parseLong(valueForSplit[0])) | ||||||||||
| .orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND)); | ||||||||||
|
|
||||||||||
| // 이미 멤버인지 중복 체크 | ||||||||||
| if (orgMemberRepository.existsByUserAndOrganization(user, organization)) | ||||||||||
| throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE); | ||||||||||
|
|
||||||||||
| orgMemberRepository.save(OrgMemberConverter.toOrgMemberMEMBER(user, organization)); | ||||||||||
|
|
||||||||||
| // Redis 사용 토큰 삭제 | ||||||||||
| redisUtil.deleteData("INVITE:" + token); | ||||||||||
|
Comment on lines
+258
to
+268
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. 토큰 수락 시 TOCTOU(Time-of-Check to Time-of-Use) 경합 조건이 있습니다. 현재 흐름:
문제는 두 사용자가 동시에 같은 토큰으로 수락 요청을 보내면, 둘 다 중복 체크를 통과하고 멤버가 두 번 생성될 수 있다는 것입니다. Redis 토큰 삭제가 DB 저장 이후에 일어나기 때문에, 그 사이 시간 동안 다른 요청이 동일 토큰을 사용할 수 있어요. 해결 방법: Redis 토큰 삭제를 먼저 수행하고(일종의 원자적 잠금 역할), 삭제 성공 시에만 멤버를 등록하세요. 이렇게 하면 토큰을 먼저 소비한 요청만 진행됩니다. 🛡️ 수정 제안+ // 토큰을 먼저 소비하여 동시 수락 방지 (원자적 삭제)
+ String redisKey = "INVITE:" + token;
+ redisUtil.deleteData(redisKey);
// 이미 멤버인지 중복 체크
if (orgMemberRepository.existsByUserAndOrganization(user, organization))
throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE);
orgMemberRepository.save(OrgMemberConverter.toOrgMemberMEMBER(user, organization));
- // Redis 사용 토큰 삭제
- redisUtil.deleteData("INVITE:" + token);🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| return new OrgResponse.OrgInvitationResponse(organization.getId(), "조직 멤버 초대 이메일을 수락하였습니다.", email); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,9 +24,17 @@ public enum OrgErrorCode implements BaseErrorCode { | |
| //409 | ||
| ORG_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_409_1", "해당 조직은 이미 활성화 상태 입니다."), | ||
|
|
||
|
|
||
| //410 | ||
| ORG_SOFT_DELETED(HttpStatus.GONE, "ORG_410_1", "해당 조직은 삭제된 조직입니다.(Soft Delete)"), | ||
|
|
||
| // 409 | ||
| ORG_MEMBER_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_MEMBER_409_1", "이미 해당 조직에 초대되어있습니다."), | ||
|
Comment on lines
+30
to
+31
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.
이 에러는 초대를 수락하려 할 때 이미 조직의 멤버인 경우에 발생합니다. 그런데 메시지 "이미 해당 조직에 초대되어있습니다"는 '초대는 받았지만 아직 수락 전인 대기 상태'처럼 읽힐 수 있어 혼란스럽습니다. 실제 상황을 더 정확하게 전달하는 메시지로 바꾸는 게 좋습니다. ✏️ 제안하는 수정- ORG_MEMBER_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_MEMBER_409_1", "이미 해당 조직에 초대되어있습니다."),
+ ORG_MEMBER_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_MEMBER_409_1", "이미 해당 조직의 멤버입니다."),🤖 Prompt for AI Agents |
||
|
|
||
| // 400 | ||
| ORG_INVITATION_INVALID(HttpStatus.BAD_REQUEST, "ORG_INVITATION_400", "조직 초대 토큰이 만료되었거나 유효하지 않습니다."), | ||
|
|
||
| // 403 | ||
| ORG_INVITATION_FORBIDDEN_USER(HttpStatus.FORBIDDEN, "ORG_INVITATION_403_1", "초대된 이메일과 현재 로그인한 사용자의 이메일이 일치하지 않습니다."), | ||
| ; | ||
|
|
||
| private final HttpStatus httpStatus; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -134,4 +134,30 @@ public ResponseEntity<DataResponse<String>> removeMember( | |
| @PathVariable Long orgId, | ||
| @PathVariable Long memberId | ||
| ); | ||
|
|
||
| @Operation(summary = "조직 초대 이메일 발송 API", description = "조직 관리자가 이메일을 입력하여 새로운 멤버를 초대합니다.") | ||
| @ApiResponses({ | ||
| @ApiResponse(responseCode = "200", description = "성공"), | ||
| @ApiResponse(responseCode = "401", description = "로그인 필요"), | ||
| @ApiResponse(responseCode = "403", description = "조직 멤버가 아닌 사용자의 요청"), | ||
| @ApiResponse(responseCode = "404", description = "조직을 찾을 수 없음"), | ||
| @ApiResponse(responseCode = "409", description = "이미 조직에 가입된 사용자") | ||
| }) | ||
| public ResponseEntity<DataResponse<OrgResponse.OrgInvitationResponse>> sendOrgInvitation( | ||
| @AuthenticationPrincipal(expression = "userId") Long userId, | ||
| @PathVariable Long orgId, | ||
| @RequestBody @Valid OrgRequest.Invite request | ||
| ); | ||
|
|
||
| @Operation(summary = "조직 초대 수락 API", description = "이메일로 받은 초대 토큰을 통해 조직 가입을 수락합니다. (로그인 필수, 본인 확인)") | ||
| @ApiResponses({ | ||
| @ApiResponse(responseCode = "200", description = "성공"), | ||
| @ApiResponse(responseCode = "400", description = "유효하지 않거나 만료된 토큰"), | ||
| @ApiResponse(responseCode = "401", description = "로그인 필요"), | ||
| @ApiResponse(responseCode = "403", description = "초대된 이메일과 로그인한 사용자가 불일치") | ||
| }) | ||
| public ResponseEntity<DataResponse<OrgResponse.OrgInvitationResponse>> acceptOrgInvitation( | ||
| @AuthenticationPrincipal(expression = "userId") Long userId, | ||
| @PathVariable String token | ||
| ); | ||
|
Comment on lines
+152
to
+162
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.
✏️ 수정 제안 `@ApiResponses`({
`@ApiResponse`(responseCode = "200", description = "성공"),
`@ApiResponse`(responseCode = "400", description = "유효하지 않거나 만료된 토큰"),
`@ApiResponse`(responseCode = "401", description = "로그인 필요"),
- `@ApiResponse`(responseCode = "403", description = "초대된 이메일과 로그인한 사용자가 불일치")
+ `@ApiResponse`(responseCode = "403", description = "초대된 이메일과 로그인한 사용자가 불일치"),
+ `@ApiResponse`(responseCode = "409", description = "이미 해당 조직의 멤버인 경우")
})🤖 Prompt for AI Agents |
||
| } | ||
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.
🧩 Analysis chain
🏁 Script executed:
Repository: WhereYouAd/WhereYouAd-Backend
Length of output: 472
🏁 Script executed:
Repository: WhereYouAd/WhereYouAd-Backend
Length of output: 174
🏁 Script executed:
Repository: WhereYouAd/WhereYouAd-Backend
Length of output: 16434
🏁 Script executed:
Repository: WhereYouAd/WhereYouAd-Backend
Length of output: 164
🏁 Script executed:
Repository: WhereYouAd/WhereYouAd-Backend
Length of output: 11013
🏁 Script executed:
Repository: WhereYouAd/WhereYouAd-Backend
Length of output: 187
🏁 Script executed:
Repository: WhereYouAd/WhereYouAd-Backend
Length of output: 1694
🏁 Script executed:
Repository: WhereYouAd/WhereYouAd-Backend
Length of output: 1451
Swagger 문서와 초대 권한 검증이 불일치합니다.
OrgControllerDocs에서는 "조직 관리자가 이메일을 입력하여 새로운 멤버를 초대합니다"라고 명시했는데, 실제 코드는existsByUserAndOrganization만 검사하여 일반 MEMBER도 초대를 보낼 수 있습니다.의도적으로 모든 멤버가 초대할 수 있다면 Swagger 설명을 수정하고, ADMIN만 허용해야 한다면 역할 검증 로직을 추가하세요.
ADMIN만 초대 가능하도록 수정하는 경우 예시
if (!orgMemberRepository.existsByUserAndOrganization(sender, organization)) { throw new OrgHandler(OrgErrorCode.ORG_FORBIDDEN); } + // ADMIN만 초대 가능하도록 역할 검증 + OrgMember senderMember = orgMemberRepository.findByUserIdAndOrgId(userId, orgId) + .orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_MEMBER_NOT_FOUND)); + if (senderMember.getRole() != OrgRole.ADMIN) { + throw new OrgHandler(OrgErrorCode.ORG_MEMBER_FORBIDDEN); + }🤖 Prompt for AI Agents