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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,5 @@ MySQL 8.4 / RDS TLS, existing-schema upgrade and native regression instructions:
[프론트 연동 전달본](docs/frontend-integration.md)에 현재 API·인증·상태 처리와 미구현 범위를 구분했다.
[회원탈퇴 문서](docs/user-withdrawal.md)에 탈퇴 API, 유료·무료 잔액 구분, 카카오/이미지 후속 작업,
거래·가입 보상 기록 보존 및 활성화 조건을 정리했다.

[필수 문서 동의](docs/legal-consents.md)에 API·기존 회원 재동의·버전/해시·단계적 활성화 방법을 정리했습니다.
11 changes: 11 additions & 0 deletions docs/frontend-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,17 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode·
- 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다.
- 구매 환불 신청·자동 회수/보류 해제 API는 아직 없다. 법정 환불 접수·운영 취소 처리 경로는 별도 준비 중이며 일괄 환불 불가를 확정한 것은 아니다.

## 필수 문서 동의 (#128)

GET /api/v1/legal/documents는 공개 상태와 필수 문서 두 항목의 버전·해시를 반환합니다.
POST /api/v1/users/me/agreements는 온보딩 전/기존 회원 모두 Bearer 인증으로 접수할 수 있습니다.
로그인·Refresh·GET /users/me 응답의 termsAccepted는 최신 두 문서의 버전·해시를 모두 동의했는지 나타냅니다.
onboardingCompleted는 기존 닉네임 온보딩 상태로 유지합니다. 기존 회원의 동의를 자동으로 채우지 않습니다.

현재 코드의 공개/필수 적용은 모두 기본 false입니다. 문안과 프론트 버전·해시가 맞기 전에 활성화하지 않습니다.
미동의 재안내·요청 본문·오류·거래/탈퇴 예외·배포 순서는 [필수 문서 동의 계약](legal-consents.md)을 따릅니다.
이 PR은 실제 문안 게시, 환불 API, 무료 우선 사용 변경을 포함하지 않습니다.

## 회원탈퇴 (#126)

