From ded03272a9ee86a2a0390f189ad8d90cbf0ef458 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 15 Oct 2026 11:23:20 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=EA=B0=80=20=EB=82=98=EA=B0=80=EB=A9=B4=20=EB=B4=87?= =?UTF-8?q?=EB=B0=A9=EC=9E=A5=EC=9C=BC=EB=A1=9C=20=EB=84=98=EA=B8=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=B0=A9=EC=9D=84=20=EB=8B=AB?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0명 방도 봇방장으로 넘기기만 해서, 하루 1번 유령방 삭제 배치가 지울 때까지 (정산 대기가 있으면 1주 넘게) 탐색·인기에 남고 남이 가입해 되살릴 수 있었다. 자진 탈퇴·회원 탈퇴·자동 강퇴에서 남는 활성 멤버가 없으면 삭제 표시한다 — 탐색·인기·상세·가입이 즉시 막히고, 이력과 실제 삭제는 같은 배치가 이어서 한다. 멤버가 1명 이상 남으면 지금처럼 봇방장 체제다. 이탈 탭은 닫힌 방도 기록으로 보여 준다. 이미 남아 있는 0명 방은 V14 로 닫는다. Co-Authored-By: Claude Opus 5.5 --- .../service/ChallengeMemberService.java | 28 ++++++++++--- .../service/MyChallengeQueryService.java | 4 +- .../room/service/AutomaticKickService.java | 5 ++- .../db/migration/V14__close_empty_rooms.sql | 14 +++++++ .../challenge/ChallengeLifecycleSpecIT.java | 41 +++++++++++++++++++ 5 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/migration/V14__close_empty_rooms.sql diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeMemberService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeMemberService.java index 25ac0968..260a8727 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeMemberService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/service/ChallengeMemberService.java @@ -219,6 +219,7 @@ public JoinBlockReason previewBlockReason(UUID userId, Challenge c, boolean invi /** * 챌린지 탈퇴(본인). 언제든 나갈 수 있다 — 구 "재참여 영구 불가"·"방장 탈퇴 불가"는 둘 다 폐기. * - 방장이 권한을 넘기지 않고 나가면 즉시 봇방장 체제로 전환하고 잔류 멤버에게 승계 알림을 보낸다(§11.2). + * 단 남는 멤버가 없으면 봇방장으로 넘기지 않고 방을 닫는다 — {@link #closeIfLastMember}. * - 감점 면제: 1년 이상 성공(LONG_SUCCESS) / 승계 3일 면책(SUCCESSION_GRACE — 모든 멤버 기준, §11.3). * - 재입장은 1주 대기. */ @@ -240,9 +241,10 @@ public LeaveResponse leave(UUID userId, UUID challengeId) { // 면제 판정은 상태를 바꾸기 전에 — 나가면서 스스로 만든 봇방장 전환으로 자기 감점을 면제받으면 안 된다. String exemptReason = resolveExemptReason(c, me, now); - // 방장이 넘기지 않고 나감 → 즉시 봇방장 체제 + 잔류 멤버에게 승계 알림 + // 마지막 멤버가 나가면 방을 닫는다. 아니면 방장이 넘기지 않고 나감 → 즉시 봇방장 체제 + 승계 알림 + boolean closed = closeIfLastMember(c); boolean botOwnerActivated = false; - if (c.isOwner(userId)) { + if (!closed && c.isOwner(userId)) { c.convertToBotOwner(now); botOwnerActivated = true; notifyBotOwnerActivated(c, userId); @@ -274,8 +276,8 @@ public LeaveResponse leave(UUID userId, UUID challengeId) { source, completedWeeks, "AUTO", scoreDelta, now), source); outboxDispatcher.requestFlush(); } - log.info("challenge_leave challengeId={} userId={} penalty={} exempt={} beforeStart={} botOwner={}", - challengeId, userId, scoreDelta, exemptReason, beforeStart, botOwnerActivated); + log.info("challenge_leave challengeId={} userId={} penalty={} exempt={} beforeStart={} botOwner={} closed={}", + challengeId, userId, scoreDelta, exemptReason, beforeStart, botOwnerActivated, closed); return new LeaveResponse(true, scoreDelta, exemptReason, rejoinAt.toString(), botOwnerActivated); } @@ -322,7 +324,7 @@ public int leaveAllExternally(UUID userId, String reason) { } - if (c.isOwner(userId)) { + if (!closeIfLastMember(c) && c.isOwner(userId)) { c.convertToBotOwner(now); notifyBotOwnerActivated(c, userId); } @@ -340,6 +342,22 @@ public int leaveAllExternally(UUID userId, String reason) { return left; } + /** + * 나가는 사람이 마지막 활성 멤버면 방을 닫는다(삭제 표시). 호출 시점에는 아직 그 사람이 ACTIVE 다. + * + *

0명 방은 봇방장으로 넘길 대상이 없다 — 넘기면 아무도 없는 방이 탐색·인기에 그대로 남아, + * 하루 1번 도는 유령방 삭제 배치가 지울 때까지(정산 대기가 있으면 1주 넘게) 노출되고 남이 가입해 + * 되살릴 수도 있었다. 삭제 표시만 하면 탐색·인기·상세·가입이 모두 즉시 막히고(커밋 뒤 탐색 인덱스 + * 재계산이 빼낸다), 이력 스냅샷과 실제 삭제는 같은 배치가 정산이 끝난 뒤 이어서 한다. + * + * @return 방을 닫았으면 true — 이때는 봇방장 전환·승계 알림을 하지 않는다 + */ + private boolean closeIfLastMember(Challenge c) { + if (memberRepository.countByChallengeIdAndStatus(c.getId(), MemberStatus.ACTIVE) > 1) return false; + c.softDelete(); + return true; + } + /** * 감점 계산에는 판정이 끝난 유효 주간만 센다. 경과 시간이나 미완료/중립 주간은 포함하지 않는다. */ diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/service/MyChallengeQueryService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/service/MyChallengeQueryService.java index e7b340d1..01a09fbb 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/service/MyChallengeQueryService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/service/MyChallengeQueryService.java @@ -104,7 +104,9 @@ private String liveSelect(MyChallengeFilter filter, UUID userId, List ar " ELSE m.success_days / (m.success_days + m.fail_days) END " + " AS DECIMAL(6,4)) AS success_rate " + "FROM challenge_members m JOIN challenges c ON c.id = m.challenge_id " + - "WHERE m.user_id = ? AND c.deleted_at IS NULL AND " + memberAndChallenge; + // 이탈 탭은 내가 나가서 닫힌(삭제 표시된) 방도 보여 준다 — 실제 삭제 뒤에는 이력에서 같은 행이 나온다. + "WHERE m.user_id = ? AND " + (filter == MyChallengeFilter.LEFT ? "" : "c.deleted_at IS NULL AND ") + + memberAndChallenge; } // ===== 원천 2: 삭제된 방의 이력 스냅샷 ===== diff --git a/src/main/java/com/ruleup/ruleup_backend/room/service/AutomaticKickService.java b/src/main/java/com/ruleup/ruleup_backend/room/service/AutomaticKickService.java index 7130196a..15259b6f 100644 --- a/src/main/java/com/ruleup/ruleup_backend/room/service/AutomaticKickService.java +++ b/src/main/java/com/ruleup/ruleup_backend/room/service/AutomaticKickService.java @@ -86,7 +86,10 @@ public boolean enforce(UUID challengeId, UUID userId, Reason reason, UUID source "kick_count=VALUES(kick_count),available_at=VALUES(available_at)", bytes(challengeId), bytes(userId), count + 1, DbTime.utc(availableAt)); if (member != null && member.isActive() && challenge != null) { - if (challenge.isOwner(userId)) challenge.convertToBotOwner(now); + // 자진 탈퇴와 같은 규칙 — 마지막 멤버가 빠지면 봇방장으로 넘기지 않고 방을 닫는다. + if (members.countByChallengeIdAndStatus(challengeId, + com.ruleup.ruleup_backend.challenge.domain.MemberStatus.ACTIVE) <= 1) challenge.softDelete(); + else if (challenge.isOwner(userId)) challenge.convertToBotOwner(now); if (permanent) member.kickPermanently(reason.name(), now); else member.kick(reason.name(), now, availableAt); challenge.bumpVersion(); diff --git a/src/main/resources/db/migration/V14__close_empty_rooms.sql b/src/main/resources/db/migration/V14__close_empty_rooms.sql new file mode 100644 index 00000000..ed79953b --- /dev/null +++ b/src/main/resources/db/migration/V14__close_empty_rooms.sql @@ -0,0 +1,14 @@ +-- ====================================================================== +-- 활성 멤버가 0명인데 닫히지 않은 방을 닫는다(삭제 표시) +-- +-- 지금까지는 마지막 멤버가 나가도 봇방장으로 넘기기만 해서, 아무도 없는 방이 하루 1번 도는 +-- 유령방 삭제 배치가 지울 때까지(정산 대기가 있으면 1주 넘게) 탐색·인기에 남았다. 이제는 나가는 +-- 순간 닫는다(ChallengeMemberService#closeIfLastMember). 이미 그렇게 남아 있는 방도 같은 상태로 +-- 맞춘다 — 탐색 인덱스는 주기 보정이 빼내고, 이력과 실제 삭제는 같은 배치가 이어서 한다. +-- ====================================================================== + +UPDATE `challenges` c + SET c.`deleted_at` = UTC_TIMESTAMP(6) + WHERE c.`deleted_at` IS NULL + AND NOT EXISTS (SELECT 1 FROM `challenge_members` m + WHERE m.`challenge_id` = c.`id` AND m.`status` = 'ACTIVE'); diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleSpecIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleSpecIT.java index 79a7ca65..49757afd 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleSpecIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleSpecIT.java @@ -94,6 +94,47 @@ class ChallengeLifecycleSpecIT extends ChallengeApiSupport { assertThat(jdbc.queryForObject("SELECT close_reason FROM challenge_history WHERE challenge_id=?",String.class,bytes(id))).isEqualTo("EMPTY"); } + private org.springframework.test.web.servlet.MvcResult leave(UUID id,String token) throws Exception { + return mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .delete("/api/v1/challenges/"+id+"/members/me").header("Authorization","Bearer "+token)).andReturn(); + } + + /** 마지막 멤버가 나가면 봇방장으로 넘기지 않고 방을 닫는다 — 탐색·상세·가입에서 바로 사라진다. */ + @Test void lastMemberLeavingClosesTheRoomInsteadOfBotOwner() throws Exception { + Member owner=member(uniq("last-leave"));Member other=member(uniq("last-leave-other")); + UUID id=insertChallenge(owner.id(),"EXERCISE","ACTIVE","GROUP"); + insertActiveMembership(id,owner.id(),"OWNER"); + + var left=leave(id,owner.token()); + assertThat(left.getResponse().getStatus()).isEqualTo(200); + assertThat((Boolean)read(left,"$.data.botOwnerActivated")).isFalse(); + + var row=jdbc.queryForMap("SELECT owner_type,deleted_at FROM challenges WHERE id=?",bytes(id)); + assertThat(row.get("owner_type")).as("아무도 없는 방을 봇방장으로 넘기지 않는다").isEqualTo("USER"); + assertThat(row.get("deleted_at")).as("0명이 되는 순간 닫힌다").isNotNull(); + assertThat(getAuth("/api/v1/challenges/"+id,other.token()).getResponse().getStatus()).isEqualTo(404); + assertThat(postJsonAuth("/api/v1/challenges/"+id+"/members",other.token(),Map.of()).getResponse().getStatus()) + .as("닫힌 방에 남이 가입해 되살릴 수 없다").isEqualTo(404); + assertThat((List)read(getAuth("/api/v1/challenges?filter=IN_PROGRESS",owner.token()),"$.data.challenges[*].challengeId")) + .doesNotContain(id.toString()); + assertThat((List)read(getAuth("/api/v1/challenges?filter=LEFT",owner.token()),"$.data.challenges[*].challengeId")) + .as("마이페이지 이탈 탭에는 기록으로 남는다").contains(id.toString()); + } + + /** 다른 멤버가 남아 있으면 지금처럼 봇방장 체제 — 방은 닫히지 않는다. */ + @Test void ownerLeavingWithOthersRemainingHandsOverToBot() throws Exception { + Member owner=member(uniq("bot-leave"));Member stay=member(uniq("bot-leave-stay")); + UUID id=insertChallenge(owner.id(),"EXERCISE","ACTIVE","GROUP"); + insertActiveMembership(id,owner.id(),"OWNER");insertActiveMembership(id,stay.id(),"MEMBER"); + + var left=leave(id,owner.token()); + assertThat((Boolean)read(left,"$.data.botOwnerActivated")).isTrue(); + var row=jdbc.queryForMap("SELECT owner_type,deleted_at FROM challenges WHERE id=?",bytes(id)); + assertThat(row.get("owner_type")).isEqualTo("BOT"); + assertThat(row.get("deleted_at")).isNull(); + assertThat(getAuth("/api/v1/challenges/"+id,stay.token()).getResponse().getStatus()).isEqualTo(200); + } + /** 관리자가 폐쇄한 방은 「완료」가 아니다 — 이탈 탭에 AUTO_CLOSED 로, 지워졌든 삭제가 미뤄졌든 같다. */ @Test void adminClosedRoomsAreStoppedNotCompleted() throws Exception { Member member=member(uniq("closed"));Member owner=member(uniq("closed-owner"));