From 42c1c277e4fa8b20a562e29502a0a546a8e64f10 Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Tue, 21 Jul 2026 23:53:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20=EC=BD=94=EB=93=9C=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?+=20=EB=A7=8C=EB=A3=8C=20=EA=B8=B0=EA=B0=84=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 버튼을 누를 때마다 항상 새 코드를 발급하던 걸, 활성 코드가 아직 안 만료됐으면 그대로 재사용하도록 바꿨다. 없거나 만료된 경우에만(있으면 먼저 폐기 후) 새로 발급한다. 만료 기간도 1년 -> 1일로 줄였다 - 재사용 방식으로 바뀌면서 "방치된 팀이 초대를 못 하게 되는 걸 막기 위해 길게 잡는다"는 예전 근거가 없어졌고, 유출된 코드가 오래 살아있는 위험이 더 커서다. - TeamInviteCodeTransactionService.issueOnce(): findByTeamIdAndRevokedAtIsNull로 찾은 기존 코드가 isUsable(now)면(= 아직 미만료, maxUses는 항상 null이라 사실상 만료 여부만 봄) 그대로 반환. 아니면 있는 경우에 한해 revoke 후 새로 발급 - activeCodeMarker 유니크 인덱스가 revoked_at IS NULL 행을 팀당 1개로만 제한하므로 (expires_at은 안 봄) 이 순서를 지켜야 한다. - 만료 기간 상수화(INVITE_CODE_TTL_DAYS = 1). 로컬 서버로 실제 API를 호출해 확인: 연속 3회 호출이 같은 코드/만료시각을 반환하고 DB에 행이 1개만 남는지(재사용), H2에서 만료 시각을 강제로 과거로 돌린 뒤 호출하면 기존 코드는 폐기되고 새 코드가 발급되는지(재발급) 둘 다 확인했다. Co-Authored-By: Claude Sonnet 5 --- docs/mvp-database-erd.md | 9 ++-- docs/team-creation-api-spec.md | 51 +++++++++++-------- .../controller/TeamMemberControllerDocs.java | 2 +- .../domain/team/entity/TeamInviteLink.java | 5 +- .../TeamInviteCodeTransactionService.java | 24 +++++++-- .../TeamInviteCodeIntegrationTest.java | 36 +++++++++++-- 6 files changed, 92 insertions(+), 35 deletions(-) diff --git a/docs/mvp-database-erd.md b/docs/mvp-database-erd.md index 4f819ca..27f6323 100644 --- a/docs/mvp-database-erd.md +++ b/docs/mvp-database-erd.md @@ -349,7 +349,7 @@ media_unit_region_columns·`V7` campaign_registration_schema와의 번호 충돌 **팀당 활성 코드 1개 제약 (DB 레벨 강제)** -새 초대 코드를 발급하면 기존에 살아있던 코드는 자동 폐기되어야 한다는 요구사항을, `team_members`의 활성 OWNER 제약과 동일한 패턴(generated column + unique index)으로 강제한다. +한 팀에 폐기되지 않은(`revoked_at IS NULL`) 코드가 동시에 2개 이상 존재할 수 없다는 요구사항을, `team_members`의 활성 OWNER 제약과 동일한 패턴(generated column + unique index)으로 강제한다. **아직 안 만료된 활성 코드가 있으면 그 코드를 그대로 재사용**하고(폐기·재발급 없음), **없거나 만료됐을 때만** 있던 코드를 폐기하고 새로 발급한다 — "팀원 초대하기" 버튼을 여러 번 눌러도 유효 기간(발급 시점 + 24시간) 안에는 같은 코드가 나온다. ```sql ALTER TABLE team_invite_links @@ -359,7 +359,7 @@ ALTER TABLE team_invite_links CREATE UNIQUE INDEX uk_invite_one_active_code_per_team ON team_invite_links(active_code_marker); ``` -새 코드를 발급하는 트랜잭션은 반드시 **① 기존 활성 코드에 `revoke()` 호출(`revoked_at` 설정) → ② 새 코드 INSERT** 순서로 처리해야 한다. 순서를 지키지 않으면(기존 코드를 안 지우고 새 코드부터 넣으면) 유니크 인덱스 위반으로 즉시 실패한다 — 즉 이 실수를 DB가 스스로 막아준다. +이 제약은 `active_code_marker`가 `revoked_at`만으로 계산되고 `expires_at`은 보지 않는다는 뜻이다 — 그래서 만료됐지만 아직 폐기되지 않은 코드가 있는 상태에서 새 코드를 발급하는 트랜잭션은 반드시 **① 기존(만료된) 코드에 `revoke()` 호출(`revoked_at` 설정) → ② 새 코드 INSERT** 순서로 처리해야 한다. 순서를 지키지 않으면(기존 코드를 안 지우고 새 코드부터 넣으면) 유니크 인덱스 위반으로 즉시 실패한다 — 즉 이 실수를 DB가 스스로 막아준다. (재사용 분기를 타는 경우는 revoke도 insert도 하지 않으므로 이 제약과 아예 무관하다.) 초대 처리 흐름: @@ -639,8 +639,9 @@ attention_count ### 팀원 초대 ```text -팀원(OWNER/ADMIN/MEMBER 누구나)이 초대 코드 생성 -→ 기존 활성 코드가 있으면 먼저 revoke, team_invite_links에 새 코드 저장(평문) +팀원(OWNER/ADMIN/MEMBER 누구나)이 초대 코드 생성 요청 +→ 활성 코드가 아직 안 만료됐으면 그대로 반환, 없거나 만료됐으면 (있는 경우 먼저 revoke 후) + team_invite_links에 새 코드 저장(평문) → 팀원이 초대 코드 입력 → 카카오 로그인 → team_members를 role=MEMBER로 생성 (또는 재가입 시 기존 행 UPDATE) diff --git a/docs/team-creation-api-spec.md b/docs/team-creation-api-spec.md index 4cb96c2..98135a6 100644 --- a/docs/team-creation-api-spec.md +++ b/docs/team-creation-api-spec.md @@ -88,8 +88,8 @@ API 단위로 담당을 분리했다 — 상세 내용은 "2. 담당자 요약" | v 백엔드(A): 하나의 트랜잭션으로 - a. 이 팀의 기존 활성 코드가 있으면 폐기(revoke) - b. 새 코드 생성 (6절 "초대 코드 생성 규칙") + a. 이 팀의 기존 활성 코드가 아직 안 만료됐으면 그대로 반환하고 끝 + b. 없거나 만료됐으면: 있는 경우 폐기(revoke) 후 새 코드 생성 (6절 "초대 코드 생성 규칙") | v 백엔드(A) -> 프론트: 새 초대 코드(평문) 응답 @@ -168,7 +168,7 @@ Spring 쪽 초대 코드 API만 다루고 Lambda와는 접점이 없다. |---|---|---|---|---| | 사업자등록증 업로드 (OCR 포함) | 백엔드 B | POST | `/api/v1/teams/business-registration` | 파일을 S3에 저장하고 OCR 결과를 응답 | | 팀 생성 | 백엔드 B | POST | `/api/v1/teams` | 팀 + 팀원(OWNER) + 사업자등록 정보를 한 트랜잭션으로 생성 | -| 초대 코드 발급 | 백엔드 A | POST | `/api/v1/teams/{teamId}/invite-code` | 팀원 초대용 코드를 새로 발급 (기존 활성 코드가 있으면 폐기) | +| 초대 코드 발급 | 백엔드 A | POST | `/api/v1/teams/{teamId}/invite-code` | 팀원 초대용 코드 발급 (활성 코드가 아직 유효하면 재사용, 없거나 만료됐으면 새로 발급) | 앞의 두 API(백엔드 B)는 "인증된 사용자인가"만 확인하면 된다 (아직 팀이 없는 시점). 초대 코드 발급은 다르다 — 이미 존재하는 팀에 대한 작업이라 "이 사용자가 이 팀 소속인가"와 역할까지 @@ -472,9 +472,11 @@ MVP 범위에서는 신경 쓰지 않기로 함) ## 6. 초대 코드 발급 API — 담당: 백엔드 A 팀 생성 직후(5절 성공 이후 프론트가 곧바로 호출) **또는** 팀원 페이지의 "팀원 초대하기" -버튼에서 호출한다 — 두 경우 모두 완전히 같은 API를 쓴다. 누를 때마다 항상 새 코드를 -발급한다 — 기존 활성 코드가 있어도 그대로 보여주지 않고, 폐기 후 새로 하나 더 만든다 -(처음 호출이라 폐기할 게 없으면 그 단계는 그냥 아무 일도 안 하고 넘어간다). +버튼에서 호출한다 — 두 경우 모두 완전히 같은 API를 쓴다. **아직 만료되지 않은 활성 코드가 +있으면 그 코드를 그대로 반환한다** — 버튼을 여러 번 눌러도 유효 기간 안에는 같은 코드가 +나온다. 활성 코드가 없거나(처음 호출) 이미 만료된 경우에만 새 코드를 발급한다(만료된 코드는 +그 직전에 폐기한다). 예전에는 호출할 때마다 무조건 새 코드를 발급했지만, 만료 기간을 1년에서 +1일로 크게 줄이면서 "누를 때마다 새 코드"일 필요가 없어져 재사용 방식으로 바꿨다. ### Request @@ -504,17 +506,20 @@ Authorization: Bearer {accessToken} `team_invite_links`에는 최초 발급 시점엔 잠글 행 자체가 없으므로, 대신 항상 존재하는 `teams` 행을 잠근다 — `TeamRepository.findByIdForUpdate()`를 쓴다(DV-113에서 추가됨, DV-112는 Javadoc만 보강). -1. 이 팀의 현재 활성 코드(`revoked_at IS NULL`)가 있으면 조회해서 폐기(`revoke(now)`) — - 없으면(팀 생성 직후 최초 호출) 이 단계는 건너뛴다 -2. 새 코드 생성 — 아래 "초대 코드 생성 규칙" -3. 새 `team_invite_links` row insert (`team`, `createdBy` = 요청자, `tokenHash`, `maxUses = null`, +1. 이 팀의 현재 활성 코드(`revoked_at IS NULL`)가 있으면 조회한다. **아직 만료 전(`now < expires_at`)이면 + 그 코드를 그대로 응답하고 끝난다** — 폐기도, 새 코드 발급도 하지 않는다. +2. 활성 코드가 없거나 이미 만료됐으면: 있는 경우에 한해 먼저 폐기(`revoke(now)`) — 처음 + 호출이라 폐기할 게 없으면 이 단계는 건너뛴다. +3. 새 코드 생성 — 아래 "초대 코드 생성 규칙" +4. 새 `team_invite_links` row insert (`team`, `createdBy` = 요청자, `inviteCode`, `maxUses = null`, `expiresAt`, `revokedAt = null`) -0단계의 잠금 덕분에 같은 팀에 대한 동시 요청은 하나씩 순서대로만 처리된다 — 뒤에 처리되는 -요청은 앞선 요청이 커밋한 새 코드를 "현재 활성 코드"로 보고 다시 폐기 후 재발급하게 되므로, -경합 상황에서도 최종적으로 팀에는 활성 코드가 정확히 1개만 남는다. 1~3단계 중 하나라도 -실패하면 전체 롤백된다(같은 트랜잭션) — 기존 코드는 폐기했는데 새 코드 발급에 실패해서 -팀에 활성 코드가 하나도 없는 상태가 되는 걸 방지한다. +0단계의 잠금 덕분에 같은 팀에 대한 동시 요청은 하나씩 순서대로만 처리된다. 재사용 분기(1번)만 +타는 요청끼리는 서로 아무 것도 바꾸지 않으니 경합이랄 게 없고, 재발급 분기(2~4번)를 타는 +요청이 있어도 뒤에 처리되는 요청은 앞선 요청이 커밋한 새 코드를 "현재 활성 코드"로 보고 그걸 +그대로 반환(아직 안 만료됐으므로)하게 되므로, 경합 상황에서도 최종적으로 팀에는 활성 코드가 +정확히 1개만 남는다. 2~4단계 중 하나라도 실패하면 전체 롤백된다(같은 트랜잭션) — 기존 코드는 +폐기했는데 새 코드 발급에 실패해서 팀에 활성 코드가 하나도 없는 상태가 되는 걸 방지한다. ### 초대 코드 생성 규칙 @@ -530,16 +535,20 @@ Authorization: Bearer {accessToken} 평문 코드가 이미 존재해서 insert가 실패하면 새 코드를 다시 뽑아 재시도한다 (예: 최대 3회). - **사용 횟수 제한 없음(`maxUses = null`)**: 한 코드를 여러 팀원이 각자 입력해서 합류하는 흐름이라, 특정 인원수로 막지 않는다. -- **만료 기간**: **발급일로부터 1년**으로 확정 (DB 스키마상 `expires_at`은 `NOT NULL`이라 - 값을 반드시 넣어야 한다). 사용자가 능동적으로 재발급 버튼을 누르지 않는 한 자동 갱신되진 - 않으므로, 방치된 팀이 초대 자체를 못 하게 되는 걸 막기 위해 길게 잡았다. +- **만료 기간**: **발급일로부터 1일**로 확정 (DB 스키마상 `expires_at`은 `NOT NULL`이라 + 값을 반드시 넣어야 한다). 예전엔 "버튼을 누를 때마다 항상 새 코드"였고 재발급하지 않으면 + 자동 갱신도 안 됐던 탓에 방치된 팀이 초대 자체를 못 하게 되는 걸 막으려고 1년으로 길게 + 잡았었다. 지금은 활성 코드가 있으면 그대로 재사용하는 방식으로 바뀌어서 "길게 잡아야 할 + 이유"가 없어졌고, 오히려 유출된 코드가 오래 살아있는 게 더 큰 위험이라 짧게 줄였다. 하루 + 안에 초대를 못 끝내면 팀원 페이지에서 "팀원 초대하기"를 다시 누르면 되고, 그 시점엔 코드가 + 이미 만료돼 있으니 새 코드가 나온다. ### Response Fields | 필드 | 타입 | 설명 | |---|---|---| -| `inviteCode` | string | 새로 발급된 초대 코드 (평문, 7자리) | -| `inviteCodeExpiresAt` | string(ISO-8601) | 새 초대 코드의 만료 시각 | +| `inviteCode` | string | 초대 코드 (평문, 7자리). 기존 코드를 재사용한 응답이면 이전과 같은 값 | +| `inviteCodeExpiresAt` | string(ISO-8601) | 이 초대 코드의 만료 시각 | ### Response Example @@ -550,7 +559,7 @@ Authorization: Bearer {accessToken} "message": "성공적으로 요청을 처리했습니다.", "result": { "inviteCode": "K2M8XZ1", - "inviteCodeExpiresAt": "2027-07-17T10:12:00+09:00" + "inviteCodeExpiresAt": "2026-07-18T10:12:00+09:00" } } ``` diff --git a/src/main/java/com/shinhan/klljs/domain/team/controller/TeamMemberControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/team/controller/TeamMemberControllerDocs.java index 05c8ece..9b9bbf5 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/controller/TeamMemberControllerDocs.java +++ b/src/main/java/com/shinhan/klljs/domain/team/controller/TeamMemberControllerDocs.java @@ -42,7 +42,7 @@ ApiResponse getMembers( @Parameter(description = "이름 또는 이메일 검색어, 최대 100자", example = "김") String keyword ); - @Operation(summary = "초대 코드 발급", description = "팀의 ACTIVE 멤버(OWNER/ADMIN/MEMBER 누구나)가 호출한다. 기존 활성 코드를 폐기하고 1년 유효한 새 코드를 반환한다.") + @Operation(summary = "초대 코드 발급", description = "팀의 ACTIVE 멤버(OWNER/ADMIN/MEMBER 누구나)가 호출한다. 아직 유효한(만료 전) 코드가 있으면 그 코드를 그대로 반환하고, 없거나 만료됐으면 새로 발급한다(유효 기간 1일). 즉 버튼을 여러 번 눌러도 코드는 유효 기간 동안 바뀌지 않는다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "발급 성공"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "미소속 또는 역할 부족 (TEAM_403_001/002)"), diff --git a/src/main/java/com/shinhan/klljs/domain/team/entity/TeamInviteLink.java b/src/main/java/com/shinhan/klljs/domain/team/entity/TeamInviteLink.java index 8e5df5d..db0385f 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/entity/TeamInviteLink.java +++ b/src/main/java/com/shinhan/klljs/domain/team/entity/TeamInviteLink.java @@ -22,8 +22,9 @@ /** * 초대 코드로 합류하면 항상 MEMBER로 합류한다 (역할 선택 없음, 승격은 팀원 관리 화면에서 별도 처리). - * 팀당 폐기되지 않은(revoked_at IS NULL) 코드는 항상 하나만 존재한다 — 새 코드를 발급하면 - * 기존 코드를 같은 트랜잭션에서 먼저 폐기해야 한다 (activeCodeMarker 유니크 인덱스가 강제). + * 팀당 폐기되지 않은(revoked_at IS NULL) 코드는 항상 하나만 존재한다 — 아직 안 만료된 코드가 + * 있으면 그대로 재사용하고, 없거나 만료된 경우에만 그 코드를 같은 트랜잭션에서 먼저 폐기한 뒤 + * 새로 발급한다 (activeCodeMarker 유니크 인덱스가 팀당 미폐기 행 1개만 강제하므로 순서를 지켜야 한다). */ @Entity @Table(name = "team_invite_links") diff --git a/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java b/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java index fa31520..17e2aaa 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java +++ b/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java @@ -21,11 +21,19 @@ import java.time.Clock; import java.time.LocalDateTime; import java.util.Locale; +import java.util.Optional; @Service @RequiredArgsConstructor public class TeamInviteCodeTransactionService { + /** + * 방치된 팀이 초대 자체를 못 하게 되는 걸 막으면서도, 유출된 코드의 노출 기간을 짧게 + * 가져가기 위한 값이다. 예전엔 1년이었지만, 버튼을 눌러도 코드가 안 바뀌는 재사용 방식으로 + * 바뀌면서 "길게 잡아야 할 이유"가 없어져 짧게 줄였다. + */ + private static final long INVITE_CODE_TTL_DAYS = 1; + private final TeamRepository teamRepository; private final TeamMemberRepository teamMemberRepository; private final TeamInviteLinkRepository teamInviteLinkRepository; @@ -45,13 +53,23 @@ public TeamInviteCodeResponse issueOnce(Long requesterId, Long teamId) { } LocalDateTime nowUtc = LocalDateTime.now(clock); - teamInviteLinkRepository.findByTeamIdAndRevokedAtIsNull(teamId).ifPresent(activeInvite -> { - activeInvite.revoke(nowUtc); + Optional activeInvite = teamInviteLinkRepository.findByTeamIdAndRevokedAtIsNull(teamId); + + // 아직 안 만료된 코드가 있으면 그대로 재사용한다 - 버튼을 눌러도 새 코드가 안 나온다. + if (activeInvite.isPresent() && activeInvite.get().isUsable(nowUtc)) { + TeamInviteLink current = activeInvite.get(); + return new TeamInviteCodeResponse(current.getInviteCode(), KstDateTimes.toKstOffset(current.getExpiresAt())); + } + + // 없거나 이미 만료된 코드만 있으면, 만료된 코드부터 폐기하고 새로 발급한다 + // (activeCodeMarker 유니크 인덱스가 팀당 미폐기 행 1개만 허용하므로 순서를 지켜야 한다). + activeInvite.ifPresent(expired -> { + expired.revoke(nowUtc); teamInviteLinkRepository.flush(); }); String rawCode = inviteCodeGenerator.generate(); - LocalDateTime expiresAtUtc = nowUtc.plusYears(1); + LocalDateTime expiresAtUtc = nowUtc.plusDays(INVITE_CODE_TTL_DAYS); TeamInviteLink invite = TeamInviteLink.builder() .team(team) .createdBy(requester.getUser()) diff --git a/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java b/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java index d300f26..a09f7c6 100644 --- a/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java +++ b/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java @@ -18,6 +18,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; import java.time.LocalDateTime; import java.util.List; @@ -37,6 +38,9 @@ class TeamInviteCodeIntegrationTest { @Autowired private EntityManager entityManager; + @Autowired + private Clock clock; + @Test void issue_createsInviteWithPlaintextCode() { Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.OWNER); @@ -51,7 +55,7 @@ void issue_createsInviteWithPlaintextCode() { } @Test - void issue_revokesPreviousInviteAndLeavesOneActiveCode() { + void issue_reusesActiveCodeWithinValidityWindow() { Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.ADMIN); TeamInviteCodeResponse first = service.issue(fixture.member().getUser().getId(), fixture.team().getId()); @@ -61,9 +65,33 @@ void issue_revokesPreviousInviteAndLeavesOneActiveCode() { List teamInvites = inviteRepository.findAll().stream() .filter(invite -> invite.getTeam().getId().equals(fixture.team().getId())) .toList(); - assertThat(teamInvites).hasSize(2); - assertThat(teamInvites).filteredOn(invite -> invite.getRevokedAt() == null).hasSize(1); - assertThat(first.inviteCode()).isNotEqualTo(second.inviteCode()); + assertThat(teamInvites).hasSize(1); + assertThat(teamInvites.getFirst().getRevokedAt()).isNull(); + assertThat(first.inviteCode()).isEqualTo(second.inviteCode()); + assertThat(first.inviteCodeExpiresAt()).isEqualTo(second.inviteCodeExpiresAt()); + } + + @Test + void issue_reissuesWhenPreviousCodeExpired() { + Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.OWNER); + TeamInviteLink expired = TeamInviteLink.builder() + .team(fixture.team()) + .createdBy(fixture.member().getUser()) + .inviteCode("OLDCOD1") + .maxUses(null) + .expiresAt(LocalDateTime.now(clock).minusHours(1)) + .build(); + entityManager.persist(expired); + entityManager.flush(); + + TeamInviteCodeResponse response = service.issue(fixture.member().getUser().getId(), fixture.team().getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(response.inviteCode()).isNotEqualTo("OLDCOD1"); + assertThat(entityManager.find(TeamInviteLink.class, expired.getId()).getRevokedAt()).isNotNull(); + TeamInviteLink newActive = inviteRepository.findByTeamIdAndRevokedAtIsNull(fixture.team().getId()).orElseThrow(); + assertThat(newActive.getInviteCode()).isEqualTo(response.inviteCode()); } @Test From b264860dc393140792184c5d79a09c87cd216316 Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Wed, 22 Jul 2026 00:52:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20-=20NULL=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EB=B0=A9=EC=A7=80=20+=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=BD=94=EB=93=9C=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [P1] findByTeamIdAndRevokedAtIsNull로 찾은 행이 V11 이전 해시 저장 방식에서 넘어와 inviteCode가 NULL인 레거시 행이면, isUsable()은 이를 걸러내지 못해(만료·폐기·사용횟수만 확인) inviteCode: null을 그대로 응답해버리는 문제가 있었다. 재사용 조건에 getInviteCode() != null을 추가(isReusable 헬퍼로 분리)하고 회귀 테스트를 추가했다. [P2] 이 PR 이전에 발급된 1년짜리 코드는 새 1일 정책과 무관하게 원래 만료 시각까지 계속 재사용됐다. V13 마이그레이션으로 배포 시점에 폐기되지 않은 초대 코드를 전부 revoke해서, 다음 발급 호출부터 모든 팀이 즉시 1일 정책을 적용받게 했다(하드 삭제가 아니라 revoke만 하므로 team_members.joined_via_invite_id로 참조되는 합류 이력은 그대로 보존된다). 같은 조건(revoked_at IS NULL)에 걸리는 레거시 NULL 코드 행도 함께 정리된다. 실제 MySQL 8.0.46 컨테이너에 V1~V11을 적용하고 세 가지 상태(1년짜리 활성 코드, 레거시 NULL 코드 활성 행, 이미 폐기된 행)를 심어둔 뒤 V13을 적용해 확인함: 앞의 두 행만 이번 마이그레이션 실행 시각으로 revoke되고, 이미 폐기돼 있던 행은 원래 폐기 시각 그대로 유지됨. active_code_marker도 세 행 모두 NULL로 정상 갱신됨. 전체 테스트 스위트 통과(311개, 0 실패). Co-Authored-By: Claude Sonnet 5 --- .../TeamInviteCodeTransactionService.java | 21 +++++++++++---- .../V13__revoke_all_active_invite_codes.sql | 17 ++++++++++++ .../TeamInviteCodeIntegrationTest.java | 26 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/V13__revoke_all_active_invite_codes.sql diff --git a/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java b/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java index 17e2aaa..8d5a860 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java +++ b/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java @@ -56,15 +56,16 @@ public TeamInviteCodeResponse issueOnce(Long requesterId, Long teamId) { Optional activeInvite = teamInviteLinkRepository.findByTeamIdAndRevokedAtIsNull(teamId); // 아직 안 만료된 코드가 있으면 그대로 재사용한다 - 버튼을 눌러도 새 코드가 안 나온다. - if (activeInvite.isPresent() && activeInvite.get().isUsable(nowUtc)) { + if (activeInvite.isPresent() && isReusable(activeInvite.get(), nowUtc)) { TeamInviteLink current = activeInvite.get(); return new TeamInviteCodeResponse(current.getInviteCode(), KstDateTimes.toKstOffset(current.getExpiresAt())); } - // 없거나 이미 만료된 코드만 있으면, 만료된 코드부터 폐기하고 새로 발급한다 - // (activeCodeMarker 유니크 인덱스가 팀당 미폐기 행 1개만 허용하므로 순서를 지켜야 한다). - activeInvite.ifPresent(expired -> { - expired.revoke(nowUtc); + // 없거나 이미 만료됐거나, V11 이전 해시 저장 방식이라 평문 코드를 복원할 수 없는(inviteCode + // == null) 레거시 행만 있으면 폐기하고 새로 발급한다 (activeCodeMarker 유니크 인덱스가 + // 팀당 미폐기 행 1개만 허용하므로 순서를 지켜야 한다). + activeInvite.ifPresent(old -> { + old.revoke(nowUtc); teamInviteLinkRepository.flush(); }); @@ -90,6 +91,16 @@ public TeamInviteCodeResponse issueOnce(Long requesterId, Long teamId) { return new TeamInviteCodeResponse(rawCode, KstDateTimes.toKstOffset(expiresAtUtc)); } + /** + * isUsable()은 만료·폐기·사용횟수만 보고 코드 자체의 존재 여부는 보지 않는다. V11 마이그레이션 + * 이전 해시 저장 방식에서 넘어온 행은 평문 코드를 복원할 수 없어 inviteCode가 null인데, + * 그 행이 아직 revoke도 만료도 안 됐다면 isUsable()만으로는 재사용 가능하다고 잘못 판단해 + * inviteCode: null을 그대로 응답해버린다 - 그래서 null 여부를 별도로 확인한다. + */ + private boolean isReusable(TeamInviteLink invite, LocalDateTime now) { + return invite.getInviteCode() != null && invite.isUsable(now); + } + private boolean isInviteCodeCollision(DataIntegrityViolationException exception) { Throwable cause = exception.getMostSpecificCause(); String message = cause == null ? exception.getMessage() : cause.getMessage(); diff --git a/src/main/resources/db/migration/V13__revoke_all_active_invite_codes.sql b/src/main/resources/db/migration/V13__revoke_all_active_invite_codes.sql new file mode 100644 index 0000000..81d02b5 --- /dev/null +++ b/src/main/resources/db/migration/V13__revoke_all_active_invite_codes.sql @@ -0,0 +1,17 @@ +-- ============================================================ +-- V13__revoke_all_active_invite_codes.sql +-- 팀원 초대 코드 정책을 "누를 때마다 새 코드 발급 + 1년 만료"에서 "만료 전이면 재사용 + +-- 1일 만료"로 바꾸면서, 배포 시점에 이미 발급돼 있던 코드도 즉시 새 정책을 적용받도록 +-- 전부 폐기한다. 하드 삭제가 아니라 revoke만 하므로, 그 코드로 합류한 이력 +-- (team_members.joined_via_invite_id)은 그대로 보존된다. +-- +-- 이 마이그레이션 직후 다음 "초대 코드 발급" 호출 시점에 각 팀마다 새 코드(1일 만료)가 +-- 발급된다. 그 순간 기존에 공유돼 있던 옛 코드는 즉시 무효화된다. +-- +-- V11 이전 해시 저장 방식에서 넘어와 invite_code가 NULL인 레거시 행도 이 조건 +-- (revoked_at IS NULL)에 해당하므로 함께 폐기된다. +-- ============================================================ + +UPDATE team_invite_links +SET revoked_at = NOW(3) +WHERE revoked_at IS NULL; diff --git a/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java b/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java index a09f7c6..42ad3cd 100644 --- a/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java +++ b/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java @@ -94,6 +94,32 @@ void issue_reissuesWhenPreviousCodeExpired() { assertThat(newActive.getInviteCode()).isEqualTo(response.inviteCode()); } + @Test + void issue_reissuesWhenPreviousCodeIsLegacyNullInviteCode() { + // V11 이전 해시 저장 방식에서 넘어온 행 재현 - revoke도 만료도 안 됐지만 평문 코드를 + // 복원할 수 없어 inviteCode가 null이다. isUsable()만 보면 재사용 가능하다고 오판해 + // inviteCode: null을 그대로 응답해버리는 회귀를 잡는 테스트다. + Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.OWNER); + TeamInviteLink legacyNullCode = TeamInviteLink.builder() + .team(fixture.team()) + .createdBy(fixture.member().getUser()) + .inviteCode(null) + .maxUses(null) + .expiresAt(LocalDateTime.now(clock).plusDays(300)) + .build(); + entityManager.persist(legacyNullCode); + entityManager.flush(); + + TeamInviteCodeResponse response = service.issue(fixture.member().getUser().getId(), fixture.team().getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(response.inviteCode()).isNotNull(); + assertThat(entityManager.find(TeamInviteLink.class, legacyNullCode.getId()).getRevokedAt()).isNotNull(); + TeamInviteLink newActive = inviteRepository.findByTeamIdAndRevokedAtIsNull(fixture.team().getId()).orElseThrow(); + assertThat(newActive.getInviteCode()).isEqualTo(response.inviteCode()); + } + @Test void issue_allowsMemberRole() { Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.MEMBER);