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
9 changes: 5 additions & 4 deletions docs/mvp-database-erd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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도 하지 않으므로 이 제약과 아예 무관하다.)

초대 처리 흐름:

Expand Down Expand Up @@ -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)
Expand Down
51 changes: 30 additions & 21 deletions docs/team-creation-api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ API 단위로 담당을 분리했다 — 상세 내용은 "2. 담당자 요약"
|
v
백엔드(A): 하나의 트랜잭션으로
a. 이 팀의 기존 활성 코드가 있으면 폐기(revoke)
b. 새 코드 생성 (6절 "초대 코드 생성 규칙")
a. 이 팀의 기존 활성 코드가 아직 안 만료됐으면 그대로 반환하고 끝
b. 없거나 만료됐으면: 있는 경우 폐기(revoke) 후 새 코드 생성 (6절 "초대 코드 생성 규칙")
|
v
백엔드(A) -> 프론트: 새 초대 코드(평문) 응답
Expand Down Expand Up @@ -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)는 "인증된 사용자인가"만 확인하면 된다 (아직 팀이 없는 시점). 초대 코드
발급은 다르다 — 이미 존재하는 팀에 대한 작업이라 "이 사용자가 이 팀 소속인가"와 역할까지
Expand Down Expand Up @@ -472,9 +472,11 @@ MVP 범위에서는 신경 쓰지 않기로 함)
## 6. 초대 코드 발급 API — 담당: 백엔드 A

팀 생성 직후(5절 성공 이후 프론트가 곧바로 호출) **또는** 팀원 페이지의 "팀원 초대하기"
버튼에서 호출한다 — 두 경우 모두 완전히 같은 API를 쓴다. 누를 때마다 항상 새 코드를
발급한다 — 기존 활성 코드가 있어도 그대로 보여주지 않고, 폐기 후 새로 하나 더 만든다
(처음 호출이라 폐기할 게 없으면 그 단계는 그냥 아무 일도 안 하고 넘어간다).
버튼에서 호출한다 — 두 경우 모두 완전히 같은 API를 쓴다. **아직 만료되지 않은 활성 코드가
있으면 그 코드를 그대로 반환한다** — 버튼을 여러 번 눌러도 유효 기간 안에는 같은 코드가
나온다. 활성 코드가 없거나(처음 호출) 이미 만료된 경우에만 새 코드를 발급한다(만료된 코드는
그 직전에 폐기한다). 예전에는 호출할 때마다 무조건 새 코드를 발급했지만, 만료 기간을 1년에서
1일로 크게 줄이면서 "누를 때마다 새 코드"일 필요가 없어져 재사용 방식으로 바꿨다.

### Request

Expand Down Expand Up @@ -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단계 중 하나라도 실패하면 전체 롤백된다(같은 트랜잭션) — 기존 코드는
폐기했는데 새 코드 발급에 실패해서 팀에 활성 코드가 하나도 없는 상태가 되는 걸 방지한다.

### 초대 코드 생성 규칙

Expand All @@ -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

Expand All @@ -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"
}
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ ApiResponse<TeamMemberListResponse> 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)"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@

/**
* 초대 코드로 합류하면 항상 MEMBER로 합류한다 (역할 선택 없음, 승격은 팀원 관리 화면에서 별도 처리).
* 팀당 폐기되지 않은(revoked_at IS NULL) 코드는 항상 하나만 존재한다 — 새 코드를 발급하면
* 기존 코드를 같은 트랜잭션에서 먼저 폐기해야 한다 (activeCodeMarker 유니크 인덱스가 강제).
* 팀당 폐기되지 않은(revoked_at IS NULL) 코드는 항상 하나만 존재한다 — 아직 안 만료된 코드가
* 있으면 그대로 재사용하고, 없거나 만료된 경우에만 그 코드를 같은 트랜잭션에서 먼저 폐기한 뒤
* 새로 발급한다 (activeCodeMarker 유니크 인덱스가 팀당 미폐기 행 1개만 강제하므로 순서를 지켜야 한다).
*/
@Entity
@Table(name = "team_invite_links")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,13 +53,24 @@ public TeamInviteCodeResponse issueOnce(Long requesterId, Long teamId) {
}

LocalDateTime nowUtc = LocalDateTime.now(clock);
teamInviteLinkRepository.findByTeamIdAndRevokedAtIsNull(teamId).ifPresent(activeInvite -> {
activeInvite.revoke(nowUtc);
Optional<TeamInviteLink> activeInvite = teamInviteLinkRepository.findByTeamIdAndRevokedAtIsNull(teamId);

// 아직 안 만료된 코드가 있으면 그대로 재사용한다 - 버튼을 눌러도 새 코드가 안 나온다.
if (activeInvite.isPresent() && isReusable(activeInvite.get(), nowUtc)) {
TeamInviteLink current = activeInvite.get();
return new TeamInviteCodeResponse(current.getInviteCode(), KstDateTimes.toKstOffset(current.getExpiresAt()));
}

// 없거나 이미 만료됐거나, V11 이전 해시 저장 방식이라 평문 코드를 복원할 수 없는(inviteCode
// == null) 레거시 행만 있으면 폐기하고 새로 발급한다 (activeCodeMarker 유니크 인덱스가
// 팀당 미폐기 행 1개만 허용하므로 순서를 지켜야 한다).
activeInvite.ifPresent(old -> {
old.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())
Expand All @@ -72,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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading