diff --git a/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaEntity.java b/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaEntity.java index 7aa31493e..611e214a2 100644 --- a/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaEntity.java +++ b/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaEntity.java @@ -61,7 +61,7 @@ public class GroupJpaEntity { @Enumerated(EnumType.STRING) private GroupCategory category; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "fk_user_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY, optional = true) + @JoinColumn(name = "fk_user_id", nullable = true) private UserJpaEntity owner; } diff --git a/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaMapper.java b/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaMapper.java index f4d63aa8b..8f725f931 100644 --- a/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaMapper.java +++ b/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaMapper.java @@ -38,7 +38,7 @@ public Group toDomain(GroupJpaEntity groupJpaEntity) { groupJpaEntity.getPassword(), groupJpaEntity.getPasswordRequired(), groupJpaEntity.getCategory(), - groupJpaEntity.getOwner().getId() + groupJpaEntity.getOwner() != null ? groupJpaEntity.getOwner().getId() : null ); } } diff --git a/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaRepository.java index 4937fa0ff..8ea4676f8 100644 --- a/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/group/GroupJpaRepository.java @@ -14,7 +14,8 @@ public interface GroupJpaRepository extends JpaRepository { @EntityGraph(attributePaths = {"owner"}) - Page findAll(Pageable pageable); + @Query("select g from GroupJpaEntity g where g.category != :excluded") + Page findAllExcluding(@Param("excluded") GroupCategory excluded, Pageable pageable); @EntityGraph(attributePaths = {"owner"}) Page findAllByCategory(GroupCategory category, Pageable pageable); @@ -22,6 +23,9 @@ public interface GroupJpaRepository extends JpaRepository @EntityGraph(attributePaths = {"owner"}) Optional findById(Long id); + @EntityGraph(attributePaths = {"owner"}) + Optional findByCategory(GroupCategory category); + boolean existsByName(String name); @EntityGraph(attributePaths = {"owner"}) @@ -30,8 +34,9 @@ public interface GroupJpaRepository extends JpaRepository from GroupJpaEntity g join GroupMemberJpaEntity gm on gm.group = g where gm.user.id = :userId + and g.category != :excluded """) - Page findAllByMemberUserId(@Param("userId") Long userId, Pageable pageable); + Page findAllByMemberUserId(@Param("userId") Long userId, @Param("excluded") GroupCategory excluded, Pageable pageable); @EntityGraph(attributePaths = {"owner"}) @Query(""" diff --git a/src/main/java/com/process/clash/adapter/persistence/group/GroupPersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/group/GroupPersistenceAdapter.java index 30b1a01cb..ab90a7ca0 100644 --- a/src/main/java/com/process/clash/adapter/persistence/group/GroupPersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/group/GroupPersistenceAdapter.java @@ -31,12 +31,19 @@ public class GroupPersistenceAdapter implements GroupRepositoryPort { @Override public Group save(Group group) { - UserJpaEntity owner = userJpaRepository.getReferenceById(group.ownerId()); + UserJpaEntity owner = group.ownerId() != null + ? userJpaRepository.getReferenceById(group.ownerId()) + : null; GroupJpaEntity entity = groupJpaMapper.toJpaEntity(group, owner); GroupJpaEntity saved = groupJpaRepository.save(entity); return groupJpaMapper.toDomain(saved); } + @Override + public Optional findByCategory(GroupCategory category) { + return groupJpaRepository.findByCategory(category).map(groupJpaMapper::toDomain); + } + @Override public Optional findById(Long groupId) { return groupJpaRepository.findById(groupId) @@ -52,7 +59,7 @@ public void deleteById(Long groupId) { @Override public PageResult findAllByPage(Integer page, Integer size) { Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page pageResult = groupJpaRepository.findAll(pageable); + Page pageResult = groupJpaRepository.findAllExcluding(GroupCategory.GLOBAL, pageable); return mapPageResult(pageResult); } @@ -66,12 +73,15 @@ public PageResult findAllByPageAndCategory(Integer page, Integer size, GroupCate @Override public PageResult findAllByMemberUserId(Long userId, Integer page, Integer size) { Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page pageResult = groupJpaRepository.findAllByMemberUserId(userId, pageable); + Page pageResult = groupJpaRepository.findAllByMemberUserId(userId, GroupCategory.GLOBAL, pageable); return mapPageResult(pageResult); } @Override public PageResult findAllByMemberUserIdAndCategory(Long userId, Integer page, Integer size, GroupCategory category) { + if (category == GroupCategory.GLOBAL) { + return new PageResult(List.of(), 0); + } Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "createdAt")); Page pageResult = groupJpaRepository.findAllByMemberUserIdAndCategory(userId, category, pageable); return mapPageResult(pageResult); diff --git a/src/main/java/com/process/clash/adapter/web/group/util/GroupCategoryConverter.java b/src/main/java/com/process/clash/adapter/web/group/util/GroupCategoryConverter.java index b864e6cb0..61006def2 100644 --- a/src/main/java/com/process/clash/adapter/web/group/util/GroupCategoryConverter.java +++ b/src/main/java/com/process/clash/adapter/web/group/util/GroupCategoryConverter.java @@ -25,6 +25,10 @@ public static GroupCategory toCategoryFilter(String category) { return null; } + if ("GLOBAL".equalsIgnoreCase(category)) { + throw new InvalidCategoryException("유효하지 않은 카테고리입니다: 'GLOBAL'"); + } + try { return GroupCategory.valueOf(category.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException e) { diff --git a/src/main/java/com/process/clash/application/group/exception/exception/conflict/CannotQuitGlobalGroupException.java b/src/main/java/com/process/clash/application/group/exception/exception/conflict/CannotQuitGlobalGroupException.java new file mode 100644 index 000000000..1c07b5762 --- /dev/null +++ b/src/main/java/com/process/clash/application/group/exception/exception/conflict/CannotQuitGlobalGroupException.java @@ -0,0 +1,10 @@ +package com.process.clash.application.group.exception.exception.conflict; + +import com.process.clash.application.common.exception.exception.ConflictException; +import com.process.clash.application.group.exception.statuscode.GroupStatusCode; + +public class CannotQuitGlobalGroupException extends ConflictException { + public CannotQuitGlobalGroupException() { + super(GroupStatusCode.CANNOT_QUIT_GLOBAL_GROUP); + } +} diff --git a/src/main/java/com/process/clash/application/group/exception/statuscode/GroupStatusCode.java b/src/main/java/com/process/clash/application/group/exception/statuscode/GroupStatusCode.java index cdc324027..c452c9e80 100644 --- a/src/main/java/com/process/clash/application/group/exception/statuscode/GroupStatusCode.java +++ b/src/main/java/com/process/clash/application/group/exception/statuscode/GroupStatusCode.java @@ -67,6 +67,12 @@ public enum GroupStatusCode implements StatusCode { "DUPLICATE_GROUP_NAME", "이미 사용 중인 그룹 이름입니다.", HttpStatus.CONFLICT + ), + + CANNOT_QUIT_GLOBAL_GROUP( + "CANNOT_QUIT_GLOBAL_GROUP", + "전체 유저 그룹에서는 탈퇴할 수 없습니다.", + HttpStatus.CONFLICT ); private final String code; diff --git a/src/main/java/com/process/clash/application/group/policy/GroupPolicy.java b/src/main/java/com/process/clash/application/group/policy/GroupPolicy.java index 000416d2e..94e623283 100644 --- a/src/main/java/com/process/clash/application/group/policy/GroupPolicy.java +++ b/src/main/java/com/process/clash/application/group/policy/GroupPolicy.java @@ -2,11 +2,13 @@ import com.process.clash.application.common.actor.Actor; import com.process.clash.application.common.exception.exception.ValidationException; +import com.process.clash.application.group.exception.exception.conflict.CannotQuitGlobalGroupException; import com.process.clash.application.group.exception.exception.conflict.GroupDuplicateNameException; import com.process.clash.application.group.exception.exception.conflict.GroupMemberLimitReachedException; import com.process.clash.application.group.exception.exception.conflict.GroupOwnerCannotQuitException; import com.process.clash.application.group.exception.exception.forbidden.GroupAccessDeniedException; import com.process.clash.domain.group.entity.Group; +import com.process.clash.domain.group.enums.GroupCategory; import org.springframework.stereotype.Component; @Component @@ -43,8 +45,23 @@ public void validateMemberLimit(int maxMembers, int memberCount) { } public void canQuitGroup(Actor actor, Group group) { + if (group.category() == GroupCategory.GLOBAL) { + throw new CannotQuitGlobalGroupException(); + } if (group.ownerId().equals(actor.id())) { throw new GroupOwnerCannotQuitException(); } } + + public void validateNotGlobal(Group group) { + if (group.category() == GroupCategory.GLOBAL) { + throw new GroupAccessDeniedException(); + } + } + + public void validateNotGlobal(GroupCategory category) { + if (category == GroupCategory.GLOBAL) { + throw new GroupAccessDeniedException(); + } + } } diff --git a/src/main/java/com/process/clash/application/group/port/out/GroupRepositoryPort.java b/src/main/java/com/process/clash/application/group/port/out/GroupRepositoryPort.java index 85c7ec2de..7e817dcca 100644 --- a/src/main/java/com/process/clash/application/group/port/out/GroupRepositoryPort.java +++ b/src/main/java/com/process/clash/application/group/port/out/GroupRepositoryPort.java @@ -13,6 +13,8 @@ public interface GroupRepositoryPort { Optional findById(Long groupId); + Optional findByCategory(GroupCategory category); + void deleteById(Long groupId); PageResult findAllByPage(Integer page, Integer size); diff --git a/src/main/java/com/process/clash/application/group/service/DeleteGroupService.java b/src/main/java/com/process/clash/application/group/service/DeleteGroupService.java index 590809003..999d00bcc 100644 --- a/src/main/java/com/process/clash/application/group/service/DeleteGroupService.java +++ b/src/main/java/com/process/clash/application/group/service/DeleteGroupService.java @@ -26,6 +26,7 @@ public void execute(DeleteGroupData.Command command) { Group group = groupRepositoryPort.findById(command.groupId()) .orElseThrow(GroupNotFoundException::new); + groupPolicy.validateNotGlobal(group); groupPolicy.validateOwnership(command.actor(), group); List memberUserIds = groupRepositoryPort.findMemberUserIdsByGroupIds(List.of(group.id())); groupRepositoryPort.deleteById(group.id()); diff --git a/src/main/java/com/process/clash/application/group/service/UpdateGroupService.java b/src/main/java/com/process/clash/application/group/service/UpdateGroupService.java index 57451bbcf..552207b73 100644 --- a/src/main/java/com/process/clash/application/group/service/UpdateGroupService.java +++ b/src/main/java/com/process/clash/application/group/service/UpdateGroupService.java @@ -30,6 +30,8 @@ public void execute(UpdateGroupData.Command command) { .orElseThrow(GroupNotFoundException::new); int currentMemberCount = groupRepositoryPort.countMembers(command.groupId()); + policy.validateNotGlobal(group); + policy.validateNotGlobal(command.category()); policy.validateOwnership(command.actor(), group); policy.validateMaxMembers(command.maxMembers()); policy.validateMemberLimit(group.maxMembers(), currentMemberCount); diff --git a/src/main/java/com/process/clash/application/user/user/service/VerifyEmailService.java b/src/main/java/com/process/clash/application/user/user/service/VerifyEmailService.java index 13b0ec934..42ff91eb2 100644 --- a/src/main/java/com/process/clash/application/user/user/service/VerifyEmailService.java +++ b/src/main/java/com/process/clash/application/user/user/service/VerifyEmailService.java @@ -1,5 +1,6 @@ package com.process.clash.application.user.user.service; +import com.process.clash.application.group.port.out.GroupRepositoryPort; import com.process.clash.application.mail.port.out.VerificationCodePort; import com.process.clash.application.user.user.data.VerifyEmailData; import com.process.clash.application.user.user.exception.exception.badrequest.VerificationCodeExpiredOrWrongEmailException; @@ -10,6 +11,7 @@ import com.process.clash.application.user.user.port.in.VerifyEmailUseCase; import com.process.clash.application.user.user.port.out.PendingUserCachePort; import com.process.clash.application.user.user.port.out.UserRepositoryPort; +import com.process.clash.domain.group.enums.GroupCategory; import com.process.clash.domain.user.user.entity.User; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -22,6 +24,7 @@ public class VerifyEmailService implements VerifyEmailUseCase { private final VerificationCodePort verificationCodePort; private final UserRepositoryPort userRepositoryPort; private final PendingUserCachePort pendingUserCachePort; + private final GroupRepositoryPort groupRepositoryPort; @Override @Transactional @@ -46,7 +49,11 @@ public void execute(VerifyEmailData.Command command) { } // 인증 완료 처리 후 DB 저장 - userRepositoryPort.save(pendingUser.active()); + User savedUser = userRepositoryPort.save(pendingUser.active()); + + // 전체 유저 그룹 자동 가입 + groupRepositoryPort.findByCategory(GroupCategory.GLOBAL) + .ifPresent(g -> groupRepositoryPort.addMember(g.id(), savedUser.id())); // Redis 정리 pendingUserCachePort.delete(command.token()); diff --git a/src/main/java/com/process/clash/domain/group/enums/GroupCategory.java b/src/main/java/com/process/clash/domain/group/enums/GroupCategory.java index 1283491cd..387aa7d7f 100644 --- a/src/main/java/com/process/clash/domain/group/enums/GroupCategory.java +++ b/src/main/java/com/process/clash/domain/group/enums/GroupCategory.java @@ -5,5 +5,6 @@ public enum GroupCategory { CLUB, TEAM, NARSHA, - ETC + ETC, + GLOBAL } diff --git a/src/main/resources/db/migration/V42__add_global_user_group.sql b/src/main/resources/db/migration/V42__add_global_user_group.sql new file mode 100644 index 000000000..de823482e --- /dev/null +++ b/src/main/resources/db/migration/V42__add_global_user_group.sql @@ -0,0 +1,12 @@ +-- 1. groups 테이블의 fk_user_id 컬럼을 nullable로 변경 +ALTER TABLE groups ALTER COLUMN fk_user_id DROP NOT NULL; + +-- 2. 전체 유저 그룹 생성 (owner 없음) +INSERT INTO groups (created_at, updated_at, name, description, max_members, password, password_required, category, fk_user_id) +VALUES (NOW(), NOW(), '전체 유저', '모든 클래시 유저가 자동으로 참여하는 그룹입니다.', 2147483647, '', false, 'GLOBAL', NULL); + +-- 3. 기존 활성 유저 전원을 GLOBAL 그룹에 추가 +INSERT INTO group_members (fk_group_id, fk_user_id) +SELECT (SELECT id FROM groups WHERE category = 'GLOBAL'), u.id +FROM users u +WHERE u.user_status = 'ACTIVE';