`DELETE /api/v1/users/me`는 Bearer 인증으로 호출하며 닉네임 온보딩 전에도 가능합니다.
Expand Down
130 changes: 130 additions & 0 deletions docs/legal-consents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 필수 약관 동의 및 기존 회원 확인 (#128)

## 범위
이용약관(TERMS)과 필수 개인정보 수집·이용 고지(PRIVACY)의 버전·SHA-256·동의 시각을 각각 저장한다.
개인정보 처리방침 전체에 대한 포괄 동의나 선택 마케팅 동의를 추가한 기능은 아니다.
문서 본문 작성·사업자 정보 확정·프론트 화면 배포는 협업 범위다. 이 API는 본문을 제공하지 않는다.
결제/환불·무료 우선 사용·회원탈퇴 자체의 정책 변경은 이 PR에 포함하지 않는다.

## API
모든 응답은 기존 ApiResponse 봉투와 Cache-Control:no-store를 사용한다.

| 메서드·경로 | 인증 | 역할 |
| --- | --- | --- |
| GET /api/v1/legal/documents | 공개 | 공개 상태와 필수 문서 2개의 버전·해시 |
| POST /api/v1/users/me/agreements | Bearer, 온보딩 전에도 가능 | 인증된 본인의 두 필수 동의 접수 |

문서 조회 예시(data 내부):
```json
{
"published": true,
"documents": [
{"type":"TERMS","version":"2026-11-07-v1","digest":"<실제 문서 SHA-256 64자리>","required":true},
{"type":"PRIVACY","version":"2026-11-07-v1","digest":"<실제 문서 SHA-256 64자리>","required":true}
]
}
```
published=false이면 동의 기능이 공개되지 않은 상태다. 해시는 미설정으로 빈 문자열일 수 있다.
조회는 문서·회원·동의 기록을 만들거나 변경하지 않는다.

동의 요청 예시:
```json
{
"termsVersion": "2026-11-07-v1",
"termsDigest": "<화면에 표시한 이용약관의 실제 SHA-256>",
"termsAccepted": true,
"privacyVersion": "2026-11-07-v1",
"privacyDigest": "<화면에 표시한 필수 수집·이용 고지의 실제 SHA-256>",
"privacyAccepted": true,
"age14OrOlder": true
}
```
각 hash는 소문자 16진수 64자리다. 예시의 꺾쇠 문구를 그대로 전송하지 않는다.
회원 ID·동의 시각을 클라이언트에서 받지 않는다. JWT의 본인 ID와 서버 UTC 시각을 사용한다.
두 필수 동의와 연령 확인은 실제 화면 확인 후에만 true로 보낸다. 초기값을 true로 고정하지 않는다.
기존 필수 체크 두 항목을 유지하려면 이용약관 확인 문구에 만14세 이상 확인을 명시하는 등 실제 확인 흐름과 맞춘다.
age14OrOlder는 자기 확인 기록이며 생년월일 수집이나 본인인증 결과를 뜻하지 않는다.

성공 data는 UserResponse다. 기존 userId/email/nickname/onboardingCompleted에 termsAccepted를 추가한다.
여기서 응답 termsAccepted는 현재 공개된 두 문서의 버전·해시를 모두 확인한 결과다.
로그인·Refresh·내 정보·닉네임·온보딩·동의 응답에서 동일하게 계산한다.
온보딩 여부를 동의 여부로 대체하거나 기존 회원을 임의로 동의 완료 처리하지 않는다.

## 프론트 흐름
1. 실제 화면에 표시할 두 문서를 버전별 UTF-8 원본 파일로 고정하고 해시를 계산한다.
2. GET /legal/documents의 공개 상태를 확인한다. published=false에서 새 동의 API가 준비됐다고 가정하지 않는다.
3. 백엔드 메타데이터와 프론트에 포함한 원본 문서 버전·해시가 일치할 때만 그 문서의 동의를 접수한다.
기존 문구를 표시하면서 서버가 준 해시만 복사해 전송하면 안 된다.
4. 카카오 복귀 후 Refresh 또는 GET /users/me의 termsAccepted를 확인한다.
미동의면 동의 화면, 동의 후 onboardingCompleted=false이면 기존 닉네임 온보딩으로 이동한다.
기존 회원은 동의만 접수하고 닉네임/가입 보상 처리를 다시 실행하지 않는다.
5. POST 성공 응답으로 회원 상태를 갱신한다. 같은 Access Token도 다음 요청부터 현재 DB 동의 상태가 반영된다.
6. 409이면 최신 문서를 다시 확인한다. 503이면 반복 동의/자동 true 처리를 하지 않고 준비 중 안내로 전환한다.

| 오류 | 의미 |
| --- | --- |
| LEGAL_400_1 | 두 필수 동의 또는 만14세 이상 확인이 누락/false |
| LEGAL_403_1 | 동의 필수 적용 상태에서 일반 기능·온보딩 사용 전 최신 동의 필요 |
| LEGAL_409_1 | 요청 버전·해시가 현재 공개 설정과 다름 |
| LEGAL_503_1 | 문서 공개 전 또는 필요한 문서 버전 행이 준비되지 않음 |
| LEGAL_503_2 | 같은 문서 버전에 다른 해시를 적용하려는 운영 설정 충돌 |

잘못된 JSON/인증 오류에는 기존 공통 오류 계약이 적용된다.
공개 GET은 인증 없이 호출할 수 있으며, 잘못된 Bearer 헤더를 일부러 보내면 기존 인증 필터가 거절할 수 있다.

## 접근 권한
MONGLE_LEGAL_ENFORCE=true일 때 현재 동의가 없는 회원은 일반 ONBOARDED 기능을 사용할 수 없다.
신규 온보딩도 같은 조건을 서버에서 검증한다.
내 정보·동의 접수·회원탈퇴는 USER 권한으로 계속 접근할 수 있다.
주문 내역/상태·잔액/원장 확인과 후속 환불 접수/조회 경로는 동의 미완료만으로 막지 않는다.
환불 경로의 권한 예외는 향후 구현을 위한 계약이며 환불 API가 이 PR에서 구현됐다는 의미는 아니다.
외부 결제 통보의 별도 인증과 공개 범위는 유지한다.
기본 enforce=false에서는 기존 회원/온보딩 흐름을 차단하지 않는다.

## 저장·동시성·보존
V8__legal_consents.sql은 기존 사용자 열을 nullable로 확장하고 두 테이블을 추가한다.
- legal_document_versions: 회원 식별정보를 저장하지 않는 문서 종류·버전·내용 해시.
- legal_consents: 내부 회원 ID, 문서 종류·버전·해시, 서버 UTC 동의 시각, 만14세 이상 확인.

이전 회원의 동의 값을 SQL로 채우지 않으며 기존 잔액·콘텐츠·온보딩은 보존한다.
문서 버전 행은 마이그레이션으로 준비하고 최초 동의 트랜잭션이 해시를 결합한다.
일단 결합한 버전의 해시는 바꾸지 않는다. 다른 해시의 동시 요청은 하나의 문서 버전에 섞여 저장되지 않는다.
회원 행 → TERMS 문서 행 → PRIVACY 문서 행 순서로 잠그고 두 동의와 회원 요약 상태를 같은 트랜잭션으로 저장한다.
같은 회원/문서/버전은 UNIQUE로 보호하며 재요청은 최초 시각을 유지한다.
문서 결합·동의 한 항목·회원 상태 중 저장 실패가 나면 전체를 롤백한다.

거래 기록이 없는 탈퇴 계정은 동의 기록을 삭제한다.
거래 기록 보존이 필요한 탈퇴 계정은 기존 정산 표식과 함께 별도 보존하고 만료 파기에서 삭제한다.
문서 버전 해시는 회원별 동의 기록과 구분하며 회원 탈퇴와 함께 덮어쓰거나 지우지 않는다.
구체적 접근/보관·파기 절차는 기존 회원탈퇴·거래 보존 문서와 처리방침에 맞춘다.

## 설정·배포 순서
| 환경변수 | 기본값 | 설명 |
| --- | --- | --- |
| MONGLE_LEGAL_PUBLISHED | false | 두 필수 문서의 공개/동의 접수 준비 상태 |
| MONGLE_LEGAL_ENFORCE | false | 일반 기능·온보딩의 최신 동의 필수 적용 |
| MONGLE_LEGAL_TERMS_DIGEST | 빈 값 | 확정 이용약관 UTF-8 원본의 SHA-256 |
| MONGLE_LEGAL_PRIVACY_DIGEST | 빈 값 | 확정 필수 수집·이용 고지 UTF-8 원본의 SHA-256 |

enforce=true/published=false 또는 공개 상태에서 잘못된 해시 설정은 시작 시 거절한다.
문서 해시는 공개 본문 식별값이며 결제/JWT 비밀키가 아니다.

1. 기존 CD/Flyway 절차로 V8을 적용하고 기본 두 플래그 false로 배포한다. V1~V7/이력은 수정하거나 재실행하지 않는다.
2. 실제 사업자·문의 정보와 정책에 맞는 두 고지 원본을 확정한다. 서로 다른 문서를 같은 내용으로 임의 합치지 않는다.
3. 프론트가 같은 원본/버전/해시를 사용하도록 배포한다. 예시 해시를 운영에 넣지 않는다.
4. 두 실제 해시와 published=true, enforce=false로 동의 저장/신규·기존 회원 흐름을 검증한다.
5. 프론트 연동이 확인된 뒤 enforce=true를 적용하고 미동의 접근·거래/탈퇴 예외를 검증한다.
6. 프론트 연동 장애 시 enforce=false로 되돌릴 수 있다. 기존 동의 기록을 지우거나 임의 동의로 채우지 않는다.

원본은 줄바꿈 LF·UTF-8 등 정한 형태로 고정한 뒤, 예를 들어 shasum -a 256 명령으로 계산한다.
화면용 HTML/JSX 전체를 임의로 해싱하거나 원본을 재입력해 해시를 바꾸지 않는다.
문서 내용 변경은 코드의 버전·신규 문서 행 마이그레이션·해시·프론트 문구를 함께 갱신한다.
기존 행/해시를 UPDATE하거나 이미 적용한 V8을 고쳐 해결하지 않는다.
동의 기록이 생긴 뒤 구버전 JAR로 수동 롤백하면 그 버전의 탈퇴·파기 경로가 새 동의 테이블을 처리하지 못할 수 있다.
프론트 연동 문제는 우선 enforce=false로 완화하고, 저장을 시작한 이후의 앱 롤백은 동의 기록 정리 호환성을 확인한다.
이 작업은 운영 환경 파일·문서 게시·실제 회원 계정 변경을 직접 실행하지 않는다.

## 검증
H2와 임시 MySQL 8.4에서 동일한 동의·권한·동시성·롤백·탈퇴 보존 계약을 실행한다.
V7→V8 업그레이드, 이전 체크섬·회원 데이터 보존, 기존 회원 미동의와 새 테이블 초기 상태를 별도 검증한다.
프론트 실제 문구·사업자 정보·운영 설정 적용은 코드 테스트와 별도다.
7 changes: 7 additions & 0 deletions docs/user-withdrawal.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,10 @@ WithdrawalRetention은 만료 후보를 소량 조회한 뒤 사용자별 잠금
비교 기록을 일괄 파기한다. 일반 HTTP API로 노출하지 않으며 새 혜택으로 임의 재사용하지 않는다.
가입 보상 식별 기록은 법정 거래 보존과 별개다. 목적·항목·기간·근거/필요성을
개인정보 처리방침에 반영하고, HMAC을 익명정보로 표시하지 않는다.

## 동의 기록 연동 (#128)

V8에서 문서별 동의 기록과 회원의 요약 상태를 추가합니다. 거래기록이 없는 탈퇴 계정의 동의는 삭제하고,
거래 관련 보존이 필요한 경우 정산 기록과 함께 보관한 후 WithdrawalRetention 만료 파기에서 제거합니다.
문서 버전 레지스트리는 개인정보 없는 본문 해시 자료이며 개인 동의 기록과 구분합니다.
동의 미완료만으로 탈퇴·금융 확인 경로를 막지 않습니다. [동의 저장·배포 계약](legal-consents.md)을 참고합니다.
2 changes: 1 addition & 1 deletion scripts/mysql84-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def stop_on_signal(signum, frame):
migration_password = secrets.token_hex(24)
sql(f"CREATE USER 'mongle_migrator'@'127.0.0.1' IDENTIFIED BY '{migration_password}' REQUIRE SSL;")
migration_databases = ("flyway_fresh", "flyway_existing", "flyway_drift", "flyway_failed",
"flyway_checksum", "flyway_privilege", "flyway_jpa", "flyway_v1", "flyway_cli", "flyway_check", "flyway_summary", "flyway_reclamation")
"flyway_checksum", "flyway_privilege", "flyway_jpa", "flyway_v1", "flyway_cli", "flyway_check", "flyway_summary", "flyway_reclamation", "flyway_legal")
schema = (repo / "src/main/resources/db/schema-initial.sql").read_text()
for database in migration_databases:
sql(f"CREATE DATABASE {database} CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ public class SocialLoginService {
private final SocialAccountRepository accounts;
private final UserRepository users;
private final TransactionTemplate read;
private final com.mongle.backend.domain.legal.LegalService legal;
private final TransactionTemplate write;
private final com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService withdrawalIdentities;

public SocialLoginService(SocialAccountRepository accounts, UserRepository users,
PlatformTransactionManager manager,
com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService withdrawalIdentities) {
com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService withdrawalIdentities,
com.mongle.backend.domain.legal.LegalService legal) {
this.legal = legal;
this.accounts = accounts;
this.users = users;
this.withdrawalIdentities = withdrawalIdentities;
Expand All @@ -51,14 +54,14 @@ public UserResponse login(SocialIdentity identity) {
Optional<SocialAccount> account = accounts.findByProviderAndProviderUserId(
identity.provider(), identity.providerUserId());
if (account.isPresent()) {
return UserResponse.from(account.get().getUser());
return legal.response(account.get().getUser());
}
User user = users.save(User.register(identity.email()));
accounts.saveAndFlush(SocialAccount.link(user, identity.provider(), identity.providerUserId()));
Long userId = user.getId();
// 이메일·소셜 제공자 사용자 ID 대신 내부 PK와 제공사만 남긴다.
CommittedLog.afterCommit(() -> log.info("소셜 회원 가입 완료: userId={}, provider={}", userId, identity.provider()));
return UserResponse.from(user);
return legal.response(user);
});
} catch (DataIntegrityViolationException duplicate) {
// 중복 가입으로 실패한 트랜잭션은 사용자 저장까지 모두 롤백된 상태다.
Expand All @@ -70,6 +73,6 @@ public UserResponse login(SocialIdentity identity) {
private Optional<UserResponse> find(SocialIdentity identity) {
return read.execute(status -> accounts.findByProviderAndProviderUserId(
identity.provider(), identity.providerUserId())
.map(account -> UserResponse.from(account.getUser())));
.map(account -> legal.response(account.getUser())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public class TokenService {
private final JwtEncoder encoder;
private final AuthProperties properties;
private final Clock authClock;
private final com.mongle.backend.domain.legal.LegalService legal;

// 쿠키에 담을 원문 토큰은 응답 DTO에 포함하지 않는다.
public record IssuedTokens(TokenResponse response, String refreshToken, Instant refreshExpiresAt) { }
Expand Down Expand Up @@ -127,7 +128,7 @@ private IssuedTokens issue(RefreshSession session, String refresh, Instant now)
String access = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(MacAlgorithm.HS256).type("JWT").build(), claims)).getTokenValue();
var response = new TokenResponse(access, "Bearer", accessExpiry.getEpochSecond() - now.getEpochSecond(),
UserResponse.from(session.getUser()));
legal.response(session.getUser()));
return new IssuedTokens(response, refresh, sessionExpiry);
}

Expand Down
Loading