Skip to content
Merged
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
Expand Up @@ -219,6 +219,7 @@ public JoinBlockReason previewBlockReason(UUID userId, Challenge c, boolean invi
/**
* 챌린지 탈퇴(본인). 언제든 나갈 수 있다 — 구 "재참여 영구 불가"·"방장 탈퇴 불가"는 둘 다 폐기.
* - 방장이 권한을 넘기지 않고 나가면 <b>즉시 봇방장 체제</b>로 전환하고 잔류 멤버에게 승계 알림을 보낸다(§11.2).
* 단 <b>남는 멤버가 없으면</b> 봇방장으로 넘기지 않고 방을 닫는다 — {@link #closeIfLastMember}.
* - 감점 면제: 1년 이상 성공(LONG_SUCCESS) / 승계 3일 면책(SUCCESSION_GRACE — 모든 멤버 기준, §11.3).
* - 재입장은 1주 대기.
*/
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
Expand All @@ -340,6 +342,22 @@ public int leaveAllExternally(UUID userId, String reason) {
return left;
}

/**
* 나가는 사람이 <b>마지막 활성 멤버</b>면 방을 닫는다(삭제 표시). 호출 시점에는 아직 그 사람이 ACTIVE 다.
*
* <p>0명 방은 봇방장으로 넘길 대상이 없다 — 넘기면 아무도 없는 방이 탐색·인기에 그대로 남아,
* 하루 1번 도는 유령방 삭제 배치가 지울 때까지(정산 대기가 있으면 1주 넘게) 노출되고 남이 가입해
* 되살릴 수도 있었다. 삭제 표시만 하면 탐색·인기·상세·가입이 모두 즉시 막히고(커밋 뒤 탐색 인덱스
* 재계산이 빼낸다), 이력 스냅샷과 실제 삭제는 같은 배치가 정산이 끝난 뒤 이어서 한다.
*
* @return 방을 닫았으면 true — 이때는 봇방장 전환·승계 알림을 하지 않는다
*/
private boolean closeIfLastMember(Challenge c) {
if (memberRepository.countByChallengeIdAndStatus(c.getId(), MemberStatus.ACTIVE) > 1) return false;
c.softDelete();
return true;
}

/**
* 감점 계산에는 판정이 끝난 유효 주간만 센다. 경과 시간이나 미완료/중립 주간은 포함하지 않는다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ private String liveSelect(MyChallengeFilter filter, UUID userId, List<Object> 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: 삭제된 방의 이력 스냅샷 =====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions src/main/resources/db/migration/V14__close_empty_rooms.sql
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)read(getAuth("/api/v1/challenges?filter=IN_PROGRESS",owner.token()),"$.data.challenges[*].challengeId"))
.doesNotContain(id.toString());
assertThat((List<String>)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"));
Expand Down
Loading