Test #466
@@ -0,0 +1,83 @@
|
|||||||
|
# 기존 관리자 서브 배너: 언어 선택만 추가하는 구현 프롬프트
|
||||||
|
|
||||||
|
## 목표와 범위
|
||||||
|
|
||||||
|
기존 관리자 프런트엔드의 서브 배너 목록·생성·수정·삭제 CRUD는 이미 구현되어 있다.
|
||||||
|
이 프롬프트만으로 기존 생성·수정 폼에 언어 선택을 추가하라. CRUD를 다시 만들거나 화면을 재설계하지 않는다.
|
||||||
|
특정 프레임워크를 가정하지 말고 작업 저장소의 기존 컴포넌트, 스타일, 폼 상태, 인증, API client, multipart 전송 방식을 재사용한다.
|
||||||
|
기존 화면·DTO·요청 직렬화·오류 처리·테스트의 영향 지점을 먼저 확인하고 최소 변경한다.
|
||||||
|
언어 필터·검색·페이지네이션, 새 의존성, feature flag, 호환성 fallback을 추가하지 않는다.
|
||||||
|
백엔드·DDL·배포는 범위 밖이며 운영 환경의 조회·변경 등 실서비스 작업은 하지 않는다.
|
||||||
|
|
||||||
|
## 연동 전 확인과 기존 API
|
||||||
|
|
||||||
|
백엔드 언어 계약은 구현·검증됐지만 운영 배포 완료를 뜻하지 않는다.
|
||||||
|
연동 전에 승인된 비운영 배포 대상의 실제 관리자 목록 응답에서 각 항목의 `lang` 반환을 확인한다.
|
||||||
|
GET 응답의 `data` 배열 항목에 `lang`이 없으면 KO로 간주하지 말고 계약 미충족으로 보고하여 연동을 중단한다. POST/PUT 응답의 언어 위치는 `data.lang`이다. 환경 자동 전환이나 mock fallback도 금지한다.
|
||||||
|
|
||||||
|
- `GET /admin/sub-banners?placement={placement}`: 기존 목록 조회. `placement`는 필수이며 `HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION`이다.
|
||||||
|
- `POST /admin/sub-banners`: 기존 생성. `PUT /admin/sub-banners/{id}`: 기존 수정. PATCH나 언어 전용 경로를 만들지 않는다.
|
||||||
|
- 기존 DELETE, placement별 목록, 서버 id 오름차순, 저장 후 갱신 흐름은 그대로 둔다. 목록은 기간·언어와 무관한 삭제되지 않은 전체 항목이다.
|
||||||
|
- 기존 `Authorization: Bearer <token>`과 ADMIN 인증 흐름을 유지한다. 비관리자 응답이 반드시 403이라고 가정하지 않는다.
|
||||||
|
- 성공은 HTTP 200의 `ApiResponse`: `success`, `message`, `data`, `errorProperty`. GET의 `data`는 배열, POST/PUT은 저장된 전체 객체다.
|
||||||
|
|
||||||
|
## 데이터와 언어 선택
|
||||||
|
|
||||||
|
기존 관리자 DTO의 8필드 `id`, `placement`, `imageUrl`, `webUrl`, `androidDeepLink`, `iosDeepLink`, `startDate`, `endDate`를 보존하고 필수 `lang`을 더해 9필드로 확장한다.
|
||||||
|
기존 필드 타입은 유지한다: `id`는 정밀도를 보존할 정수 식별자, `placement`는 위 enum, `imageUrl`은 URL 문자열, 세 링크와 두 날짜는 각각 문자열 또는 null이다.
|
||||||
|
요청·응답·폼의 언어 타입은 저장소 언어에 맞는 enum 또는 리터럴 유니온 `KO | JA | EN`으로 제한하며 nullable로 만들지 않는다.
|
||||||
|
|
||||||
|
1. 기존 생성·수정 폼에 연결된 label `언어`를 가진 접근 가능한 select를 추가한다. 키보드 조작과 기존 포커스·오류 표시 패턴을 유지한다.
|
||||||
|
2. 선택지는 값/표시명을 `KO`/`한국어`, `JA`/`일본어`, `EN`/`영어`로 제공한다. 신규 생성 폼의 기본값만 KO다.
|
||||||
|
3. 수정 폼은 기존 목록 객체의 저장된 `lang`으로 채운다. JA/EN 행을 KO로 초기화하거나 비동기 로딩 중 기본값으로 덮어쓰지 않는다. 별도 상세 GET은 없다.
|
||||||
|
4. 생성·수정 시 현재 선택을 대문자 코드로 반드시 직렬화한다. 이미지·기간만 수정할 때도 저장된 언어 선택을 유지한다.
|
||||||
|
5. 필요하면 기존 목록 행 스타일에 언어 표시만 추가한다. 목록 구조·정렬·조회 조건은 바꾸지 않는다.
|
||||||
|
|
||||||
|
## 요청 직렬화: 기존 multipart에 lang만 추가
|
||||||
|
|
||||||
|
POST/PUT의 전체 요청은 기존대로 `multipart/form-data`다.
|
||||||
|
선택한 `lang`은 `Content-Type: application/json`인 **`request` JSON 파트 내부**에 넣는다.
|
||||||
|
별도 form field, header, query로 보내거나 요청 전체를 JSON으로 바꾸지 않는다.
|
||||||
|
`image` 파일 파트는 생성 시 필수, 수정 시 선택이며 생략하면 기존 이미지가 유지된다.
|
||||||
|
`placement`, `webUrl`, `androidDeepLink`, `iosDeepLink` 네 JSON 필드는 생성·수정 모두 반드시 보낸다.
|
||||||
|
수정의 네 필드는 교체 값이며 링크 null은 삭제다. 언어만 바꿀 때도 현재 placement·세 링크·이미지·기간을 보존한다.
|
||||||
|
기존 링크 유효성 검사와 원문 보존을 유지하며 언어 변경을 이유로 주소를 비우거나 변형하지 않는다.
|
||||||
|
|
||||||
|
아래는 `request` 파트 내용의 예시다. POST/PUT 모두 이 JSON을 multipart로 감싸고, POST에는 `image`도 포함한다.
|
||||||
|
PUT에서는 실제 폼의 기존 필드·기간 값을 사용한다. 이 예시의 주소나 null로 덮어쓰지 않는다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"placement": "HOME_RECOMMENDATION",
|
||||||
|
"webUrl": "https://example.test/promo",
|
||||||
|
"androidDeepLink": "sampleapp://promo",
|
||||||
|
"iosDeepLink": null,
|
||||||
|
"startDate": "2026-11-01 09:30",
|
||||||
|
"endDate": null,
|
||||||
|
"lang": "JA"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
날짜 의미와 기존 직렬화는 변경하지 않는다. KST(`Asia/Seoul`) `yyyy-MM-dd HH:mm`이며 UTC ISO/브라우저 시간대로 변환하지 않는다.
|
||||||
|
생성의 날짜 생략/null은 무제한, 수정의 생략은 해당 경계 보존, null은 해당 경계 해제다. 시작 분 `:00`부터 종료 분 `:59`까지 포함한다.
|
||||||
|
언어만 수정할 때 기존 날짜를 의도치 않게 null로 바꾸지 않는다. 기존 날짜 검증과 개별 경계 해제 동작도 유지한다.
|
||||||
|
|
||||||
|
## 언어 오류와 운영자 안내
|
||||||
|
|
||||||
|
서버는 `lang` 생성 생략 시 KO, 수정 생략 시 기존 언어를 보존한다. 이는 서버 호환 규칙이며 새 폼은 선택값을 명시적으로 보낸다.
|
||||||
|
서버는 대소문자 무시·앞뒤 trim을 허용하지만 폼은 정규 코드만 보낸다. 명시적 null, 비문자열(숫자·boolean·배열·객체), blank, 미지원 코드는 HTTP 400이다.
|
||||||
|
언어를 지우는 null 선택지나 전송은 없다. 언어 오류 후에도 선택값·다른 필드·선택 파일·기존 이미지·날짜 상태를 지우지 않는다.
|
||||||
|
기존 HTTP 200 `success:false` 실패 처리를 유지하고 HTTP 상태와 `success`를 함께 확인한다. 400은 표준 Spring Boot body일 수 있어 `ApiResponse`로 단정하지 않는다.
|
||||||
|
앱 공개 노출은 서버가 resolve한 국가 기준 JP→JA, 그 외→KO다. 서버 회원 국가 매핑이 우선하며 일반 접속 국가 헤더 누락은 KR이다. `Accept-Language`나 관리자 브라우저 언어 기준이 아니다.
|
||||||
|
EN은 저장·관리자 목록 조회가 가능하지만 현재 앱 공개 국가 매핑에서는 노출되지 않는다. 이 안내를 폼에 눈에 보이게 표시하되 EN 선택·저장을 막지 않는다.
|
||||||
|
배너 소재 언어와 연결 이벤트 언어는 운영자가 수동으로 일치시킨다. 기존 KO 값은 자동 분류의 결과가 아니다. URL 언어 추론·파싱, 자동 번역은 하지 않는다.
|
||||||
|
|
||||||
|
## 완료 기준과 구현자 제출물
|
||||||
|
|
||||||
|
1. 신규 기본 KO와 JA/EN 선택 각각을 확인하고, POST/PUT의 `request` JSON 안에 정규 `lang`과 필수 네 필드가 포함되는 focused test를 추가한다.
|
||||||
|
2. 저장·목록 재조회·편집 재진입에서 KO/JA/EN이 유지되고, JA/EN 행이 해당 언어로 미리 선택되는지 확인한다. 이미지·날짜만 수정해도 언어가 보존되어야 한다.
|
||||||
|
3. null·비문자열·blank·미지원 언어의 400 거부와 오류 표시를 확인한다. HTTP 200 `success:false` 및 Boot 오류 body에서도 입력·이미지·날짜가 보존되는지 테스트한다.
|
||||||
|
4. 기존 목록·생성·수정·삭제와 이미지 필수/생략, 링크·기간 동작을 회귀 검증한다. 언어만 수정해도 다른 값이 바뀌지 않는지 확인한다.
|
||||||
|
5. 실제 UI에서 label과 키보드 선택·저장, EN 경고, 저장 후 재편집을 수동 QA한다. 배포 대상의 실제 `lang` 응답 확인과 누락 시 연동 중단도 기록한다.
|
||||||
|
|
||||||
|
구현자는 변경 파일 목록, focused test 실행 명령·결과, 실제 UI/manual QA 수행 내용·결과와 미검증 사항을 제출한다. 이 문서 작성 자체를 UI 구현·검증 완료로 보고하지 않는다.
|
||||||
@@ -645,3 +645,21 @@ export TMPDIR=/tmp/soda-banner-impl.CfAkhD NODE_COMPILE_CACHE=/tmp/soda-banner-i
|
|||||||
- Native MySQL·운영 DB에는 실행하지 않았으며, H2 결과를 MySQL 실행 증거로 표현하지 않는다. 앱 소스·CREATE SQL 변경이 없어 Gradle 테스트/빌드/린트는 반복하지 않았다. 임시 driver는 결과 기록 후 정리한다.
|
- Native MySQL·운영 DB에는 실행하지 않았으며, H2 결과를 MySQL 실행 증거로 표현하지 않는다. 앱 소스·CREATE SQL 변경이 없어 Gradle 테스트/빌드/린트는 반복하지 않았다. 임시 driver는 결과 기록 후 정리한다.
|
||||||
- 독립 scoped 리뷰 `ses_e630f286affevZ6Rc77rxuL3Dn`: APPROVE, 문서화된 1회 실행 전제 아래 데이터 손실 회귀·차단 사항 없음. 기존 Phase 7의 동적 SQL 검토 기록은 당시 이력이며 현재 계약은 DEC-013/P8이다.
|
- 독립 scoped 리뷰 `ses_e630f286affevZ6Rc77rxuL3Dn`: APPROVE, 문서화된 1회 실행 전제 아래 데이터 손실 회귀·차단 사항 없음. 기존 Phase 7의 동적 SQL 검토 기록은 당시 이력이며 현재 계약은 DEC-013/P8이다.
|
||||||
- Teardown: Java 검증 프로세스 잔존 없음 확인, 소유 `/tmp/soda-banner-sql.CIhdCg`와 `/tmp/soda-sql-review.439u9H`의 내용·실경로를 확인한 뒤 삭제하고 부재를 확인했다. 임시 notepad도 삭제했다. 공유 H2/Gradle 캐시는 보존했다.
|
- Teardown: Java 검증 프로세스 잔존 없음 확인, 소유 `/tmp/soda-banner-sql.CIhdCg`와 `/tmp/soda-sql-review.439u9H`의 내용·실경로를 확인한 뒤 삭제하고 부재를 확인했다. 임시 notepad도 삭제했다. 공유 H2/Gradle 캐시는 보존했다.
|
||||||
|
|
||||||
|
## 관리자 언어 증분 프롬프트 (`DOC-LANG-T1`) — 2026-11-03
|
||||||
|
|
||||||
|
- Objective: 언어 변경 전 관리자 CRUD가 이미 적용된 프로젝트에서 언어 선택만 추가할 수 있는 독립 프롬프트를 제공한다.
|
||||||
|
- 기준: 사용자 후속 요청, 기존 `SB-013` 및 관리자 API 계약. 새로운 API/제품 정책을 정의하지 않는다.
|
||||||
|
- Files: 신규 `docs/20261029_가로형서브배너/admin-language-prompt.md`, 이 계획의 검증 기록.
|
||||||
|
- 범위 밖: 관리자 전체 재구현, 실제 UI/서버/SQL 변경·운영 연동·배포.
|
||||||
|
- TDD 예외: 문서 전용. 생성 multipart의 lang, 수정 기존 언어 보존, 유효 enum/오류·노출 정책을 현재 코드/계약과 대조하고 `git diff --check`로 검증한다. Gradle·브라우저 QA는 실제 UI 구현 작업에서 수행한다.
|
||||||
|
- [x] 신규 증분 프롬프트를 작성하고 현재 계약과 대조했다.
|
||||||
|
- [x] 문서 내용 대조·공백 검증과 소유 임시 자원 정리를 완료했다. 문서 변경만 단일 커밋으로 기록한다.
|
||||||
|
|
||||||
|
### DOC-LANG-T1 검증 기록
|
||||||
|
|
||||||
|
- 기존 관리자 CRUD는 사용자 진술을 기준으로 이미 적용된 것으로 두고 전체 재구현 대신 언어 필드 추가만 지시했다. 새로운 framework/UI 디자인이나 backend 계약은 만들지 않았다.
|
||||||
|
- 새 `admin-language-prompt.md` 전체를 부모가 읽고 기존 `admin-prompt.md`, `api-contract.md`, `AdminSubBannerController`, `SubBannerService`, request/response DTO와 대조했다. S1 생성 KO/JA/EN 및 multipart `request.lang`, S2 수정 저장값 보존·invalid 처리, S3 기존 CRUD/기간/이미지/링크 보존과 EN 비노출 안내를 확인했다.
|
||||||
|
- GET `data` 배열 항목의 lang 및 POST/PUT `data.lang` 위치를 명확히 했다. 언어 누락 응답을 KO로 추정하거나 미확인 배포 상태를 성공으로 간주하지 않도록 명시했다.
|
||||||
|
- 문서 전용으로 Gradle·LSP·브라우저·실제 관리자 요청은 실행하지 않았다. 실제 UI 구현자에게 focused test와 실제 화면 QA 증거 제출을 요구했다.
|
||||||
|
- Teardown: 소유 `/tmp/soda-admin-prompt.McfWCQ`, `/tmp/soda-admin-prompt-worker.jDSFlG`가 비어 있음을 확인하고 제거 후 부재를 확인했다. 임시 notepad도 삭제했다. 실행 서버·브라우저·QA 프로세스를 생성하지 않았다. 기존 관리자 전체 프롬프트와 backend 코드는 변경하지 않았다.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE charge
|
||||||
|
ADD COLUMN is_first_charged BIT(1) NULL COMMENT '최초 유료 충전 여부, 최초 완료 전환에서만 확정';
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# 결제 완료 응답 확장 구현 계획
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | Phase 1·2 구현·검증·리뷰 완료 |
|
||||||
|
| 작성일 | 2026-11-03 (KST) |
|
||||||
|
| 요구사항 기준 | [prd.md](prd.md) |
|
||||||
|
| API 기준 | `prd.md` §4 API 계약 |
|
||||||
|
| 현재 Phase | Phase 2: 완료 응답 chargeId 확장 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
| 다음 Goal | 없음; 운영 적용·클라이언트·CI 환경 확인은 별도 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
다섯 인증된 결제 완료 요청이 소유권을 확인하고, 보존되는 최초 유료 충전 사실과 gateway 기반 IAP 여부를 `ApiResponse.data`로 반환한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 구현 및 Gate 완료 | 4/4 | 없음 | 운영 DB rollout은 별도 승인 |
|
||||||
|
| 2 | 구현 및 Gate 완료 | 1/1 | 없음 | 운영·클라이언트·CI 환경 검증은 별도 |
|
||||||
|
|
||||||
|
- 사용자 “문서대로 진행” 승인으로 P1-T1부터 실행한다. 완료 증거가 있는 Task만 체크하며 운영 DB 적용·실결제·배포는 하지 않는다.
|
||||||
|
- `P1-T1 -> P1-T2 -> P1-T3 -> P1-T4 -> P1-GATE` 순서로 진행한다. 특히 `ChargeService` 변경은 한 번에 하나의 goal만 실행한다.
|
||||||
|
- `P1-GATE`는 구현 task가 모두 끝난 뒤 read-only 최종 확인이다. 검증 명령은 구현 승인 이후 실행하며, 전체 회귀의 필요성은 프로젝트 가이드의 조건으로 판단한다.
|
||||||
|
|
||||||
|
## 범위와 기술 제약
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- PRD의 PAY-001~PAY-005, 기존 tracking 호출 조건·위치 유지 및 최초 판정 분류 영향 검증.
|
||||||
|
- 신규 nullable `charge.is_first_charged` 컬럼 한 개를 사용하는 엔티티 매핑과 최초 fact 기록.
|
||||||
|
- TDD 기반 repository, service, 실제 controller HTTP serialization 통합 검증.
|
||||||
|
- Payverse webhook의 같은 최초 판정과 charge -> member lock 순서 보존.
|
||||||
|
- 구현 승인 후 Task 1.2에서 컬럼 추가 SQL 파일 작성 및 정적·엔티티 매핑 정합성 검토. 실제 DB 적용은 별도 운영 승인 대상이다.
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 운영 DDL 실행, 배포 및 운영 DB 접속. 승인된 Kotlin·테스트·SQL 파일 작성은 완료했으며 운영 적용은 포함하지 않는다.
|
||||||
|
- 별도 contract/review 파일 생성은 계획에 포함하지 않는다. Task 1.2에는 구현 승인 후 SQL 파일 작성이 포함되며, SQL 적용·배포·운영 DB 확인은 별도 운영 승인 대상이다.
|
||||||
|
- transaction ID, timestamp, Payment 쪽 중복 flag, `isIap` 저장 필드, 과거 최초 fact backfill.
|
||||||
|
- AppsFlyer/Firebase 전송·SDK, client 변경, 실제 가격 조회, 분석 이벤트 dedup, first-charge bonus 복구.
|
||||||
|
- Google purchaseToken dedup/idempotency와 포괄 결제 리팩터링.
|
||||||
|
|
||||||
|
### 제약
|
||||||
|
|
||||||
|
- Kotlin/Spring 및 현재 `Charge`, `Payment`, `ApiResponse` 계약을 따른다. 기존 요청·인증·오류 semantics를 바꾸지 않는다.
|
||||||
|
- `PaymentStatus`는 `can/payment/Payment.kt` 내부 enum이다. `PaymentStatus.kt`는 존재하지 않는다.
|
||||||
|
- `Charge.isFirstCharged: Boolean? = null`, DB 컬럼 이름 `is_first_charged`를 사용한다. 실제 MySQL boolean 호환 타입을 rollout 전 검증한다.
|
||||||
|
- Task 1.2에서 `docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql`을 작성한다. nullable 단일 컬럼과 컬럼 comment만 포함하고 backfill하지 않는다. 실제 MySQL 타입 호환성은 rollout 전에 확인하며, SQL 실행은 별도 운영 승인 없이는 하지 않는다. 프로젝트 migration runner를 추측하지 않는다.
|
||||||
|
- 우선 schema를 nullable 컬럼으로 배포한 뒤 모든 구 writer가 drain된 상태에서 신 writer를 운영한다. rolling 혼재 중 과거 writer는 최초 fact를 기록하지 않는다는 한계를 확인한다. 각 배포 작업은 본 계획 범위가 아니며 별도 승인·운영 확인이 필요하다.
|
||||||
|
- 초기 문서 전용 단계에서는 코드 명령을 실행하지 않았다. 이후 사용자 구현·ULW 승인에 따라 focused test -> 직접 영향 회귀 -> 전체 test/build/lint를 수행했다.
|
||||||
|
|
||||||
|
### Phase 1: 결제 완료 사실과 응답 전달
|
||||||
|
|
||||||
|
**Phase 결과:** 소유자만 유효한 결제를 완료할 수 있고 최초 유료 충전 사실이 저장되며, 다섯 완료 endpoint가 확정된 응답 DTO를 실제 JSON으로 노출한다.
|
||||||
|
|
||||||
|
**선행조건:** 이 PRD와 계획 문서 검토 완료, 구현 시작에 대한 별도 명시 승인. 배포 전 MySQL 타입·schema-first 조건은 OPS-001을 별도로 확인한다.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** P1-T1~P1-T4 구현 및 focused/영향 회귀 증거 완료 후 P1-GATE read-only 검증 통과. 모든 체크박스와 Progress는 실제 구현·명령 실행 이후에만 갱신한다.
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 충전 소유권 검증**
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 완료 shortcut 및 결제·잔액 변경 전에 요청 회원이 대상 charge의 소유자인지 검증한다.
|
||||||
|
|
||||||
|
- **시작 조건:** 별도 명시적 구현 승인, PRD PAY-002 검토.
|
||||||
|
- **완료 증거:** 다른 회원의 REQUEST 및 COMPLETE charge가 `can.charge.invalid_payment_info`로 거부되고 provider·상태·잔액·event side effect가 없음을 통합 검증. 본인 경로와 coupon 회귀 확인.
|
||||||
|
- **범위 밖:** 인증 방식, 오류 status/key 변경, webhook 인증 우회, 범용 보안 refactor.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt` (신규, `@SpringBootTest`, 필요한 외부 provider는 bounded test double)
|
||||||
|
- Regression: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 기존 verify service arguments `memberId: Long`, order/charge ID; 기존 `MemberRepository.findByIdForUpdate(memberId)`와 charge lock.
|
||||||
|
- Produces: 소유권 위반을 기존 `SodaException(messageKey = "can.charge.invalid_payment_info")`로 일관되게 거부하는 service 동작. `verify`, `verifyHecto`, `payverseVerify`, `appleVerify`, `processGoogleIap` 포함.
|
||||||
|
|
||||||
|
- [x] **RED:** `shouldRejectForeignPendingChargeBeforeProviderOrMutation`, `shouldRejectForeignCompletedChargeBeforeShortcut` 통합 테스트를 추가한다. 해당 member와 다른 member 소유의 REQUEST/COMPLETE charge를 만들고 오류 key, provider 호출 0회, 상태·잔액·event 미변경을 assert한다.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldRejectForeignPendingChargeBeforeProviderOrMutation' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldRejectForeignCompletedChargeBeforeShortcut'`를 실행해 ownership assertion 실패를 확인한다.
|
||||||
|
- [x] **GREEN:** 다섯 service 경로에서 완료 shortcut, 외부 검증 호출, 결제·잔액 기록보다 먼저 charge owner ID와 요청 `memberId`를 비교한다. 기존 오류 key를 유지한다.
|
||||||
|
- [x] **GREEN 확인:** 위 focused 명령과 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest'`가 성공하고 side effect assertion이 모두 통과함을 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 검증에서 생성한 중복만 정리한다. 두 focused 테스트와 `ChargeServiceTest`를 재실행하고 실제 명령·결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
**P1-T1 Progress, 2026-11-03 KST: 구현·검증 및 독립 리뷰 완료**
|
||||||
|
|
||||||
|
- 무엇/왜: 다섯 charge 잠금 조회 직후 `charge.member?.id != memberId`를 검사해 회원 잠금, 완료 shortcut 및 provider 접근보다 먼저 기존 오류 key로 거부한다. `isFirstCharged`, DTO, webhook, 가격·스키마는 변경하지 않았다.
|
||||||
|
- 테스트: 실제 Spring service transaction proxy와 H2 저장소를 사용한다. fixture를 별도 transaction으로 commit하고 서비스 호출 뒤 새 transaction에서 상태·결제 metadata·양 회원의 6종 잔액·charge/event job 수를 비교한다. REQUEST 5건, COMPLETE 5건, 본인 완료 replay 5건이다.
|
||||||
|
- 네트워크 차단: OkHttp/Google은 호출 시 AssertionError를 던지는 대역이다. 직접 생성하던 Bootpay 두 곳은 동일 인자로 생성하는 `internal createBootpay` 하나만 추출해 서비스 spy에서 차단 대역을 주입했다. 실제 PG gateway REQUEST로 RED를 입증했으며 provider 및 event service 호출 0회를 GREEN에서 검증한다. 저장소와 transaction manager는 대역이 아니다.
|
||||||
|
- 실제 실행 환경: 모든 명령 앞에 `export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/payment-ulw.7P2uSv/implement.gayCgD NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`를 실행하고 Gradle에 `--offline --console=plain`을 더했다. 아래 로그 경로는 이 TMPDIR 기준이다.
|
||||||
|
- RED: 위 두 method focused 명령은 exit 1, 10건 중 10건 실패(`t1-red.log`). COMPLETE 5건은 SodaException 미발생, REQUEST 5건은 차단 provider 접근 및 호출 0회 assertion 위반이다. 먼저 수행한 COMPLETE-only RED도 exit 1, 5/5 실패(`t1-red-completed.log`)다. 초기 timeout 및 Mockito matcher 설정 오류는 RED 증거에서 제외했다.
|
||||||
|
- GREEN: 동일 focused 명령은 exit 0, 10/10 성공(`t1-green-focused.log`). 초기 lint의 신규 테스트 argument 배치 3건만 수정했다.
|
||||||
|
- 회귀 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest' --tests 'kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest' --offline --console=plain`은 exit 0, 27/27 성공(각 15/4/7/1, skip 0, failure/error 0; `t1-green-regression.log`, `t1-green-test-counts.txt`). 기존 테스트 파일은 수정하지 않았다.
|
||||||
|
- 정적 검증: `./gradlew ktlintCheck --offline --console=plain` exit 0(`t1-ktlint-green.log`). focused/회귀 실행에서 Kotlin production/test compile도 성공했다. LSP 도구는 제공되지 않아 실행하지 않았다.
|
||||||
|
- 한계/관찰: H2 + opt-in embedded Redis만 사용했으며 운영 DB·외부 결제·HTTP QA는 실행하지 않았다. 기존 coupon 통합 테스트에서도 보이는 background scheduler의 H2 `FOR UPDATE SKIP LOCKED` SQL 오류 로그와 기존 Gradle deprecation 경고는 범위 밖으로 남긴다. 테스트 결과 실패는 없다. 전체 suite/build/실제 HTTP 최종 검증은 부모의 이후 P1-GATE 범위다.
|
||||||
|
- 인계: 상세 보고서는 위 TMPDIR의 `t1-report.md`다. Task 1.1 최종 체크와 다음 Goal 이동은 부모 리뷰 이후 처리하며 P1-T2는 시작하지 않았다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 저장형 최초 유료 충전 fact**
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T2`:** 현재 charge를 제외한 동일 회원의 다른 유료 이력을 조회해 최초 값을 REQUEST -> COMPLETE 시 한 번 저장하고 webhook에서도 같은 규칙으로 기록한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료, schema-first 배포 및 MySQL nullable 타입 확인 절차가 문서에 명시됨. 실제 schema 배포는 이 Task 승인 범위 밖이다.
|
||||||
|
- **완료 증거:** history truth table, `flush/clear/reload`, rollback, 동시 완료 테스트와 웹훅-first 후 verify 재조회가 PRD PAY-003을 충족한다. 승인된 구현 작업에서 SQL 파일을 작성하고 정적 검사와 내용·타입 정합성 검토를 완료한다. 이는 MySQL 실행이나 런타임 스키마 증거가 아니다.
|
||||||
|
- **범위 밖:** 기존 완료 null 값 backfill, Payment 중복 flag, timestamp/ID 순서 기반 우선권, SQL 실행·배포.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`
|
||||||
|
- Create: `docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepositoryTest.kt` (신규, `@DataJpaTest(properties = ["spring.cache.type=none"])`, 전역 Redis initializer 금지)
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt`
|
||||||
|
- Reference only: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundService.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `P1-T1`의 ownership guard; 기존 charge lock 다음 member lock 규칙.
|
||||||
|
- Produces: `Charge.isFirstCharged: Boolean? = null`; repository 존재 조회 `existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean`; 기존 완료 DTO의 `isFirstCharged`에는 `charge.isFirstCharged == true`를 매핑한다. 위 경로의 SQL 파일에는 `charge.is_first_charged` nullable 단일 컬럼 ADD와 `COMMENT`만 작성한다. 기존 조회 제거는 이 변경으로 orphan이 확인된 경우에만 하며 다른 count consumer는 보존한다.
|
||||||
|
|
||||||
|
**P1-T2 사전 기술 판정:**
|
||||||
|
|
||||||
|
- Ruling: 여섯 완료 writer(`verify`, `verifyHecto`, `payverseVerify`, `appleVerify`, `processGoogleIap`, `payverseWebhook`)에만 `READ_COMMITTED`를 적용한다. MySQL REPEATABLE READ에서 member 잠금 전 lazy payment 조회가 만든 오래된 snapshot을 이후 이력 조회가 재사용하는 위험을 없앤다. 이력 행 추가 잠금은 charge -> member 순서와 충돌할 수 있어 사용하지 않는다. 기존 controller는 외부 transaction 없이 서비스 proxy를 호출한다. 전역 isolation·생성·쿠폰·환불 경계는 변경하지 않는다.
|
||||||
|
- Ruling: SQL 컬럼은 Hibernate 5.6.15 `MySQL8Dialect`의 기본 Boolean 매핑과 일치하는 `BIT(1) NULL`로 작성한다. 개념 예시의 `BOOLEAN`(MySQL TINYINT 별칭) 대신 매핑에 맞춘다. `@Column(name = "is_first_charged")`를 명시하고 변환기·전역 dialect 변경은 하지 않는다. 정적 dialect 근거이며 운영 MySQL 적용 증거는 아니다.
|
||||||
|
- 범위 관찰: `can/charge/temp/ChargeTempService.verify`도 PG 완료를 기록하지만 이번 승인된 다섯 API 및 웹훅 밖이다. 그 경로의 운영 사용·동시 실행은 미확인이고 수정하지 않는다. 동시성 보장은 이번 여섯 writer 사이에 한정하며 temp 경로까지 전역 보장이라고 표현하지 않는다.
|
||||||
|
|
||||||
|
- [x] **RED:** repository `shouldFindOnlyOtherSameMemberPaidChargeOrReturnedCancellation`에 네 gateway, `(CHARGE, COMPLETE)`, `(CANCEL, RETURN)`, current-charge exclusion 및 `EVENT + COMPLETE` 제외 케이스를 둔다. service `shouldPersistFirstFactOnlyForFirstPaidCompletion`, `shouldPreserveLegacyNullOnReplayAndPersistFactAcrossRefund`, `shouldPersistFactWhenPayverseWebhookCompletesFirst`, `shouldSetExactlyOneFirstFactForConcurrentCharges`를 추가한다. 과거 완료 null은 재생에서도 null 유지 후 응답 false여야 한다. 동시성 test는 분리된 committed transaction, latch, timeout을 사용하고 sleep이나 mock-only lock 주장으로 대체하지 않는다.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeRepositoryTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldPersistFirstFactOnlyForFirstPaidCompletion'` 실행 결과에서 의도한 mapping/query assertion 실패를 확인한다.
|
||||||
|
- [x] **GREEN:** nullable `is_first_charged`를 매핑하고 다른 charge의 이력에 대한 exists 판정을 구현한다. 현재 charge를 제외하고 gateway 4종 전체에서 두 qualifying pair만 포함한다. verify writers와 Payverse webhook은 charge 잠금 후 member 잠금 내부에서 최초 판정과 최초 상태/잔액 전환을 수행하며 실패·rollback이면 marker도 함께 rollback한다. `completeResponse`와 Payverse 완료 DTO의 `isFirstCharged`는 저장값 `== true`로 매핑한다. webhook signature check와 charge -> member lock order를 유지한다. 비활성화된 오래된 isFirstCharged 조회는 실제 소비처가 없을 때만 정리한다.
|
||||||
|
- [x] **GREEN 확인:** 위 focused 명령과 다음 history assertions를 통과시킨다: mixed gateways, legacy null, true/false 저장 후 flush/clear/reload, CANCEL+RETURN, 이벤트·쿠폰·admin·ads 제외, out-of-order 생성일, 기존 유료 이력 없는 회원의 두 동시 완료 중 정확히 한 true, 기존 유료 이력이 있으면 둘 다 false, 후속 구매·환불 후 기존 marker 불변, webhook-first replay. 실패 transaction에는 marker 없음.
|
||||||
|
- [x] **REFACTOR:** 새 query와 first-transition 처리를 최소로 정리한다. `ChargeRepositoryTest`, `ChargeServiceIntegrationTest`의 이력·동시성 테스트와 `AdminChargeRefundServiceTest`를 재실행하고 H2로 입증된 범위와 MySQL 미검증 범위를 구분해 Progress에 기록한다.
|
||||||
|
- [x] **SQL 파일 작성 및 정적 검토:** `docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql`에는 nullable `ADD COLUMN`과 컬럼 `COMMENT`만 작성한다. `DEFAULT FALSE`, backfill, `UPDATE`, `DROP`은 포함하지 않으며 entity column name과 type 의미의 정합성을 검토한다. `Boolean?`의 실제 MySQL type은 확인 전까지 확정하지 않고, 파일 정적 검사와 별도로 사람 검토를 기록한다.
|
||||||
|
- [x] **SQL 정적 검사:** 미래 구현 시 `GIT_MASTER=1 git diff --no-index --check -- /dev/null docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql`을 실행한다. 이 검사는 공백 오류만 확인하며 SQL 실행, MySQL 타입 호환성 또는 런타임 스키마를 증명하지 않는다. 해당 DDL의 실제 적용과 운영/공유 DB 검증은 별도 운영 승인 대상이다. 격리된 테스트 DB를 사용하는 기존 통합 테스트는 승인된 구현·테스트 범위에서 수행하되 운영 검증과 구분한다.
|
||||||
|
|
||||||
|
**P1-T2 Progress, 2026-11-03 KST: 구현·focused 검증 및 독립 리뷰 완료**
|
||||||
|
|
||||||
|
- 무엇/왜: nullable `Charge.isFirstCharged`를 명시 컬럼에 매핑하고, 회원 잠금 아래 최초 REQUEST 완료 시에만 기록한다. 기존 최초 count 조회만 scalar exists로 교체했으며 다른 count 조회는 그대로다. 여섯 완료 writer에만 `READ_COMMITTED`를 적용하고 webhook도 member 잠금 뒤에 상태를 변경한다. 응답은 저장값 `== true`이며 Payverse도 기존 `completeResponse`를 재사용한다.
|
||||||
|
- RED 실제 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeRepositoryTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldPersistFirstFactOnlyForFirstPaidCompletion' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldReplayOwnCompletedChargeWithoutMutation' --offline --console=plain`은 exit 1, 11건 실패다. 유효한 RED는 mapping 부재 1건, 성공 완료 뒤 persistent attribute 부재 4건, legacy 완료 null 응답이 false 대신 true인 5건으로 총 10건이다. PG 1건은 provider double의 BigDecimal scale 불일치로 인한 fixture 실패이므로 RED 증거에서 제외하고 DB 가격 scale에 맞췄다. query truth matrix 자체의 RED 실행을 주장하지 않는다.
|
||||||
|
- 최종 GREEN/lint 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeRepositoryTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest' ktlintCheck --continue --offline --console=plain`은 exit 0, 169/169 성공(repository 110, service integration 52, refund 7; skip/failure/error 0), `ktlintCheck` 성공이다. production/test Kotlin compile도 통과했다. 중간 신규 fixture nickname 누락과 lint argument 배치 오류는 수정했으며 RED로 세지 않는다.
|
||||||
|
- 검증 범위: repository의 105개 상태/gateway 조합, nullable 3종 flush/clear/reload, current/다른 회원 제외 및 역순 생성일을 검증했다. 실제 service proxy를 통해 다섯 성공 경로, legacy null, mixed gateway, 완료/환불 이력, 후속 구매 replay, 실제 관리자 환불, PG/Hecto/Apple RETURN 재검증의 null/true/false 보존, webhook-first와 실패 서명, 여섯 writer의 flush 뒤 rollback을 검증했다.
|
||||||
|
- 동시성: fixture만 `TransactionTemplate`으로 commit한다. 서로 다른 두 service 호출은 각자의 실제 proxy transaction 안에서 service spy의 prelude가 실제 charge 잠금과 lazy payment read를 수행한 뒤 latch로 합류하고 real method를 실행한다. 외부 worker transaction에 service를 합류시키거나 transaction manager/repository를 mock하지 않는다. 기존 이력 없음/있음에서 true 개수 1/0, 두 완료와 잔액을 확인했다. 동일 charge 동시 replay도 한 번만 지급한다. await/get/종료에 timeout을 두고 sleep은 없다. provider 대역 및 webhook event 호출 내부의 `TransactionSynchronizationManager`로 실제 `READ_COMMITTED`도 확인했다.
|
||||||
|
- SQL: 두 줄의 nullable `BIT(1) NULL COMMENT` ADD만 작성했다. default/backfill/update/drop은 없다. `shouldMapFirstFactAsPersistentAttribute`는 엔티티 컬럼 이름·nullable 및 로컬 Hibernate 5.6 `BooleanType.INSTANCE.sqlType()`에 대한 `MySQL8Dialect.getTypeName(...) == "bit"`를 확인했다. 위 SQL 전용 `git diff --no-index --check`와 `git diff --check`는 exit 0이다. 파일 내용의 작성자 자체 정적 검토이며 사람/독립 승인이나 운영 MySQL 타입·DDL 실행 증거는 아니다.
|
||||||
|
- 환경/한계: 모든 Gradle 실행은 `$TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/payment-ulw.7P2uSv/first-marker.k2Y93W` 및 그 하위 Node/Bun cache, `JAVA_TOOL_OPTIONS=-Djava.io.tmpdir=$TMPDIR`를 사용했다. 실제 provider/운영 DB 접속은 없다. H2 결과는 MySQL snapshot/lock 검증이 아니며 `ChargeTempService.verify`는 승인 범위 밖으로 보존했다. 기존 H2 VALUE 테이블 DDL 경고, scheduler의 `SKIP LOCKED` SQL 오류 로그, Gradle deprecation은 테스트 실패 없이 남아 있다.
|
||||||
|
- 인계: 위 TMPDIR의 `t2-report.md`, `t2-red.log`, `t2-green-committed-proxy.log`가 상세 증거다. LSP 도구는 없어 실행하지 않았고 compile/lint로 대체했다. 전체 suite/build, HTTP/DTO/isIap, T3 및 운영 rollout은 실행하지 않았다. 이 Task 최상위 체크와 다음 Goal 이동은 부모 리뷰 뒤 처리한다.
|
||||||
|
|
||||||
|
- [x] **Task 1.3: 완료 응답과 gateway 표식**
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T3`:** 서비스 완료 DTO와 기존 tracking 결과를 다섯 HTTP 완료 응답으로 전달하고 저장된 gateway로 IAP를 표시한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T2` 완료. 이 Task와 다음 service-integrated Task는 순차 실행한다.
|
||||||
|
- **완료 증거:** controller 통합 HTTP serialization에서 다섯 경로의 공통 envelope·응답 필드와 생성/webhook의 불변 shape를 검증한다.
|
||||||
|
- **범위 밖:** 새로운 요청/응답 외 필드, 실결제 연동, 가격 원천 수정, AppsFlyer/Firebase 전송과 dedup.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt` (신규, `@SpringBootTest`, 외부 provider는 bounded test double)
|
||||||
|
- Regression: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `P1-T2`가 저장한 `Charge.isFirstCharged`와 기존 `ChargeCompleteResponse(price, currencyCode, isFirstCharged)` 작성 경로.
|
||||||
|
- Produces: `ChargeCompleteResponse(price: BigDecimal, currencyCode: String, isFirstCharged: Boolean, isIap: Boolean)`; controller 반환의 `ApiResponse.data`가 이 response다. IAP는 `paymentGateway == GOOGLE_IAP || paymentGateway == APPLE_IAP`로만 도출한다.
|
||||||
|
|
||||||
|
- [x] **RED:** `shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints`, `shouldSerializeCompletionFlagsAndPreservePriceAndCurrency`, `shouldKeepCreationAndWebhookResponseShapes` 실제 HTTP 통합 테스트를 추가한다. `price=9.99` JSON number와 통화 값을 정확히 비교하고 모든 gateway 값, `isFirstCharged` true/false/null, 응답 key 이름·non-null boolean 및 envelope 4 key를 검증한다. 생성 3개와 webhook은 기존 응답 shape를 고정한다.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest.shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints'`를 실행해 `data`가 Unit/null인 현재 계약으로 실패하는지 확인한다.
|
||||||
|
- [x] **GREEN:** DTO에 Boolean `isIap`을 더하고 각 완료 응답의 stored gateway에서 계산한다. 다섯 controller 경로에서 기존 service 결과를 tracking에 전달한 뒤 `ApiResponse.ok(response)`로 반환한다. creation·webhook 응답과 기존 price/currency 계산은 변경하지 않는다.
|
||||||
|
- [x] **GREEN 확인:** 세 HTTP test를 실행해 다섯 endpoint 모두 `data.price`, `currencyCode`, `isFirstCharged`, `isIap`이 있고 boolean이 JSON boolean임을 확인한다. 생성/webhook shape가 보존된다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경으로 생긴 중복만 다듬고 controller 및 service integration focused test와 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest'`를 재실행한다. partner eligibility에서 기존 tracking 호출 위치를 유지하며 legacy null은 REPEAT_PAYMENT로 처리되고, 저장된 true를 반환하는 replay는 기존처럼 FIRST_PAYMENT가 재기록될 수 있음을 확인한다. dedup이나 bonus 복원은 추가하지 않는다.
|
||||||
|
|
||||||
|
**P1-T3 Progress, 2026-11-03 KST: 구현·HTTP/focused 검증 및 독립 리뷰 완료**
|
||||||
|
|
||||||
|
- 무엇/왜: 기존 완료 DTO에 non-null `isIap: Boolean`만 추가하고 `completeResponse`에서 저장된 Google/Apple gateway 여부로 계산한다. 다섯 controller 완료 경로는 기존 tracking 호출 직후 `ApiResponse.ok(response)`를 반환한다. 금액·통화 원천, 최초 marker/소유권 로직, 생성 3개 및 webhook 구현은 변경하지 않았다.
|
||||||
|
- HTTP 테스트: 준비된 초안을 `ChargeControllerIntegrationTest`로 추가했다. `@SpringBootTest(RANDOM_PORT)`와 `TestRestTemplate`, 실제 JWT·Redis 토큰 저장소·서비스 transaction proxy·H2 repository를 사용한다. Google은 실제 신규 REQUEST 생성부터 완료까지 실행하고 다른 네 endpoint는 저장된 완료 fixture/replay를 사용한다. 외부 PG/OkHttp/Google만 차단 대역으로 대체하며, tracking 호출 관찰용 대역과 background worker 대역은 초안대로 유지한다. 서비스 결과나 transaction/repository는 mock하지 않는다.
|
||||||
|
- RED 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest.shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints' --offline --console=plain`은 exit 1, 5/5 실패다. 다섯 경로 모두 HTTP 200 `data:{}`이며 `expected: <[price, currencyCode, isFirstCharged, isIap]> but was: <[]>` assertion으로 실패했다. compile·인증·fixture 설정 실패가 아닌 실제 응답 누락이다(`t3-red.log`, `t3-red-http-evidence.log`).
|
||||||
|
- 최종 GREEN 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest' ktlintCheck --offline --console=plain`은 exit 0, 78/78 성공(HTTP 22, service integration 52, service unit 4; skip/failure/error 0), `ktlintCheck` 성공이다(`t3-verified.log`). production/test compile도 성공했으며 마지막 실행의 production compile은 UP-TO-DATE다. 중간 두 combined 실행은 테스트 78/78 통과 후 신규 테스트 줄바꿈 lint만 실패했고, 포맷 수정 후 lint-only exit 0 및 위 최종 명령 exit 0을 확인했다. 기존 로그는 보존했다.
|
||||||
|
- 실제 계약 증거: HTTP 요청 56회에서 envelope 4 key와 완료 data 4 key, JSON number `9.9900`의 정확한 9.99 값 및 USD, non-null JSON boolean, 네 gateway의 false/false/true/true를 확인했다. 저장된 true/false/null 재조회 2회에서 상태·잔액·metadata 불변과 FIRST/REPEAT tracking 전달을 확인했다. 생성 shape는 `chargeId` 또는 `chargeId + payloadJson`이며 webhook은 `{"receiveResult":"SUCCESS"}`다. 타인 소유 REQUEST/COMPLETE, 다섯 익명 요청 401, 잘못된 webhook IP/서명 404 및 정상 서명 완료·replay·verify도 통과했다. 생성/webhook 검증은 각각 `shouldKeepCreationResponseShapes`, `shouldKeepSignedWebhookResponseShapeAndRejectInvalidIpOrSignature`로 분리했다.
|
||||||
|
- 환경·정리: 모든 명령은 `TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/payment-ulw.7P2uSv/http-response.f24ToP`, 그 하위 Node/Bun cache 및 `JAVA_TOOL_OPTIONS=-Djava.io.tmpdir=$TMPDIR`를 사용했다. 최종 test JVM/Tomcat PID 22172(HTTP 61240), embedded Redis PID 22191(127.0.0.1:61237)은 종료 후 `ps`에서 없고 두 port의 LISTEN도 없다. 운영 DB·외부 provider 접속은 없다. LSP 미제공으로 compile/lint로 대체했으며 전체 suite/build/최종 리뷰는 명시된 부모 범위로 실행하지 않았다. H2 결과는 운영 MySQL 검증이 아니다. 기존 service-test scheduler의 H2 `SKIP LOCKED` 오류, Gradle 경고 및 HTTP context 종료 시 Lettuce thread 경고는 테스트 실패나 잔류 PID 없이 남아 있다.
|
||||||
|
- 인계: 위 TMPDIR의 `t3-report.md`와 `t3-green-http-counts-evidence.log`에 명령·결과·HTTP 원문·정리 증거를 기록했다. P1-T3 내부 체크만 갱신하며 Task 최상위 체크, 다음 Goal 이동 및 T4/GATE는 부모 리뷰 뒤 처리한다.
|
||||||
|
|
||||||
|
- [x] **Task 1.4: 결제 완료 통합 회귀 잠금**
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T4`:** 사용자 가시 계약과 writer 동시성·재시도·환불 불변식을 전체 영향 테스트 조합으로 고정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T3` 완료.
|
||||||
|
- **완료 증거:** 실제 저장/HTTP 응답, webhook-first replay, 소유권 실패 부수효과 및 동시 완료 테스트가 통과하고 직접 영향 feature 회귀 명령이 결과와 함께 기록된다.
|
||||||
|
- **범위 밖:** 테스트 편의를 위해 provider에 실요청, H2 동시성 결과를 MySQL 증명으로 주장, 미승인 배포·테스트 전체 suite.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepositoryTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt`
|
||||||
|
- Regression: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt`
|
||||||
|
- Regression: `src/test/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundServiceTest.kt`
|
||||||
|
- Regression: charge event tests under `src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/` (저장소 실제 class 이름에 맞는 기존 테스트만 선택)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 이전 task가 추가한 repository exists 계약, persisted nullable fact, 완료 response 및 HTTP 계약.
|
||||||
|
- Produces: `P1-GATE`에서 다시 실행할 focused test 명령 집합과 제한·외부 검증 기록. production interface 추가 없음.
|
||||||
|
|
||||||
|
- **TDD 예외 사유:** 앞선 Task에서 구현·RED/GREEN 검증한 동작의 회귀 보강이다. 이미 통과하는 추가 assertion을 위해 인위적인 production 실패를 만들지 않는다.
|
||||||
|
- **대체 검증 방법:** 기존 RED/GREEN 증거와 새 회귀 assertion을 대조하고, 누락 동작을 발견한 경우 해당 실패를 기록한 뒤 범위 내 후속 회귀 수정 Goal로 처리한다.
|
||||||
|
|
||||||
|
- [x] **RED 대조:** PAY-001~PAY-005의 회귀 항목을 P1-T1~T3의 실패·통과 증거와 대조했다. 동시 완료, 기존 이력, 환불, 웹훅, 소유권, IAP, 생성 응답 보존이 모두 기존에 추가한 테스트에 포함된다.
|
||||||
|
- [x] **RED 확인 예외:** 이 Task에서는 추가 누락이나 결함이 없어 새 RED를 만들지 않았다. 위 TDD 예외 규칙대로 기존 증거를 재사용하며 인위적인 production 실패는 만들지 않았다.
|
||||||
|
- [x] **GREEN 대조:** 새 코드·테스트 추가 없이 앞선 3개 통합 테스트 클래스의 최종 상태를 검토했다. 기존 테스트 삭제·완화·skip 추가는 없다.
|
||||||
|
- [x] **GREEN 확인:** 사용자 ULW 요청에 따라 `./gradlew test build ktlintCheck --continue --offline --console=plain` 전체 실행에 직접 영향 charge/event/refund/coupon 회귀를 함께 포함했다. 339 suites, 3,112 tests, failure/error/skipped 모두 0, exit 0이다.
|
||||||
|
- [x] **REFACTOR 검토:** 추가 정리는 필요하지 않아 수행하지 않았다. 실제 서비스 proxy·분리된 transaction·bounded latch 동시성, H2/외부 provider 대역 한계와 실제 HTTP 증거를 아래 Gate 기록에 남겼다.
|
||||||
|
|
||||||
|
#### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** PRD의 요구사항, 변경 파일, 직접 영향 테스트와 배포 전 미검증 조건을 read-only로 대조해 계획 완료 여부를 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** 별도 구현 승인 이후 `P1-T1`~`P1-T4`의 구현 체크박스와 각 task Progress 증거가 모두 완료.
|
||||||
|
- **완료 증거:** 아래 검증 명령의 실제 결과, PAY 요구사항 추적표, 생성/webhook 불변 및 미검증 외부/운영 조건을 기록한다. 누락된 요구는 통과로 표시하지 않는다.
|
||||||
|
- **범위 밖:** 이 Gate에서 직접 코드 변경, test 완화, schema/앱 배포, external analytics 연결. 범위 내 결함은 후속 회귀 수정 Goal로, 범위 밖 변경은 별도 승인 뒤 처리한다.
|
||||||
|
- **TDD 예외 사유:** read-only final Gate이며 production/test 코드를 추가 변경하지 않는다.
|
||||||
|
- **대체 검증 방법:** `P1-T4` 명령 결과와 PRD §9 traceability, diff scope, checkbox/Progress를 대조한다. schema type 확인은 DB 담당자가 제공한 별도 증거가 있어야만 검증 완료로 표기한다.
|
||||||
|
|
||||||
|
- [x] `P1-T1`~`P1-T4`의 모든 체크와 검증 결과가 문서에 실제로 누적됐다.
|
||||||
|
- [x] PAY-001~PAY-005 각각에 test 및 명령 결과가 대응하고 최종 독립 리뷰가 승인 범위의 차단 사항 없이 통과했다.
|
||||||
|
- [x] 구현 승인 범위, 문서·소스 변경 범위와 스키마/애플리케이션 운영 승인 상태를 기록했다. 코드·테스트·SQL 작성은 승인 범위이고 커밋·푸시·운영 DB 적용은 수행하지 않았다.
|
||||||
|
- [x] H2 동시성 결과를 운영 MySQL 증거로 승격하지 않고, schema type·schema-first·구 writer 종료 조건을 OPS-001로 남겼다.
|
||||||
|
|
||||||
|
**Gate 명령 예시, 구현 승인 뒤에만 실행:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeRepositoryTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest'
|
||||||
|
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.can.charge.event.*'
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** focused tests 성공과 charge event 관련 직접 영향 회귀 성공. 위 조건이 전체 회귀 조건을 만족하지 않으면 전체 `test`/`build`를 관성적으로 실행하지 않고 생략 근거와 대체 검증을 기록한다. H2 test 통과는 MySQL 실제 컬럼 타입 및 운영 lock 격리를 검증하지 않는다.
|
||||||
|
|
||||||
|
### Phase 2: 완료 응답 chargeId 확장
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 기존 충전 ID 반환**
|
||||||
|
|
||||||
|
**Goal 실행 `P2-T1`:** 다섯 완료 API가 기존 네 필드와 실제 저장된 `Charge.id`를 `chargeId: Long`으로 반환한다.
|
||||||
|
|
||||||
|
- **시작 조건:** 사용자 chargeId 추가 구현 승인, Phase 1 완료. 기존 완료 증거와 커밋은 유지한다.
|
||||||
|
- **완료 증거:** 기존 실제 HTTP 테스트가 ID 누락으로 RED, 추가 후 모든 완료 경로·재조회·웹훅 후 verify에서 저장 ID와 일치한다.
|
||||||
|
- **범위 밖:** DB·SQL·결제 처리·격리 수준·Jenkins/Gradle 메모리·Google 재시도 정책·SDK 연동 변경, 커밋·푸시.
|
||||||
|
- **Files:** `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt`, 현재 PRD·계획.
|
||||||
|
- **Interfaces:** `ChargeCompleteResponse(chargeId: Long, price: BigDecimal, currencyCode: String, isFirstCharged: Boolean, isIap: Boolean)`; 기존 `completeResponse(charge)`가 저장된 ID를 매핑한다.
|
||||||
|
- [x] **RED:** HTTP 공통 검증을 다섯 key로 확장하고 예상 Charge.id의 정확한 정수 일치를 검사한다. Google은 실제 생성된 행에서 기대 ID를 읽는다. 재조회 및 웹훅 후 verify에서도 같은 ID를 검사한다.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest.shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints' --offline --console=plain`에서 기존 응답의 chargeId 누락 assertion 실패를 확인한다.
|
||||||
|
- [x] **GREEN:** DTO에 `chargeId: Long` 한 필드, 공통 mapper에 `chargeId = charge.id!!` 한 매핑만 추가한다.
|
||||||
|
- [x] **GREEN 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.*' --offline --console=plain` 및 `./gradlew ktlintCheck --offline --console=plain`로 응답·연결된 충전 회귀를 검증한다.
|
||||||
|
- [x] **REFACTOR:** 추가 추상화 없이 변경 diff와 PRD 현재 계약을 대조한다. 실제 HTTP ID·나머지 필드·생성/webhook 불변 증거를 기록한다.
|
||||||
|
|
||||||
|
#### Phase 2 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P2-GATE`:** chargeId 후속 변경의 계약·회귀·범위를 최종 검증한다.
|
||||||
|
|
||||||
|
- **시작 조건:** P2-T1 완료.
|
||||||
|
- **완료 증거:** 저장 ID와 응답 ID의 실제 HTTP 일치, Kotlin compile/lint, 요청된 전체 회귀 및 독립 리뷰 결과.
|
||||||
|
- **범위 밖:** Jenkins exit 137의 원인 추정 수정, 운영 DB·실결제·클라이언트 코드 변경.
|
||||||
|
- **TDD 예외 사유/대체 검증:** read-only Gate다. P2-T1의 RED/GREEN과 diff를 검토하고 ULW 전체 검증 명령 `./gradlew test build ktlintCheck --continue --offline --console=plain`을 실행한다. Jenkins 환경을 재현하거나 해결했다고 주장하지 않는다.
|
||||||
|
- [x] 전체 테스트·빌드·lint 및 실제 HTTP evidence를 확인한다.
|
||||||
|
- [x] 독립 리뷰와 작업용 리소스 정리를 완료하고 문서를 갱신한다.
|
||||||
|
|
||||||
|
## 데이터베이스 rollout 참고, 실행 금지
|
||||||
|
|
||||||
|
Task 1.2에서 [20261103_charge_first_charged_ddl.sql](20261103_charge_first_charged_ddl.sql)을 작성했다. 아래 SQL은 해당 파일의 내용이다. Hibernate dialect의 정적 매핑은 테스트했지만 실제 MySQL 환경의 적용·검증은 별도 운영 승인 대상이다. 애플리케이션 배포 전에 컬럼을 먼저 반영해야 한다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE charge
|
||||||
|
ADD COLUMN is_first_charged BIT(1) NULL COMMENT '최초 유료 충전 여부, 최초 완료 전환에서만 확정';
|
||||||
|
```
|
||||||
|
|
||||||
|
NULL 과거 행은 그대로 둔다. backfill 문장은 없다. 선행 컬럼 배포 이후 신버전 writer 투입, 구 writer drain 확인 및 rollout 순서는 OPS-001에 따른 별도 운영 승인 대상이다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### P2 커밋 직전 재검증, 2026-11-03 KST
|
||||||
|
|
||||||
|
- 사용자 `/commit-with-validation` 요청으로 chargeId 후속 변경의 커밋을 승인받고 전역 공용 절차와 저장소 규칙을 확인했다.
|
||||||
|
- `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.*' ktlintCheck --offline --console=plain` exit 0, `BUILD SUCCESSFUL in 3m 27s`. 테스트는 실제 실행됐으며 XML 4 suites / 188 tests / failures=0 / errors=0 / skipped=0을 확인했다. compile·lint는 UP-TO-DATE다.
|
||||||
|
- `git diff --check` exit 0. DTO·공통 mapper·HTTP 회귀 테스트를 하나의 변경 단위로, PRD·계획을 계약 및 검증 기록 단위로 커밋한다.
|
||||||
|
- production/test 추가 수정이 없는 커밋 요청이므로 전체 회귀·build는 반복하지 않았다. 현재 XML/HTML은 위 focused 결과로 교체됐다. 운영 DB·실결제·클라이언트 호환성·Jenkins 환경은 이번에도 미검증이다.
|
||||||
|
|
||||||
|
### P2-T1 chargeId 후속 확장, 2026-11-03 KST
|
||||||
|
|
||||||
|
- 사용자 승인: chargeId 추가 제안에 대한 “수정하자”. 기존 PRD·계획에 PAY-006/P2-T1을 먼저 추가했으며 중복 계획 문서는 생성하지 않았다.
|
||||||
|
- RED: 위 다섯 endpoint focused 명령 exit 1, 5 tests / 5 failures / 0 errors / 0 skipped. 실제 HTTP 응답의 key가 기존 네 개여서 chargeId 포함 다섯 key assertion이 실패했다. 테스트 설정/컴파일 오류가 아니다.
|
||||||
|
- 구현: production 변경은 DTO의 `val chargeId: Long`과 공통 `completeResponse`의 `chargeId = charge.id!!` 두 줄뿐이다. 결제·잠금·최초 판정·DB·SQL·CI 설정은 변경하지 않았다.
|
||||||
|
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.*' ktlintCheck --continue --offline --console=plain` exit 0, 1m 41s, 4 suites / 188 tests / failures=0 / errors=0 / skipped=0. Kotlin compile와 ktlintCheck를 실행했다. LSP 도구는 제공되지 않는다.
|
||||||
|
- 실제 HTTP: 기존 RANDOM_PORT/TestRestTemplate/JWT/저장소 흐름에서 모든 chargeId는 JSON 정수이며 fixture 또는 Google 생성 행의 ID와 일치했다. true/false/null replay는 같은 ID를 두 번 반환했고 Payverse signed webhook 후 verify도 원래 chargeId를 반환했다. 생성/webhook 응답과 기존 네 필드는 그대로다.
|
||||||
|
- 현재 증거: `build/test-results/test/TEST-kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest.xml`의 `HTTP EVIDENCE`. 전체 검증 및 독립 리뷰는 P2-GATE에서 이어서 수행한다. Jenkins exit 137은 별도 미해결 환경 조사이며 로컬 성공을 Jenkins 해결로 간주하지 않는다.
|
||||||
|
|
||||||
|
### P2-GATE 후속 최종 검증, 2026-11-03 KST
|
||||||
|
|
||||||
|
- 전체 명령: `./gradlew test build ktlintCheck --continue --offline --console=plain` exit 0, `BUILD SUCCESSFUL in 20m 8s`. 최종 XML 341 suites / 3,128 tests / failures=0 / errors=0 / skipped=0. `test`는 실제 실행됐고 lint·compile은 직전 성공 결과의 UP-TO-DATE를 포함한다.
|
||||||
|
- 실제 HTTP 증거: 기존 22개 HTTP 테스트에서 56회 POST했으며 Google 신규 생성 행, 기존 charge 재조회 및 Payverse 웹훅 선완료 뒤 verify의 ID가 기대 저장 ID와 정확히 일치했다. 예: `/charge/payverse/verify`는 `data={"chargeId":12,"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":false}`를 반환했다. 같은 PG charge 재조회 두 번은 모두 chargeId 25를 반환했다. 금액·통화·두 boolean 및 생성/webhook shape는 기존 assertion을 유지했다.
|
||||||
|
- 독립 리뷰: 실제 5개 파일 diff, 공통 생성자 및 HTTP assertion·문서 계약을 검토해 Spec PASS / Quality PASS, 차단 사항 없음. 최초 리뷰 결과 전달 오류 후 재검토 응답을 직접 수신해 확인했다. 리뷰 이후 production/test 변경은 없다.
|
||||||
|
- 변경 범위: production 두 줄, 기존 HTTP 테스트의 ID 검증 확장, 기존 PRD·계획 갱신만 포함한다. 새 DB 컬럼·SQL·결제 처리·Jenkins/Gradle 메모리 설정 변경은 없으며 커밋·푸시는 하지 않았다.
|
||||||
|
- 한계: LSP 도구 미제공으로 Kotlin compile·ktlint를 사용했다. 외부 provider는 테스트 대역이며 운영 DB·실결제·클라이언트 호환성·Jenkins 메모리 오류는 미검증이다. 전체 XML/HTML은 `build/test-results/test/`, `build/reports/tests/test/`에 있으며 후속 실행 시 덮어써질 수 있다.
|
||||||
|
- 정리·범위 확인: test worker·Redis 잔류 없음, 작업 전용 Gradle PID 41633 및 자식 Kotlin PID 41668 TERM 종료 후 ps 부재 확인. 전용 `charge-id.qGHVkX` 경로·하위 캐시·임시 로그와 notepad를 제거했다. 공유 캐시/다른 프로세스는 유지했다. `git diff --check` exit 0이며 수정 파일은 DTO·mapper·기존 HTTP 테스트·PRD·계획 5개뿐이다.
|
||||||
|
|
||||||
|
### 커밋 직전 재검증, 2026-11-03 KST
|
||||||
|
|
||||||
|
- 사용자 `/commit-with-validation` 요청으로 전역 공용 절차와 저장소 커밋 규칙을 확인했다.
|
||||||
|
- `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.*' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest' --offline --console=plain` exit 0, 195건 성공(failures/errors/skipped=0), 58초다. 현재 XML과 HTML 보고서는 이 focused 실행 결과이며 앞선 전체 회귀 보고서를 대체한다.
|
||||||
|
- `./gradlew ktlintCheck --offline --console=plain` exit 0(UP-TO-DATE), `git diff --check` 및 커밋별 staged 공백 검사를 수행한다.
|
||||||
|
- 이번 요청은 검증된 변경의 커밋이며 production/test 추가 수정이 없어 전체 `test`/`build`는 반복하지 않았다. 결제·관리자 환불 직접 영향 회귀로 확인했으며 운영 MySQL·실결제·클라이언트 호환성은 검증하지 않았다.
|
||||||
|
|
||||||
|
### P1-T4 / P1-GATE 최종 판정, 2026-11-03 KST
|
||||||
|
|
||||||
|
- 상태: 승인 범위 구현·검증·독립 리뷰 완료. 운영 배포 승인이나 실결제 검증을 뜻하지 않는다.
|
||||||
|
- 전체 실행: `./gradlew test build ktlintCheck --continue --offline --console=plain` exit 0, `BUILD SUCCESSFUL in 13m 34s`. 첫 실행은 600초 도구 제한으로 중단됐고 실패 테스트를 보고하지 않았다. 제한을 1,800초로 늘린 재실행이 완료됐으며 최종 결과는 재실행 XML만 집계했다.
|
||||||
|
- 집계: `build/test-results/test/TEST-*.xml` 339 suites, tests=3112, failures=0, errors=0, skipped=0. `test`는 실제 실행됐고 compile/lint/bootJar 등은 이전 성공 출력의 UP-TO-DATE 결과를 포함한다. 테스트 무력화·건너뜀은 추가하지 않았다.
|
||||||
|
- 직접 영향 증거: 저장소 110건, 서비스 통합 52건, HTTP 통합 22건 모두 전체 suite 안에서도 통과했다. 기존 쿠폰·환불·충전 이벤트 회귀도 포함됐다. 부모가 앞서 별도로 재실행한 저장소·서비스 162건도 exit 0이었다.
|
||||||
|
- 실제 표면 QA: 부모의 전체 실행에서 `ChargeControllerIntegrationTest`가 실제 Tomcat 62247 포트에 `TestRestTemplate`으로 56회 POST했다. 실제 JWT/Redis 인증, 서비스 proxy, H2 저장소를 통과하고 외부 provider만 대역으로 차단했다. MockMvc 또는 서비스 반환값 stub 결과가 아니다.
|
||||||
|
- HTTP 원문/테스트 증거: `build/test-results/test/TEST-kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest.xml`의 `HTTP EVIDENCE` 56개와 `build/reports/tests/test/index.html`. 아래 발췌는 실제 성공 응답이다. XML은 후속 테스트 실행으로 덮어써질 수 있으므로 핵심 결과를 이 문서에도 보존한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /charge/verify 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":false}
|
||||||
|
POST /charge/verify/hecto 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":false}
|
||||||
|
POST /charge/payverse/verify 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":false}
|
||||||
|
POST /charge/apple/verify 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":true}
|
||||||
|
POST /charge/google 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":true}
|
||||||
|
POST /charge/google 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":false,"isIap":true}
|
||||||
|
POST /charge/payverse/webhook 200 {"receiveResult":"SUCCESS"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 오류·보존 QA: 타인 소유 REQUEST/COMPLETE는 기존 success=false 및 결제 정보 오류를 반환하고 잔액/provider/event 변경이 없다. 익명 완료 요청 5종은 401, 잘못된 webhook IP/서명은 404다. 생성 응답·true/false/null 재조회·partner tracking 조건과 재호출 분류도 검증됐다.
|
||||||
|
- 독립 리뷰: T1 및 T2 단계별 spec/quality PASS, 최종 T3+전체 변경 리뷰 PASS. 최종 리뷰는 production 5파일 diff, 신규 테스트 3개 전체, SQL 및 실제 RED/GREEN/HTTP 로그를 읽고 차단 사항 없음을 확인했다. 최종 리뷰 이후 production/test 변경은 없다.
|
||||||
|
- 구현 세부 판정: Bootpay 생성식 2곳은 실결제 없는 테스트를 위해 기존 생성자를 그대로 호출하는 internal 함수로만 모았다. 여섯 완료 writer의 READ_COMMITTED와 BIT(1) DDL은 위 P1-T2 판정을 따른다. 추가 모듈·의존성·전역 설정은 만들지 않았다.
|
||||||
|
- 미검증: 도구에 LSP 진단 기능이 없어 Kotlin compile·ktlint로 대신 확인했다. 운영 MySQL snapshot/DDL, 실제 provider·tracking 비동기 적재, 클라이언트 호환성/정확한 분석 가격은 미검증이다. `ChargeTempService.verify`와 구 writer 혼재까지 동시성 보장을 확대하지 않는다.
|
||||||
|
- 기존 환경 관찰: H2 VALUE/`SKIP LOCKED` scheduler SQL 오류 로그, Gradle deprecation 및 일부 종료 경고는 기존 환경에서 보이며 테스트 failure/error는 없다. 이 작업과 무관한 설정은 수정하지 않았다.
|
||||||
|
- QA 종료 확인: 위 XML에 Tomcat stop과 Hikari shutdown이 있으며 전체 실행 종료 후 test worker/redis-server 프로세스는 없다. 실행용 임시 로그는 핵심 증거를 이 문서에 옮긴 뒤 작업 종료 시 제거한다. 운영 DB·실결제·배포·커밋·푸시는 수행하지 않았다.
|
||||||
|
- 공백 검사 기록 정정: 이전 단계의 `git diff --no-index --check -- /dev/null`에 기재한 exit 0은 종료 코드 확인이 부정확했다. 최종 직접 집계에서는 신규 파일 6개 모두 exit 1, 진단 출력 없음이었다. 이는 `--no-index`의 파일 차이 종료 코드이며 공백 오류 증거가 아니다. 추적 파일의 `git diff --check` 및 별도 trailing-whitespace 검색과 함께 판정하며 과거 기록을 삭제하지 않고 정정한다.
|
||||||
|
- 소유 프로세스 정리: 작업 전용 java.io.tmpdir의 Gradle PID 14157/19040/20991과 그 자식 Kotlin PID 14225를 TERM 종료했다. `ps -p 14225,14157,19040,20991` 재조회는 header만 출력(exit 1)해 종료를 확인했다. 관련 HTTP 포트 62247/61240 및 Redis 포트 61237도 listener가 없다. 다른 workspace의 공유 Gradle 프로세스와 캐시는 건드리지 않았다.
|
||||||
|
- 임시 경로 정리: `payment-ulw.7P2uSv` 실행 루트와 하위 task 로그·Node/Bun/Java 임시 캐시, 별도로 확인한 작업 소유 Redis 임시 경로 두 개 및 ULW notepad를 제거했다. 경로 부재를 재조회해 확인했으며, 위 단계의 TMPDIR 링크는 실행 당시 역사 기록이다. 핵심 결과는 이 문서에 보존했고 최종 원문 테스트 증거는 `build/test-results/test/`와 `build/reports/tests/test/`에 남겨 두었다.
|
||||||
|
|
||||||
|
### 구현 시작, 2026-11-03 KST
|
||||||
|
|
||||||
|
- 사용자 구현 승인: “문서대로 진행”, ULW. 기존 test 브랜치 workspace에서 시작하며 자동 worktree를 만들지 않는다.
|
||||||
|
- 시작 상태: 기존 사용자 문서 두 개만 untracked이며 소스 변경은 없다. 공유 ChargeService 변경은 순차 진행한다.
|
||||||
|
- 기준 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest' --console=plain` 성공(exit 0). 기존 쿠폰·환불 동작을 유지한다.
|
||||||
|
- 시나리오: 다섯 완료 HTTP 응답, 타인 소유 REQUEST/COMPLETE 차단, 첫 결제·환불·legacy null·웹훅·재조회, 동시 완료에서 한 번만 최초 판정, 기존 기능 회귀.
|
||||||
|
- 검증 계획: focused RED/GREEN부터 전체 test/build/ktlint와 격리 실제 HTTP QA로 확장한다. 외부 결제 호출은 테스트 대역으로 차단한다.
|
||||||
|
- 현재 도구에 LSP 진단 기능이 없어 Kotlin compile·ktlint로 확인하며 LSP 검증했다고 보고하지 않는다. 운영 MySQL 검증과 클라이언트 확인은 별도다.
|
||||||
|
|
||||||
|
### 문서 작성, 2026-11-03 KST
|
||||||
|
|
||||||
|
- 상태: 문서 초안 완료, 구현 대기.
|
||||||
|
- 무엇을: 신규 PRD와 단일 Phase 계획의 기준 경로·요구 traceability·미실행 제약을 기록했다.
|
||||||
|
- 어떻게: 요구된 프로젝트 가이드, 샘플, 실제 결제 service/controller/entity/repository, payment enum/gateway, API envelope, refund/event 경로 및 관련 테스트를 읽었다.
|
||||||
|
- 검증: 두 문서 전체를 읽고 PAY-001~PAY-005, 응답 필드·판정 규칙·Task 의존성을 대조했다. 두 파일 각각의 `git diff --no-index --check -- /dev/null` 검사는 exit 0이다. `git status --short --untracked-files=all`에서 이 디렉터리의 신규 Markdown 두 파일만 확인했다.
|
||||||
|
- 생략: 문서 전용 변경이므로 Gradle 테스트/build/task 및 DB 작업은 실행하지 않았다. 계획의 코드 검증 명령은 미래 구현 단계의 예시이며 실행 결과가 아니다. 구현 체크박스는 모두 미완료로 유지한다.
|
||||||
|
- 남은 항목: 별도 구현 승인, 운영 MySQL type·rollout 확인, 클라이언트/분석 금액 원천 확인은 미완료 외부 조건이다.
|
||||||
|
- 다음 행동: 구현 승인이 있을 때만 `P1-T1`을 시작한다.
|
||||||
|
|
||||||
|
### 문서 범위 수정, 2026-11-03 KST
|
||||||
|
|
||||||
|
- 무엇을: PRD PAY-003 및 DEC-005와 계획 Task 1.2를 일치시켜, 구현 승인 뒤 nullable 최초 충전 컬럼 SQL 파일 작성이 구현 산출물임을 명시했다. SQL 적용·배포·운영 DB 확인은 별도 운영 승인으로 유지한다.
|
||||||
|
- 범위: 수정 파일은 이 PRD와 `plan-task.md`뿐이다. SQL 파일은 생성하지 않았고 코드·테스트는 변경하지 않았다.
|
||||||
|
- 검증 상태: 이번 수정에서는 테스트·빌드·DB 작업을 실행하지 않았다. 구현 체크박스는 모두 미완료이며 이 기록은 PASS 또는 선행 검증 결과를 추가하지 않는다.
|
||||||
|
- 후속 문서 검증: PRD와 계획의 SQL 작성 범위·산출물 경로·승인 경계를 대조했다. 두 Markdown의 `git diff --no-index --check -- /dev/null`은 각각 exit 0이며, 실제 SQL·소스 파일 생성이나 변경은 없다. 운영 MySQL 타입 호환성과 SQL 실행은 검증하지 않았다.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# 결제 완료 응답 확장 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | chargeId 후속 확장 포함 구현·검증 완료 |
|
||||||
|
| 작성일 | 2026-11-03 (KST) |
|
||||||
|
| 최종 수정일 | 2026-11-03 (KST) |
|
||||||
|
| 대상 제품 | Sodalive 결제 완료 응답과 최초 유료 충전 판정 |
|
||||||
|
| 작성자·결정권자 | 제품 요청자 |
|
||||||
|
| 관련 API Contract | 이 문서 §API 계약 (별도 contract 파일 없음) |
|
||||||
|
| 관련 구현 계획 | [plan-task.md](plan-task.md) |
|
||||||
|
| 관련 review | 없음 |
|
||||||
|
|
||||||
|
## 1. 개요와 문제
|
||||||
|
|
||||||
|
서버 서비스는 결제 완료 정보와 최초 충전 여부를 이미 계산하지만, 다섯 완료 경로의 HTTP 응답은 `ApiResponse.data`에 `Unit`을 반환한다. 최초 여부도 응답 시점의 전체 완료 행 개수에 기대므로 충전·환불·동시 완료의 의미를 안정적으로 보존하지 못한다. 이 작업은 완료 응답 계약을 전달하고, 최초 유료 충전 사실을 한 번 확정해 재시도 후에도 유지한다.
|
||||||
|
|
||||||
|
## 2. 목표 및 제외
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
|
||||||
|
- 다섯 결제 완료 경로에서 동일한 `ChargeCompleteResponse`를 반환한다.
|
||||||
|
- 소유 회원만 해당 충전을 확인할 수 있게 하며, 회원 잠금 안에서 최초 유료 충전을 한 번 판정한다.
|
||||||
|
- 최초 여부와 IAP 여부를 저장된 결제 정보로 응답하고 기존 금액·통화 원천과 추적 호출 조건·위치를 유지한다. 추적의 FIRST/REPEAT 분류는 저장된 최초 판정값을 따른다.
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 충전 생성 endpoint와 webhook의 HTTP 응답 모양 변경.
|
||||||
|
- 클라이언트 앱 변경, AppsFlyer 또는 Firebase SDK/server 연동, 이벤트 중복 제거, 첫 충전 보너스 복구.
|
||||||
|
- Apple/Google/PG 외부 가격 조회, 실제 청구 금액 재검증·가격 원천 변경, 새 transaction ID·timestamp 필드.
|
||||||
|
- Google `purchaseToken` 중복 방지/idempotency 수정, 범용 결제 보안 refactor, 그 밖의 상태·결제 경로 수정.
|
||||||
|
- 운영 SQL 실행/배포 및 운영 조치. 승인된 애플리케이션 코드·테스트·SQL 파일 작성은 완료했으며 운영 적용은 포함하지 않는다.
|
||||||
|
|
||||||
|
## 3. 확정 요구사항과 추적
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 및 수용 기준 | 계획 연결 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| PAY-001 | 확정 | `POST /charge/verify`, `/charge/verify/hecto`, `/charge/payverse/verify`, `/charge/apple/verify`, `/charge/google`가 `ApiResponse.data`에 `ChargeCompleteResponse`를 반환한다. 생성 응답과 Payverse webhook 응답은 바뀌지 않는다. | P1-T3, P1-T4 |
|
||||||
|
| PAY-002 | 확정 | `charge.member.id`와 인증 회원 ID가 다르면 완료 shortcut, provider 호출, 결제/회원 잔액 변경보다 먼저 `can.charge.invalid_payment_info`로 거부한다. `processGoogleIap`도 포함한다. Payverse webhook 서명 검증과 IP 인증은 유지한다. | P1-T1, P1-T4 |
|
||||||
|
| PAY-003 | 확정 | `Charge.isFirstCharged: Boolean? = null`을 nullable로 저장한다. 요청에서 완료로 처음 전환될 때 회원 잠금 아래 한 번만 판정한다. 같은 회원의 다른 charge 중 다음 두 쌍만 유료 이력이다: `(ChargeStatus.CHARGE, PaymentStatus.COMPLETE)`, `(ChargeStatus.CANCEL, PaymentStatus.RETURN)`. 현재 charge는 제외하고 모든 PG/PAYVERSE/APPLE_IAP/GOOGLE_IAP를 포함한다. 결과 true는 다른 유료 이력이 없음을 뜻한다. 날짜와 ID 순서로 첫 충전을 정하지 않는다. 구현 승인 후 nullable 컬럼 추가 및 `COMMENT`를 포함한 SQL 파일을 작성하되, 과거 행은 NULL로 두고 backfill하지 않는다. `Boolean?`의 실제 MySQL 컬럼 타입 호환성은 rollout 전에 확인한다. | P1-T2, P1-T4 |
|
||||||
|
| PAY-004 | 확정 | 완료 응답에 `isIap: Boolean`을 추가한다. `GOOGLE_IAP`와 `APPLE_IAP`이면 true, `PG`와 `PAYVERSE`이면 false다. 단말 OS와 무관하게 저장된 gateway만 사용한다. `price`, `currencyCode`의 타입·값 원천은 유지한다. | P1-T3, P1-T4 |
|
||||||
|
| PAY-005 | 확정 | 소유권, 최초 판정·동시성·재시도·환불·webhook, 다섯 HTTP 응답 및 기존 추적 효과를 focused TDD와 통합 테스트로 검증한다. 모든 요구사항은 P1-GATE의 증거와 연결한다. | P1-T1~P1-T4, P1-GATE |
|
||||||
|
| PAY-006 | 확정 | 완료 응답에 `chargeId: Long`을 추가한다. 다섯 완료 API에서 실제 저장된 `Charge.id`와 일치하는 non-null JSON 정수를 반환하고 동일 charge 재조회·Payverse 웹훅 선완료 뒤에도 같은 ID를 유지한다. DB·SQL·결제 처리는 변경하지 않는다. | P2-T1, P2-GATE |
|
||||||
|
|
||||||
|
## 4. API 계약
|
||||||
|
|
||||||
|
### 공통 성공 envelope
|
||||||
|
|
||||||
|
`ApiResponse`의 기존 필드와 의미를 유지한다. 요청 DTO, 인증 방식, 기존 오류 의미와 HTTP status 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"chargeId": 123,
|
||||||
|
"price": 9.99,
|
||||||
|
"currencyCode": "USD",
|
||||||
|
"isFirstCharged": true,
|
||||||
|
"isIap": false
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data.price`는 기존 `BigDecimal`의 JSON number 직렬화다. `currencyCode`는 기존 locale에서 가져오는 통화 문자열이다. `isFirstCharged`와 `isIap`은 non-null JSON boolean이다.
|
||||||
|
|
||||||
|
`data.chargeId`는 서버 충전 건의 `Long` ID이며 JSON 정수로 반환한다. 결제사 거래 ID가 아니고 신규 DB 컬럼도 필요 없다. 기존 Google API는 재요청마다 새 charge를 생성하므로 동일 purchase token의 멱등 키로 보장하지 않는다. 현재 완료 data 계약은 기존 네 필드에 chargeId를 더한 다섯 필드다.
|
||||||
|
|
||||||
|
### 완료 경로
|
||||||
|
|
||||||
|
| Method / path | 완료 결과 | 변경 여부 |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /charge/verify` | `ApiResponse<ChargeCompleteResponse>` | `data`만 `Unit`에서 완료 응답으로 변경 |
|
||||||
|
| `POST /charge/verify/hecto` | `ApiResponse<ChargeCompleteResponse>` | 동일 |
|
||||||
|
| `POST /charge/payverse/verify` | `ApiResponse<ChargeCompleteResponse>` | 동일 |
|
||||||
|
| `POST /charge/apple/verify` | `ApiResponse<ChargeCompleteResponse>` | 동일 |
|
||||||
|
| `POST /charge/google` | `ApiResponse<ChargeCompleteResponse>` | 동일 |
|
||||||
|
|
||||||
|
나머지 외부 필드, 요청 DTO, 인증 및 오류 계약은 현재와 같다. `POST /charge`, `/charge/payverse`, `/charge/apple` 생성 응답과 `/charge/payverse/webhook`의 응답은 그대로 둔다. 새 status code를 도입하지 않는다.
|
||||||
|
|
||||||
|
### 최초 판정 의미와 데이터 호환성
|
||||||
|
|
||||||
|
- 최초 fact는 최초 `REQUEST -> COMPLETE` 전환 시 `Charge.isFirstCharged`에 true 또는 false로 고정한다. 재시도, 이후 충전 및 환불은 기존 non-null 값을 덮어쓰지 않는다.
|
||||||
|
- 과거 완료 charge의 null은 backfill하지 않는다. 응답에서는 `charge.isFirstCharged == true`인 경우에만 true를 내고, null은 false로 직렬화한다. 이 false는 “최초가 아님이 확인됨”이 아니라 “최초임이 확인되지 않음”을 뜻한다.
|
||||||
|
- 최초 판정 이력 조회는 현재 처리 중인 charge를 제외한 동일 회원의 다른 유료 이력만 찾는다. `EVENT`, `COUPON`, `ADMIN`, `ADS` 등은 결제 gateway/상태가 유료처럼 보여도 제외한다. `REFUND_CHARGE`도 포함하지 않는다.
|
||||||
|
- Payverse webhook 완료 writer도 동일한 최초 fact 판정을 수행한다. webhook은 charge 잠금 다음 회원 잠금을 획득하고, 회원 잠금 안에서 이력 판정과 상태·잔액 전환을 수행한다. 기존 charge -> member 잠금 순서를 유지한다.
|
||||||
|
- 스키마 변경은 `charge.is_first_charged` nullable boolean 의미의 단일 컬럼만 추가하고 컬럼 `COMMENT`를 포함한 SQL 파일을 작성한다. 기존 행 값은 null로 두며 backfill하지 않는다. `Boolean?`의 실제 MySQL 컬럼 타입 호환성은 rollout 전에 확인한다.
|
||||||
|
- 구현 시 컬럼 물리 타입은 Hibernate 5.6의 MySQL 기본 Boolean 매핑에 맞춘 `BIT(1) NULL`을 사용한다. 여섯 완료 writer의 이력 조회는 `READ_COMMITTED`와 기존 회원 잠금을 조합한다. 이는 MySQL의 과거 snapshot 재사용 방지를 위한 국소 변경이며 운영 DB 실행 검증을 뜻하지 않는다.
|
||||||
|
- 애플리케이션보다 스키마를 먼저 배포해야 한다. 구버전 writer와 신버전 writer가 동시에 동작하는 rolling deployment 중에는 구버전 writer가 최초 fact를 저장하지 않을 수 있으므로, writer 혼재 구간의 데이터 정확성을 보장한다고 간주하지 않는다. 배포 순서와 구 writer drain을 별도로 조정한다. 구체 운영 조치와 DDL 실행은 이 문서 작성 범위가 아니다.
|
||||||
|
|
||||||
|
## 5. 결제 금액 및 외부 의존
|
||||||
|
|
||||||
|
기존 `price`는 기록된 결제 metadata이지 검증된 실제 청구 가격의 보증이 아니다. Apple 생성 요청에서 price가 없으면 현재 경로는 0을 기록하고, locale이 없으면 응답 currency는 기존처럼 `KRW`로 fallback한다. 이 동작과 `BigDecimal` 타입을 바꾸지 않는다. 정확한 분석 금액은 확인된 원본 구매 또는 검증된 결제 데이터에서 가져와야 하며 이 작업은 그 원천을 결정하거나 조회하지 않는다.
|
||||||
|
|
||||||
|
AppsFlyer는 분석에 필요한 DATA를 논의한 맥락일 뿐이다. 서버가 AppsFlyer/Firebase로 이벤트를 보내거나 클라이언트 SDK·클라이언트를 변경하는 작업은 아니다. 클라이언트 호환성과 정확한 분석 금액 원천, 분석 이벤트 dedup은 외부에서 검증되지 않았고 구현 완료를 전제하지 않는다. 결제 완료 재호출이 partner tracking eligibility와 함께 기존 tracking 호출을 다시 수행할 수 있으므로 FIRST_PAYMENT 이벤트가 반복 기록될 수 있다. 새 dedup은 보장하지 않는다.
|
||||||
|
|
||||||
|
## 6. 구현 근거와 파일 경계
|
||||||
|
|
||||||
|
구현 전 코드는 `ChargeService`에서 완료 DTO를 만들고 `ChargeController`에서 그 결과를 tracking에 사용한 뒤 `ApiResponse.ok(Unit)`을 반환했다. 기존 `ChargeRepository.isFirstCharged(memberId)`는 CHARGE/COMPLETE 행 개수를 세었고 webhook은 COMPLETE 변경 후 member 잠금을 획득했다. 구현 후에는 소유권 검사, 저장형 최초 판정과 회원 잠금 안의 완료 처리, 확장 DTO 반환으로 변경했다.
|
||||||
|
|
||||||
|
주요 production 경로:
|
||||||
|
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt` (`PaymentStatus`는 이 파일 안에 선언됨)
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundService.kt`
|
||||||
|
|
||||||
|
현재 확인한 관련 테스트 근거는 `src/test/kotlin/kr/co/vividnext/sodalive/can/CanControllerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundServiceTest.kt`다. 새 repository 통합 테스트와 charge service/controller 테스트의 정확한 경로는 plan에 적는다. 결제 생성·이벤트·관리자 환불 경로는 최초 판정의 범위와 무관한 기존 동작을 회귀 검증한다.
|
||||||
|
|
||||||
|
## 7. 수용 기준
|
||||||
|
|
||||||
|
- [x] PAY-001: 실제 HTTP 직렬화에서 다섯 완료 endpoint의 `data`가 네 필드를 포함하고 `isFirstCharged`와 `isIap`은 JSON boolean이다. price `9.99`의 number 직렬화와 기존 currency 값이 보존된다. 생성 응답과 webhook shape는 변화가 없다.
|
||||||
|
- [x] PAY-002: 타 회원 소유의 pending 및 완료 charge 모두에서 같은 오류 키로 거부된다. provider 호출, 결제 상태, 잔액 및 event 처리 side effect가 없다. 본인 소유 완료 재호출은 저장된 최초 판정값을 반환하고 상태·잔액을 중복 변경하지 않는다.
|
||||||
|
- [x] PAY-003: PG/PAYVERSE/APPLE_IAP/GOOGLE_IAP 완료 이력, `CANCEL + RETURN`, legacy null, 현재 건 제외, `EVENT + COMPLETE`, replay, webhook-first, rollback을 검증했다. 기존 유료 이력 없는 회원의 동시 완료는 정확히 하나만 true, 이력이 있으면 둘 다 false이며 날짜/ID 순서에 의존하지 않는다. `20261103_charge_first_charged_ddl.sql`은 nullable 단일 BIT(1) 컬럼과 COMMENT만 포함하고 backfill하지 않는다. H2 및 정적 Hibernate 매핑 검증이며 운영 MySQL 적용·검증은 하지 않았다.
|
||||||
|
- [x] PAY-004: 네 gateway에 대해 `isIap` 값이 각각 false/false/true/true이고 OS에 영향받지 않는다. 기존 price 및 currency sourcing, null -> false 응답 규칙을 유지한다.
|
||||||
|
- [x] PAY-005: 기존 charge coupon test와 관리자 환불 test를 포함한 직접 영향 회귀를 통과하고 모든 변경 요구가 P1-GATE에 추적된다.
|
||||||
|
- [x] PAY-006: 실제 HTTP에서 다섯 완료 API의 chargeId가 저장된 Charge.id와 일치한다. 동일 charge 재조회·웹훅 후 verify는 같은 ID이며 기존 금액·통화·첫 결제·IAP 및 생성/webhook 응답을 보존한다.
|
||||||
|
|
||||||
|
완료 근거: 전체 339 suites / 3,112 tests, failures=0, errors=0, skipped=0. `test build ktlintCheck` exit 0 및 독립 리뷰 PASS. 실제 HTTP 56회 증거와 환경 한계는 [plan-task.md](plan-task.md)의 최종 Gate 기록에 있다. 운영 배포 승인을 뜻하지 않는다.
|
||||||
|
|
||||||
|
chargeId 후속 검증: focused 188건 및 최신 전체 341 suites / 3,128 tests, failures/errors/skipped=0, `test build ktlintCheck` exit 0, 독립 리뷰 PASS. 이전 완료 증거는 보존하며 현재 결과는 plan의 P2 기록을 따른다. Jenkins exit 137 해결이나 운영 배포 검증을 의미하지 않는다.
|
||||||
|
|
||||||
|
## 8. 미검증 항목과 Decision Log
|
||||||
|
|
||||||
|
| ID | 상태 | 항목 | 처리 또는 재개 조건 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| EXT-001 | 외부 의존 | 클라이언트가 새 `data` 응답을 허용하는지 | 클라이언트 owner가 호환성을 확인한다. 이 문서 작성의 차단 조건은 아니다. |
|
||||||
|
| EXT-002 | 외부 의존 | 분석에 사용할 정확한 실제 결제 금액 원천 및 이벤트 dedup | 분석/클라이언트 담당자가 데이터 원천과 dedup 요구를 별도 결정한다. 완료 사실로 간주하지 않는다. |
|
||||||
|
| OPS-001 | 배포 전 조건 | nullable 컬럼의 실제 MySQL 타입 호환성, schema-first 순서, 구 writer drain | SQL 파일 작성·정적 검토는 P1-T2에 포함한다. 운영/DB 담당자의 실제 DB 적용·운영 검증과 앱 배포는 별도 승인 대상이다. |
|
||||||
|
| EXT-003 | 범위 밖 관찰 | `ChargeTempService.verify`는 별도 PG 완료 writer이며 이번 대상 밖이다. | 운영 사용 여부를 확인해야 한다. 이번 동시성 검증은 지정된 다섯 API와 Payverse 웹훅 사이에 한정하며 temp 경로 동시 실행까지 보장하지 않는다. |
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 및 영향 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2026-11-03 | DEC-001 | 확정 | 다섯 완료 경로만 응답 DTO를 노출하고 생성 및 webhook 응답은 유지한다. | PAY-001, P1-T3 |
|
||||||
|
| 2026-11-03 | DEC-002 | 확정 | 최초 여부는 nullable charge-level fact이며 이력 판정 후 재계산하지 않는다. legacy null은 보수적으로 응답 false로 매핑한다. | PAY-003, P1-T2 |
|
||||||
|
| 2026-11-03 | DEC-003 | 확정 | IAP 판정은 저장된 gateway만 사용하며 price/currency 의미를 확장하지 않는다. 분석 연동은 제외한다. | PAY-004, P1-T3 |
|
||||||
|
| 2026-11-03 | DEC-004 | 확정 | 이번 승인 범위는 새 요구·계획 문서 작성뿐이다. 코드·DDL·테스트 실행은 구현 승인을 기다린다. | 모든 요구사항, P1-T1 |
|
||||||
|
| 2026-11-03 | DEC-005 | 확정 | DEC-004의 문서 전용 승인 범위는 유지한다. 구현을 별도 승인한 뒤 nullable 컬럼 추가와 `COMMENT`를 담은 SQL 파일을 작성한다. SQL 적용·배포·운영 DB 확인은 별도 운영 승인 대상이며 현재 수행하지 않는다. | PAY-003, P1-T2, OPS-001 |
|
||||||
|
| 2026-11-03 | DEC-006 | 정정, 사용자 구현 승인 | “문서대로 진행” 및 ULW 요청에 따라 P1-T1부터 구현·격리 테스트·전체 회귀·HTTP QA를 수행한다. SQL 파일 작성은 포함하며 운영 DB 적용·실결제·배포·커밋·푸시는 별도 승인 경계를 유지한다. | PAY-001~PAY-005, P1-GATE |
|
||||||
|
| 2026-11-03 | DEC-007 | 구현 세부 결정 | 동시 판정의 최신 이력 가시성 확보를 위해 여섯 완료 writer만 READ_COMMITTED로 설정하고, Hibernate MySQL dialect에 맞춰 DDL을 BIT(1)로 작성한다. 추가 temp writer는 범위 밖 관찰로 기록하며 임의 확장하지 않는다. | PAY-003, OPS-001, EXT-003 |
|
||||||
|
| 2026-11-03 | DEC-008 | 확정, 사용자 후속 구현 승인 | 완료 응답에 기존 Charge.id를 chargeId로 추가한다. PAY-001의 기존 네 필드 완료 증거는 유지하고 현재 계약은 다섯 필드로 확장한다. 별도 DB·SQL·결제·CI 설정 변경과 커밋·푸시는 제외한다. | PAY-006, P2-T1, P2-GATE |
|
||||||
|
|
||||||
|
## 9. 계획 추적
|
||||||
|
|
||||||
|
| 요구사항 | 자동 검증 예정 | 계획 Goal |
|
||||||
|
|---|---|---|
|
||||||
|
| PAY-001 | `ChargeControllerIntegrationTest`의 다섯 실제 직렬화 assertion | P1-T3, P1-T4, P1-GATE |
|
||||||
|
| PAY-002 | `ChargeServiceIntegrationTest` 소유권 및 부수효과 assertions | P1-T1, P1-T4, P1-GATE |
|
||||||
|
| PAY-003 | `ChargeRepositoryTest`, `ChargeServiceIntegrationTest` 이력·동시성·재시도 assertions 및 SQL 산출물의 정적·매핑 정합성 검토 | P1-T2, P1-T4, P1-GATE |
|
||||||
|
| PAY-004 | `ChargeControllerIntegrationTest` gateway mapping 및 price/currency assertions | P1-T3, P1-T4, P1-GATE |
|
||||||
|
| PAY-005 | focused, charge/event/admin 영향 회귀 | P1-T1~P1-T4, P1-GATE |
|
||||||
|
| PAY-006 | `ChargeControllerIntegrationTest`의 저장 ID 일치·재조회·웹훅 후 verify assertions | P2-T1, P2-GATE |
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# 가입·로그인 신규 회원 여부 공통 응답 구현 계획
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현 및 검증 완료 |
|
||||||
|
| 작성일 | 2026-11-03 |
|
||||||
|
| 요구사항 기준 | [prd.md](prd.md) |
|
||||||
|
| API 기준 | `prd.md` §5 인라인 계약 |
|
||||||
|
| 현재 Phase | Phase 1, 가입·로그인 응답 필드 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
| 다음 Goal | 없음 |
|
||||||
|
| 완료 Task | `1/1` |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
공유 `LoginResponse`를 사용하는 명시된 가입·로그인 성공 응답은 계정 생성 여부를 required JSON boolean `data.isNew`로 반환하며 기존 응답 계약과 부수 동작을 유지한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `1/1` | 없음 | 배포 전 소비자 decoder 호환성 확인 |
|
||||||
|
|
||||||
|
`P1-T1`과 `P1-GATE`를 완료했다. 구현·계약 검증·독립 리뷰를 마쳤으며 배포와 커밋은 수행하지 않았다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- 공유 `LoginResponse`에 trailing `isNew: Boolean = false`와 명시적 JSON property 이름을 추가한다.
|
||||||
|
- 이메일 signup 두 endpoint는 controller 응답에서 `copy(isNew = true)`, 소셜은 `copy(isNew = response.isNew)`를 적용한다. `/member/login`의 일반·`isAdmin`·`isCreator`와 `/creator-admin/member/login`은 기본값 `false`다.
|
||||||
|
- 단일 계약 테스트 클래스에서 HTTP 응답, DTO 기본 생성자 직렬화, side effect 조건을 검증한다.
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 서비스/provider 코드, DB, endpoint, 공개된 다른 응답 필드 변경.
|
||||||
|
- client/UI, refresh 흐름, 가입 판정 로직, 인증 및 마케팅 동작 변경.
|
||||||
|
- 무관한 error/auth suite나 입력 파싱 테스트 추가.
|
||||||
|
- 전용 `/admin/member/login` 또는 `AdminMemberLoginResponse` 변경. 관리자 DTO는 삭제하지 않고 기존 `token`, `role` 계약을 유지한다.
|
||||||
|
|
||||||
|
## 전역 제약
|
||||||
|
|
||||||
|
- 기술 스택: Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper, Jackson.
|
||||||
|
- 대상 공유 `LoginResponse` 성공 응답은 nullable이 아닌 JSON Boolean `isNew`를 포함한다. `new` alias는 추가하지 않는다.
|
||||||
|
- 공유 DTO 계약은 원래 5개 + `isNew` 총 6개 key다. `AdminMemberLoginResponse`와 전용 `/admin/member/login`은 계약에서 제외하며 기존 응답을 변경하지 않는다.
|
||||||
|
- 기존 envelope, fields, 값, message, status, 인증 입력, 로깅 및 side effect를 보존한다.
|
||||||
|
- `JsonInclude`, 전역 mapper, service/provider, DB 변경과 dependency 추가는 금지한다.
|
||||||
|
- 구현 production 파일은 `LoginResponse.kt`와 `MemberController.kt`, 정확히 2개다.
|
||||||
|
- 모든 실행 체크박스는 구현 승인 전 미완료로 둔다. 커밋은 별도 요청 없이는 하지 않는다.
|
||||||
|
|
||||||
|
## 파일 책임 및 인터페이스
|
||||||
|
|
||||||
|
| 경로 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt` | trailing `isNew: Boolean = false`와 getter/constructor `JsonProperty` |
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt` | 두 signup 응답의 true override 및 공통 social response 값 복사 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberLoginResponseContractTest.kt` | HTTP와 기본 DTO serialization 계약, side effect 검증 |
|
||||||
|
|
||||||
|
## Phase 1: 가입·로그인 성공 응답 계약
|
||||||
|
|
||||||
|
**Phase 결과:** 모든 대상 응답의 `isNew` 값이 endpoint별 계약과 일치하고 기존 응답과 부수 동작은 보존된다.
|
||||||
|
|
||||||
|
**선행조건:** 사용자가 구현을 명시적으로 요청하고 이 PRD/계획을 기준으로 삼음.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1` 구현·focused/회귀 검증과 `P1-GATE` 판정이 기록됨.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 공통 응답 계약 구현과 검증**
|
||||||
|
|
||||||
|
**Goal `P1-T1` objective:** 가입은 true, 소셜은 기존 내부 값, `/member/login` 일반·`isAdmin`·`isCreator` 및 creator-admin 로그인은 false를 required boolean `data.isNew`로 반환하고 기존 계약을 유지한다. 전용 `/admin/member/login`은 제외한다.
|
||||||
|
|
||||||
|
- **시작 조건:** 명시적 구현 요청, `prd.md`의 `SOCIAL-001`~`SOCIAL-004` 확인.
|
||||||
|
- **완료 증거:** 아래 task 체크박스, focused test, 지정 회귀/lint 성공 및 실제 결과가 Progress에 기록됨.
|
||||||
|
- **범위 밖:** production 파일 목록 외 변경, service/provider 수정, DB 변경, client 변경, 새로운 auth/error 입력 시나리오.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberLoginResponseContractTest.kt`
|
||||||
|
- Verify existing: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberControllerTest.kt`
|
||||||
|
- Verify existing: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
||||||
|
- Verify existing: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberControllerTest.kt`
|
||||||
|
- Verify existing: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberServiceTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 현재 `SocialLoginResponse.loginResponse: LoginResponse`, `SocialLoginResponse.isNew: Boolean`, 기존 signup 결과 및 controller 응답 경계.
|
||||||
|
- Produces: `LoginResponse`의 trailing `isNew: Boolean = false`; signup 응답 `copy(isNew = true)`; social 응답 `copy(isNew = response.isNew)`.
|
||||||
|
|
||||||
|
#### 테스트 범위와 설계
|
||||||
|
|
||||||
|
새 계약 테스트는 `@SpringBootTest`와 `@AutoConfigureMockMvc`로 application `ObjectMapper`와 HTTP converter 결과를 확인한다. 외부 provider와 실제 가입 동작을 호출하지 않도록 경계를 mock한다. 컨텍스트에 필요할 때만 기존 Redis initializer opt-in을 사용한다.
|
||||||
|
|
||||||
|
공유 service fixture는 `LoginResponse(42L, "issued-token", "member", "", "profile-url")`다. token은 가짜 테스트 값이다. 새 Kotlin property를 직접 참조하지 않고 HTTP/mapper JSON을 검증해 구현 전에도 테스트가 컴파일되게 한다.
|
||||||
|
|
||||||
|
- `shouldExposeSocialIsNewAsBooleanForEveryProvider`: social provider 4종 × true/false. Google/Kakao는 올바른 Authorization 입력, Apple/LINE은 body `identityToken` 및 `nonce`를 전달한다. 모든 응답은 기존 값, `success`, `member.signup.success` message를 보존하고 `data` key가 정확히 기존 shared 5개와 `isNew`인 6개인지 확인한다. `isNew`는 JSON boolean이며 `new` key는 없다.
|
||||||
|
- `shouldReturnNewForBothEmailSignupEndpoints`: `/member/signup` 및 `/member/signup/v2`가 fixture service의 기본 false 결과와 무관하게 true를 반환하는지 검증한다. 기존 필드/envelope/message/status도 유지한다.
|
||||||
|
- 회원 login의 일반, `isAdmin`, `isCreator` 경로와 `/creator-admin/member/login`은 false를 반환한다. 이들 응답은 공통 `LoginResponse` 계약을 따른다.
|
||||||
|
- `shouldSerializeDefaultLoginResponseWithIsNewFalse`: 공유 DTO 기본 생성자 결과가 application `ObjectMapper`로 serialize될 때 `isNew: false`가 존재하고 정확히 6 keys를 가진다.
|
||||||
|
- 소셜 8 경우 모두에서 `recordAction(42L, false, ActionType.SIGN_UP)`은 `isNew=true`일 때만 한 번 호출된다. nonblank PID의 `AdTrackingHistoryType.SIGNUP`은 true/false 모두에서 기존대로 한 번 호출된다.
|
||||||
|
- 신규 가입 HTTP 응답은 외부 가입/DB를 실행하지 않는 fixture/mock으로 만든다. provider 등록이나 signup service 로직을 테스트에서 재구현하지 않는다. 전용 관리자 로그인용 fixture와 HTTP 테스트는 추가하지 않는다.
|
||||||
|
- 기존 응답 field/envelope/status/message assertions는 변경하지 않는다. 새 auth 입력 suite는 만들지 않는다.
|
||||||
|
|
||||||
|
#### TDD 실행 체크리스트
|
||||||
|
|
||||||
|
- [x] **RED:** 단일 계약 테스트 클래스 `MemberLoginResponseContractTest`를 작성해 위 소셜, 두 signup, 회원 세 login 분기, creator-admin login, 공유 DTO 기본 생성자 직렬화 및 side effect 조건을 표현한다.
|
||||||
|
- [x] **RED 확인:** focused 명령을 실행한다. 기대 실패는 `isNew` 누락/값 오류 assertion이어야 하며 컨텍스트·설정 기동 실패는 유효한 RED가 아니다.
|
||||||
|
- [x] **GREEN:** `LoginResponse`에 trailing `isNew: Boolean = false`와 `@get:JsonProperty("isNew")`, `@param:JsonProperty("isNew")`를 추가한다. `MemberController.kt`의 두 signup endpoint 응답은 `copy(isNew = true)`, common social response는 `copy(isNew = response.isNew)`로 반환한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 focused 명령으로 endpoint별 값, 정확한 key 집합, 원래 response 값 및 side effect assertion이 통과하는지 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경에서 만든 중복만 정리하고 focused, 직접 영향 회귀, ktlint를 다시 실행해 결과를 기록한다. 서비스/provider는 변경하지 않는다.
|
||||||
|
|
||||||
|
**P1-T1 focused 명령:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest'
|
||||||
|
```
|
||||||
|
|
||||||
|
회귀 검증 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest' \
|
||||||
|
--tests 'kr.co.vividnext.sodalive.member.MemberControllerTest' \
|
||||||
|
--tests 'kr.co.vividnext.sodalive.member.MemberServiceTest' \
|
||||||
|
--tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest' \
|
||||||
|
--tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest'
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
집중/회귀 test와 ktlint의 기대 결과는 각 명령 exit code 0이다. 전체 test/build는 공통·여러 domain 영향이 새로 확인되거나 targeted 실패로 범위를 판별할 수 없거나 사용자 요청이 있을 때만 실행한다. 그 밖에는 생략 이유와 대체 검증을 기록한다.
|
||||||
|
|
||||||
|
### Phase 1 Gate `P1-GATE`
|
||||||
|
|
||||||
|
**Goal objective:** 구현 결과가 PRD의 네 요구사항과 모든 가입·로그인 응답 호환성 기준을 충족하는지 최종 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1`의 모든 체크박스와 완료 증거가 충족됨.
|
||||||
|
- **완료 증거:** 아래 검증과 code diff/계약 대조 결과가 기록됨.
|
||||||
|
- **범위 밖:** 구현 변경, 새 요구사항, 전체 test/build의 기본 실행.
|
||||||
|
- **검증 방식:** read-only로 계획·PRD·diff·테스트 결과를 대조한다.
|
||||||
|
- **TDD 예외 사유 및 대체 검증:** 동작을 바꾸지 않는 read-only Gate이므로 새 실패 테스트 대신 위 focused/회귀/lint 명령의 실제 결과와 계약 대조표를 확인한다.
|
||||||
|
|
||||||
|
- [x] focused 계약 테스트, 지정 회귀 테스트, `ktlintCheck` 결과가 성공 기록으로 남았다.
|
||||||
|
- [x] 모든 endpoint-value 매핑이 required JSON boolean이며 원래 field와 값도 보존된다.
|
||||||
|
- [x] shared `LoginResponse` key 집합은 정확히 6개며 alias `new`가 없다. 전용 관리자 DTO와 endpoint는 변경 대상에서 제외되었는지 diff를 read-only로 확인한다.
|
||||||
|
- [x] 성공 envelope/message/status, 요청 인증 입력, side effect 경계가 보존된다.
|
||||||
|
- [x] 변경 production 파일은 지정한 정확히 2개이며 새 테스트는 지정한 계약 클래스 하나다. service/provider 변경은 없다.
|
||||||
|
- [x] 전체 test/build를 실행해 전체 테스트 3,128건과 빌드 성공을 확인했다. 최초 명령 시간 초과와 후속 성공을 모두 기록했다.
|
||||||
|
|
||||||
|
Gate 검증은 구현 요청 후에만 한다. 새 필드의 strict decoder 위험은 공유 `LoginResponse` 소비자에게만 적용되며 creator-admin 소비자도 포함한다. 전용 `/admin/member/login`은 제외한다.
|
||||||
|
|
||||||
|
## 실행 순서
|
||||||
|
|
||||||
|
| 순서 | Goal | 선행조건 | 상태 |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 1 | `P1-T1` | 사용자의 명시적 구현 요청 | 완료 |
|
||||||
|
| 2 | `P1-GATE` | `P1-T1` 완료 증거 | 완료 |
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### P1-T1 실행 시작 — 2026-11-03
|
||||||
|
|
||||||
|
- 사용자 승인: “문서대로 진행”, `ulw`. 최신 `DEC-005` 범위를 유지하며 구현을 시작한다.
|
||||||
|
- 사전 대조: P1-T1은 내부 소셜 boolean을 공유 DTO 응답에 연결하고, P1-GATE는 그 계약·테스트 증거를 확인한다. 계약·파일 범위 간 충돌은 없다.
|
||||||
|
- 검증은 focused 테스트부터 시작한다. 이번 요청의 ULW 기준에 따라 전체 테스트와 빌드도 확인하되 기존 실패는 별도로 기록한다.
|
||||||
|
- 현재 도구에 `lsp_diagnostics`가 없어 컴파일·lint로 대체 확인하며 LSP 실행 여부를 가장하지 않는다.
|
||||||
|
- 구현 완료 체크박스는 실제 RED/GREEN/검증 증거 확보 후 갱신한다. 커밋은 별도 요청 없이 하지 않는다.
|
||||||
|
|
||||||
|
- 2026-11-03: PRD와 plan 문서 준비 완료. 코드 변경 및 구현 검증은 미착수다. 저장소의 문서 전용 규칙에 따라 테스트/Gradle 실행을 생략했다.
|
||||||
|
- 계획 근거: `MemberController.processSocialLogin`이 provider 응답의 `isNew`를 이미 사용하고, 네 `findOrRegister` 경로와 provider 결과 전달이 확인됐다. 제안 DTO annotation 조합은 기존 JSON 패턴에 근거하며 런타임 검증 전이다.
|
||||||
|
- 문서 정합성/공백 검사: 상위 실행자가 실제 확인 후 결과를 추가한다. 현재 통과했다고 기록하지 않는다.
|
||||||
|
- 역사 기록, 당시의 소셜 전용 nullable 제안에 대한 기록이며 현재 계약의 검증 증거가 아님:
|
||||||
|
- 2026-11-03 문서 검증: PRD의 `SOCIAL-001`~`SOCIAL-004`를 Task·Gate·테스트 범위와 대조했다. 소셜 응답은 boolean, 비소셜 응답은 key 생략이며 운영 코드 변경 예정 범위는 두 파일이다.
|
||||||
|
- 문서 전용 공백 검사 명령은 아래와 같으며 각각 exit code 0이었다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --no-index --check -- /dev/null docs/20261103_소셜로그인_신규회원여부응답/prd.md
|
||||||
|
git diff --no-index --check -- /dev/null docs/20261103_소셜로그인_신규회원여부응답/plan-task.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- `git status --short --untracked-files=all`은 신규 문서 두 개만 표시했다. 구현 체크박스는 모두 미완료로 유지하며 Gradle/test/build/lint는 실행하지 않았다.
|
||||||
|
- 위 기록은 당시 결과 그대로 보존하며, 현재 수정에 대한 검증 결과로 간주하지 않는다.
|
||||||
|
- 2026-11-03 계약 정정: 승인된 uniform required Boolean 계약을 반영했다. 구현은 미승인·미착수이며, 이번 수정으로 새 테스트/구현 검증 결과를 주장하지 않는다.
|
||||||
|
- 2026-11-03 정정 문서 검증: `SOCIAL-001`~`SOCIAL-004`, endpoint별 값, 두 DTO의 6/3 key 계약과 구현 3개 파일 범위를 대조했다. 현재 두 문서를 대상으로 위 `git diff --no-index --check`를 각각 실행해 exit code 0을 확인했고, `git status --short --untracked-files=all`은 기존 미추적 문서 두 개만 표시했다. 구현 체크박스는 모두 미완료이며 테스트·빌드·lint는 실행하지 않았다.
|
||||||
|
- 2026-11-03 `DEC-005` 범위 정정: 현재 구현 계획은 공유 `LoginResponse`와 `MemberController.kt` 두 운영 파일, `MemberLoginResponseContractTest` 하나만 대상으로 한다. 전용 `/admin/member/login`과 `AdminMemberLoginResponse`는 변경하지 않으며 기존 `token`, `role` 응답을 유지한다. 이전 DEC-003·004 및 진행 기록의 두 DTO/전용 관리자 포함 주장은 역사 기록으로 보존되며 이 범위에서만 최신 정정이 우선한다. `P1-T1` 및 Gate는 미완료, 구현은 미착수다. 이번 수정에서 새 검증을 수행하거나 주장하지 않는다.
|
||||||
|
- 2026-11-03 `DEC-005` 문서 검증: PRD와 현재 계약·파일·테스트·회귀·Gate를 대조했다. 위 공백 검사 명령을 두 문서에 각각 실행해 exit code 0을 확인했고, `git status --short --untracked-files=all`은 기존 미추적 문서 두 개만 표시했다. 코드 변경 없이 문서만 수정했으므로 테스트·빌드·lint는 실행하지 않았다.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-11-03 | `DEC-001` | 권고, superseded historical | nullable 기본값 `isNew`와 property-level `NON_NULL`, 명시적 JSON getter/constructor 이름을 사용한다. | 최초 소셜 전용 제안. 현재 계약은 DEC-003으로 대체한다. | 당시 `P1-T1`, `P1-GATE` |
|
||||||
|
| 2026-11-03 | `DEC-002` | 확정 | 공통 처리기에서만 `response.isNew`를 복사하고 서비스/provider에는 손대지 않는다. | 네 endpoint가 동일 처리기를 사용한다. 기존 부수 동작을 최소 범위로 보존한다. | `P1-T1` |
|
||||||
|
| 2026-11-03 | `DEC-003` | 확정 | 두 DTO에 trailing required `isNew: Boolean = false`를 추가하고 JSON key를 항상 포함한다. `JsonInclude`, nullable, alias, 전역 mapper 변경은 금지한다. | 승인된 공통 가입·로그인 응답 계약. | `P1-T1`, `P1-GATE` |
|
||||||
|
| 2026-11-03 | `DEC-004` | 확정 | signup 두 endpoint는 true, social은 기존 internal 값, 회원·creator·admin 로그인은 false로 응답한다. | signup service가 shared login DTO를 기본값으로 반환하므로 controller 경계 override가 필요하다. | `P1-T1`, `P1-GATE` |
|
||||||
|
| 2026-11-03 | `DEC-005` | 확정, DEC-003·004의 관리자 전용 로그인 범위 정정 | `isNew`는 공유 `LoginResponse`에만 적용한다. `/member/login`의 `isAdmin` 및 `isCreator` 경로와 `/creator-admin/member/login`은 공유 계약에 포함하지만, 전용 `/admin/member/login`과 `AdminMemberLoginResponse`는 제외한다. 해당 DTO는 삭제하거나 변경하지 않고 기존 `token`, `role` 응답을 유지한다. | 사용자 정정: “AdminMemberLoginResponse 는 없어도 될 것 같다. 문서 수정”. 이 결정은 DEC-003·004의 전용 관리자 DTO/endpoint 포함 주장에 대해서만 우선한다. | 현재 API 계약, `P1-T1`, `P1-GATE` |
|
||||||
|
|
||||||
|
## 미확정 잔여 위험
|
||||||
|
|
||||||
|
- 새 JSON field를 거부하는 strict decoder 위험은 공유 `LoginResponse` 소비자에 적용되며 `/creator-admin/member/login` 소비자도 포함한다. 전용 `/admin/member/login` 소비자는 이번 변경 대상이 아니다.
|
||||||
|
|
||||||
|
## P1-T1 구현 실행 증거 - 2026-11-03, 상위 검토 대기
|
||||||
|
|
||||||
|
이 절은 위임된 P1-T1 구현과 검증 증거만 추가한다. Task 1.1 및 `P1-GATE` 완료 판정, 전체 테스트/빌드와 최종 문서 갱신은 상위 실행자에게 남긴다. 기존 상태 및 역사 기록은 변경하지 않았다.
|
||||||
|
|
||||||
|
- [x] **사전 baseline:** 새 테스트 작성 전 지정된 기존 네 테스트 클래스 9건 통과, exit code 0.
|
||||||
|
- [x] **RED:** 새 계약 클래스는 새 Kotlin 필드를 직접 참조하지 않은 상태로 컴파일됐다. 소셜 8건, 이메일 가입 2건, 회원 로그인 3건, creator-admin 1건, 기본 DTO 직렬화 1건이 모두 `isNew must be a present JSON boolean` assertion으로 실패했다. 15 tests, 15 failures, 0 errors, 0 skipped, exit code 1. HTTP 상태/envelope/message 및 부수 동작 검증은 이 assertion 이전에 통과했다.
|
||||||
|
- [x] **GREEN:** 운영 코드 정확히 두 파일에 trailing Boolean 기본값 및 getter/parameter JSON 이름, 두 signup의 true 복사, social 내부 값 복사만 추가했다. 같은 focused 명령 15건 통과, exit code 0.
|
||||||
|
- [x] **회귀 및 형식 정리:** 계약 15건과 기존 9건을 합쳐 24건 통과했다. 최초 lint는 새 테스트의 import 정렬 및 CSV 인자 줄바꿈 5건으로 실패했고 해당 부분만 정리했다. 최종 동일 회귀 + `ktlintCheck`는 exit code 0, 24 tests, 0 failures/errors/skipped였다. 최종 main/test ktlint report는 모두 비어 있다.
|
||||||
|
|
||||||
|
모든 Gradle 실행은 아래 환경을 export하고 `--no-daemon`을 사용했다. `JAVA_HOME`은 macOS `/usr/libexec/java_home -v 17`이 런타임을 발견하지 못해 실제 설치된 mise Java 17 경로를 사용했다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/isnew-ulw.nzxcOe/p1-t1.87jfwJ
|
||||||
|
export NODE_COMPILE_CACHE="$TMPDIR/node-compile-cache"
|
||||||
|
export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$TMPDIR/bun-transpiler"
|
||||||
|
export JAVA_HOME=/Users/klaus/.local/share/mise/installs/java/liberica-17.0.15+10
|
||||||
|
./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.member.MemberControllerTest' --tests 'kr.co.vividnext.sodalive.member.MemberServiceTest' --tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest' --tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest'
|
||||||
|
./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest'
|
||||||
|
./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest' --tests 'kr.co.vividnext.sodalive.member.MemberControllerTest' --tests 'kr.co.vividnext.sodalive.member.MemberServiceTest' --tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest' --tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest'
|
||||||
|
./gradlew --no-daemon ktlintCheck
|
||||||
|
./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest' --tests 'kr.co.vividnext.sodalive.member.MemberControllerTest' --tests 'kr.co.vividnext.sodalive.member.MemberServiceTest' --tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest' --tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest' ktlintCheck --continue
|
||||||
|
```
|
||||||
|
|
||||||
|
두 번째 명령은 RED(exit 1)와 GREEN(exit 0)에 같은 조건으로 실행했다. 원문 로그와 단계별 testcase 증거는 위 `TMPDIR`의 `baseline.log`, `red.log`, `red-contract-evidence.txt`, `green.log`, `green-contract-evidence.txt`, `regression.log`, `regression-evidence.txt`, `ktlint.log`, `final-validation.log`, `final-validation-evidence.txt`에 보존했다. 전체 위임 보고서는 같은 경로의 `p1-t1-report.md`다.
|
||||||
|
|
||||||
|
검증 표면은 application context의 실제 controller, Jackson HTTP converter 및 application `ObjectMapper`다. 서비스/provider/비동기 부수 동작 경계만 mock하고 외부 인증 및 실제 회원 생성은 실행하지 않았다. JSON boolean, 정확한 6개 key와 `new` 부재, 기존 5개 값(빈 email 포함), 200 상태/envelope/message, true 전용 소셜 가입 action, true/false 모두의 nonblank PID SIGNUP 및 이메일 가입 두 경로의 기존 action 차이를 확인했다.
|
||||||
|
|
||||||
|
LSP 도구는 없어 실행하지 않았고 `compileKotlin`, `compileTestKotlin` 및 `ktlintCheck`로 대체했다. 테스트 context의 기존 스케줄러가 H2의 `FOR UPDATE SKIP LOCKED` 비지원 오류를 출력하며 Redis 종료 중 재접속 경고도 남지만 최종 testcase 실패는 없다. 기존 `MemberService.kt:617-618` 불필요한 `!!` 컴파일 경고는 수정하지 않았다. 전체 suite/build, 외부 provider 실인증, 배포 소비자 strict decoder 검증은 상위 실행/배포 검증 범위다. 커밋·stage 및 전용 관리자 응답 변경은 하지 않았다.
|
||||||
|
|
||||||
|
## P1-GATE 최종 검증 - 2026-11-03
|
||||||
|
|
||||||
|
### 자동 검증과 독립 리뷰
|
||||||
|
|
||||||
|
- `./gradlew --no-daemon build --continue`: bootJar/assemble까지 완료했지만 전체 테스트 단계에서 shell의 600초 제한으로 중단됐다. 이를 통과로 판정하지 않았다.
|
||||||
|
- `./gradlew --no-daemon -I "$RUN_TMP/full-suite.init.gradle" build --continue`: exit code 0, `BUILD SUCCESSFUL in 26m 41s`. 임시 init script는 테스트별 passed/failed/skipped 출력만 켰고 코드·빌드 설정은 변경하지 않았다. 임시 HTTP QA 소스는 이 빌드에 포함되지 않았다.
|
||||||
|
- `build/reports/tests/test/index.html`: **3,128 tests, 0 failures, 0 ignored, 100% successful**. 해당 실행의 계약 클래스 XML도 15 tests, 0 failures/errors/skipped다. Kotlin 컴파일, main/test lint, bootJar를 포함한 빌드가 성공했다.
|
||||||
|
- 독립 read-only reviewer: **스펙 PASS, 품질 PASS, 차단·비차단 finding 0건**. 두 운영 파일의 diff, 신규 테스트, DTO 생성자/직접 호출부, 내부 소셜 값 전달과 제외된 관리자 경계를 대조했다. 가입 true override 누락, false 생략 및 alias 오류를 테스트가 검출함을 확인했다. 리뷰 이후 운영/테스트 코드는 추가로 바꾸지 않았다.
|
||||||
|
|
||||||
|
### 실제 소켓 HTTP QA
|
||||||
|
|
||||||
|
- 실행: `./gradlew --no-daemon -I "$RUN_TMP/socket-qa.YVsXnB/socket-qa.init.gradle" test --tests 'kr.co.vividnext.sodalive.member.IsNewSocketQaTest'` — exit code 0, `BUILD SUCCESSFUL in 1m 41s`.
|
||||||
|
- 임시 테스트는 `@SpringBootTest(RANDOM_PORT)`와 `TestRestTemplate.exchange`로 `127.0.0.1:49841`의 실제 Tomcat에 요청했다. 컨트롤러·보안 필터·Jackson은 실제 코드이며, 외부 인증/가입/추적의 서비스 경계는 테스트 mock이다. 임시 Kotlin source와 init script는 저장소 밖의 작업 전용 경로에만 생성했다.
|
||||||
|
- 이메일 가입 2건 true, 소셜 4종 × 신규/기존 8건 true/false, `/member/login` 일반·isAdmin·isCreator 3건 false, creator-admin 1건 false: **14건 모두 HTTP 200**, 원래 다섯 값·빈 email 보존, 정확히 여섯 key, boolean 타입/값 및 `new` 부재를 확인했다. 소셜/이메일 기존 tracking/action 조건도 통과했다.
|
||||||
|
- 실제 응답 예시(가짜 fixture token):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success":true,"message":"Congratulations on your sign up.","data":{"userId":42,"token":"issued-token","nickname":"member","email":"","profileImage":"profile-url","isNew":true},"errorProperty":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success":true,"message":"You are logged in.","data":{"userId":42,"token":"issued-token","nickname":"member","email":"","profileImage":"profile-url","isNew":false},"errorProperty":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 검증 한계와 정리
|
||||||
|
|
||||||
|
- LSP 도구는 제공되지 않아 실행하지 못했다. 컴파일·전체 lint는 통과했으며 LSP clean을 가장하지 않는다.
|
||||||
|
- 외부 provider 실인증, 운영 DB, 소비자 decoder 배포 호환성은 검증하지 않았다. 기존 스케줄러/H2·Redis 종료 경고는 테스트 실패와 구분해 위 기록에 남겼다.
|
||||||
|
- `@DirtiesContext`에 의한 Tomcat 종료 로그를 확인했다. `ps`로 QA worker 84785 및 시간 초과했던 81629/81841, 관련 Gradle test/Redis 프로세스가 남지 않음을 확인했고 `lsof -iTCP:49841 -sTCP:LISTEN`과 작업 root의 `lsof +D`도 비어 있었다.
|
||||||
|
- `$RUN_TMP`는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/isnew-ulw.nzxcOe`다. 정리 후 임시 로그/리뷰/하네스 원본은 남기지 않으며 검증 요약과 실제 응답은 본 절, 지속 테스트는 `MemberLoginResponseContractTest.kt`, 전체 결과는 `build/reports/tests/test/index.html`에 남는다. 커밋·stage·배포는 수행하지 않았다.
|
||||||
|
- 정리 영수증: 소유한 `$RUN_TMP`만 삭제한 뒤 `test ! -e "$RUN_TMP"`가 exit code 0을 반환했다. 구현/리뷰/HTTP QA 하위 경로, notepad, 임시 init script 및 런타임 캐시 정리를 완료했다. 공유 캐시·다른 작업 디렉터리는 변경하지 않았다.
|
||||||
|
|
||||||
|
## 커밋 전 재검증 - 2026-11-03
|
||||||
|
|
||||||
|
- 사용자 `/commit-with-validation` 요청으로 변경 5개 파일과 최신 계약을 대조했다. 운영 코드와 테스트는 수정하지 않았다.
|
||||||
|
- 아래 명령은 exit code 0, `BUILD SUCCESSFUL in 50s`였다. XML 결과는 5개 클래스 총 24건, 실패·오류·무시 0건이며 lint는 `UP-TO-DATE`였다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
JAVA_HOME=/Users/klaus/.local/share/mise/installs/java/liberica-17.0.15+10 TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode ./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest' --tests 'kr.co.vividnext.sodalive.member.MemberControllerTest' --tests 'kr.co.vividnext.sodalive.member.MemberServiceTest' --tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest' --tests 'kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest' ktlintCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- 기존 Gate 이후 구현 변경이 없어 전체 테스트·빌드는 반복하지 않고 직접 영향 회귀를 실행했다. 이번 실행으로 `build/reports/tests/test/index.html`과 XML은 위 24건 결과로 갱신됐으며, 앞 절의 3,128건은 이전 실행 기록이다.
|
||||||
|
- Redis 종료 중 재접속 경고가 있었으나 테스트 실패는 없었다. 외부 provider 실인증과 소비자 decoder 호환성은 이번에도 검증하지 않았다.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 가입·로그인 신규 회원 여부 공통 응답 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 및 검증 완료 |
|
||||||
|
| 작성일 | 2026-11-03 |
|
||||||
|
| 최종 수정일 | 2026-11-03 |
|
||||||
|
| 대상 기능 | 가입·로그인 신규 회원 여부 공통 응답 |
|
||||||
|
| 관련 구현 계획 | [plan-task.md](plan-task.md) |
|
||||||
|
| 관련 API Contract | 본 문서 §5에 인라인으로 기록 |
|
||||||
|
| 관련 review | 없음 |
|
||||||
|
|
||||||
|
## 1. 개요와 문제
|
||||||
|
|
||||||
|
가입과 로그인 성공 응답에서 클라이언트가 이번 요청 중 계정이 생성됐는지 알 수 있도록 `data.isNew`를 공통 제공한다. 소셜 로그인은 이미 결정된 `isNew`를 반환하며, 가입 요청은 생성 성공에 `true`, 계정 생성을 수행하지 않는 로그인은 `false`를 반환한다.
|
||||||
|
|
||||||
|
`isNew`는 오직 계정이 이번 요청에서 생성됐는지를 나타낸다. 온보딩 상태, 최초 기기 로그인, 이벤트의 exactly-once 보장을 뜻하지 않는다.
|
||||||
|
|
||||||
|
## 2. 목표와 제외 범위
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
|
||||||
|
- 공유 `LoginResponse`를 사용하는 대상 가입·로그인 성공 응답의 `data.isNew`를 누락 없는 JSON boolean으로 제공한다.
|
||||||
|
- 기존 응답 필드, envelope, 인증 및 회원 생성 동작을 유지한다.
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 회원 조회·생성 기준, 토큰 발급, 계정 연결, 마케팅 및 가입 로그 변경.
|
||||||
|
- DB 스키마, 로그인 요청, 오류·메시지·HTTP 상태 변경.
|
||||||
|
- 신규 refresh API, 다른 응답 필드, 클라이언트/UI 변경.
|
||||||
|
- 기존 가입 로직 또는 서비스 구조 리팩터링.
|
||||||
|
- 전용 관리자 로그인 `/admin/member/login` 응답 변경. 기존 `AdminMemberLoginResponse`의 `token`, `role` 계약을 유지하며 `isNew`를 추가하지 않는다.
|
||||||
|
|
||||||
|
## 3. 구현 전 근거와 동작
|
||||||
|
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialLoginResponse.kt`: 소셜 인증 결과가 `isNew: Boolean`을 보유한다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/member/social/MemberResolveResult.kt`: 회원 해석 결과에도 같은 값이 있다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`: 네 `findOrRegister` overload는 활성 기존 회원을 `false`, 생성한 회원을 `true`로 반환한다. 두 이메일 signup service 메서드는 shared login 흐름을 사용하므로 signup 응답 경계의 override가 필요하다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt`: 소셜 공통 처리기에서 기존 `isNew`를 가입 로그에 쓰지만 응답에는 복사하지 않는다. signup 두 endpoint도 응답 경계에서 `true`로 덮어써야 한다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt`: 현재 공개 필드는 `userId`, `token`, `nickname`, `email`, `profileImage`다.
|
||||||
|
|
||||||
|
## 4. 기능 요구사항
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `SOCIAL-001` | 확정 | 표에 명시한 공유 `LoginResponse` 기반 가입·로그인 성공 응답에 required `isNew`를 제공한다. | endpoint-value 표의 지정 값이 JSON boolean으로 존재하며 null 또는 누락이 없다. | `P1-T1`, `P1-GATE` |
|
||||||
|
| `SOCIAL-002` | 확정 | 기존 응답을 보존한다. | 기존 필드와 값, envelope, 성공 message 및 HTTP 동작이 그대로 유지된다. | `P1-T1`, `P1-GATE` |
|
||||||
|
| `SOCIAL-003` | 확정 | 가입 성공은 `true`, 계정 생성을 하지 않는 로그인 성공은 `false`를 제공한다. | 아래 endpoint-value 표의 모든 경로에서 매핑 값이 일치한다. | `P1-T1`, `P1-GATE` |
|
||||||
|
| `SOCIAL-004` | 확정 | 기존 부수 동작을 바꾸지 않는다. | 토큰·인증·계정 조회/생성·마케팅·가입 로그의 조건과 순서가 변경되지 않는다. 소셜 가입 action은 기존처럼 `true`에서만 기록되며 소셜 nonblank PID의 `SIGNUP` 추적은 기존처럼 true/false 모두 유지된다. 이메일 두 가입 경로의 기존 로그 차이도 변경하지 않는다. | `P1-T1`, `P1-GATE` |
|
||||||
|
|
||||||
|
## 5. API 계약
|
||||||
|
|
||||||
|
기존 `ApiResponse` envelope와 endpoint를 유지한다. 공유 `LoginResponse`에 trailing `isNew: Boolean = false`를 추가하고 생성자 parameter와 getter에 각각 `@param:JsonProperty("isNew")`, `@get:JsonProperty("isNew")`를 지정한다. `JsonInclude` 및 전역 mapper 설정은 변경하지 않는다. 아래 endpoint-value 표의 성공 응답에서 `isNew` key는 존재하고 JSON boolean이어야 한다. null, 누락, 새 alias `new`는 허용하지 않는다. 전용 관리자 로그인 `/admin/member/login`은 기존 `AdminMemberLoginResponse`를 유지하며 `isNew`를 포함하지 않는다.
|
||||||
|
|
||||||
|
| 성공 endpoint | `data.isNew` | 근거 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `POST /member/signup` | `true` | 이번 요청에서 회원 생성 |
|
||||||
|
| `POST /member/signup/v2` | `true` | 이번 요청에서 회원 생성 |
|
||||||
|
| `POST /member/login/google` | 기존 내부 `isNew` | 소셜 회원 해석 결과 |
|
||||||
|
| `POST /member/login/kakao` | 기존 내부 `isNew` | 소셜 회원 해석 결과 |
|
||||||
|
| `POST /member/login/apple` | 기존 내부 `isNew` | 소셜 회원 해석 결과 |
|
||||||
|
| `POST /member/login/line` | 기존 내부 `isNew` | 소셜 회원 해석 결과 |
|
||||||
|
| `POST /member/login` 일반 로그인 | `false` | 계정을 생성하지 않는 로그인 |
|
||||||
|
| `POST /member/login` `isAdmin` 로그인 | `false` | 계정을 생성하지 않는 로그인 |
|
||||||
|
| `POST /member/login` `isCreator` 로그인 | `false` | 계정을 생성하지 않는 로그인 |
|
||||||
|
| `POST /creator-admin/member/login` | `false` | 계정을 생성하지 않는 로그인 |
|
||||||
|
|
||||||
|
이메일 signup controller는 shared `MemberService` 가입 메서드가 내부에서 기본 `LoginResponse`를 만들어도 응답 경계에서 `copy(isNew = true)`로 덮어쓴다. 소셜 성공 경로는 `copy(isNew = response.isNew)`를 사용한다. `/member/login`의 일반, `isAdmin`, `isCreator` 로그인과 `/creator-admin/member/login`은 공유 `LoginResponse` 기본값 `false`를 사용한다. 전용 `/admin/member/login`은 기존 응답을 유지한다.
|
||||||
|
|
||||||
|
공유 `LoginResponse`의 JSON은 원래 다섯 key에 `isNew`를 더한 정확히 여섯 key다. 전용 관리자 로그인 응답은 이 계약에서 제외한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"userId": 123,
|
||||||
|
"token": "synthetic-token-not-valid",
|
||||||
|
"nickname": "sample",
|
||||||
|
"email": "",
|
||||||
|
"profileImage": "https://example.invalid/profile.png",
|
||||||
|
"isNew": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 호환성 및 위험
|
||||||
|
|
||||||
|
- 서버를 먼저 배포하면 새 JSON 필드를 strict decoder로 읽는 기존 클라이언트가 거부할 수 있다. 위험은 공유 `LoginResponse`를 소비하는 클라이언트에 해당하며 `/creator-admin/member/login` 소비자도 포함된다. 전용 `/admin/member/login` 소비자는 이번 변경의 대상이 아니다. 배포 전 공유 응답 소비자의 호환성을 확인한다.
|
||||||
|
- 모든 명시 대상 성공 응답에서 `isNew`는 required boolean이다. null 값, key 생략, alias `new`는 계약 위반이다.
|
||||||
|
|
||||||
|
## 7. 완료 기준 및 추적성
|
||||||
|
|
||||||
|
- [x] endpoint-value 표의 모든 공유 `LoginResponse` 성공 응답에서 `data.isNew`가 누락 없는 JSON boolean이며 지정 값과 일치한다. (`SOCIAL-001`, `P1-T1`)
|
||||||
|
- [x] 기존 응답 필드, 성공 envelope/message/HTTP 동작 및 provider별 인증 입력이 유지된다. (`SOCIAL-002`, `P1-T1`)
|
||||||
|
- [x] 가입은 `true`, 소셜은 기존 내부 값, `/member/login` 일반·`isAdmin`·`isCreator` 및 creator-admin 로그인은 `false`다. (`SOCIAL-003`, `P1-T1`)
|
||||||
|
- [x] 소셜 가입 action과 nonblank PID의 `SIGNUP` 추적, 이메일 각 가입 경로의 로그 등 기존 side effect 조건이 바뀌지 않는다. (`SOCIAL-004`, `P1-T1`)
|
||||||
|
- [x] 구현 Task와 Phase Gate가 계획대로 검증되고 결과가 계획 문서에 기록된다. (`P1-GATE`)
|
||||||
|
|
||||||
|
## 8. 미결 사항
|
||||||
|
|
||||||
|
없음. strict decoder 호환성은 구현을 막는 설계 질문이 아니라 공유 `LoginResponse` 소비자에 대한 배포 전 잔여 위험이다.
|
||||||
|
|
||||||
|
## 9. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-11-03 | `DEC-001` | 권고, superseded historical | 공유 `LoginResponse`에 nullable 기본값 `isNew`를 추가하고 getter/constructor JSON 이름을 명시한다. | 최초 소셜 전용 제안. 현재 계약은 DEC-003으로 대체한다. | 당시 `SOCIAL-001`~`SOCIAL-003`, `P1-T1` |
|
||||||
|
| 2026-11-03 | `DEC-002` | 확정 | 공통 `processSocialLogin`에서 `response.isNew`를 복사해 반환한다. 서비스·provider 서명과 부수 동작은 변경하지 않는다. | 네 endpoint가 공통 처리기를 사용하며 가입 로깅은 이미 같은 값을 이용한다. | `SOCIAL-001`, `SOCIAL-004`, `P1-T1` |
|
||||||
|
| 2026-11-03 | `DEC-003` | 확정 | 두 응답 DTO에 required non-null `Boolean = false`를 추가하고 JSON key `isNew`를 항상 포함한다. null 생략이나 별도 alias는 허용하지 않는다. | 승인된 공통 가입·로그인 계약은 모든 성공 응답에서 값이 분명해야 한다. | `SOCIAL-001`~`SOCIAL-003`, `P1-T1` |
|
||||||
|
| 2026-11-03 | `DEC-004` | 확정 | 이메일 가입 두 경로는 응답 경계에서 `true`, 소셜은 기존 내부 값, 일반·creator·admin 로그인의 성공은 `false`로 매핑한다. | 공유 서비스의 가입 결과 DTO 기본값은 false이므로 signup controller의 override가 필요하다. | `SOCIAL-001`, `SOCIAL-003`, `P1-T1` |
|
||||||
|
| 2026-11-03 | `DEC-005` | 확정, DEC-003·004의 관리자 전용 로그인 범위 정정 | `isNew`는 공유 `LoginResponse`에만 적용한다. `/member/login`의 `isAdmin` 및 `isCreator` 경로와 `/creator-admin/member/login`은 공유 계약에 포함하지만, 전용 `/admin/member/login`과 `AdminMemberLoginResponse`는 제외한다. 해당 DTO는 삭제하거나 변경하지 않고 기존 `token`, `role` 응답을 유지한다. | 사용자 정정: “AdminMemberLoginResponse 는 없어도 될 것 같다. 문서 수정”. 이 결정은 DEC-003·004의 전용 관리자 DTO/endpoint 포함 주장에 대해서만 우선한다. | 현재 API 계약, `P1-T1`, `P1-GATE` |
|
||||||
|
|
||||||
|
## 10. 문서 진행 기록
|
||||||
|
|
||||||
|
- 2026-11-03 구현 완료: `LoginResponse.kt`, `MemberController.kt`에만 운영 변경을 적용했다. 계약 테스트 15건, 직접 회귀 합계 24건 및 lint 통과 후 실제 loopback HTTP 요청 14건이 모두 200과 계약에 맞는 JSON을 반환했다. 전체 빌드와 테스트 3,128건도 실패·무시 0건으로 통과했고 독립 리뷰는 스펙·품질 PASS였다. 자세한 명령과 증거는 `plan-task.md`의 P1-GATE 최종 검증 기록에 있다.
|
||||||
|
- 검증 경계: 실제 controller·Jackson·HTTP 서버를 사용하되 외부 인증/회원 생성은 서비스 경계 mock으로 격리했다. LSP 도구가 없어 Kotlin 컴파일과 lint로 확인했으며 실제 소셜 provider 인증 및 클라이언트 decoder 호환성은 배포 전 확인 항목이다. 아래 기록은 각 시점의 이력이다.
|
||||||
|
|
||||||
|
- 문서 준비만 완료했다. 구현, 테스트, 빌드, lint는 실행하지 않았다.
|
||||||
|
- `docs/agent-guides/문서유지보수.md`에 따라 문서 전용 작업에서 Gradle 검증은 생략한다. 실제 문서 간 정합성과 공백 검사 결과만 기록한다.
|
||||||
|
- 역사 기록, 당시의 소셜 전용 nullable 제안에 대한 기록이며 현재 계약의 검증 증거가 아님:
|
||||||
|
- 2026-11-03 문서 검증: PRD와 계획의 네 요구사항, API 필드, 제외 범위, Goal 연결을 대조했다.
|
||||||
|
- 이 디렉터리의 `prd.md`, `plan-task.md` 각각을 `/dev/null`과 `git diff --no-index --check`로 비교해 exit code 0을 확인했다.
|
||||||
|
- `git status --short --untracked-files=all`에서 신규 문서 두 개만 확인했다. 구현·테스트·빌드 검증 결과는 아니다.
|
||||||
|
- 위 기록은 당시 결과 그대로 보존하며, 현재 수정에 대한 검증 결과로 간주하지 않는다.
|
||||||
|
- 2026-11-03 계약 정정: 승인된 uniform required Boolean 계약을 반영했다. DEC-001의 nullable/생략 방침은 superseded historical이며, 구현은 미승인·미착수다. 이번 갱신으로 새 테스트/구현 검증 결과를 주장하지 않는다.
|
||||||
|
- 2026-11-03 정정 문서 검증: endpoint별 값, 두 DTO의 필수 boolean, 운영 코드 3개 파일과 테스트 계획을 PRD/계획 간 대조했다. 두 문서의 `git diff --no-index --check`는 각각 exit code 0이며, 작업 트리는 기존 미추적 문서 두 개만 표시했다. 테스트·빌드·lint는 문서 전용 규칙에 따라 실행하지 않았다.
|
||||||
|
- 2026-11-03 `DEC-005` 범위 정정: 현재 계약은 공유 `LoginResponse`만 대상으로 한다. `/admin/member/login` 및 `AdminMemberLoginResponse`는 기존 `token`, `role`을 유지하고 `isNew` 적용에서 제외한다. 이전 DEC-003·004 및 진행 기록의 두 DTO/전용 관리자 포함 주장은 역사 기록으로 보존되며 이 범위에서만 최신 정정이 우선한다. 이번 수정은 문서만 변경했으며 새 테스트나 구현 검증은 수행하지 않았다.
|
||||||
|
- 2026-11-03 `DEC-005` 문서 검증: 제외 경로·DTO, 운영 코드 2개 파일, 테스트·회귀 목록을 계획과 대조했다. 두 문서의 `git diff --no-index --check`는 각각 exit code 0이고 작업 트리는 기존 미추적 문서 두 개만 표시했다. 코드·테스트는 변경하지 않았으며 Gradle 검증은 실행하지 않았다.
|
||||||
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.can.use.UseCan
|
|||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import javax.persistence.CascadeType
|
import javax.persistence.CascadeType
|
||||||
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
import javax.persistence.Enumerated
|
import javax.persistence.Enumerated
|
||||||
@@ -44,4 +45,7 @@ data class Charge(
|
|||||||
var title: String? = null
|
var title: String? = null
|
||||||
|
|
||||||
var googleProductId: String? = null
|
var googleProductId: String? = null
|
||||||
|
|
||||||
|
@Column(name = "is_first_charged")
|
||||||
|
var isFirstCharged: Boolean? = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package kr.co.vividnext.sodalive.can.charge
|
|||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class ChargeCompleteResponse(
|
data class ChargeCompleteResponse(
|
||||||
|
val chargeId: Long,
|
||||||
val price: BigDecimal,
|
val price: BigDecimal,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val isFirstCharged: Boolean
|
val isFirstCharged: Boolean,
|
||||||
|
val isIap: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class ChargeController(
|
|||||||
|
|
||||||
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
|
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
|
||||||
@@ -100,7 +100,7 @@ class ChargeController(
|
|||||||
|
|
||||||
val response = service.verify(memberId = member.id!!, verifyRequest)
|
val response = service.verify(memberId = member.id!!, verifyRequest)
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/verify/hecto")
|
@PostMapping("/verify/hecto")
|
||||||
@@ -114,7 +114,7 @@ class ChargeController(
|
|||||||
|
|
||||||
val response = service.verifyHecto(memberId = member.id!!, verifyRequest)
|
val response = service.verifyHecto(memberId = member.id!!, verifyRequest)
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/apple")
|
@PostMapping("/apple")
|
||||||
@@ -140,7 +140,7 @@ class ChargeController(
|
|||||||
|
|
||||||
val response = service.appleVerify(memberId = member.id!!, verifyRequest)
|
val response = service.appleVerify(memberId = member.id!!, verifyRequest)
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/google")
|
@PostMapping("/google")
|
||||||
@@ -172,7 +172,7 @@ class ChargeController(
|
|||||||
)
|
)
|
||||||
|
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface ChargeQueryRepository {
|
|||||||
fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
|
fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
|
||||||
fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
|
fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
|
||||||
fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int
|
fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int
|
||||||
fun isFirstCharged(memberId: Long): Boolean
|
fun existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean
|
||||||
fun getChargeCount(memberId: Long): Int
|
fun getChargeCount(memberId: Long): Int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,19 +86,24 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
|
|||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isFirstCharged(memberId: Long): Boolean {
|
override fun existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(charge.id)
|
.selectOne()
|
||||||
.from(charge)
|
.from(charge)
|
||||||
.innerJoin(charge.member, member)
|
|
||||||
.innerJoin(charge.payment, payment)
|
.innerJoin(charge.payment, payment)
|
||||||
.where(
|
.where(
|
||||||
member.id.eq(memberId),
|
charge.member.id.eq(memberId),
|
||||||
charge.status.eq(ChargeStatus.CHARGE),
|
charge.id.ne(excludedChargeId),
|
||||||
payment.status.eq(PaymentStatus.COMPLETE)
|
payment.paymentGateway.`in`(
|
||||||
|
PaymentGateway.PG,
|
||||||
|
PaymentGateway.PAYVERSE,
|
||||||
|
PaymentGateway.APPLE_IAP,
|
||||||
|
PaymentGateway.GOOGLE_IAP
|
||||||
|
),
|
||||||
|
charge.status.eq(ChargeStatus.CHARGE).and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
|
.or(charge.status.eq(ChargeStatus.CANCEL).and(payment.status.eq(PaymentStatus.RETURN)))
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetchFirst() != null
|
||||||
.size <= 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getChargeCount(memberId: Long): Int {
|
override fun getChargeCount(memberId: Long): Int {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import org.springframework.http.HttpHeaders
|
|||||||
import org.springframework.retry.annotation.Backoff
|
import org.springframework.retry.annotation.Backoff
|
||||||
import org.springframework.retry.annotation.Retryable
|
import org.springframework.retry.annotation.Retryable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Isolation
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
@@ -98,7 +99,7 @@ class ChargeService(
|
|||||||
private val serverEnv: String
|
private val serverEnv: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||||
fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
|
fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
|
||||||
val chargeId = request.orderId.toLongOrNull() ?: return false
|
val chargeId = request.orderId.toLongOrNull() ?: return false
|
||||||
val charge = chargeRepository.findByIdForUpdate(chargeId) ?: return false
|
val charge = chargeRepository.findByIdForUpdate(chargeId) ?: return false
|
||||||
@@ -142,6 +143,8 @@ class ChargeService(
|
|||||||
request.sign == expectedSign
|
request.sign == expectedSign
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
|
val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false
|
||||||
|
recordFirstFact(charge, member.id!!)
|
||||||
// payverseVerify의 226~246 라인과 동일 처리
|
// payverseVerify의 226~246 라인과 동일 처리
|
||||||
charge.payment?.receiptId = request.tid
|
charge.payment?.receiptId = request.tid
|
||||||
val mappedMethod = if (request.schemeGroup == "PVKR") {
|
val mappedMethod = if (request.schemeGroup == "PVKR") {
|
||||||
@@ -153,7 +156,6 @@ class ChargeService(
|
|||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
charge.payment?.locale = request.requestCurrency
|
charge.payment?.locale = request.requestCurrency
|
||||||
|
|
||||||
val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false
|
|
||||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||||
true
|
true
|
||||||
@@ -300,10 +302,13 @@ class ChargeService(
|
|||||||
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
|
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||||
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
|
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
||||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
if (charge.member?.id != memberId) {
|
||||||
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
}
|
||||||
val member = memberRepository.findByIdForUpdate(memberId)
|
val member = memberRepository.findByIdForUpdate(memberId)
|
||||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
@@ -354,6 +359,7 @@ class ChargeService(
|
|||||||
verifyResponse.requestAmount.compareTo(expectedAmount) == 0
|
verifyResponse.requestAmount.compareTo(expectedAmount) == 0
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
|
recordFirstFact(charge, member.id!!)
|
||||||
// verify 함수의 232~248 라인과 동일 처리
|
// verify 함수의 232~248 라인과 동일 처리
|
||||||
charge.payment?.receiptId = verifyResponse.tid
|
charge.payment?.receiptId = verifyResponse.tid
|
||||||
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
|
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
|
||||||
@@ -369,11 +375,7 @@ class ChargeService(
|
|||||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return completeResponse(charge)
|
||||||
price = charge.payment!!.price,
|
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
@@ -384,11 +386,7 @@ class ChargeService(
|
|||||||
|
|
||||||
PaymentStatus.COMPLETE -> {
|
PaymentStatus.COMPLETE -> {
|
||||||
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
|
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
|
||||||
return ChargeCompleteResponse(
|
return completeResponse(charge)
|
||||||
price = charge.payment!!.price,
|
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -416,19 +414,22 @@ class ChargeService(
|
|||||||
return ChargeResponse(chargeId = charge.id!!)
|
return ChargeResponse(chargeId = charge.id!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||||
fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
||||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
if (charge.member?.id != memberId) {
|
||||||
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
}
|
||||||
val member = memberRepository.findByIdForUpdate(memberId)
|
val member = memberRepository.findByIdForUpdate(memberId)
|
||||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
||||||
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
||||||
return completeResponse(charge, memberId)
|
return completeResponse(charge)
|
||||||
}
|
}
|
||||||
|
|
||||||
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
|
val bootpay = createBootpay(bootpayApplicationId, bootpayPrivateKey)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bootpay.accessToken
|
bootpay.accessToken
|
||||||
@@ -438,13 +439,14 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
|
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
|
||||||
|
recordFirstFact(charge, member.id!!)
|
||||||
charge.payment?.receiptId = verifyResult.receiptId
|
charge.payment?.receiptId = verifyResult.receiptId
|
||||||
charge.payment?.method = verifyResult.method
|
charge.payment?.method = verifyResult.method
|
||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||||
|
|
||||||
return completeResponse(charge, memberId)
|
return completeResponse(charge)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
@@ -456,19 +458,22 @@ class ChargeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||||
fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
||||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
if (charge.member?.id != memberId) {
|
||||||
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
}
|
||||||
val member = memberRepository.findByIdForUpdate(memberId)
|
val member = memberRepository.findByIdForUpdate(memberId)
|
||||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
||||||
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
||||||
return completeResponse(charge, memberId)
|
return completeResponse(charge)
|
||||||
}
|
}
|
||||||
|
|
||||||
val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey)
|
val bootpay = createBootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bootpay.accessToken
|
bootpay.accessToken
|
||||||
@@ -478,6 +483,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1) {
|
if (verifyResult.status == 1) {
|
||||||
|
recordFirstFact(charge, member.id!!)
|
||||||
charge.payment?.receiptId = verifyResult.receiptId
|
charge.payment?.receiptId = verifyResult.receiptId
|
||||||
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
||||||
"${verifyResult.pg}-${verifyResult.method}"
|
"${verifyResult.pg}-${verifyResult.method}"
|
||||||
@@ -488,7 +494,7 @@ class ChargeService(
|
|||||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||||
|
|
||||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||||
return completeResponse(charge, memberId)
|
return completeResponse(charge)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
@@ -522,20 +528,24 @@ class ChargeService(
|
|||||||
return ChargeResponse(chargeId = charge.id!!)
|
return ChargeResponse(chargeId = charge.id!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||||
fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse {
|
fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdForUpdate(verifyRequest.chargeId)
|
val charge = chargeRepository.findByIdForUpdate(verifyRequest.chargeId)
|
||||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
if (charge.member?.id != memberId) {
|
||||||
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
}
|
||||||
val member = memberRepository.findByIdForUpdate(memberId)
|
val member = memberRepository.findByIdForUpdate(memberId)
|
||||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
|
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
|
||||||
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
||||||
return completeResponse(charge, memberId)
|
return completeResponse(charge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검증로직
|
// 검증로직
|
||||||
if (requestRealServerVerify(verifyRequest)) {
|
if (requestRealServerVerify(verifyRequest)) {
|
||||||
|
recordFirstFact(charge, member.id!!)
|
||||||
charge.payment?.receiptId = verifyRequest.receiptString
|
charge.payment?.receiptId = verifyRequest.receiptString
|
||||||
charge.payment?.method = messageSource
|
charge.payment?.method = messageSource
|
||||||
.getMessage("can.charge.payment_method.apple_iap", langContext.lang)
|
.getMessage("can.charge.payment_method.apple_iap", langContext.lang)
|
||||||
@@ -544,7 +554,7 @@ class ChargeService(
|
|||||||
member.charge(charge.chargeCan, charge.rewardCan, "ios")
|
member.charge(charge.chargeCan, charge.rewardCan, "ios")
|
||||||
|
|
||||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||||
return completeResponse(charge, memberId)
|
return completeResponse(charge)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
@@ -583,7 +593,7 @@ class ChargeService(
|
|||||||
return charge.id!!
|
return charge.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||||
fun processGoogleIap(
|
fun processGoogleIap(
|
||||||
memberId: Long,
|
memberId: Long,
|
||||||
chargeId: Long,
|
chargeId: Long,
|
||||||
@@ -592,23 +602,27 @@ class ChargeService(
|
|||||||
): ChargeCompleteResponse {
|
): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdForUpdate(chargeId)
|
val charge = chargeRepository.findByIdForUpdate(chargeId)
|
||||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
if (charge.member?.id != memberId) {
|
||||||
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
|
}
|
||||||
val member = memberRepository.findByIdForUpdate(memberId)
|
val member = memberRepository.findByIdForUpdate(memberId)
|
||||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.status == PaymentStatus.REQUEST) {
|
if (charge.payment!!.status == PaymentStatus.REQUEST) {
|
||||||
val orderId = verifyPurchase(purchaseToken, productId)
|
val orderId = verifyPurchase(purchaseToken, productId)
|
||||||
if (orderId.isNotBlank()) {
|
if (orderId.isNotBlank()) {
|
||||||
|
recordFirstFact(charge, member.id!!)
|
||||||
charge.payment!!.orderId = orderId
|
charge.payment!!.orderId = orderId
|
||||||
charge.payment!!.status = PaymentStatus.COMPLETE
|
charge.payment!!.status = PaymentStatus.COMPLETE
|
||||||
member.charge(charge.chargeCan, 0, "aos")
|
member.charge(charge.chargeCan, 0, "aos")
|
||||||
|
|
||||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||||
return completeResponse(charge, memberId)
|
return completeResponse(charge)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "can.charge.purchase_failed_contact")
|
throw SodaException(messageKey = "can.charge.purchase_failed_contact")
|
||||||
}
|
}
|
||||||
} else if (charge.payment!!.status == PaymentStatus.COMPLETE) {
|
} else if (charge.payment!!.status == PaymentStatus.COMPLETE) {
|
||||||
return completeResponse(charge, memberId)
|
return completeResponse(charge)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
@@ -690,11 +704,24 @@ class ChargeService(
|
|||||||
return String.format(template, *args)
|
return String.format(template, *args)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun completeResponse(charge: Charge, memberId: Long): ChargeCompleteResponse {
|
internal fun createBootpay(applicationId: String, privateKey: String): Bootpay {
|
||||||
|
return Bootpay(applicationId, privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordFirstFact(charge: Charge, memberId: Long) {
|
||||||
|
if (charge.payment?.status == PaymentStatus.REQUEST && charge.isFirstCharged == null) {
|
||||||
|
charge.isFirstCharged = !chargeRepository.existsSuccessfulPaidCharge(memberId, charge.id!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun completeResponse(charge: Charge): ChargeCompleteResponse {
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
|
chargeId = charge.id!!,
|
||||||
price = charge.payment!!.price,
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = charge.isFirstCharged == true,
|
||||||
|
isIap = charge.payment!!.paymentGateway == PaymentGateway.GOOGLE_IAP ||
|
||||||
|
charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.springframework.stereotype.Service
|
|||||||
class SendEmailService(private val amazonSimpleEmailService: AmazonSimpleEmailService) {
|
class SendEmailService(private val amazonSimpleEmailService: AmazonSimpleEmailService) {
|
||||||
fun sendTemplatedEmail(template: String, templateData: String, receiver: String) {
|
fun sendTemplatedEmail(template: String, templateData: String, receiver: String) {
|
||||||
val senderDto = TemplatedEmailSenderDto(
|
val senderDto = TemplatedEmailSenderDto(
|
||||||
senderEmail = "yozmlive.noreply@gmail.com",
|
senderEmail = "noreply@sodalive.net",
|
||||||
template = template,
|
template = template,
|
||||||
templateData = templateData,
|
templateData = templateData,
|
||||||
to = receiver
|
to = receiver
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class MemberController(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val message = messageSource.getMessage("member.signup.success", langContext.lang)
|
val message = messageSource.getMessage("member.signup.success", langContext.lang)
|
||||||
return ApiResponse.ok(message = message, data = response.loginResponse)
|
return ApiResponse.ok(message = message, data = response.loginResponse.copy(isNew = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/signup")
|
@PostMapping("/signup")
|
||||||
@@ -96,7 +96,7 @@ class MemberController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val message = messageSource.getMessage("member.signup.success", langContext.lang)
|
val message = messageSource.getMessage("member.signup.success", langContext.lang)
|
||||||
return ApiResponse.ok(message = message, data = response.loginResponse)
|
return ApiResponse.ok(message = message, data = response.loginResponse.copy(isNew = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@@ -440,7 +440,7 @@ class MemberController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val message = messageSource.getMessage("member.signup.success", langContext.lang)
|
val message = messageSource.getMessage("member.signup.success", langContext.lang)
|
||||||
return ApiResponse.ok(message = message, data = response.loginResponse)
|
return ApiResponse.ok(message = message, data = response.loginResponse.copy(isNew = response.isNew))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractBearerToken(authHeader: String, provider: MemberProvider): String {
|
private fun extractBearerToken(authHeader: String, provider: MemberProvider): String {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package kr.co.vividnext.sodalive.member.login
|
package kr.co.vividnext.sodalive.member.login
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
data class LoginResponse(
|
data class LoginResponse(
|
||||||
val userId: Long,
|
val userId: Long,
|
||||||
val token: String,
|
val token: String,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val email: String,
|
val email: String,
|
||||||
val profileImage: String
|
val profileImage: String,
|
||||||
|
@get:JsonProperty("isNew")
|
||||||
|
@param:JsonProperty("isNew")
|
||||||
|
val isNew: Boolean = false
|
||||||
)
|
)
|
||||||
|
|||||||
+558
@@ -0,0 +1,558 @@
|
|||||||
|
package kr.co.vividnext.sodalive.can.charge
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.bootpay.Bootpay
|
||||||
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
|
import kr.co.vividnext.sodalive.can.CanRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
|
import kr.co.vividnext.sodalive.google.GooglePlayService
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorker
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
|
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobWorker
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.apache.commons.codec.digest.DigestUtils
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.boot.test.mock.mockito.SpyBean
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:charge-controller-http;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||||
|
"jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==",
|
||||||
|
"apple.iap-verify-url=https://apple.invalid/verifyReceipt",
|
||||||
|
"apple.iap-verify-sandbox-url=https://apple-sandbox.invalid/verifyReceipt",
|
||||||
|
"payverse.host=https://payverse.invalid",
|
||||||
|
"payverse.inbound-ip=192.0.2.40",
|
||||||
|
"payverse.usd-mid=http-usd-mid",
|
||||||
|
"payverse.usd-client-key=http-usd-client",
|
||||||
|
"payverse.usd-secret-key=http-usd-secret"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
class ChargeControllerIntegrationTest @Autowired constructor(
|
||||||
|
private val restTemplate: TestRestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val chargeRepository: ChargeRepository,
|
||||||
|
private val canRepository: CanRepository,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val tokenRepository: MemberTokenRepository,
|
||||||
|
private val tokenProvider: TokenProvider,
|
||||||
|
private val jobRepository: ChargeEventJobRepository,
|
||||||
|
private val transactionTemplate: TransactionTemplate,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@SpyBean
|
||||||
|
private lateinit var service: ChargeService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var googlePlayService: GooglePlayService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var trackingService: AdTrackingService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var chargeEventJobWorker: ChargeEventJobWorker
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var translationJobWorker: TranslationJobWorker
|
||||||
|
|
||||||
|
private lateinit var bootpay: Bootpay
|
||||||
|
private val memberIds = mutableListOf<Long>()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
val blockedRequest = Request.Builder().url("https://network-blocked.invalid").build()
|
||||||
|
Mockito.doThrow(AssertionError("External HTTP is blocked"))
|
||||||
|
.`when`(okHttpClient).newCall(Mockito.any(Request::class.java) ?: blockedRequest)
|
||||||
|
Mockito.doThrow(AssertionError("Real Google verification is blocked"))
|
||||||
|
.`when`(googlePlayService).verifyAndConsumePurchase(Mockito.anyString(), Mockito.anyString())
|
||||||
|
bootpay = Mockito.mock(Bootpay::class.java)
|
||||||
|
Mockito.doThrow(AssertionError("Real Bootpay token requests are blocked")).`when`(bootpay).accessToken
|
||||||
|
Mockito.doThrow(AssertionError("Real Bootpay receipt requests are blocked"))
|
||||||
|
.`when`(bootpay).getReceipt(Mockito.anyString())
|
||||||
|
Mockito.doReturn(bootpay).`when`(service).createBootpay(Mockito.anyString(), Mockito.anyString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanUp() {
|
||||||
|
tokenRepository.deleteAllById(memberIds)
|
||||||
|
jobRepository.deleteAllInBatch()
|
||||||
|
chargeRepository.deleteAll()
|
||||||
|
canRepository.deleteAllInBatch()
|
||||||
|
memberRepository.deleteAllById(memberIds)
|
||||||
|
memberIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(VerificationPath::class)
|
||||||
|
@DisplayName("다섯 완료 HTTP 경로는 저장된 충전 ID와 완료 정보 및 기존 envelope를 반환한다")
|
||||||
|
fun shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints(path: VerificationPath) {
|
||||||
|
val owner = createMember(container = if (path.isIap) "web" else "ios")
|
||||||
|
val chargeId = if (path == VerificationPath.GOOGLE) {
|
||||||
|
allowGooglePurchase()
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
createCharge(owner, path.gateway, first = true)
|
||||||
|
}
|
||||||
|
val before = chargeId?.let { storedState(it, owner.id!!) }
|
||||||
|
|
||||||
|
val data = successData(post(path.path, verificationRequest(path, chargeId ?: 0), token(owner)))
|
||||||
|
|
||||||
|
val expectedChargeId = chargeId ?: transactionTemplate.execute { chargeRepository.findAll().single().id!! }!!
|
||||||
|
assertCompletion(data, chargeId = expectedChargeId, first = true, iap = path.isIap)
|
||||||
|
if (chargeId == null) {
|
||||||
|
assertCompleted(expectedChargeId, owner.id!!, PaymentGateway.GOOGLE_IAP, listOf(0, 0, 100, 0, 0, 0))
|
||||||
|
Mockito.verify(googlePlayService).verifyAndConsumePurchase("http-purchase", "http-product")
|
||||||
|
Mockito.verifyNoInteractions(bootpay, okHttpClient)
|
||||||
|
} else {
|
||||||
|
assertEquals(before, storedState(chargeId, owner.id!!))
|
||||||
|
assertNoProviderCalls()
|
||||||
|
}
|
||||||
|
assertTracking(owner.id!!, AdTrackingHistoryType.FIRST_PAYMENT)
|
||||||
|
Mockito.verifyNoMoreInteractions(googlePlayService, trackingService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = VerificationPath::class, names = ["GOOGLE"], mode = EnumSource.Mode.EXCLUDE)
|
||||||
|
@DisplayName("저장된 true·false·null과 금액·통화는 재호출 및 단말 종류와 무관하게 직렬화된다")
|
||||||
|
fun shouldSerializeCompletionFlagsAndPreservePriceAndCurrency(path: VerificationPath) {
|
||||||
|
listOf(true to true, false to false, null to false).forEach { (first, expected) ->
|
||||||
|
val owner = createMember(container = if (path.isIap) "web" else "aos")
|
||||||
|
val chargeId = createCharge(owner, path.gateway, first = first)
|
||||||
|
val before = storedState(chargeId, owner.id!!)
|
||||||
|
val bearer = token(owner)
|
||||||
|
|
||||||
|
repeat(2) {
|
||||||
|
assertCompletion(
|
||||||
|
successData(post(path.path, verificationRequest(path, chargeId), bearer)),
|
||||||
|
chargeId = chargeId,
|
||||||
|
first = expected,
|
||||||
|
iap = path.isIap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(before, storedState(chargeId, owner.id!!))
|
||||||
|
val type = if (first == true) AdTrackingHistoryType.FIRST_PAYMENT else AdTrackingHistoryType.REPEAT_PAYMENT
|
||||||
|
assertTracking(owner.id!!, type, count = 2)
|
||||||
|
}
|
||||||
|
assertNoProviderCalls()
|
||||||
|
Mockito.verifyNoMoreInteractions(trackingService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Google 신규 REQUEST는 기존 유료 이력이 있으면 false를 저장하고 REPEAT_PAYMENT를 전달한다")
|
||||||
|
fun shouldCompleteNewGoogleChargeAsRepeatAfterLegacyPaidHistory() {
|
||||||
|
val owner = createMember(container = "ios")
|
||||||
|
val legacyId = createCharge(owner, PaymentGateway.GOOGLE_IAP, first = null)
|
||||||
|
val legacyBefore = storedState(legacyId, owner.id!!)
|
||||||
|
allowGooglePurchase()
|
||||||
|
|
||||||
|
val data = successData(post("/charge/google", googleRequest(), token(owner)))
|
||||||
|
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
val created = chargeRepository.findAll().single { it.id != legacyId }
|
||||||
|
assertCompletion(data, chargeId = created.id!!, first = false, iap = true)
|
||||||
|
assertEquals(false, created.isFirstCharged)
|
||||||
|
assertEquals(PaymentStatus.COMPLETE, created.payment!!.status)
|
||||||
|
assertEquals("http-google-order", created.payment!!.orderId)
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
legacyBefore.copy(balances = listOf(listOf(0, 0, 100, 0, 0, 0)), chargeCount = 2),
|
||||||
|
storedState(legacyId, owner.id!!)
|
||||||
|
)
|
||||||
|
assertTracking(owner.id!!, AdTrackingHistoryType.REPEAT_PAYMENT)
|
||||||
|
Mockito.verify(googlePlayService).verifyAndConsumePurchase("http-purchase", "http-product")
|
||||||
|
Mockito.verifyNoMoreInteractions(googlePlayService, trackingService)
|
||||||
|
Mockito.verifyNoInteractions(bootpay, okHttpClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = VerificationPath::class, names = ["GOOGLE"], mode = EnumSource.Mode.EXCLUDE)
|
||||||
|
@DisplayName("타인 소유 REQUEST·COMPLETE HTTP 요청은 동일한 기존 오류와 무변경 상태를 유지한다")
|
||||||
|
fun shouldRejectForeignChargesBeforeShortcutOrMutation(path: VerificationPath) {
|
||||||
|
listOf(PaymentStatus.REQUEST, PaymentStatus.COMPLETE).forEach { status ->
|
||||||
|
val owner = createMember()
|
||||||
|
val requester = createMember()
|
||||||
|
val chargeId = createCharge(
|
||||||
|
owner,
|
||||||
|
path.gateway,
|
||||||
|
status,
|
||||||
|
first = if (status == PaymentStatus.COMPLETE) true else null
|
||||||
|
)
|
||||||
|
val before = storedState(chargeId, owner.id!!, requester.id!!)
|
||||||
|
|
||||||
|
val response = post(path.path, verificationRequest(path, chargeId), token(requester))
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode, response.body)
|
||||||
|
val body = objectMapper.readTree(response.body)
|
||||||
|
assertEnvelope(body)
|
||||||
|
assertEquals(false, body["success"].booleanValue())
|
||||||
|
assertEquals("There is an error with the payment information.", body["message"].textValue())
|
||||||
|
assertEquals(true, body["data"].isNull)
|
||||||
|
assertEquals(true, body["errorProperty"].isNull)
|
||||||
|
assertEquals(before, storedState(chargeId, owner.id!!, requester.id!!))
|
||||||
|
}
|
||||||
|
assertNoProviderCalls()
|
||||||
|
Mockito.verifyNoInteractions(trackingService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(VerificationPath::class)
|
||||||
|
@DisplayName("다섯 완료 HTTP 경로는 익명 요청을 401로 거부하고 charge를 생성하지 않는다")
|
||||||
|
fun shouldRejectAnonymousVerificationBeforeCreation(path: VerificationPath) {
|
||||||
|
val before = chargeRepository.count()
|
||||||
|
assertEquals(HttpStatus.UNAUTHORIZED, post(path.path, verificationRequest(path, 0)).statusCode)
|
||||||
|
assertEquals(before, chargeRepository.count())
|
||||||
|
assertNoProviderCalls()
|
||||||
|
Mockito.verifyNoInteractions(trackingService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("partner PID와 미래 만료시각이 모두 유효한 회원에게만 기존 tracking이 전달된다")
|
||||||
|
fun shouldKeepPartnerTrackingEligibility() {
|
||||||
|
val future = LocalDateTime.now().plusDays(1)
|
||||||
|
listOf(
|
||||||
|
null to future,
|
||||||
|
"" to future,
|
||||||
|
" " to future,
|
||||||
|
"http-partner" to null,
|
||||||
|
"http-partner" to LocalDateTime.now().minusDays(1)
|
||||||
|
).forEach { (pid, expiration) ->
|
||||||
|
val owner = createMember(pid = pid, expiration = expiration)
|
||||||
|
val chargeId = createCharge(owner, PaymentGateway.PG, first = true)
|
||||||
|
assertCompletion(
|
||||||
|
successData(post("/charge/verify", VerifyRequest("http-receipt", "$chargeId"), token(owner))),
|
||||||
|
chargeId = chargeId,
|
||||||
|
first = true,
|
||||||
|
iap = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertNoProviderCalls()
|
||||||
|
Mockito.verifyNoInteractions(trackingService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PG·Apple·Payverse 생성 HTTP 응답은 chargeId 및 기존 payloadJson shape만 반환한다")
|
||||||
|
fun shouldKeepCreationResponseShapes() {
|
||||||
|
val owner = createMember()
|
||||||
|
val bearer = token(owner)
|
||||||
|
val can = canRepository.saveAndFlush(Can("HTTP pack", 100, 10, BigDecimal("9.99"), "USD", CanStatus.SALE))
|
||||||
|
val requests = listOf(
|
||||||
|
Triple("/charge", ChargeRequest(can.id!!, PaymentGateway.PG), PaymentGateway.PG),
|
||||||
|
Triple(
|
||||||
|
"/charge/apple",
|
||||||
|
AppleChargeRequest("HTTP pack", 100, PaymentGateway.APPLE_IAP, BigDecimal("9.99"), "USD"),
|
||||||
|
PaymentGateway.APPLE_IAP
|
||||||
|
),
|
||||||
|
Triple("/charge/payverse", PayverseChargeRequest(can.id!!), PaymentGateway.PAYVERSE)
|
||||||
|
)
|
||||||
|
requests.forEach { (path, request, gateway) ->
|
||||||
|
val data = successData(post(path, request, bearer))
|
||||||
|
val keys = if (path == "/charge/payverse") setOf("chargeId", "payloadJson") else setOf("chargeId")
|
||||||
|
assertEquals(keys, data.fieldNames().asSequence().toSet())
|
||||||
|
assertEquals(true, data["chargeId"].isIntegralNumber)
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
val charge = chargeRepository.findById(data["chargeId"].longValue()).orElseThrow()
|
||||||
|
assertEquals(owner.id, charge.member!!.id)
|
||||||
|
assertEquals(PaymentStatus.REQUEST, charge.payment!!.status)
|
||||||
|
assertEquals(gateway, charge.payment!!.paymentGateway)
|
||||||
|
assertEquals(null, charge.isFirstCharged)
|
||||||
|
}
|
||||||
|
if (path == "/charge/payverse") {
|
||||||
|
assertEquals(true, data["payloadJson"].isTextual)
|
||||||
|
val payload = objectMapper.readTree(data["payloadJson"].textValue())
|
||||||
|
assertEquals(
|
||||||
|
setOf(
|
||||||
|
"mid", "clientKey", "orderId", "customerId", "productName", "requestCurrency",
|
||||||
|
"requestAmount", "reqDate", "sign"
|
||||||
|
),
|
||||||
|
payload.fieldNames().asSequence().toSet()
|
||||||
|
)
|
||||||
|
assertEquals(data["chargeId"].asText(), payload["orderId"].textValue())
|
||||||
|
assertEquals("USD", payload["requestCurrency"].textValue())
|
||||||
|
assertEquals(0, BigDecimal("9.99").compareTo(payload["requestAmount"].decimalValue()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoProviderCalls()
|
||||||
|
Mockito.verifyNoInteractions(trackingService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Payverse signed webhook은 기존 단일 key 응답과 IP·서명 검사 및 완료 replay를 유지한다")
|
||||||
|
fun shouldKeepSignedWebhookResponseShapeAndRejectInvalidIpOrSignature() {
|
||||||
|
val owner = createMember()
|
||||||
|
val chargeId = createCharge(owner, PaymentGateway.PAYVERSE, PaymentStatus.REQUEST)
|
||||||
|
val request = PayverseWebhookRequest(
|
||||||
|
"PAYMENT", "http-usd-mid", "http-tid", "PVKR", "041", "$chargeId",
|
||||||
|
"USD", BigDecimal("9.99"), "SUCCESS", "20261103120000",
|
||||||
|
DigestUtils.sha512Hex("||http-usd-secret||http-usd-mid||$chargeId||9.99||20261103120000||")
|
||||||
|
)
|
||||||
|
val before = storedState(chargeId, owner.id!!)
|
||||||
|
assertEquals(HttpStatus.NOT_FOUND, post("/charge/payverse/webhook", request, ip = "192.0.2.41").statusCode)
|
||||||
|
assertEquals(before, storedState(chargeId, owner.id!!))
|
||||||
|
assertEquals(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
post("/charge/payverse/webhook", request.copy(sign = "invalid"), ip = "192.0.2.40").statusCode
|
||||||
|
)
|
||||||
|
assertEquals(before, storedState(chargeId, owner.id!!))
|
||||||
|
|
||||||
|
repeat(2) {
|
||||||
|
val response = post("/charge/payverse/webhook", request, ip = "192.0.2.40")
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode, response.body)
|
||||||
|
val body = objectMapper.readTree(response.body)
|
||||||
|
assertEquals(setOf("receiveResult"), body.fieldNames().asSequence().toSet())
|
||||||
|
assertEquals("SUCCESS", body["receiveResult"].textValue())
|
||||||
|
assertCompleted(chargeId, owner.id!!, PaymentGateway.PAYVERSE, listOf(100, 10, 0, 0, 0, 0))
|
||||||
|
}
|
||||||
|
assertEquals(1L, chargeRepository.count())
|
||||||
|
assertNoProviderCalls()
|
||||||
|
Mockito.verifyNoInteractions(trackingService)
|
||||||
|
val completed = storedState(chargeId, owner.id!!)
|
||||||
|
assertCompletion(
|
||||||
|
successData(post("/charge/payverse/verify", PayverseVerifyRequest("http-tid", "$chargeId"), token(owner))),
|
||||||
|
chargeId = chargeId,
|
||||||
|
first = true,
|
||||||
|
iap = false
|
||||||
|
)
|
||||||
|
assertEquals(completed, storedState(chargeId, owner.id!!))
|
||||||
|
assertTracking(owner.id!!, AdTrackingHistoryType.FIRST_PAYMENT)
|
||||||
|
assertNoProviderCalls()
|
||||||
|
Mockito.verifyNoMoreInteractions(trackingService)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(
|
||||||
|
pid: String? = "http-partner",
|
||||||
|
expiration: LocalDateTime? = LocalDateTime.now().plusDays(1),
|
||||||
|
container: String = "web"
|
||||||
|
): Member {
|
||||||
|
return memberRepository.saveAndFlush(
|
||||||
|
Member(
|
||||||
|
email = "http-${UUID.randomUUID()}@test.invalid",
|
||||||
|
password = "test-password",
|
||||||
|
nickname = "http-owner",
|
||||||
|
activePid = pid,
|
||||||
|
partnerExpirationDatetime = expiration,
|
||||||
|
container = container
|
||||||
|
)
|
||||||
|
).also { memberIds.add(it.id!!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCharge(
|
||||||
|
owner: Member,
|
||||||
|
gateway: PaymentGateway,
|
||||||
|
status: PaymentStatus = PaymentStatus.COMPLETE,
|
||||||
|
first: Boolean? = null
|
||||||
|
): Long = transactionTemplate.execute {
|
||||||
|
val charge = Charge(100, 10).apply {
|
||||||
|
member = entityManager.getReference(Member::class.java, owner.id!!)
|
||||||
|
isFirstCharged = first
|
||||||
|
payment = Payment(status = status, paymentGateway = gateway).apply {
|
||||||
|
price = BigDecimal("9.99")
|
||||||
|
locale = "USD"
|
||||||
|
receiptId = "stored-receipt"
|
||||||
|
method = "stored-method"
|
||||||
|
orderId = "stored-order"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chargeRepository.saveAndFlush(charge).id!!
|
||||||
|
}!!
|
||||||
|
|
||||||
|
private fun token(member: Member): String = tokenProvider.createToken(
|
||||||
|
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_USER"))),
|
||||||
|
member.id!!
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun googleRequest() = GoogleChargeRequest(
|
||||||
|
"HTTP pack",
|
||||||
|
100,
|
||||||
|
BigDecimal("9.99"),
|
||||||
|
"USD",
|
||||||
|
"http-product",
|
||||||
|
"http-purchase",
|
||||||
|
PaymentGateway.GOOGLE_IAP
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun verificationRequest(path: VerificationPath, chargeId: Long): Any = when (path) {
|
||||||
|
VerificationPath.VERIFY, VerificationPath.HECTO -> VerifyRequest("http-receipt", "$chargeId")
|
||||||
|
VerificationPath.PAYVERSE -> PayverseVerifyRequest("http-transaction", "$chargeId")
|
||||||
|
VerificationPath.APPLE -> AppleVerifyRequest("http-receipt", chargeId)
|
||||||
|
VerificationPath.GOOGLE -> googleRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allowGooglePurchase() {
|
||||||
|
Mockito.doReturn("http-google-order").`when`(googlePlayService)
|
||||||
|
.verifyAndConsumePurchase("http-purchase", "http-product")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(path: String, request: Any, bearer: String? = null, ip: String? = null): ResponseEntity<String> {
|
||||||
|
val headers = HttpHeaders().apply {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
bearer?.let { setBearerAuth(it) }
|
||||||
|
ip?.let { set("X-Forwarded-For", it) }
|
||||||
|
}
|
||||||
|
val response = restTemplate.exchange(path, HttpMethod.POST, HttpEntity(request, headers), String::class.java)
|
||||||
|
println("HTTP EVIDENCE POST $path ${response.statusCodeValue}: ${response.body}")
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertEnvelope(body: JsonNode) {
|
||||||
|
assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet())
|
||||||
|
assertEquals(true, body["success"].isBoolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun successData(response: ResponseEntity<String>): JsonNode {
|
||||||
|
assertEquals(HttpStatus.OK, response.statusCode, response.body)
|
||||||
|
val body = objectMapper.readTree(response.body)
|
||||||
|
assertEnvelope(body)
|
||||||
|
assertEquals(true, body["success"].booleanValue(), response.body)
|
||||||
|
assertEquals(true, body["message"].isNull)
|
||||||
|
assertEquals(true, body["errorProperty"].isNull)
|
||||||
|
assertEquals(true, body["data"].isObject, response.body)
|
||||||
|
return body["data"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertCompletion(data: JsonNode, chargeId: Long, first: Boolean, iap: Boolean) {
|
||||||
|
assertEquals(
|
||||||
|
setOf("chargeId", "price", "currencyCode", "isFirstCharged", "isIap"),
|
||||||
|
data.fieldNames().asSequence().toSet()
|
||||||
|
)
|
||||||
|
assertEquals(true, data["chargeId"].isIntegralNumber)
|
||||||
|
assertEquals(chargeId, data["chargeId"].longValue())
|
||||||
|
assertEquals(true, data["price"].isNumber)
|
||||||
|
assertEquals(0, BigDecimal("9.99").compareTo(data["price"].decimalValue()))
|
||||||
|
assertEquals("USD", data["currencyCode"].textValue())
|
||||||
|
listOf("isFirstCharged" to first, "isIap" to iap).forEach { (key, expected) ->
|
||||||
|
assertEquals(true, data[key].isBoolean, data.toString())
|
||||||
|
assertEquals(expected, data[key].booleanValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertTracking(memberId: Long, type: AdTrackingHistoryType, count: Int = 1) {
|
||||||
|
val prices = ArgumentCaptor.forClass(BigDecimal::class.java)
|
||||||
|
Mockito.verify(trackingService, Mockito.times(count)).saveTrackingHistory(
|
||||||
|
Mockito.eq("http-partner") ?: "",
|
||||||
|
Mockito.eq(type) ?: type,
|
||||||
|
Mockito.eq(memberId),
|
||||||
|
prices.capture(),
|
||||||
|
Mockito.eq("USD")
|
||||||
|
)
|
||||||
|
prices.allValues.forEach { assertEquals(0, BigDecimal("9.99").compareTo(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoProviderCalls() {
|
||||||
|
Mockito.verifyNoInteractions(bootpay, okHttpClient, googlePlayService)
|
||||||
|
Mockito.verify(service, Mockito.never()).createBootpay(Mockito.anyString(), Mockito.anyString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertCompleted(chargeId: Long, ownerId: Long, gateway: PaymentGateway, expectedBalances: List<Int>) {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
val charge = chargeRepository.findById(chargeId).orElseThrow()
|
||||||
|
assertEquals(ownerId, charge.member!!.id)
|
||||||
|
assertEquals(true, charge.isFirstCharged)
|
||||||
|
assertEquals(PaymentStatus.COMPLETE, charge.payment!!.status)
|
||||||
|
assertEquals(gateway, charge.payment!!.paymentGateway)
|
||||||
|
assertEquals(expectedBalances, balances(memberRepository.findById(ownerId).orElseThrow()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storedState(chargeId: Long, vararg memberIds: Long): StoredState = transactionTemplate.execute {
|
||||||
|
val charge = chargeRepository.findById(chargeId).orElseThrow()
|
||||||
|
val payment = charge.payment!!
|
||||||
|
StoredState(
|
||||||
|
charge.status,
|
||||||
|
charge.isFirstCharged,
|
||||||
|
charge.chargeCan,
|
||||||
|
charge.rewardCan,
|
||||||
|
payment.status,
|
||||||
|
payment.paymentGateway,
|
||||||
|
payment.price,
|
||||||
|
payment.locale,
|
||||||
|
payment.receiptId,
|
||||||
|
payment.method,
|
||||||
|
payment.orderId,
|
||||||
|
memberIds.map { balances(memberRepository.findById(it).orElseThrow()) },
|
||||||
|
chargeRepository.count(),
|
||||||
|
jobRepository.count()
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
|
||||||
|
private fun balances(member: Member) = listOf(
|
||||||
|
member.pgChargeCan,
|
||||||
|
member.pgRewardCan,
|
||||||
|
member.googleChargeCan,
|
||||||
|
member.googleRewardCan,
|
||||||
|
member.appleChargeCan,
|
||||||
|
member.appleRewardCan
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class VerificationPath(val path: String, val gateway: PaymentGateway, val isIap: Boolean) {
|
||||||
|
VERIFY("/charge/verify", PaymentGateway.PG, false),
|
||||||
|
HECTO("/charge/verify/hecto", PaymentGateway.PG, false),
|
||||||
|
PAYVERSE("/charge/payverse/verify", PaymentGateway.PAYVERSE, false),
|
||||||
|
APPLE("/charge/apple/verify", PaymentGateway.APPLE_IAP, true),
|
||||||
|
GOOGLE("/charge/google", PaymentGateway.GOOGLE_IAP, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StoredState(
|
||||||
|
val chargeStatus: ChargeStatus,
|
||||||
|
val first: Boolean?,
|
||||||
|
val chargeCan: Int,
|
||||||
|
val rewardCan: Int,
|
||||||
|
val paymentStatus: PaymentStatus,
|
||||||
|
val gateway: PaymentGateway,
|
||||||
|
val price: BigDecimal,
|
||||||
|
val locale: String?,
|
||||||
|
val receiptId: String?,
|
||||||
|
val method: String?,
|
||||||
|
val orderId: String?,
|
||||||
|
val balances: List<List<Int>>,
|
||||||
|
val chargeCount: Long,
|
||||||
|
val eventJobCount: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package kr.co.vividnext.sodalive.can.charge
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.hibernate.dialect.MySQL8Dialect
|
||||||
|
import org.hibernate.type.BooleanType
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.Arguments
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
|
import org.junit.jupiter.params.provider.NullSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@DataJpaTest(properties = ["spring.cache.type=none"])
|
||||||
|
@Import(QueryDslConfig::class)
|
||||||
|
class ChargeRepositoryTest @Autowired constructor(
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val repository: ChargeRepository
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("최초 유료 충전 fact는 엔티티에 저장되는 속성이다")
|
||||||
|
fun shouldMapFirstFactAsPersistentAttribute() {
|
||||||
|
assertTrue(entityManager.metamodel.entity(Charge::class.java).attributes.any { it.name == "isFirstCharged" })
|
||||||
|
val column = Charge::class.java.getDeclaredField("isFirstCharged").getAnnotation(Column::class.java)
|
||||||
|
assertEquals("is_first_charged", column.name)
|
||||||
|
assertEquals(true, column.nullable)
|
||||||
|
assertEquals("bit", MySQL8Dialect().getTypeName(BooleanType.INSTANCE.sqlType()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@NullSource
|
||||||
|
@ValueSource(booleans = [true, false])
|
||||||
|
@DisplayName("nullable 최초 fact의 null true false가 DB 왕복 후 보존된다")
|
||||||
|
fun shouldRoundTripNullableFirstFact(marker: Boolean?) {
|
||||||
|
val owner = owner()
|
||||||
|
val charge = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.REQUEST, PaymentGateway.PG).apply {
|
||||||
|
isFirstCharged = marker
|
||||||
|
}
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
assertEquals(marker, repository.findById(charge.id!!).orElseThrow().isFirstCharged)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("historyCases")
|
||||||
|
@DisplayName("유료 이력은 네 gateway와 정확한 충전 완료 또는 취소 환불 쌍만 인정한다")
|
||||||
|
fun shouldFindOnlyOtherSameMemberPaidChargeOrReturnedCancellation(
|
||||||
|
chargeStatus: ChargeStatus,
|
||||||
|
paymentStatus: PaymentStatus,
|
||||||
|
gateway: PaymentGateway,
|
||||||
|
expected: Boolean
|
||||||
|
) {
|
||||||
|
val owner = owner()
|
||||||
|
val current = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.PG)
|
||||||
|
saveCharge(owner, chargeStatus, paymentStatus, gateway)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
assertEquals(expected, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 charge와 다른 회원 이력은 제외하고 생성 순서 잔액 marker로 이력을 제한하지 않는다")
|
||||||
|
fun shouldExcludeCurrentAndOtherMembersWithoutOrderingOrMarkerFilters() {
|
||||||
|
val owner = owner()
|
||||||
|
val current = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.PG)
|
||||||
|
saveCharge(owner(), ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.APPLE_IAP)
|
||||||
|
assertEquals(false, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!))
|
||||||
|
val later = saveCharge(owner, ChargeStatus.CANCEL, PaymentStatus.RETURN, PaymentGateway.PAYVERSE)
|
||||||
|
later.createdAt = LocalDateTime.of(2020, 1, 1, 0, 0)
|
||||||
|
current.createdAt = LocalDateTime.of(2030, 1, 1, 0, 0)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
assertEquals(true, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun owner(): Member {
|
||||||
|
return Member(password = "password", nickname = "history-owner").also { entityManager.persist(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCharge(owner: Member, status: ChargeStatus, paymentStatus: PaymentStatus, gateway: PaymentGateway): Charge {
|
||||||
|
return repository.save(
|
||||||
|
Charge(0, 0, status).apply {
|
||||||
|
member = owner
|
||||||
|
payment = Payment(paymentStatus, gateway)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun historyCases(): List<Arguments> {
|
||||||
|
val pairs = listOf(
|
||||||
|
Triple(ChargeStatus.CHARGE, PaymentStatus.COMPLETE, true),
|
||||||
|
Triple(ChargeStatus.CANCEL, PaymentStatus.RETURN, true),
|
||||||
|
Triple(ChargeStatus.CHARGE, PaymentStatus.RETURN, false),
|
||||||
|
Triple(ChargeStatus.CANCEL, PaymentStatus.COMPLETE, false),
|
||||||
|
Triple(ChargeStatus.CHARGE, PaymentStatus.REQUEST, false),
|
||||||
|
Triple(ChargeStatus.CANCEL, PaymentStatus.REQUEST, false)
|
||||||
|
) + listOf(ChargeStatus.EVENT, ChargeStatus.COUPON, ChargeStatus.ADMIN, ChargeStatus.ADS, ChargeStatus.REFUND_CHARGE)
|
||||||
|
.flatMap { status -> PaymentStatus.values().map { Triple(status, it, false) } }
|
||||||
|
return pairs.flatMap { (status, payment, paid) ->
|
||||||
|
listOf(PaymentGateway.PG, PaymentGateway.PAYVERSE, PaymentGateway.APPLE_IAP, PaymentGateway.GOOGLE_IAP)
|
||||||
|
.map { Arguments.of(status, payment, it, paid) } +
|
||||||
|
Arguments.of(status, payment, PaymentGateway.POINT_CLICK_AD, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
package kr.co.vividnext.sodalive.can.charge
|
||||||
|
|
||||||
|
import kr.co.bootpay.Bootpay
|
||||||
|
import kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundService
|
||||||
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.google.GooglePlayService
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Protocol
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.apache.commons.codec.digest.DigestUtils
|
||||||
|
import org.junit.jupiter.api.Assertions.assertAll
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.Arguments
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.boot.test.mock.mockito.SpyBean
|
||||||
|
import org.springframework.core.env.Environment
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.TransactionDefinition
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:charge-service-integration;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||||
|
"apple.iap-verify-url=https://apple.invalid/verifyReceipt",
|
||||||
|
"apple.iap-verify-sandbox-url=https://apple-sandbox.invalid/verifyReceipt",
|
||||||
|
"payverse.host=https://payverse.invalid",
|
||||||
|
"payverse.usd-mid=t2-test-mid",
|
||||||
|
"payverse.usd-secret-key=t2-test-secret"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
class ChargeServiceIntegrationTest @Autowired constructor(
|
||||||
|
private val chargeRepository: ChargeRepository,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val jobRepository: ChargeEventJobRepository,
|
||||||
|
private val transactionTemplate: TransactionTemplate,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val environment: Environment,
|
||||||
|
private val refundService: AdminChargeRefundService
|
||||||
|
) {
|
||||||
|
@SpyBean
|
||||||
|
private lateinit var service: ChargeService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var googlePlayService: GooglePlayService
|
||||||
|
|
||||||
|
@SpyBean
|
||||||
|
private lateinit var chargeEventJobService: ChargeEventJobService
|
||||||
|
|
||||||
|
private lateinit var bootpay: Bootpay
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
val blockedRequest = Request.Builder().url("https://network-blocked.invalid").build()
|
||||||
|
Mockito.doThrow(AssertionError("외부 HTTP 호출 금지"))
|
||||||
|
.`when`(okHttpClient).newCall(Mockito.any(Request::class.java) ?: blockedRequest)
|
||||||
|
Mockito.doThrow(AssertionError("실제 Google 구매 검증 금지"))
|
||||||
|
.`when`(googlePlayService).verifyAndConsumePurchase(Mockito.anyString(), Mockito.anyString())
|
||||||
|
bootpay = Mockito.mock(Bootpay::class.java)
|
||||||
|
Mockito.doThrow(AssertionError("실제 Bootpay 토큰 요청 금지")).`when`(bootpay).accessToken
|
||||||
|
Mockito.doThrow(AssertionError("실제 Bootpay 영수증 조회 금지"))
|
||||||
|
.`when`(bootpay).getReceipt(Mockito.anyString())
|
||||||
|
Mockito.doReturn(bootpay).`when`(service).createBootpay(Mockito.anyString(), Mockito.anyString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(VerificationPath::class)
|
||||||
|
@DisplayName("타인 소유 완료 충전은 다섯 검증 경로에서 완료 shortcut보다 먼저 거부된다")
|
||||||
|
fun shouldRejectForeignCompletedChargeBeforeShortcut(path: VerificationPath) {
|
||||||
|
val fixture = createFixture(path.gateway, PaymentStatus.COMPLETE)
|
||||||
|
val before = storedState(fixture)
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
verify(path, fixture.requesterId, fixture.chargeId)
|
||||||
|
}
|
||||||
|
assertEquals("can.charge.invalid_payment_info", exception.messageKey)
|
||||||
|
},
|
||||||
|
{ assertEquals(before, storedState(fixture)) },
|
||||||
|
{ assertNoProviderOrEventCalls() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(VerificationPath::class)
|
||||||
|
@DisplayName("타인 소유 요청 충전은 다섯 검증 경로에서 provider 호출과 변경 전에 거부된다")
|
||||||
|
fun shouldRejectForeignPendingChargeBeforeProviderOrMutation(path: VerificationPath) {
|
||||||
|
val fixture = createFixture(path.gateway, PaymentStatus.REQUEST)
|
||||||
|
val before = storedState(fixture)
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
verify(path, fixture.requesterId, fixture.chargeId)
|
||||||
|
}
|
||||||
|
assertEquals("can.charge.invalid_payment_info", exception.messageKey)
|
||||||
|
},
|
||||||
|
{ assertEquals(before, storedState(fixture)) },
|
||||||
|
{ assertNoProviderOrEventCalls() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(VerificationPath::class)
|
||||||
|
@DisplayName("본인 소유 완료 충전 재조회는 상태와 잔액을 중복 변경하지 않는다")
|
||||||
|
fun shouldReplayOwnCompletedChargeWithoutMutation(path: VerificationPath) {
|
||||||
|
val fixture = createFixture(path.gateway, PaymentStatus.COMPLETE)
|
||||||
|
val before = storedState(fixture)
|
||||||
|
|
||||||
|
val response = verify(path, fixture.ownerId, fixture.chargeId)
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(0, BigDecimal("9.99").compareTo(response.price)) },
|
||||||
|
{ assertEquals("USD", response.currencyCode) },
|
||||||
|
{ assertEquals(false, response.isFirstCharged) },
|
||||||
|
{ assertEquals(before, storedState(fixture)) },
|
||||||
|
{ assertNoProviderOrEventCalls() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(VerificationPath::class)
|
||||||
|
@DisplayName("첫 완료 fact는 저장되고 이후 구매 뒤에도 첫 결제 응답에 유지된다")
|
||||||
|
fun shouldPersistFirstFactOnlyForFirstPaidCompletion(path: VerificationPath) {
|
||||||
|
val fixture = createFixture(path.gateway, PaymentStatus.REQUEST)
|
||||||
|
enableSuccessfulProvider(path, fixture)
|
||||||
|
assertEquals(true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged)
|
||||||
|
assertEquals(true, storedState(fixture).firstFact)
|
||||||
|
val second = addCharge(fixture, PaymentGateway.GOOGLE_IAP)
|
||||||
|
enableSuccessfulProvider(VerificationPath.GOOGLE, second)
|
||||||
|
assertEquals(false, verify(VerificationPath.GOOGLE, second.ownerId, second.chargeId).isFirstCharged)
|
||||||
|
assertEquals(false, storedState(second).firstFact)
|
||||||
|
val beforeReplay = storedState(fixture)
|
||||||
|
assertEquals(true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged)
|
||||||
|
assertEquals(beforeReplay, storedState(fixture))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("paidHistories")
|
||||||
|
@DisplayName("과거 완료 null과 환불 null은 gateway가 달라도 다음 최초 fact를 false로 확정한다")
|
||||||
|
fun shouldRespectLegacyPaidHistory(gateway: PaymentGateway, returned: Boolean) {
|
||||||
|
val history = createFixture(gateway, if (returned) PaymentStatus.RETURN else PaymentStatus.COMPLETE)
|
||||||
|
if (returned) {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
chargeRepository.findById(history.chargeId).orElseThrow().status = ChargeStatus.CANCEL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val next = addCharge(history, PaymentGateway.GOOGLE_IAP)
|
||||||
|
enableSuccessfulProvider(VerificationPath.GOOGLE, next)
|
||||||
|
assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged)
|
||||||
|
assertEquals(false, storedState(next).firstFact)
|
||||||
|
assertEquals(null, storedState(history).firstFact)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(VerificationPath::class)
|
||||||
|
@DisplayName("과거 완료 null은 보존하고 실제 관리자 환불 후에도 확정된 최초 fact는 보존한다")
|
||||||
|
fun shouldPreserveLegacyNullOnReplayAndPersistFactAcrossRefund(path: VerificationPath) {
|
||||||
|
val legacy = createFixture(path.gateway, PaymentStatus.COMPLETE)
|
||||||
|
assertEquals(false, verify(path, legacy.ownerId, legacy.chargeId).isFirstCharged)
|
||||||
|
assertEquals(null, storedState(legacy).firstFact)
|
||||||
|
val first = createFixture(path.gateway, PaymentStatus.REQUEST)
|
||||||
|
enableSuccessfulProvider(path, first)
|
||||||
|
assertEquals(true, verify(path, first.ownerId, first.chargeId).isFirstCharged)
|
||||||
|
refundService.refund(AdminChargeRefundRequest(first.chargeId))
|
||||||
|
val refunded = storedState(first)
|
||||||
|
assertEquals(ChargeStatus.CANCEL, refunded.chargeStatus)
|
||||||
|
assertEquals(PaymentStatus.RETURN, refunded.paymentStatus)
|
||||||
|
assertEquals(true, refunded.firstFact)
|
||||||
|
val next = addCharge(first, PaymentGateway.GOOGLE_IAP)
|
||||||
|
enableSuccessfulProvider(VerificationPath.GOOGLE, next)
|
||||||
|
assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged)
|
||||||
|
assertEquals(true, storedState(first).firstFact)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("refundedReverifications")
|
||||||
|
@DisplayName("기존 PG Hecto Apple 환불 재검증 동작은 유지하되 marker는 재계산하지 않는다")
|
||||||
|
fun shouldNotRecomputeFactForNonRequestReverification(path: VerificationPath, marker: Boolean?) {
|
||||||
|
val fixture = createFixture(path.gateway, PaymentStatus.RETURN)
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
chargeRepository.findById(fixture.chargeId).orElseThrow().isFirstCharged = marker
|
||||||
|
}
|
||||||
|
enableSuccessfulProvider(path, fixture)
|
||||||
|
assertEquals(marker == true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged)
|
||||||
|
assertEquals(marker, storedState(fixture).firstFact)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(VerificationPath::class)
|
||||||
|
@DisplayName("완료 후 이벤트 처리 실패는 최초 fact 결제 상태와 잔액을 함께 rollback한다")
|
||||||
|
fun shouldRollBackFirstFactWhenCompletionFails(path: VerificationPath) {
|
||||||
|
val fixture = createFixture(path.gateway, PaymentStatus.REQUEST)
|
||||||
|
enableSuccessfulProvider(path, fixture)
|
||||||
|
val before = storedState(fixture)
|
||||||
|
failAfterCompletion(fixture)
|
||||||
|
assertThrows(RuntimeException::class.java) { verify(path, fixture.ownerId, fixture.chargeId) }
|
||||||
|
assertEquals(before, storedState(fixture))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Payverse webhook 최초 완료는 fact를 저장하고 이후 verify는 중복 지급 없이 재생한다")
|
||||||
|
fun shouldPersistFactWhenPayverseWebhookCompletesFirst() {
|
||||||
|
val fixture = createFixture(PaymentGateway.PAYVERSE, PaymentStatus.REQUEST)
|
||||||
|
val request = webhook(fixture)
|
||||||
|
val before = storedState(fixture)
|
||||||
|
assertEquals(false, service.payverseWebhook(request.copy(sign = "invalid")))
|
||||||
|
assertEquals(before, storedState(fixture))
|
||||||
|
Mockito.doAnswer { invocation ->
|
||||||
|
assertReadCommitted()
|
||||||
|
invocation.callRealMethod()
|
||||||
|
}.`when`(chargeEventJobService).createAndProcessImmediate(fixture.chargeId, fixture.ownerId)
|
||||||
|
assertEquals(true, service.payverseWebhook(request))
|
||||||
|
assertEquals(true, storedState(fixture).firstFact)
|
||||||
|
val next = addCharge(fixture, PaymentGateway.GOOGLE_IAP)
|
||||||
|
enableSuccessfulProvider(VerificationPath.GOOGLE, next)
|
||||||
|
assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged)
|
||||||
|
val completed = storedState(fixture)
|
||||||
|
assertEquals(true, service.payverseWebhook(request))
|
||||||
|
assertEquals(true, verify(VerificationPath.PAYVERSE, fixture.ownerId, fixture.chargeId).isFirstCharged)
|
||||||
|
assertEquals(completed, storedState(fixture))
|
||||||
|
assertNoProviderOrEventCallsForReplay(fixture)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Payverse webhook 실패도 최초 fact와 잔액을 함께 rollback한다")
|
||||||
|
fun shouldRollBackWebhookFactWhenCompletionFails() {
|
||||||
|
val fixture = createFixture(PaymentGateway.PAYVERSE, PaymentStatus.REQUEST)
|
||||||
|
val before = storedState(fixture)
|
||||||
|
failAfterCompletion(fixture)
|
||||||
|
assertThrows(IllegalStateException::class.java) { service.payverseWebhook(webhook(fixture)) }
|
||||||
|
assertEquals(before, storedState(fixture))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = [false, true])
|
||||||
|
@DisplayName("동일 회원 다른 charge의 동시 완료는 기존 이력이 없으면 하나만 true 있으면 둘 다 false다")
|
||||||
|
fun shouldSetExactlyOneFirstFactForConcurrentCharges(existingHistory: Boolean) {
|
||||||
|
val first = createFixture(PaymentGateway.GOOGLE_IAP, PaymentStatus.REQUEST)
|
||||||
|
val second = addCharge(first, PaymentGateway.PG)
|
||||||
|
if (existingHistory) addCharge(first, PaymentGateway.APPLE_IAP, PaymentStatus.COMPLETE)
|
||||||
|
enableSuccessfulProvider(VerificationPath.GOOGLE, first)
|
||||||
|
enableSuccessfulProvider(VerificationPath.HECTO, second)
|
||||||
|
val bothChargesLocked = CountDownLatch(2)
|
||||||
|
Mockito.doAnswer { invocation ->
|
||||||
|
awaitOtherLockedCharge(first, bothChargesLocked)
|
||||||
|
invocation.callRealMethod()
|
||||||
|
}.`when`(service).processGoogleIap(first.ownerId, first.chargeId, "test-product", "test-purchase")
|
||||||
|
Mockito.doAnswer { invocation ->
|
||||||
|
awaitOtherLockedCharge(second, bothChargesLocked)
|
||||||
|
invocation.callRealMethod()
|
||||||
|
}.`when`(service).verifyHecto(second.ownerId, VerifyRequest("test-receipt", second.chargeId.toString()))
|
||||||
|
val executor = Executors.newFixedThreadPool(2)
|
||||||
|
try {
|
||||||
|
val futures = listOf(first to VerificationPath.GOOGLE, second to VerificationPath.HECTO).map { (fixture, path) ->
|
||||||
|
executor.submit<ChargeCompleteResponse> {
|
||||||
|
verify(path, fixture.ownerId, fixture.chargeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val responses = futures.map { it.get(30, TimeUnit.SECONDS) }
|
||||||
|
assertEquals(if (existingHistory) 0 else 1, responses.count { it.isFirstCharged })
|
||||||
|
val states = listOf(storedState(first), storedState(second))
|
||||||
|
assertEquals(if (existingHistory) 0 else 1, states.count { it.firstFact == true })
|
||||||
|
assertEquals(if (existingHistory) 2 else 1, states.count { it.firstFact == false })
|
||||||
|
assertTrue(states.all { it.paymentStatus == PaymentStatus.COMPLETE })
|
||||||
|
assertEquals(listOf(200, 20, 300, 20, 300, 30), states.first().balances.first())
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow()
|
||||||
|
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun awaitOtherLockedCharge(fixture: Fixture, bothChargesLocked: CountDownLatch) {
|
||||||
|
assertReadCommitted()
|
||||||
|
val locked = chargeRepository.findByIdForUpdate(fixture.chargeId)!!
|
||||||
|
assertEquals(PaymentStatus.REQUEST, locked.payment!!.status)
|
||||||
|
bothChargesLocked.countDown()
|
||||||
|
assertTrue(bothChargesLocked.await(10, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("동일 charge 동시 재시도는 한 번만 지급하고 같은 최초 fact를 반환한다")
|
||||||
|
fun shouldCreditSameChargeOnlyOnceUnderConcurrentReplay() {
|
||||||
|
val fixture = createFixture(PaymentGateway.GOOGLE_IAP, PaymentStatus.REQUEST)
|
||||||
|
enableSuccessfulProvider(VerificationPath.GOOGLE, fixture)
|
||||||
|
val ready = CountDownLatch(2)
|
||||||
|
val executor = Executors.newFixedThreadPool(2)
|
||||||
|
try {
|
||||||
|
val futures = (1..2).map {
|
||||||
|
executor.submit<ChargeCompleteResponse> {
|
||||||
|
ready.countDown()
|
||||||
|
assertTrue(ready.await(10, TimeUnit.SECONDS))
|
||||||
|
verify(VerificationPath.GOOGLE, fixture.ownerId, fixture.chargeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(futures.map { it.get(30, TimeUnit.SECONDS) }.all { it.isFirstCharged })
|
||||||
|
assertEquals(true, storedState(fixture).firstFact)
|
||||||
|
assertEquals(listOf(100, 10, 300, 20, 300, 30), storedState(fixture).balances.first())
|
||||||
|
Mockito.verify(googlePlayService, Mockito.times(1)).verifyAndConsumePurchase("test-purchase", "test-product")
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow()
|
||||||
|
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failAfterCompletion(fixture: Fixture) {
|
||||||
|
Mockito.doAnswer {
|
||||||
|
assertReadCommitted()
|
||||||
|
val charge = chargeRepository.findById(fixture.chargeId).orElseThrow()
|
||||||
|
assertEquals(true, charge.isFirstCharged)
|
||||||
|
assertEquals(PaymentStatus.COMPLETE, charge.payment!!.status)
|
||||||
|
entityManager.flush()
|
||||||
|
throw IllegalStateException("test event failure after completion")
|
||||||
|
}.`when`(chargeEventJobService).createAndProcessImmediate(fixture.chargeId, fixture.ownerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertReadCommitted() {
|
||||||
|
assertTrue(TransactionSynchronizationManager.isActualTransactionActive())
|
||||||
|
assertEquals(
|
||||||
|
TransactionDefinition.ISOLATION_READ_COMMITTED,
|
||||||
|
TransactionSynchronizationManager.getCurrentTransactionIsolationLevel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoProviderOrEventCallsForReplay(fixture: Fixture) {
|
||||||
|
Mockito.verifyNoInteractions(bootpay, okHttpClient)
|
||||||
|
Mockito.verify(chargeEventJobService, Mockito.times(1)).createAndProcessImmediate(fixture.chargeId, fixture.ownerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun webhook(fixture: Fixture): PayverseWebhookRequest {
|
||||||
|
val sign = DigestUtils.sha512Hex("||t2-test-secret||t2-test-mid||${fixture.chargeId}||9.99||20261103||")
|
||||||
|
return PayverseWebhookRequest(
|
||||||
|
"PAYMENT", "t2-test-mid", "test-tid", "OTHER", "card", fixture.chargeId.toString(),
|
||||||
|
"USD", BigDecimal("9.99"), "SUCCESS", "20261103", sign
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableSuccessfulProvider(path: VerificationPath, fixture: Fixture) {
|
||||||
|
when (path) {
|
||||||
|
VerificationPath.VERIFY, VerificationPath.HECTO -> {
|
||||||
|
Mockito.doAnswer {
|
||||||
|
assertReadCommitted()
|
||||||
|
hashMapOf<String, Any>("access_token" to "test-token")
|
||||||
|
}.`when`(bootpay).accessToken
|
||||||
|
Mockito.doReturn(
|
||||||
|
hashMapOf<String, Any>(
|
||||||
|
"receipt_id" to "test-receipt",
|
||||||
|
"method" to "card",
|
||||||
|
"pg" to "test-pg",
|
||||||
|
"status" to 1,
|
||||||
|
"price" to BigDecimal("9.9900")
|
||||||
|
)
|
||||||
|
).`when`(bootpay).getReceipt("test-receipt")
|
||||||
|
}
|
||||||
|
VerificationPath.GOOGLE -> Mockito.doAnswer {
|
||||||
|
assertReadCommitted()
|
||||||
|
"test-order"
|
||||||
|
}.`when`(googlePlayService)
|
||||||
|
.verifyAndConsumePurchase("test-purchase", "test-product")
|
||||||
|
VerificationPath.APPLE, VerificationPath.PAYVERSE -> {
|
||||||
|
val url = if (path == VerificationPath.APPLE) {
|
||||||
|
"https://apple.invalid/verifyReceipt"
|
||||||
|
} else {
|
||||||
|
"https://payverse.invalid/payment/search/transaction/test-transaction"
|
||||||
|
}
|
||||||
|
val body = if (path == VerificationPath.APPLE) {
|
||||||
|
"""{"status":0}"""
|
||||||
|
} else {
|
||||||
|
val customerId = "${environment.getRequiredProperty("server.env")}_user_${fixture.ownerId}"
|
||||||
|
"""{"resultStatus":"SUCCESS","tid":"test-tid","schemeGroup":"OTHER","schemeCode":"card",
|
||||||
|
"transactionType":"PAYMENT","transactionStatus":"SUCCESS","transactionMessage":"ok",
|
||||||
|
"orderId":"${fixture.chargeId}","customerId":"$customerId",
|
||||||
|
"requestCurrency":"USD","requestAmount":9.99}"""
|
||||||
|
}
|
||||||
|
val call = Mockito.mock(Call::class.java)
|
||||||
|
Mockito.doAnswer {
|
||||||
|
assertReadCommitted()
|
||||||
|
Response.Builder().request(Request.Builder().url(url).build()).protocol(Protocol.HTTP_1_1)
|
||||||
|
.code(200).message("OK").body(body.toResponseBody("application/json".toMediaType())).build()
|
||||||
|
}.`when`(call).execute()
|
||||||
|
Mockito.doAnswer { invocation ->
|
||||||
|
val request = invocation.getArgument<Request>(0)
|
||||||
|
assertEquals(url, request.url.toString())
|
||||||
|
call
|
||||||
|
}.`when`(okHttpClient).newCall(Mockito.any(Request::class.java) ?: Request.Builder().url(url).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addCharge(fixture: Fixture, gateway: PaymentGateway, status: PaymentStatus = PaymentStatus.REQUEST): Fixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val original = chargeRepository.findById(fixture.chargeId).orElseThrow()
|
||||||
|
val charge = Charge(100, 10).apply {
|
||||||
|
title = "100 + 10"
|
||||||
|
can = original.can
|
||||||
|
member = original.member
|
||||||
|
payment = Payment(status, gateway).apply {
|
||||||
|
price = BigDecimal("9.99")
|
||||||
|
locale = "USD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chargeRepository.saveAndFlush(charge)
|
||||||
|
entityManager.clear()
|
||||||
|
fixture.copy(chargeId = charge.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verify(path: VerificationPath, memberId: Long, chargeId: Long): ChargeCompleteResponse {
|
||||||
|
return when (path) {
|
||||||
|
VerificationPath.VERIFY -> service.verify(memberId, VerifyRequest("test-receipt", chargeId.toString()))
|
||||||
|
VerificationPath.HECTO -> service.verifyHecto(memberId, VerifyRequest("test-receipt", chargeId.toString()))
|
||||||
|
VerificationPath.PAYVERSE -> service.payverseVerify(
|
||||||
|
memberId,
|
||||||
|
PayverseVerifyRequest("test-transaction", chargeId.toString())
|
||||||
|
)
|
||||||
|
VerificationPath.APPLE -> service.appleVerify(memberId, AppleVerifyRequest("test-receipt", chargeId))
|
||||||
|
VerificationPath.GOOGLE -> service.processGoogleIap(memberId, chargeId, "test-product", "test-purchase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFixture(gateway: PaymentGateway, status: PaymentStatus): Fixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val owner = Member(password = "password", nickname = "charge-owner").apply {
|
||||||
|
pgChargeCan = 100
|
||||||
|
pgRewardCan = 10
|
||||||
|
googleChargeCan = 200
|
||||||
|
googleRewardCan = 20
|
||||||
|
appleChargeCan = 300
|
||||||
|
appleRewardCan = 30
|
||||||
|
}
|
||||||
|
val requester = Member(password = "password", nickname = "charge-requester").apply {
|
||||||
|
pgChargeCan = 400
|
||||||
|
pgRewardCan = 40
|
||||||
|
googleChargeCan = 500
|
||||||
|
googleRewardCan = 50
|
||||||
|
appleChargeCan = 600
|
||||||
|
appleRewardCan = 60
|
||||||
|
}
|
||||||
|
entityManager.persist(owner)
|
||||||
|
entityManager.persist(requester)
|
||||||
|
val product = Can("100 + 10", 100, 10, BigDecimal("9.99"), "USD", CanStatus.SALE)
|
||||||
|
entityManager.persist(product)
|
||||||
|
val charge = Charge(100, 10).apply {
|
||||||
|
can = product
|
||||||
|
title = "100 + 10"
|
||||||
|
member = owner
|
||||||
|
payment = Payment(status = status, paymentGateway = gateway).apply {
|
||||||
|
price = BigDecimal("9.99")
|
||||||
|
locale = "USD"
|
||||||
|
receiptId = "stored-receipt"
|
||||||
|
method = "stored-method"
|
||||||
|
orderId = "stored-order"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chargeRepository.saveAndFlush(charge)
|
||||||
|
val fixture = Fixture(owner.id!!, requester.id!!, charge.id!!)
|
||||||
|
entityManager.clear()
|
||||||
|
fixture
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storedState(fixture: Fixture): StoredState {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
val charge = chargeRepository.findById(fixture.chargeId).orElseThrow()
|
||||||
|
val payment = charge.payment!!
|
||||||
|
val balances = listOf(fixture.ownerId, fixture.requesterId).map { memberId ->
|
||||||
|
val member = memberRepository.findById(memberId).orElseThrow()
|
||||||
|
listOf(
|
||||||
|
member.pgChargeCan,
|
||||||
|
member.pgRewardCan,
|
||||||
|
member.googleChargeCan,
|
||||||
|
member.googleRewardCan,
|
||||||
|
member.appleChargeCan,
|
||||||
|
member.appleRewardCan
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StoredState(
|
||||||
|
charge.member!!.id!!, charge.status, charge.chargeCan, charge.rewardCan,
|
||||||
|
payment.status, payment.receiptId, payment.method, payment.orderId,
|
||||||
|
balances, chargeRepository.count(), jobRepository.count(), charge.isFirstCharged
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoProviderOrEventCalls() {
|
||||||
|
Mockito.verifyNoInteractions(bootpay, okHttpClient, googlePlayService)
|
||||||
|
Mockito.verify(service, Mockito.never()).createBootpay(Mockito.anyString(), Mockito.anyString())
|
||||||
|
Mockito.verify(chargeEventJobService, Mockito.never())
|
||||||
|
.createAndProcessImmediate(Mockito.anyLong(), Mockito.anyLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class VerificationPath(val gateway: PaymentGateway) {
|
||||||
|
VERIFY(PaymentGateway.PG),
|
||||||
|
HECTO(PaymentGateway.PG),
|
||||||
|
PAYVERSE(PaymentGateway.PAYVERSE),
|
||||||
|
APPLE(PaymentGateway.APPLE_IAP),
|
||||||
|
GOOGLE(PaymentGateway.GOOGLE_IAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Fixture(val ownerId: Long, val requesterId: Long, val chargeId: Long)
|
||||||
|
|
||||||
|
private data class StoredState(
|
||||||
|
val ownerId: Long,
|
||||||
|
val chargeStatus: ChargeStatus,
|
||||||
|
val chargeCan: Int,
|
||||||
|
val rewardCan: Int,
|
||||||
|
val paymentStatus: PaymentStatus,
|
||||||
|
val receiptId: String?,
|
||||||
|
val method: String?,
|
||||||
|
val orderId: String?,
|
||||||
|
val balances: List<List<Int>>,
|
||||||
|
val chargeCount: Long,
|
||||||
|
val eventJobCount: Long,
|
||||||
|
val firstFact: Boolean?
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun paidHistories(): List<Arguments> {
|
||||||
|
return listOf(PaymentGateway.PG, PaymentGateway.PAYVERSE, PaymentGateway.APPLE_IAP, PaymentGateway.GOOGLE_IAP)
|
||||||
|
.flatMap { gateway -> listOf(Arguments.of(gateway, false), Arguments.of(gateway, true)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun refundedReverifications(): List<Arguments> {
|
||||||
|
return listOf(VerificationPath.VERIFY, VerificationPath.HECTO, VerificationPath.APPLE)
|
||||||
|
.flatMap { path -> listOf(null, true, false).map { Arguments.of(path, it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package kr.co.vividnext.sodalive.member
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberService
|
||||||
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
|
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
||||||
|
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||||
|
import kr.co.vividnext.sodalive.member.login.LoginResponse
|
||||||
|
import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2
|
||||||
|
import kr.co.vividnext.sodalive.member.signUp.SignUpResponse
|
||||||
|
import kr.co.vividnext.sodalive.member.social.SocialAuthService
|
||||||
|
import kr.co.vividnext.sodalive.member.social.SocialAuthServiceResolver
|
||||||
|
import kr.co.vividnext.sodalive.member.social.SocialLoginResponse
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||||
|
import kr.co.vividnext.sodalive.useraction.UserActionService
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class MemberLoginResponseContractTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var memberService: MemberService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var creatorAdminMemberService: CreatorAdminMemberService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var socialAuthServiceResolver: SocialAuthServiceResolver
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var trackingService: AdTrackingService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var userActionService: UserActionService
|
||||||
|
|
||||||
|
private val loginResponse = LoginResponse(42L, "issued-token", "member", "", "profile-url")
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "{0} isNew={1}")
|
||||||
|
@CsvSource(
|
||||||
|
"GOOGLE, false",
|
||||||
|
"GOOGLE, true",
|
||||||
|
"KAKAO, false",
|
||||||
|
"KAKAO, true",
|
||||||
|
"APPLE, false",
|
||||||
|
"APPLE, true",
|
||||||
|
"LINE, false",
|
||||||
|
"LINE, true"
|
||||||
|
)
|
||||||
|
@DisplayName("모든 소셜 로그인은 내부 가입 여부와 기존 JSON 계약 및 부수 동작을 유지한다")
|
||||||
|
fun shouldExposeSocialIsNewAsBooleanForEveryProvider(provider: MemberProvider, isNew: Boolean) {
|
||||||
|
val authService = Mockito.mock(SocialAuthService::class.java)
|
||||||
|
val nonce = if (provider == MemberProvider.APPLE || provider == MemberProvider.LINE) "login-nonce" else null
|
||||||
|
Mockito.`when`(socialAuthServiceResolver.resolve(provider)).thenReturn(authService)
|
||||||
|
Mockito.`when`(authService.authenticate("provider-token", "web", "campaign-pid", "push-token", nonce))
|
||||||
|
.thenReturn(SocialLoginResponse(42L, "campaign-pid", loginResponse, isNew))
|
||||||
|
|
||||||
|
val request = post("/member/login/${provider.name.lowercase()}")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(
|
||||||
|
"""{
|
||||||
|
"container":"web", "marketingPid":"campaign-pid", "pushToken":"push-token",
|
||||||
|
"identityToken":"provider-token", "nonce":"login-nonce"
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
if (nonce == null) request.header("Authorization", "Bearer provider-token")
|
||||||
|
|
||||||
|
val response = perform(request)
|
||||||
|
|
||||||
|
Mockito.verify(authService).authenticate("provider-token", "web", "campaign-pid", "push-token", nonce)
|
||||||
|
Mockito.verify(trackingService).saveTrackingHistory("campaign-pid", AdTrackingHistoryType.SIGNUP, 42L)
|
||||||
|
Mockito.verify(userActionService, Mockito.times(if (isNew) 1 else 0))
|
||||||
|
.recordAction(42L, false, ActionType.SIGN_UP)
|
||||||
|
Mockito.verifyNoMoreInteractions(trackingService, userActionService)
|
||||||
|
assertEnvelope(response, "Congratulations on your sign up.")
|
||||||
|
assertLoginData(response.path("data"), isNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["/member/signup", "/member/signup/v2"])
|
||||||
|
@DisplayName("두 이메일 가입 응답은 서비스 DTO 기본값과 무관하게 신규 회원 여부를 true로 반환한다")
|
||||||
|
fun shouldReturnNewForBothEmailSignupEndpoints(path: String) {
|
||||||
|
val signupResponse = SignUpResponse(42L, "campaign-pid", loginResponse)
|
||||||
|
val request = if (path == "/member/signup") {
|
||||||
|
val body = """{
|
||||||
|
"email":"member@test.com", "password":"test-password", "nickname":"member", "gender":"NONE",
|
||||||
|
"marketingPid":"campaign-pid", "isAgreeTermsOfService":true, "isAgreePrivacyPolicy":true, "container":"web"
|
||||||
|
}"""
|
||||||
|
Mockito.`when`(memberService.signUp(null, body)).thenReturn(signupResponse)
|
||||||
|
multipart(path).file(MockMultipartFile("request", "", MediaType.TEXT_PLAIN_VALUE, body.toByteArray()))
|
||||||
|
} else {
|
||||||
|
Mockito.`when`(
|
||||||
|
memberService.signUpV2(
|
||||||
|
SignUpRequestV2(
|
||||||
|
email = "member@test.com",
|
||||||
|
password = "test-password",
|
||||||
|
marketingPid = "campaign-pid",
|
||||||
|
isAgreeTermsOfService = true,
|
||||||
|
isAgreePrivacyPolicy = true,
|
||||||
|
container = "web"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).thenReturn(signupResponse)
|
||||||
|
post(path).contentType(MediaType.APPLICATION_JSON).content(
|
||||||
|
"""{
|
||||||
|
"email":"member@test.com", "password":"test-password", "marketingPid":"campaign-pid",
|
||||||
|
"isAgreeTermsOfService":true, "isAgreePrivacyPolicy":true, "container":"web"
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = perform(request)
|
||||||
|
|
||||||
|
Mockito.verify(trackingService).saveTrackingHistory("campaign-pid", AdTrackingHistoryType.SIGNUP, 42L)
|
||||||
|
Mockito.verify(userActionService, Mockito.times(if (path == "/member/signup/v2") 1 else 0))
|
||||||
|
.recordAction(42L, false, ActionType.SIGN_UP)
|
||||||
|
Mockito.verifyNoMoreInteractions(trackingService, userActionService)
|
||||||
|
assertEnvelope(response, "Congratulations on your sign up.")
|
||||||
|
assertLoginData(response.path("data"), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "isAdmin={0}, isCreator={1}")
|
||||||
|
@CsvSource("false, false", "true, false", "false, true")
|
||||||
|
@DisplayName("일반 및 관리자 및 크리에이터 회원 로그인은 신규 회원 여부를 false로 반환한다")
|
||||||
|
fun shouldReturnFalseForEveryMemberLoginBranch(isAdmin: Boolean, isCreator: Boolean) {
|
||||||
|
val request = LoginRequest("member@test.com", "test-password", isAdmin, isCreator)
|
||||||
|
Mockito.`when`(memberService.login(request))
|
||||||
|
.thenReturn(ApiResponse.ok(data = loginResponse, message = "You are logged in."))
|
||||||
|
|
||||||
|
val response = perform(
|
||||||
|
post("/member/login").contentType(MediaType.APPLICATION_JSON).content(
|
||||||
|
"""{"email":"member@test.com","password":"test-password","isAdmin":$isAdmin,"isCreator":$isCreator}"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Mockito.verify(memberService).login(request)
|
||||||
|
assertEnvelope(response, "You are logged in.")
|
||||||
|
assertLoginData(response.path("data"), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 관리자 로그인은 공유 응답의 신규 회원 여부를 false로 반환한다")
|
||||||
|
fun shouldReturnFalseForCreatorAdminLogin() {
|
||||||
|
val request = LoginRequest("member@test.com", "test-password")
|
||||||
|
Mockito.`when`(creatorAdminMemberService.login(request))
|
||||||
|
.thenReturn(ApiResponse.ok(data = loginResponse, message = "Logged in."))
|
||||||
|
|
||||||
|
val response = perform(
|
||||||
|
post("/creator-admin/member/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"email":"member@test.com","password":"test-password"}""")
|
||||||
|
)
|
||||||
|
|
||||||
|
Mockito.verify(creatorAdminMemberService).login(request)
|
||||||
|
assertEnvelope(response, "Logged in.")
|
||||||
|
assertLoginData(response.path("data"), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("애플리케이션 Jackson은 기본 LoginResponse의 isNew false를 생략하지 않는다")
|
||||||
|
fun shouldSerializeDefaultLoginResponseWithIsNewFalse() {
|
||||||
|
assertLoginData(objectMapper.readTree(objectMapper.writeValueAsString(loginResponse)), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun perform(request: MockHttpServletRequestBuilder): JsonNode {
|
||||||
|
val response = mockMvc.perform(request.header("Accept-Language", "en"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andReturn().response
|
||||||
|
return objectMapper.readTree(response.contentAsByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertEnvelope(response: JsonNode, message: String) {
|
||||||
|
assertEquals(setOf("success", "message", "data", "errorProperty"), response.fieldNames().asSequence().toSet())
|
||||||
|
assertTrue(response.path("success").isBoolean)
|
||||||
|
assertTrue(response.path("success").booleanValue())
|
||||||
|
assertEquals(message, response.path("message").textValue())
|
||||||
|
assertTrue(response.path("errorProperty").isNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertLoginData(data: JsonNode, isNew: Boolean) {
|
||||||
|
assertTrue(data.path("isNew").isBoolean, "isNew must be a present JSON boolean: $data")
|
||||||
|
assertEquals(isNew, data.path("isNew").booleanValue())
|
||||||
|
assertFalse(data.has("new"))
|
||||||
|
assertEquals(
|
||||||
|
setOf("userId", "token", "nickname", "email", "profileImage", "isNew"),
|
||||||
|
data.fieldNames().asSequence().toSet()
|
||||||
|
)
|
||||||
|
assertEquals(42L, data.path("userId").longValue())
|
||||||
|
assertEquals("issued-token", data.path("token").textValue())
|
||||||
|
assertEquals("member", data.path("nickname").textValue())
|
||||||
|
assertEquals("", data.path("email").textValue())
|
||||||
|
assertEquals("profile-url", data.path("profileImage").textValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user