Compare commits
447
Commits
test
..
b978f5bc8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b978f5bc8d | ||
|
|
3f660e81f9 | ||
|
|
c9b0bd13b5 | ||
|
|
92402419c5 | ||
|
|
c4fa2fa2f8 | ||
|
|
b4027fdee7 | ||
|
|
eb262b8d53 | ||
|
|
f5397df65e | ||
|
|
0c6aeb5c8a | ||
|
|
d8083e34ea | ||
|
|
8eeade49d1 | ||
|
|
b36764abff | ||
|
|
7844ea74d7 | ||
|
|
e04fa67d1d | ||
|
|
bd1ec54d36 | ||
|
|
4442fa6d91 | ||
|
|
795c25159f | ||
|
|
cdc7cb8612 | ||
|
|
efefef51f4 | ||
|
|
e2b9eb8bf3 | ||
|
|
1aa7a89f7b | ||
|
|
45aa26f1cb | ||
|
|
626f02b45e | ||
|
|
540c5cb317 | ||
|
|
3aa6a35adb | ||
|
|
81c0d2586c | ||
|
|
965bb068fc | ||
|
|
d082e0b745 | ||
|
|
c8b83272a3 | ||
|
|
1939fdcb33 | ||
|
|
f521a240c2 | ||
|
|
0c35624dfb | ||
|
|
5c24addf31 | ||
|
|
8671c8efc7 | ||
|
|
16c17f4bfa | ||
|
|
0e821fae1b | ||
|
|
6a10eff15f | ||
|
|
fea329e637 | ||
|
|
681e4a4036 | ||
|
|
c23f574162 | ||
|
|
c884d7d6c9 | ||
|
|
116e8cbca3 | ||
|
|
c8187ba147 | ||
|
|
676bd0b79e | ||
|
|
7522f06bf3 | ||
|
|
a9d2d1ab48 | ||
|
|
e0e371cdc9 | ||
|
|
b49344d0e9 | ||
|
|
5cc152307a | ||
|
|
1fd3d41d7e | ||
|
|
c1b9dd730d | ||
|
|
d40cd32c50 | ||
|
|
0289607fd9 | ||
|
|
1bec644372 | ||
|
|
f8a6d1b221 | ||
|
|
ec769a1307 | ||
|
|
8e4fb0d313 | ||
|
|
cc3a620642 | ||
|
|
be0884e974 | ||
|
|
34456395fd | ||
|
|
26ddeb9591 | ||
|
|
cd535a628c | ||
|
|
de32b537f4 | ||
|
|
9c271fc1f6 | ||
|
|
2ddbfbccd6 | ||
|
|
80786deb72 | ||
|
|
8ca2e185ac | ||
|
|
484711ad1b | ||
|
|
e80ceca0c5 | ||
|
|
33293a6533 | ||
|
|
f0c1d4e32a | ||
|
|
6cd319ec76 | ||
|
|
6557ec2aed | ||
|
|
f2f8a34319 | ||
|
|
c50ac6ed2c | ||
|
|
11b9c349d1 | ||
|
|
ef9f8d65e1 | ||
|
|
299f2100e9 | ||
|
|
fd5c794480 | ||
|
|
68197de095 | ||
|
|
587f3d6b58 | ||
|
|
9b6167d46d | ||
|
|
008ee3b4e5 | ||
|
|
3a57ad23bb | ||
|
|
729552335a | ||
|
|
02ae507c87 | ||
|
|
5818abf69d | ||
|
|
ee403915f0 | ||
|
|
1a660088de | ||
|
|
5196c80ca8 | ||
|
|
c9c09c2998 | ||
|
|
3ea33c4c7b | ||
|
|
451a1aa4f2 | ||
|
|
90555fd34f | ||
|
|
0dc430b098 | ||
|
|
1f2103c7fa | ||
|
|
062c17c51e | ||
|
|
de169b79a1 | ||
|
|
aa24de0a5a | ||
|
|
e5937d573a | ||
|
|
6da86e12bd | ||
|
|
9049022a74 | ||
|
|
7b6f3a7a5f | ||
|
|
53e9678efa | ||
|
|
e4f547fa92 | ||
|
|
b69756ef81 | ||
|
|
1a3a9149a2 | ||
|
|
ce120a6d5d | ||
|
|
08b5fd23ab | ||
|
|
eb18e2d009 | ||
|
|
a27852ed44 | ||
|
|
c7925c1706 | ||
|
|
be59bd7e89 | ||
|
|
51ce143fc2 | ||
|
|
89eb11f808 | ||
|
|
30d89987a4 | ||
|
|
7959d3e5ed | ||
|
|
1e29573ef7 | ||
|
|
cc2f533dc6 | ||
|
|
32b0c19f9d | ||
|
|
9af2d768e8 | ||
|
|
5677824cde | ||
|
|
e8f1bc09f9 | ||
|
|
d1a936d55b | ||
|
|
dc97eaa835 | ||
|
|
dcbe57806c | ||
|
|
b14438cc15 | ||
|
|
b27d3bd5c6 | ||
|
|
03ebc9cfe9 | ||
|
|
24841b9850 | ||
|
|
d35a3d1a8c | ||
|
|
60c4e0b528 | ||
|
|
84f33d1bc2 | ||
|
|
c4e1709b99 | ||
|
|
e7a5fd5819 | ||
|
|
4bde03643c | ||
|
|
1bc52b56af | ||
|
|
9c33fd93f7 | ||
|
|
3c087bc275 | ||
|
|
8ad13c289e | ||
|
|
7577f48a09 | ||
|
|
0251906964 | ||
|
|
2723a5f134 | ||
|
|
c3c60605fd | ||
|
|
238f704b22 | ||
|
|
5639d8ac8e | ||
|
|
9aac591591 | ||
|
|
ffa8e5aebb | ||
|
|
cbbfe014cc | ||
|
|
83028f7817 | ||
|
|
70d1795557 | ||
|
|
8c6c681424 | ||
|
|
50bc9f4ff3 | ||
|
|
f00ea03fad | ||
|
|
f22e7b9ad1 | ||
|
|
c7ec95f4bb | ||
|
|
229e7a8ccc | ||
|
|
3c616474ff | ||
|
|
56eb6b3ce3 | ||
|
|
545836d43c | ||
|
|
219f83dec0 | ||
|
|
a76a841238 | ||
|
|
c26680de84 | ||
|
|
8fffad9d3a | ||
|
|
f4f0f203a2 | ||
|
|
b7196f5a0c | ||
|
|
5d33a18890 | ||
|
|
96186a1a50 | ||
|
|
bc8bc479d1 | ||
|
|
47595b1291 | ||
|
|
01a88964df | ||
|
|
3a2b77379f | ||
|
|
dc4e5f75cd | ||
|
|
d0178d551c | ||
|
|
827333108d | ||
|
|
587b90bd27 | ||
|
|
4dc20c5e90 | ||
|
|
ac25782f2b | ||
|
|
20437d56e7 | ||
|
|
f0b412828a | ||
|
|
367faac5c3 | ||
|
|
84deaaa970 | ||
|
|
a2b39466c2 | ||
|
|
03586c4005 | ||
|
|
6ea69e1510 | ||
|
|
553c6dc539 | ||
|
|
6cc22f5b6d | ||
|
|
9103d67cc1 | ||
|
|
25083fb0e4 | ||
|
|
d2dc045255 | ||
|
|
b8621dfbb0 | ||
|
|
93633940dd | ||
|
|
b6f5325351 | ||
|
|
7c32c08f1f | ||
|
|
1d268da08d | ||
|
|
797666ae0d | ||
|
|
dcf470997e | ||
|
|
0974d1dbf8 | ||
|
|
12a35db6cd | ||
|
|
9abbb05ad8 | ||
|
|
1ecaf69b0b | ||
|
|
e334d1e5d9 | ||
|
|
b735e861d0 | ||
|
|
4eb433d372 | ||
|
|
2416ae61f3 | ||
|
|
01fb336985 | ||
|
|
b6af88a732 | ||
|
|
58a2a17d6d | ||
|
|
79f5a0f520 | ||
|
|
7f6c0f7f04 | ||
|
|
f658df4dca | ||
|
|
9d43b8e23a | ||
|
|
4270aef79b | ||
|
|
1c0dc82d44 | ||
|
|
c1e325aadf | ||
|
|
cec87da69d | ||
|
|
f68f24cb2c | ||
|
|
ed094347fc | ||
|
|
b8afdffbe1 | ||
|
|
f6ba79f31c | ||
|
|
5f3b1663d2 | ||
|
|
66e786b4bb | ||
|
|
f671114574 | ||
|
|
ce37060d94 | ||
|
|
7d19a4d184 | ||
|
|
22f28a2f8a | ||
|
|
ceef9ca979 | ||
|
|
efe8f4f939 | ||
|
|
ba692a1195 | ||
|
|
d732bad042 | ||
|
|
4c935c3bee | ||
|
|
c160dd791f | ||
|
|
23cd1b4601 | ||
|
|
031fc8ba1b | ||
|
|
c6853289ad | ||
|
|
2497bb69bc | ||
|
|
a58a67e0a2 | ||
|
|
4315fe12a5 | ||
|
|
42f10a8899 | ||
|
|
1e4b47f989 | ||
|
|
ff255dbfae | ||
|
|
dbe9b72feb | ||
|
|
95a714b391 | ||
|
|
28f58c7f56 | ||
|
|
8bd46d8f21 | ||
|
|
e1bb8e54ed | ||
|
|
1de705b063 | ||
|
|
f6926ad356 | ||
|
|
2cdbbb1b37 | ||
|
|
4dce8c8f03 | ||
|
|
97a5bace6f | ||
|
|
d4d51ec48f | ||
|
|
fb91398462 | ||
|
|
105dadd798 | ||
|
|
2abf2837d3 | ||
|
|
422aa67af6 | ||
|
|
7fffab6985 | ||
|
|
5a4be3d2c1 | ||
|
|
f39a7681db | ||
|
|
c60a7580ba | ||
|
|
97edb56edc | ||
|
|
6ebca8d22b | ||
|
|
95371ad934 | ||
|
|
2c176825fd | ||
|
|
fae7de48d3 | ||
|
|
b8230646a2 | ||
|
|
43279541dd | ||
|
|
b4791977c1 | ||
|
|
ef917ecc25 | ||
|
|
a93faad951 | ||
|
|
fd001d24d3 | ||
|
|
7aa5884797 | ||
|
|
5b237a1547 | ||
|
|
2e37990d87 | ||
|
|
dd07d724a8 | ||
|
|
03ce8618e7 | ||
|
|
db1a7a7fd6 | ||
|
|
36a82d7f53 | ||
|
|
3a34401113 | ||
|
|
9927268330 | ||
|
|
c45c97e29d | ||
|
|
c64a315226 | ||
|
|
a4cafca6ab | ||
|
|
46284a0660 | ||
|
|
05df86e15a | ||
|
|
8b433027e2 | ||
|
|
5bd4ff7610 | ||
|
|
d693c397ea | ||
|
|
1d8d1ec9a5 | ||
|
|
5e491f11ee | ||
|
|
7cedea06ac | ||
|
|
2e5f750e50 | ||
|
|
20289cad10 | ||
|
|
e0d64c31c7 | ||
|
|
8c1b95dc97 | ||
|
|
fb5641343e | ||
|
|
87765941eb | ||
|
|
1809862c16 | ||
|
|
300f784f7d | ||
|
|
67a045eae6 | ||
|
|
2a79903a28 | ||
|
|
d3222ce083 | ||
|
|
406a421742 | ||
|
|
10bf728faf | ||
|
|
607617747c | ||
|
|
f0a69eb1a2 | ||
|
|
6b307a6e17 | ||
|
|
08d08a934a | ||
|
|
c500c12668 | ||
|
|
62060adeba | ||
|
|
b2fc75edb8 | ||
|
|
a999dd2085 | ||
|
|
49f95ab100 | ||
|
|
1a84d5b30c | ||
|
|
3b65050632 | ||
|
|
d0df31674c | ||
|
|
1fe88402e2 | ||
|
|
67097696e6 | ||
|
|
8e7e77067a | ||
|
|
9899390b61 | ||
|
|
80c476a908 | ||
|
|
59da1d6e49 | ||
|
|
5aef7dac33 | ||
|
|
faf7aa06b6 | ||
|
|
38ef6e5583 | ||
|
|
c0b15b5d94 | ||
|
|
2cfc067ea1 | ||
|
|
a91db4f956 | ||
|
|
8a09780a02 | ||
|
|
45e8ec6505 | ||
|
|
4554b85914 | ||
|
|
8aa79c4a9c | ||
|
|
c8d3210b57 | ||
|
|
2282a49563 | ||
|
|
b82fdfb2c8 | ||
|
|
2d17eac199 | ||
|
|
e482bc3aad | ||
|
|
ec022b74d1 | ||
|
|
dc42c09ce3 | ||
|
|
046a34d2a4 | ||
|
|
9ff6ec1888 | ||
|
|
d2950106ec | ||
|
|
962f800d2e | ||
|
|
962107e507 | ||
|
|
039bd11963 | ||
|
|
5c250ea4ae | ||
|
|
e3405bcec6 | ||
|
|
0fd1c2235f | ||
|
|
b20c29b022 | ||
|
|
12d5dcd298 | ||
|
|
2c305dc6c6 | ||
|
|
62f76f7433 | ||
|
|
858ce524f9 | ||
|
|
3795fb4a40 | ||
|
|
0c01aeec50 | ||
|
|
892206744d | ||
|
|
9e2c1474db | ||
|
|
16328f73d9 | ||
|
|
e0d4f53cf4 | ||
|
|
e09a59c5b4 | ||
|
|
049e654535 | ||
|
|
c927dc4ecd | ||
|
|
fe4ecd0ad8 | ||
|
|
78d476fe80 | ||
|
|
a11c8465d5 | ||
|
|
366304a9b7 | ||
|
|
4356663688 | ||
|
|
26b55e6fcf | ||
|
|
0d743f7204 | ||
|
|
6cbe113b3e | ||
|
|
6409b69d6c | ||
|
|
c5164c76fc | ||
|
|
baade8e138 | ||
|
|
b848d6b4e0 | ||
|
|
d8139d2ab0 | ||
|
|
e96d8f7469 | ||
|
|
2acffd8afc | ||
|
|
3c8e72073c | ||
|
|
724d7a9d9b | ||
|
|
2da3b0db78 | ||
|
|
685ad7afaf | ||
|
|
264cf75964 | ||
|
|
c773dbc7b5 | ||
|
|
37cbc64f52 | ||
|
|
cb1dde17bb | ||
|
|
c29988acf4 | ||
|
|
eadbf56dae | ||
|
|
4b3b455135 | ||
|
|
e6ac177396 | ||
|
|
3d0e29003f | ||
|
|
78b9b00f77 | ||
|
|
0ee7faa551 | ||
|
|
e5fdced681 | ||
|
|
afb99fef64 | ||
|
|
7dfaa36024 | ||
|
|
0496f665aa | ||
|
|
0d19e1be74 | ||
|
|
4aff0111aa | ||
|
|
63b3ba2bb2 | ||
|
|
7444b41f60 | ||
|
|
8e90dbc8b6 | ||
|
|
9f70722521 | ||
|
|
52fae596fa | ||
|
|
ccb67957bc | ||
|
|
fb82538d0d | ||
|
|
72ee39612e | ||
|
|
51fd5408dc | ||
|
|
3fae40fbef | ||
|
|
0745890af0 | ||
|
|
4abe1730a7 | ||
|
|
626f0e6989 | ||
|
|
9f42d9d173 | ||
|
|
f90a93c4bc | ||
|
|
8000ad6c6a | ||
|
|
1f1f1bea1a | ||
|
|
d95460c7cd | ||
|
|
a3d93d4b08 | ||
|
|
07a92af982 | ||
|
|
f4618877d4 | ||
|
|
2b914fd222 | ||
|
|
109e42a5a3 | ||
|
|
fa515ad39c | ||
|
|
f09673a795 | ||
|
|
f71536c614 | ||
|
|
7bdddc7ae8 | ||
|
|
aa8926a624 | ||
|
|
be71e59be2 | ||
|
|
4d7753378f | ||
|
|
60257c4ef4 | ||
|
|
1e0b79bf62 | ||
|
|
6883434d0d | ||
|
|
eda2193e64 | ||
|
|
99bf829c88 | ||
|
|
5feafe1b48 | ||
|
|
c9292b7d04 | ||
|
|
ae7e1a91c1 | ||
|
|
3e1887e0d1 | ||
|
|
474646db47 | ||
|
|
56f7b6c449 | ||
|
|
76b2b5f7e3 | ||
|
|
e918d809eb | ||
|
|
7af059e543 | ||
|
|
897726e1ec | ||
|
|
8b98a2dd07 | ||
|
|
cca75420f0 | ||
|
|
86c627ed1d | ||
|
|
d55514e3a7 |
@@ -27,7 +27,6 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("org.redisson:redisson-spring-data-27:3.19.2")
|
implementation("org.redisson:redisson-spring-data-27:3.19.2")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
|
||||||
implementation("org.springframework.boot:spring-boot-starter-aop")
|
implementation("org.springframework.boot:spring-boot-starter-aop")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
# v2 콘텐츠 목록 요청 언어별 번역 구현 계획
|
|
||||||
|
|
||||||
- 작성일: 2026-09-09
|
|
||||||
- 상태: Phase 1·2 구현 및 자동 검증 완료, test 서버 HTTP Gate 대기
|
|
||||||
- 요구사항·API 기준: [prd.md](prd.md)
|
|
||||||
- 현재 활성 Goal: P1-GATE test 서버 HTTP 확인 대기
|
|
||||||
- 다음 Goal: P2-GATE test 서버 HTTP 확인
|
|
||||||
- 실행 여부와 결과는 각 Task/Gate 실행 기록을 기준으로 한다.
|
|
||||||
|
|
||||||
## 목표와 범위
|
|
||||||
|
|
||||||
PRD의 7개 GET API에서 오디오 제목·시리즈 제목/시리즈명·테마명을 요청 언어로 표시하고,
|
|
||||||
번역이 없거나 공백이면 기존 원문을 반환한다. 제외 항목은 PRD §2와 동일하다.
|
|
||||||
|
|
||||||
| Phase | 결과 | 상태 | 완료 Task |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | 채널·메인 전체 목록 번역 통일 | 자동 검증 완료, test 서버 HTTP Gate 대기 | 2/2 |
|
|
||||||
| 2 | 추천·더보기·랭킹 번역 통일 | 자동 검증 완료, test 서버 HTTP Gate 대기 | 2/2 |
|
|
||||||
|
|
||||||
## 구현 원칙
|
|
||||||
|
|
||||||
- 조사는 v2 패키지에서 시작하고 연결된 기존 번역 저장·조회 코드를 확인한다.
|
|
||||||
- 기존 LangInterceptor/LangContext와 ko/en/ja 파싱을 재사용하며 공통 언어 파서를 변경하지 않는다.
|
|
||||||
- Kotlin/Spring 기존 계층과 스타일을 따른다. 공개 응답 스키마와 인증·필터·정렬·페이지 조건은 유지한다.
|
|
||||||
- 번역 테이블 locale 조인 또는 기존 일괄 조회를 재사용한다. 항목 수만큼 추가 조회하는 N+1은 금지한다.
|
|
||||||
- 랭킹·추천 스냅샷의 집계/저장과 요청별 번역 표시를 분리한다.
|
|
||||||
- 저장 데이터·DDL·번역 생성 파이프라인·배너 국가별 선택 정책은 변경하지 않는다.
|
|
||||||
- 파일 목록은 수정 후보 및 확인 책임 범위다. 기존 동작이 맞는 파일은 테스트로만 확인한다.
|
|
||||||
- 기존 함수의 모든 호출자를 확인하고 공용 호출에 변경이 전파되면 관련 테스트와 이 문서에 기록한다.
|
|
||||||
- 새 추상화·의존성 없이 기존 Port/Repository를 최소 확장한다.
|
|
||||||
|
|
||||||
## 공통 참조 파일
|
|
||||||
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt
|
|
||||||
|
|
||||||
## 공통 테스트 데이터
|
|
||||||
|
|
||||||
같은 원문에 서로 다른 ko/en/ja 번역을 저장한다. 번역 행 없음·빈 문자열·공백·다른 locale만 존재·
|
|
||||||
오디오와 시리즈 중 한쪽만 번역된 경우를 포함한다.
|
|
||||||
조회 요청에는 ko/en/ja, en-US, ja-JP, 누락, 빈 값, 미지원 fr를 사용한다.
|
|
||||||
en→ja→en 연속 요청에서 언어가 섞이지 않아야 한다.
|
|
||||||
언어별 응답은 대상 문자열만 달라지고 ID·개수·순서·필터·페이지·가격·제외 필드는 같아야 한다.
|
|
||||||
|
|
||||||
### Phase 1: 채널·메인 전체 목록
|
|
||||||
|
|
||||||
시작 조건: 사용자 구현 요청. 결과: LIST-01~04의 번역 표시와 기존 동작 유지.
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 채널 오디오·시리즈·홈의 번역 표시 통일**
|
|
||||||
|
|
||||||
**Goal P1-T1**
|
|
||||||
- Objective: 오디오 title·seriesName·테마명, 홈 오디오/시리즈/AUDIO 일정 제목의 번역과 필드별 fallback을 검증한다. 이미 구현된 시리즈·테마 번역은 회귀를 먼저 확인하며 불필요하게 수정하지 않는다.
|
|
||||||
- 요구사항: LIST-01, LIST-02, LIST-04, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
|
||||||
- 시작 조건: 사용자의 구현 착수 요청과 PRD 확인.
|
|
||||||
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
|
||||||
- 범위 밖: 메인 목록·추천·랭킹 변경.
|
|
||||||
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
|
||||||
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
|
||||||
|
|
||||||
**확인/수정 후보 파일**
|
|
||||||
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt
|
|
||||||
|
|
||||||
**테스트 파일**
|
|
||||||
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt
|
|
||||||
|
|
||||||
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
|
||||||
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
|
||||||
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest' --tests '*DefaultCreatorChannelSeriesQueryRepositoryTest' --tests '*CreatorChannelAudioEndToEndTest' --tests '*CreatorChannelSeriesEndToEndTest' --tests '*CreatorChannelHomeEndToEndTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
|
||||||
|
|
||||||
**Task 실행 기록:**
|
|
||||||
|
|
||||||
- 2026-09-09: 사용자가 현재 브랜치 구현을 지시해 worktree 없이 `test` 브랜치에서 진행했다.
|
|
||||||
- RED: `./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest.shouldFindAudioContentsWithTranslatedTitleFallback' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest.shouldFindHomeAudioContentsWithTranslatedTitleFallback' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest.shouldFindHomeSchedulesAndSeriesWithTranslatedTitleFallback'` 실행. 종료 코드 1, 3개 테스트가 제목 assertion 실패로 실패했다.
|
|
||||||
- GREEN: 같은 RED 명령 재실행. 종료 코드 0, 오디오 title·홈 오디오 title/seriesName·AUDIO 스케줄 title·홈 시리즈 title의 번역 및 공백 fallback을 확인했다.
|
|
||||||
- Focused: `./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest' --tests '*DefaultCreatorChannelSeriesQueryRepositoryTest' --tests '*CreatorChannelAudioEndToEndTest' --tests '*CreatorChannelSeriesEndToEndTest' --tests '*CreatorChannelHomeEndToEndTest'` 실행. 종료 코드 0.
|
|
||||||
- 리뷰 보완: 같은 콘텐츠에 en/ja 번역을 함께 저장해 locale별 제목 선택과 ID 순서 불변을 추가 검증했다. 홈 service는 `LangContext.lang.code`가 최신 오디오·오디오 목록·스케줄·시리즈 port 호출에 전달되는지 확인했다.
|
|
||||||
- 보완 후 Focused 재실행: 위 P1-T1 focused command 실행. 종료 코드 0.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: 메인 전체 목록 오디오 번역과 시리즈 회귀 확인**
|
|
||||||
|
|
||||||
**Goal P1-T2**
|
|
||||||
- Objective: 각 기존 type에서 오디오 제목 번역을 확인하고 기존 시리즈 locale 전달·fallback을 보존한다. 기존 요일·정렬·유료/무료 필터와 페이지 결과가 동일해야 한다.
|
|
||||||
- 요구사항: LIST-03, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
|
||||||
- 시작 조건: P1-T1 완료.
|
|
||||||
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
|
||||||
- 범위 밖: 추천·랭킹 변경.
|
|
||||||
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
|
||||||
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
|
||||||
|
|
||||||
**확인/수정 후보 파일**
|
|
||||||
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt
|
|
||||||
|
|
||||||
**테스트 파일**
|
|
||||||
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt
|
|
||||||
|
|
||||||
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
|
||||||
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
|
||||||
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*DefaultMainContentAllQueryRepositoryTest' --tests '*MainContentAllQueryServiceTest' --tests '*MainContentAllEndToEndTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
|
||||||
|
|
||||||
**Task 실행 기록:**
|
|
||||||
|
|
||||||
- RED: `./gradlew test --tests '*MainContentAllQueryServiceTest.shouldQueryAudiosForAudioType' --tests '*DefaultMainContentAllQueryRepositoryTest.shouldFindAudiosWithTranslatedTitleFallback'` 실행. 종료 코드 1, service locale 전달 assertion과 repository 오디오 title assertion이 실패했다.
|
|
||||||
- GREEN/Focused: `./gradlew test --tests '*DefaultMainContentAllQueryRepositoryTest' --tests '*MainContentAllQueryServiceTest' --tests '*MainContentAllEndToEndTest'` 실행. 종료 코드 0, 오디오 title 번역·공백 fallback과 기존 시리즈 번역 회귀를 확인했다.
|
|
||||||
- 리뷰 보완: 같은 오디오에 en/ja 번역을 함께 저장해 locale별 title 선택과 ID 순서 불변을 추가 검증했다.
|
|
||||||
- 보완 후 Focused 재실행: 위 P1-T2 focused command 실행. 종료 코드 0.
|
|
||||||
|
|
||||||
#### Phase 1 Gate — P1-GATE
|
|
||||||
|
|
||||||
- Objective: Phase 1의 요청 헤더부터 DB 번역 선택·HTTP 응답까지 확인한다.
|
|
||||||
- 시작 조건: P1-T1, P1-T2 완료.
|
|
||||||
- 완료 증거: 해당 Task 명령 통과, 아래 수동 확인과 실제 명령·결과·환경 기록.
|
|
||||||
- 범위 밖: 운영 데이터 변경, Papago 실행, 테스트 삭제·완화.
|
|
||||||
- [x] 해당 Phase의 모든 focused test 명령을 실행하여 종료 코드 0을 확인한다.
|
|
||||||
- [ ] test 서버에서 같은 API·query를 유지하고 Accept-Language만 바꿔 GET 요청한다.
|
|
||||||
- [ ] ko/en/ja 번역·없는 번역·공백 번역·기본 언어 동작을 실제 JSON에서 확인한다.
|
|
||||||
- [ ] 대상 외 필드, 순서·개수·페이지·인증·성인/차단/구매 정책이 보존되는지 확인한다.
|
|
||||||
- [ ] 요청별 번역 조회가 항목 수에 비례해 늘지 않고 GET에서 번역 작업/외부 API/쓰기 호출이 없는지 확인한다.
|
|
||||||
- [ ] 검증 로그는 인증 토큰·개인정보를 제거해 기록한다.
|
|
||||||
|
|
||||||
수동 HTTP는 test 서버와 합성 테스트 계정을 사용한다. 인증이 필요한 API는 test 서버용 토큰을 사용하고 문서에 값을 기록하지 않는다.
|
|
||||||
test 서버 배포·DB 준비가 안 되면 수동 Gate를 완료 처리하지 않고 원인과 재개 조건을 남긴다.
|
|
||||||
|
|
||||||
**Phase 1 Gate 실행 기록:**
|
|
||||||
|
|
||||||
- 2026-09-09: P1-T1 focused command 재실행. 종료 코드 0.
|
|
||||||
- 2026-09-09: P1-T2 focused command 실행. 종료 코드 0.
|
|
||||||
- 2026-09-09: `./gradlew ktlintCheck`는 신규 테스트 호출 줄바꿈과 import 정렬 지적 후 수정해 재실행했다. 최종 종료 코드 0.
|
|
||||||
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0.
|
|
||||||
- 2026-09-09: Oracle 리뷰에서 자동 검증 공백을 지적받아 다중 locale 선택, 홈 locale 전달, 공백 fallback 보강 테스트를 추가했다. 보강 focused test 종료 코드 0.
|
|
||||||
- 2026-09-09: 보강 후 P1-T1 focused command, P1-T2 focused command, `./gradlew ktlintCheck`, `./gradlew assemble`을 다시 실행했다. 각 종료 코드 0.
|
|
||||||
- 로컬 수동 테스트가 불가능하다는 사용자 확인에 따라 HTTP Gate를 test 서버로 이관했다. 재개 조건: test 서버 배포와 합성 인증 수단 준비 후 동일 query에서 `Accept-Language`만 바꿔 LIST-01~04 JSON을 확인한다.
|
|
||||||
|
|
||||||
### Phase 2: 추천·더보기·랭킹
|
|
||||||
|
|
||||||
시작 조건: P1-GATE 통과. 결과: LIST-05~07의 번역 표시와 요청 간 격리.
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 추천·더보기의 모든 오디오 제목 번역**
|
|
||||||
|
|
||||||
**Goal P2-T1**
|
|
||||||
- Objective: 일반 카드와 별도 SQL을 쓰는 mostCommentedAudios를 모두 검증한다. 더보기 두 type과 FIRST_AUDIO_CONTENT 공유 호출자를 확인한다. 추천 스냅샷·백그라운드 작업에 요청 범위 의존을 추가하지 않으며, 공유 홈 추천 호출의 기존 응답을 회귀 검증한다.
|
|
||||||
- 요구사항: LIST-05, LIST-07, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
|
||||||
- 시작 조건: P1-GATE 완료.
|
|
||||||
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
|
||||||
- 범위 밖: 배너·댓글·닉네임 및 다른 API의 신규 번역 기능.
|
|
||||||
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
|
||||||
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
|
||||||
|
|
||||||
**확인/수정 후보 파일**
|
|
||||||
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt
|
|
||||||
|
|
||||||
**테스트 파일**
|
|
||||||
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt
|
|
||||||
|
|
||||||
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
|
||||||
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
|
||||||
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*DefaultAudioRecommendationQueryRepositoryTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*ContentOverviewFacadeTest' --tests '*AudioRecommendationEndToEndTest' --tests '*ContentOverviewEndToEndTest' --tests '*HomeRecommendationFacadeTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
|
||||||
|
|
||||||
**Task 실행 기록:**
|
|
||||||
|
|
||||||
- 2026-09-09: RED는 이전 구현 담당자가 아래 focused command로 포착했다. 원시 Gradle 콘솔 로그는 현재 세션에 보관되어 있지 않아 출력은 재구성하지 않는다. LIST-05 전체 카드와 LIST-07 NEW_AND_HOT_AUDIO/FIRST_AUDIO_CONTENT가 요청 locale 번역 대신 원문 제목을 반환한 assertion 실패였다.
|
|
||||||
- 2026-09-09: GREEN 구현 후 저장소에서 locale별 배치 조회와 nonblank title fallback을 적용했다. Oracle 보완으로 같은 fixture의 en→ja→en MockMvc 격리와 빈 문자열·공백·누락·다른 locale fallback, most-commented의 ID 순서·댓글·작성자 프로필 필드 보존을 추가 검증했다.
|
|
||||||
- 2026-09-09: Focused: 위 command 실행, `BUILD SUCCESSFUL in 1m 43s`. production 코드는 Oracle 보완에서 변경하지 않았고, `pointAudios`의 기존 개수·인덱스 검증을 누적 fixture와 `rand()` 정렬에 독립적인 fixture ID·가격 조건으로 교체했다.
|
|
||||||
- 수동 HTTP는 로컬 실행이 불가능하다는 사용자 확인에 따라 test 서버 P2-GATE에서 대기한다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: 랭킹 응답 제목을 요청 언어로 표시**
|
|
||||||
|
|
||||||
**Goal P2-T2**
|
|
||||||
- Objective: 같은 랭킹 스냅샷의 contentId로 요청 locale 번역을 일괄 조회하여 표시 제목에만 반영한다. 번역 없음·공백이면 기존 snapshot.title을 사용한다. 집계 재실행 없이 번역 반영과 rank·rankChange·점수 불변을 검증한다.
|
|
||||||
- 요구사항: LIST-06, RANK-01, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
|
||||||
- 시작 조건: P2-T1 완료.
|
|
||||||
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
|
||||||
- 범위 밖: 집계·점수·스냅샷 저장 형식 변경.
|
|
||||||
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
|
||||||
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
|
||||||
|
|
||||||
**확인/수정 후보 파일**
|
|
||||||
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt
|
|
||||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt
|
|
||||||
|
|
||||||
**테스트 파일**
|
|
||||||
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt
|
|
||||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt
|
|
||||||
|
|
||||||
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
|
||||||
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
|
||||||
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*AudioRankingQueryServiceTest' --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest' --tests '*AudioRankingControllerTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
|
||||||
|
|
||||||
**Task 실행 기록:**
|
|
||||||
|
|
||||||
- 2026-09-09: RED: `./gradlew test --tests '*AudioRankingQueryServiceTest' --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest' --tests '*AudioRankingControllerTest'` 종료 코드 1. `AudioRankingQueryServiceTest.shouldUseRequestedLocaleTitlesWithoutChangingRankingValues`의 `expected: <[en-title-2, en-title-1]> but was: <[audio-2, audio-1]>` 및 `AudioRankingControllerTest.shouldIsolateRequestedLocaleTitlesWithoutChangingRankingResponse`의 `JSON path "$.data.items[0].title" expected:<en-title-2> but was:<audio-2>`를 확인했다. `DefaultAudioRankingSnapshotPersistenceAdapterTest.shouldTranslateSnapshotTitlesInBatchesWithSnapshotFallback`도 snapshot title 반환 assertion으로 실패했다.
|
|
||||||
- 2026-09-09: GREEN: `LangContext.lang.code`을 조회 port에만 전달하고, 최신·이전 각 스냅샷 결과 집합에서 `findByContentIdInAndLocale` 한 번으로 유효한 title만 적용했다. 빈 문자열·공백·누락·다른 locale 전용 번역은 각 snapshot.title로 fallback하며, replace/집계/refresh 경로는 변경하지 않았다.
|
|
||||||
- 2026-09-09: Focused: 위 명령 종료 코드 0, `BUILD SUCCESSFUL in 40s`. query service의 en→ja→en locale 전달과 title 외 `showRankChange`·type·순서·rank·rankChange·isNew·닉네임·cover URL 불변, 실제 adapter의 최신·이전 조회 배치 8회 statement, controller의 en→ja→en JSON을 검증했다.
|
|
||||||
- 2026-09-09: 직접 호출자 회귀: `./gradlew test --tests '*AudioRankingSnapshotRefreshServiceTest'` 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
|
||||||
- 수동 HTTP는 test 서버 배포 후 P2-GATE에서 수행한다.
|
|
||||||
- 2026-09-09: P2-T2 spec-review 보강: `./gradlew test --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest'` 종료 코드 0, `BUILD SUCCESSFUL in 34s`. `rendered_payload`를 SQL NULL로 저장한 실제 translation row는 converter의 빈 payload로 읽혀 snapshot.title으로 fallback했다. `{ "title": null }` JSON은 non-null `ContentTranslationPayload.title` 계약 밖이라 생성하지 않았다.
|
|
||||||
- 2026-09-09: 빈 latest 조회 후 Hibernate `prepareStatementCount=1`, 이어진 빈 previous 조회 후 누적 `=2`를 확인했다. 각 snapshot native SELECT만 발생했고 ContentTranslation SELECT는 0회다. non-empty latest·previous en→ja→en 여섯 조회는 snapshot+batch translation 각 1회씩 총 12 statements였으며, title만 빈 문자열로 정규화한 전체 `AudioRankingSnapshotRecord`가 locale 간 동일했다. latest score base 10~14와 previous 20~24의 final/normalized/raw·count·growth·boost 필드를 포함했고, 재조회한 저장 snapshot title과 모든 score 값도 변경되지 않았다.
|
|
||||||
|
|
||||||
#### Phase 2 Gate — P2-GATE
|
|
||||||
|
|
||||||
- Objective: Phase 2의 요청 헤더부터 DB 번역 선택·HTTP 응답까지 확인한다.
|
|
||||||
- 시작 조건: P2-T1, P2-T2 완료.
|
|
||||||
- 완료 증거: 해당 Task 명령 통과, 아래 수동 확인과 실제 명령·결과·환경 기록.
|
|
||||||
- 범위 밖: 운영 데이터 변경, Papago 실행, 테스트 삭제·완화.
|
|
||||||
- [x] 해당 Phase의 모든 focused test 명령을 실행하여 종료 코드 0을 확인한다.
|
|
||||||
- [ ] test 서버에서 같은 API·query를 유지하고 Accept-Language만 바꿔 GET 요청한다.
|
|
||||||
- [ ] ko/en/ja 번역·없는 번역·공백 번역·기본 언어 동작을 실제 JSON에서 확인한다.
|
|
||||||
- [ ] 대상 외 필드, 순서·개수·페이지·인증·성인/차단/구매 정책이 보존되는지 확인한다.
|
|
||||||
- [ ] 요청별 번역 조회가 항목 수에 비례해 늘지 않고 GET에서 번역 작업/외부 API/쓰기 호출이 없는지 확인한다.
|
|
||||||
- [ ] 검증 로그는 인증 토큰·개인정보를 제거해 기록한다.
|
|
||||||
|
|
||||||
수동 HTTP는 test 서버와 합성 테스트 계정을 사용한다. 인증이 필요한 API는 test 서버용 토큰을 사용하고 문서에 값을 기록하지 않는다.
|
|
||||||
test 서버 배포·DB 준비가 안 되면 수동 Gate를 완료 처리하지 않고 원인과 재개 조건을 남긴다.
|
|
||||||
|
|
||||||
**Phase 2 Gate 실행 기록:**
|
|
||||||
|
|
||||||
- 2026-09-09: P2-T1 focused command를 `--rerun-tasks`로 강제 재실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 5m 12s`, 10 tasks executed.
|
|
||||||
- 2026-09-09: P2-T2 focused command를 `--rerun-tasks`로 강제 재실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 5m 14s`, 10 tasks executed.
|
|
||||||
- 2026-09-09: `AudioRankingSnapshotRefreshServiceTest` 직접 호출자 회귀를 실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
|
||||||
- 로컬 수동 테스트는 불가능하다는 사용자 확인에 따라 LIST-01~07 HTTP 검증을 test 서버 배포 후 수행한다.
|
|
||||||
|
|
||||||
## 실행 순서와 완료 기준
|
|
||||||
|
|
||||||
P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE. 2026-09-09 사용자 지시로 로컬 P1-GATE를 test 서버로 이관하고 P2 자동 구현·검증을 먼저 완료했다.
|
|
||||||
한 번에 하나의 Task 또는 Gate만 활성화하며, 완료된 기록은 되돌리거나 삭제하지 않는다.
|
|
||||||
|
|
||||||
최종 품질 명령:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests '*HomeRecommendation*'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
./gradlew assemble
|
|
||||||
```
|
|
||||||
|
|
||||||
기대 결과: 각 자동 검증 명령 종료 코드 0. 대상 7개 API의 실제 HTTP 확인은 test 서버 Gate에서 별도 완료한다.
|
|
||||||
전체 test는 공통 인증·언어 파서·serialization 변경 또는 targeted 결과로 영향 판단이 불가능할 때 추가한다.
|
|
||||||
생략 시 국소적인 표시값 변경이라는 근거와 대신 실행한 위 영향 범위 회귀를 기록한다.
|
|
||||||
초기 문서 작성 단계에서는 위 구현 검증 명령을 실행하지 않았으며, 구현 후 결과는 각 Task/Gate 기록에 누적한다.
|
|
||||||
|
|
||||||
## 결정 기록
|
|
||||||
|
|
||||||
PRD의 DEC-LIST-01~07을 따른다.
|
|
||||||
기존 파싱·API 계약·원문 fallback을 유지하며, 요청 외 영역으로 확대해야 할 경우 먼저 문서와 사용자 결정을 갱신한다.
|
|
||||||
|
|
||||||
## 문서 검증 기록
|
|
||||||
|
|
||||||
- 2026-09-09: 사용자 인터뷰의 원문 fallback과 댓글·닉네임·배너 유지 결정을 PRD와 Task에 반영.
|
|
||||||
- 초기 문서 작성 당시 코드·테스트·운영 데이터 변경 없이 구현 Task와 Gate는 전부 미착수였다.
|
|
||||||
- 경로 존재 확인 및 필수 Gradle 명령 확인 결과는 아래에 누적한다.
|
|
||||||
- 2026-09-09: Task의 기존 소스·테스트 경로 존재를 확인했다. 채널 홈 EndToEndTest는 현재 없으므로 신규 작성 예정으로 구분했다. 구현 완료 체크박스와 템플릿 placeholder가 남아 있지 않음을 확인했다.
|
|
||||||
- 2026-09-09: 문서 유지보수 규칙에 따라 `./gradlew tasks --all` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 26s`; test·ktlintCheck·assemble 태스크를 확인했다. 기능 테스트·빌드·HTTP 검증은 구현하지 않은 문서 작업이므로 실행하지 않았다.
|
|
||||||
- 2026-09-09: Phase 1·2 전체 영향 범위 test 명령 실행. 종료 코드 0, `BUILD SUCCESSFUL in 3m 23s`.
|
|
||||||
- 2026-09-09: 최초 `./gradlew ktlintCheck`에서 신규 랭킹·추천 테스트의 들여쓰기와 긴 줄 위반을 확인해 포맷만 수정했다. 관련 두 repository test 종료 코드 0, 최종 `./gradlew ktlintCheck` 종료 코드 0.
|
|
||||||
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
|
||||||
- 2026-09-09: 전체 `./gradlew test` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 8m 19s`.
|
|
||||||
- 2026-09-09: 문서 상태를 Phase 1·2 자동 검증 완료와 test 서버 HTTP Gate 대기로 갱신하고 `./gradlew tasks --all`로 명령 유효성을 재확인했다. 종료 코드 0.
|
|
||||||
- 2026-09-09: 전체 영향 범위 회귀 명령을 실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 3m 23s`.
|
|
||||||
- 2026-09-09: `./gradlew ktlintCheck` 최초 실행에서 신규 테스트 두 파일의 들여쓰기·긴 줄 위반을 확인해 포맷만 수정했다. 관련 ranking adapter·recommendation repository 테스트 재실행 종료 코드 0, 최종 `ktlintCheck` 종료 코드 0.
|
|
||||||
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
|
||||||
- 2026-09-09: 문서 상태와 test 서버 Gate 갱신 후 `./gradlew tasks --all` 재실행. 종료 코드 0, `BUILD SUCCESSFUL in 928ms`.
|
|
||||||
- 로컬 수동 테스트는 불가능하므로 자동 검증 완료 상태로 test 서버 배포를 준비한다. 배포 후 P1-GATE부터 LIST-01~07의 실제 HTTP 응답을 순서대로 확인한다.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# v2 콘텐츠 목록 요청 언어별 번역 PRD
|
|
||||||
|
|
||||||
## 문서 정보
|
|
||||||
|
|
||||||
- 작성일: 2026-09-09
|
|
||||||
- 상태: Phase 1·2 구현 및 자동 검증 완료, test 서버 HTTP Gate 대기
|
|
||||||
- 결정권자: 사용자
|
|
||||||
- 관련 계획: [plan-task.md](plan-task.md)
|
|
||||||
- 공개 API 경로·요청·응답 스키마는 유지하며 별도 API 계약 문서는 만들지 않는다.
|
|
||||||
- 사용자의 2026-09-09 구현 지시 이후 Phase 1·2 코드·테스트 구현을 진행했다. 운영 데이터 변경과 번역 실행은 수행하지 않는다.
|
|
||||||
|
|
||||||
## 1. 문제와 목표
|
|
||||||
|
|
||||||
동일한 콘텐츠라도 v2 조회 위치에 따라 번역 제목과 원문이 혼재한다.
|
|
||||||
요청의 Accept-Language로 결정된 언어에 맞춰 이미 저장된 번역을 표시하고,
|
|
||||||
번역이 없거나 공백이면 해당 필드의 기존 원문을 표시한다.
|
|
||||||
|
|
||||||
서버의 언어 결정은 기존 LangInterceptor → 요청 범위 LangContext를 재사용한다.
|
|
||||||
앱의 설정 언어가 실제 헤더로 전송되는지는 이 서버 작업에서 보장하지 않는다.
|
|
||||||
|
|
||||||
## 2. 대상 API와 필드
|
|
||||||
|
|
||||||
아래 경로는 모두 GET이다. 기존 응답에 존재하는 필드만 변경하며 설명·태그 같은 새 필드를 추가하지 않는다.
|
|
||||||
|
|
||||||
| ID | API | 대상 |
|
|
||||||
|---|---|---|
|
|
||||||
| LIST-01 | /api/v2/creator-channels/{creatorId}/audio | 오디오 title, 연결된 seriesName, 테마명 |
|
|
||||||
| LIST-02 | /api/v2/creator-channels/{creatorId}/series | 시리즈 title. 기존 연재 요일 언어 처리 유지 |
|
|
||||||
| LIST-03 | /api/v2/audio/contents | 오디오 title, 시리즈 title. 기존 type·정렬·요일 필터 유지 |
|
|
||||||
| LIST-04 | /api/v2/creator-channels/{creatorId}/home | 오디오 카드 title·seriesName, 시리즈 title, 일정 중 AUDIO 유형의 title |
|
|
||||||
| LIST-05 | /api/v2/audio/recommendations | latestAudios, newAndHotAudios, freeAudios, pointAudios, mostCommentedAudios, recommendedAudios의 title |
|
|
||||||
| LIST-06 | /api/v2/audio/rankings | 각 랭킹 항목 title |
|
|
||||||
| LIST-07 | /api/v2/contents | NEW_AND_HOT_AUDIO, FIRST_AUDIO_CONTENT의 title |
|
|
||||||
|
|
||||||
- LIST-05의 originalSeries는 현재 seriesId·coverImageUrl만 반환한다. 번역용 title 필드를 추가하지 않는다.
|
|
||||||
- LIST-04의 라이브 제목, 후원·팬톡 문구 등 오디오·시리즈·테마 외 필드는 대상이 아니다.
|
|
||||||
- /api/v2/home/recommendations 등 다른 경로는 신규 기능 범위가 아니다. 공유 조회 코드 변경 시 기존 동작 회귀를 검증한다.
|
|
||||||
- 관리자 목록, 상세·검색 API, 음성 파일 번역, 설명·태그 응답 확장은 제외한다.
|
|
||||||
|
|
||||||
## 3. 기능 요구사항과 수용 기준
|
|
||||||
|
|
||||||
| ID | 상태/근거 | 요구사항 | 수용 기준 | 연결 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| LANG-01 | 사용자 확정 | 대상 API 전체에서 Accept-Language 기준 번역 선택 | 동일 데이터에 ko/en/ja 요청 시 해당 locale 번역 표시 | 모든 Task |
|
|
||||||
| LANG-02 | 기존 동작 유지 | Lang.fromAcceptLanguage 파싱 재사용 | 헤더 누락·빈 값·미지원 값은 ko, en-US는 en, ja-JP는 ja. q 가중치 파싱 확장 없음 | P1-GATE |
|
|
||||||
| TEXT-01 | 사용자 확정 | 번역 없음·빈 문자열·공백 문자열은 원문 표시 | null/빈 문자열/공백별 원문 일치, 다른 언어 번역으로 대체하지 않음 | 모든 Task |
|
|
||||||
| TEXT-02 | 사용자 확정 | 오디오 제목·시리즈 제목/시리즈명·테마명만 적용 | 댓글·닉네임·배너는 기존 값·선택 정책 유지 | 모든 Task |
|
|
||||||
| COMPAT-01 | 기존 기능 유지 | 언어는 표시 문자열에만 영향 | ID·개수·순서·hasNext·권한·성인/차단/구매 필터·가격·이미지 불변 | 각 Gate |
|
|
||||||
| RANK-01 | 구현 제약 | 랭킹 집계와 번역 표시를 분리 | 같은 스냅샷으로 언어만 바꿔 제목 변경, 순위·점수·rankChange 불변 | P2-T2 |
|
|
||||||
| READ-01 | 범위 제약 | 저장된 번역만 조회 | GET에서 Papago 호출·번역 작업 생성·DB 쓰기 없음 | 각 Gate |
|
|
||||||
| ISOLATE-01 | 기존 요청 범위 유지 | 언어가 다른 요청끼리 값 공유 금지 | en→ja→en 요청에서도 언어 혼입 없음 | 각 Gate |
|
|
||||||
|
|
||||||
원문이란 번역 적용 전 해당 경로에서 반환하던 문자열이다.
|
|
||||||
랭킹은 기존 스냅샷 title을 fallback으로 유지하며 최신 콘텐츠 제목으로 갱신하는 별도 기능을 추가하지 않는다.
|
|
||||||
번역 완료 전 원문이 보이는 것은 정상이다. 원문과 요청 언어가 같아도 별도 추정 없이 같은 locale 번역 조회/fallback 규칙을 적용한다.
|
|
||||||
|
|
||||||
## 4. 기술 근거와 최소 변경 방향
|
|
||||||
|
|
||||||
모든 경로는 src/main/kotlin/kr/co/vividnext/sodalive/ 아래를 기준으로 한다.
|
|
||||||
|
|
||||||
| 근거 파일 | 현재 확인한 동작 |
|
|
||||||
|---|---|
|
|
||||||
| i18n/LangInterceptor.kt, i18n/Lang.kt, configs/WebConfig.kt | 전체 경로에서 헤더를 읽고 ko/en/ja로 결정 |
|
|
||||||
| content/translation/ContentTranslationRepository.kt | findByContentIdInAndLocale 일괄 조회가 이미 존재 |
|
|
||||||
| i18n/translation/TranslationReadModelMaterializer.kt | content·series 번역을 renderedPayload에 저장 |
|
|
||||||
| v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt | 오디오 title·seriesName·테마는 요청 locale 번역 우선 |
|
|
||||||
| v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt | 시리즈 번역 우선과 원문 fallback 구현됨 |
|
|
||||||
| v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt | 오디오·시리즈 제목 및 AUDIO 일정 제목은 요청 locale 번역 우선 |
|
|
||||||
| v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt | 오디오·시리즈 제목은 요청 locale 번역 우선 |
|
|
||||||
| v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt | 일반 카드 및 mostCommentedAudios 제목은 요청 locale 번역 우선 |
|
|
||||||
| v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt | FIRST_AUDIO_CONTENT 제목은 요청 locale 번역 우선 |
|
|
||||||
| v2/content/ranking/application/AudioRankingQueryService.kt | 요청 locale을 조회 port에 전달하고 snapshot 표시 제목만 번역 |
|
|
||||||
|
|
||||||
기존 QueryService → Port → Persistence 경계를 유지하며 필요한 locale을 전달한다.
|
|
||||||
기존 번역 조인 또는 ID 일괄 조회를 재사용한다. 항목별 단건 조회로 N+1을 만들지 않는다.
|
|
||||||
페이지·정렬·필터 결과를 유지한 채 제목을 매핑한다. 별도 범용 번역 프레임워크·캐시·의존성·DDL은 추가하지 않는다.
|
|
||||||
랭킹/추천 스냅샷 생성 작업에 요청 범위 LangContext를 주입하지 않는다.
|
|
||||||
|
|
||||||
## 5. 검증 시나리오
|
|
||||||
|
|
||||||
1. 같은 원문에 ko/en/ja별 서로 다른 번역을 준비하고 7개 API의 대상 필드가 헤더에 맞는지 확인한다.
|
|
||||||
2. 번역 행 없음, 빈 제목, 공백 제목, 다른 locale 번역만 존재하는 경우 필드별 원문 fallback을 확인한다.
|
|
||||||
3. 오디오 번역만 있고 시리즈 번역이 없는 혼합 사례에서 각 필드가 독립적으로 선택되는지 확인한다.
|
|
||||||
4. 언어를 바꿔도 콘텐츠 ID·순서·페이지·필터·랭킹과 제외 필드가 동일한지 확인한다.
|
|
||||||
5. 실제 HTTP 요청과 DB 조회 테스트로 연결을 검증한다. Mock 응답의 문자열 확인만으로 완료 처리하지 않는다.
|
|
||||||
|
|
||||||
## 6. 결정 기록
|
|
||||||
|
|
||||||
| 날짜 | ID | 구분 | 내용 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 2026-09-09 | DEC-LIST-01 | 사용자 확정 | 앞서 조사한 조회 위치 모두 Accept-Language에 따른 번역 표시 |
|
|
||||||
| 2026-09-09 | DEC-LIST-02 | 사용자 확정 | 번역 없음·공백이면 원문 표시 |
|
|
||||||
| 2026-09-09 | DEC-LIST-03 | 사용자 확정 | 오디오 제목·시리즈 제목/시리즈명·테마명 대상, 댓글·닉네임·배너 유지 |
|
|
||||||
| 2026-09-09 | DEC-LIST-04 | 기존 동작 유지 | 언어 파싱·기본 언어·인증·조회 조건·응답 스키마 유지 |
|
|
||||||
| 2026-09-09 | DEC-LIST-05 | 사용자 제한 | 문서만 작성, 구현하지 않음 |
|
|
||||||
| 2026-09-09 | DEC-LIST-06 | 사용자 지시 | 현재 브랜치에서 Phase 1 구현 진행. PRD에도 구현 상태를 최종 갱신 |
|
|
||||||
| 2026-09-09 | DEC-LIST-07 | 사용자 지시 | 로컬 수동 테스트가 불가능하므로 Phase 2까지 자동 검증을 완료하고 test 서버에서 HTTP 검증 |
|
|
||||||
|
|
||||||
제품 결정이 필요한 미결 항목은 없다. test 서버 배포 후 대상 7개 API의 수동 HTTP 검증이 남아 있다.
|
|
||||||
@@ -1,965 +0,0 @@
|
|||||||
# 크리에이터 커뮤니티 게시물 본문 번역 구현 계획
|
|
||||||
|
|
||||||
| 문서 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 상태 | 기존 로컬 Gate 기록 유지 · `P1-R4` 구현·자동 회귀·수정 후 리뷰 완료 · 테스트 서버 수동 검증 대기 |
|
|
||||||
| 작성일 | 2026-09-10 |
|
|
||||||
| 요구사항 / API 기준 | [PRD](prd.md), 특히 §3 요구사항과 §5 API 계약 |
|
|
||||||
| 기준 템플릿 | `docs/sample/sample-plan-task.md` |
|
|
||||||
| 현재 Phase / 활성 Goal | `P1-R4` 및 P1/P2 영향 회귀 완료 / 활성 구현 Goal 없음 |
|
|
||||||
| 다음 Goal | 테스트 브랜치 배포 후 실제 MySQL/Papago/HTTP 수동 검증 |
|
|
||||||
|
|
||||||
## 목표·범위·제약
|
|
||||||
|
|
||||||
본문 원문을 보존하면서 한국어·영어·일본어 번역을 저장하고, 상세·목록·미리보기 전체에 일관되게 제공한다.
|
|
||||||
신규/본문 수정은 전체 목표 언어를 처리하고, 기존 게시물의 누락 번역은 상세에서 요청 언어만 예약한다.
|
|
||||||
|
|
||||||
기존 Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper, JUnit 5, QueryDSL/JPA, Papago를 사용한다.
|
|
||||||
새 큐·스케줄러·외부 의존성·공개 API 필드를 추가하지 않는다. 원문과 번역을 분리하며 구매/차단/성인 정책을 유지한다.
|
|
||||||
새 재사용 기능은 `v2/creator/channel/community/translation` 아래에 두고 기존 공통 번역 연결부만 확장한다.
|
|
||||||
현재 PRD의 제외 범위는 모든 Goal에 적용한다. 신규 파일 경로는 아래 표에서 `생성`으로 구분한다.
|
|
||||||
|
|
||||||
로컬 검증은 Gradle 자동 테스트와 ktlint으로 한정한다. DDL·HTTP·실제 MySQL 동시성은 로컬에서 실행하지 않고,
|
|
||||||
테스트 브랜치 배포 후 이 문서의 수동 체크리스트에서 확인한다.
|
|
||||||
|
|
||||||
## 현재 상태와 실행 순서
|
|
||||||
|
|
||||||
| Phase | 결과 | 상태 | 완료 Task | 다음 Goal |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | 현재 본문에 대응하는 번역 저장·감지 기반 | `P1-R4` 포함 로컬 자동 검증 완료 | 6/6 | 테스트 서버 수동 검증 |
|
|
||||||
| 2 | 작성·수정·상세의 비동기 연결과 폴백 | 완료 | 2/2 | 테스트 서버 수동 검증 |
|
|
||||||
| 3 | 모든 목록·홈 적용과 전체 사용자 흐름 검증 | 로컬 Gate 완료 | 2/2 | 테스트 서버 수동 검증 |
|
|
||||||
|
|
||||||
실행 순서: `P1-T1 → P1-T2 → P1-GATE → P1-R1 → P1-GATE 재검증 → P1-R2 → P1-GATE 재검증 → P1-R3 → P1-GATE 재검증 → P2-T1 → P2-T2 → P2-GATE → P3-T1 → P3-T2 → P3-GATE → P3-R1 → P3-GATE 재검증 → P1-R4 → P1/P2 영향 회귀 → 수정 후 리뷰`.
|
|
||||||
Task 또는 Gate 하나만 활성 Goal로 운용한다. 각 Goal의 시작 조건·체크박스·완료 증거·검증 기록을 모두 충족해야 완료한다.
|
|
||||||
과거 완료 기록은 되돌리지 않고 후속 문제를 별도 수정 Task로 누적한다.
|
|
||||||
|
|
||||||
2026-09-10 후속 리뷰: [Phase 1](reviews/phase-1-review.md)은 수정 1건, [Phase 2](reviews/phase-2-review.md)와
|
|
||||||
[Phase 3](reviews/phase-3-review.md)는 추가 확정 발견 사항 없음.
|
|
||||||
당시 후속 실행 순서: `P1-R4 → P1-GATE 영향 범위 재검토 → P2 상세 경로 영향 재검토 → 기존 테스트 서버 수동 검증`.
|
|
||||||
당시 정적 리뷰는 사용자 지시에 따라 테스트를 재실행하지 않았다. 이후 `P1-R4`의 RED/GREEN, P1/P2 영향 회귀와
|
|
||||||
수정 후 리뷰를 완료했다. 기존 Task/Gate 기록은 유지하며 남은 단계는 테스트 서버 수동 검증이다.
|
|
||||||
|
|
||||||
## 설계 계약
|
|
||||||
|
|
||||||
### 데이터와 공통 파이프라인
|
|
||||||
|
|
||||||
- `CreatorCommunity.languageCode: String?`, `contentRevision: Long`을 추가한다. 기존 행은 NULL/0, 신규 행도 0부터 시작한다.
|
|
||||||
실제 원문 문자열 변경 때만 개정 번호를 증가시키고 언어를 NULL로 초기화한다. 동시 수정 시 개정 번호가 충돌하지 않도록
|
|
||||||
기존 소유권 조건을 유지한 행 잠금 조회를 사용한다.
|
|
||||||
- 신규 `CreatorCommunityTranslation`에는 `creatorCommunityId`, `locale`, `content`, `sourceRevision`, `sourceHash`,
|
|
||||||
`sourceLanguage`와 기존 BaseEntity 시각 필드를 둔다. 게시물 ID와 locale 조합을 유일하게 만든다.
|
|
||||||
- `LanguageDetectTargetType`과 `LanguageTranslationTargetType`에 `CREATOR_COMMUNITY`를 추가한다.
|
|
||||||
번역 필드 키는 `content` 하나다. 다른 리소스 분기는 수정하지 않는다.
|
|
||||||
- `TranslationSource`에 커뮤니티 원문의 개정 번호를 전달할 선택 필드를 추가하고 기존 호출은 기본값으로 유지한다.
|
|
||||||
`TranslationSourceExtractor`는 활성 커뮤니티의 본문·언어·개정 번호를 같은 스냅샷으로 추출한다.
|
|
||||||
- materializer는 추출된 개정 번호에 대응하는 메모리만 사용한다. 저장 시 현재 행의 개정 번호·언어·활성을 다시 검사하고
|
|
||||||
동일한 짧은 DB 트랜잭션 안에서 upsert한다. 오래된 값이 새 번역을 덮어쓰지 않도록 개정 검사와 쓰기를 직렬화한다.
|
|
||||||
외부 Papago 호출은 해당 잠금 밖에서 기존 워커가 처리한다.
|
|
||||||
- 기존 `translation_job` 유일 키와 메모리 키는 유지한다. 이미 완료한 원문으로 복원되는 경우에는 메모리에서
|
|
||||||
현재 개정 번호의 조회 모델을 먼저 materialize하고, 부족한 경우에만 기존 scheduler를 호출한다.
|
|
||||||
|
|
||||||
### 신규 공유 서비스 경계
|
|
||||||
|
|
||||||
`CreatorCommunityTranslationService` 하나에 다음의 명확히 분리된 진입점을 둔다. 단순 내부 호출을 위한 별도 port는 만들지 않는다.
|
|
||||||
|
|
||||||
| 인터페이스 | 책임 / 호출 조건 |
|
|
||||||
|---|---|
|
|
||||||
| `findDisplayContents(postIds: List<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent>` | 읽기 전용. 현재 게시물과 번역을 일괄 조회해 표시 본문과 유효 번역 여부를 반환한다. 감지·예약·materialize 금지. 조회 권한 판정은 호출자가 먼저 수행한다. |
|
|
||||||
| `requestTranslations(postId: Long, targetLanguage: String? = null)` | 별도 쓰기 트랜잭션. NULL이면 원문 외 지원 언어 전체, 값이 있으면 그 언어만 처리한다. 언어가 없으면 감지 이벤트를 발행하고, 있으면 메모리 복원 후 누락 작업을 예약한다. |
|
|
||||||
|
|
||||||
감지 이벤트에는 커뮤니티용 `sourceRevision: Long? = null`, `targetLanguage: String? = null` 정보를 추가한다.
|
|
||||||
기존 이벤트 발행자는 기본값으로 동작한다. 커뮤니티 핸들러는 감지 요청 당시 개정과 현재 개정을 비교한다.
|
|
||||||
감지 결과 저장은 개정 번호·활성 상태 조건을 포함한 갱신으로 처리하고, 저장 커밋 후 공유 서비스를 호출한다.
|
|
||||||
상세에서 넘어온 targetLanguage를 유지해야 기존 글의 첫 감지가 전체 언어 번역으로 확대되지 않는다.
|
|
||||||
|
|
||||||
이미 감지된 본문에는 감지를 반복하지 않고 해당 언어로 예약을 이어간다. 다중 상세 요청에서 감지 API의 동시 중복 호출을
|
|
||||||
완전히 제거하는 신규 분산 잠금은 추가하지 않는다. 기존 감지 캐시를 재사용하고 작업 중복/유일성 경합은 기존 DB 키와
|
|
||||||
커뮤니티 예약 경계에서 제어한다. 유일 키 경합으로 정상 상세 응답이 실패하지 않도록 통합 검증한다.
|
|
||||||
|
|
||||||
### 트랜잭션과 표시 순서
|
|
||||||
|
|
||||||
- 작성·수정 커밋 후 처리에는 기존 커밋 후 실행 패턴을 재사용한다. 감지·예약은 롤백된 원문을 소비하지 않는다.
|
|
||||||
- 레거시 서비스와 v2 facade/query service는 읽기 전용 트랜잭션이 있으므로 상세의 감지/예약은 별도 Spring bean의
|
|
||||||
`REQUIRES_NEW` 진입점을 통해 처리한다. 자기 호출로 트랜잭션 프록시를 우회하지 않는다.
|
|
||||||
- 권한/노출 판정 → 현재 원문/유효 번역 선택 → 기존 유료 마스킹 → 응답 조립 순서로 적용한다.
|
|
||||||
- 목록·채널 홈·홈 추천·팔로잉은 일괄 읽기 메서드만 호출한다. 추천 스냅샷에는 언어별 본문을 넣지 않고
|
|
||||||
기존 후보 ID로 얻은 상세 레코드의 본문만 응답 조립 전에 치환한다.
|
|
||||||
- 원문을 담은 관리자 편집 응답, 댓글·답글, 푸시 이벤트와 정산 조회는 그대로 둔다.
|
|
||||||
|
|
||||||
### Phase 1: 번역 저장과 원문 언어 감지 기반
|
|
||||||
|
|
||||||
#### 구현 항목
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 개정 번호에 대응하는 본문 번역 저장·조회·메모리 복원**
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P1-T1` — 현재 본문과 일치하는 번역만 저장·조회할 수 있다.
|
|
||||||
|
|
||||||
- 시작 조건: PRD `CCT-001`, `CCT-007`, `CCT-010` 확인, 사용자 구현 요청.
|
|
||||||
- 완료 증거: 아래 파일과 local automatic gate, 원문 복원/오래된 번역 차단 검증 기록.
|
|
||||||
실제 MySQL DDL·HTTP·동시성 확인은 테스트 브랜치 배포 후 수동 체크리스트로 이관한다.
|
|
||||||
- 범위 밖: 언어 감지 이벤트 연결, HTTP 진입점 변경.
|
|
||||||
- 생산 인터페이스: 설계 계약의 `findDisplayContents`, 언어 확정 상태의 `requestTranslations`, 커뮤니티 source/materializer 분기.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationSourceExtractor.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt` |
|
|
||||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt` |
|
|
||||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslationRepository.kt` |
|
|
||||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
|
||||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt` |
|
|
||||||
| 생성 | `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql` |
|
|
||||||
|
|
||||||
- [x] **RED:** 번역 없음/동일 언어/현재 개정 일치/이전 개정/다른 언어 결과의 표시 테스트와 본문 A→B→A 복원 테스트를 작성한다.
|
|
||||||
materializer는 작업 실행 도중 원문 변경·비활성화 시 오래된 번역을 저장/노출하지 않는 테스트로 보호한다.
|
|
||||||
- [x] **RED 확인:** 아래 focused 명령에서 해당 동작의 assertion 실패를 확인한다. 컴파일 오류만으로 RED를 완료하지 않는다.
|
|
||||||
- [x] **GREEN:** 기존 메모리/워커를 연결하고 일괄 읽기, 개정 검사, 현재 메모리 복원을 최소 구현한다.
|
|
||||||
MySQL DDL은 기존 행 NULL/0 보존, locale 유일 키, 모든 컬럼 COMMENT, 테이블 COMMENT를 포함한다.
|
|
||||||
시각은 `TIMESTAMP`, created/updated 기본값은 `docs/agent-guides/문서유지보수.md`를 따른다.
|
|
||||||
- [x] **GREEN 확인:** focused test에서 원문 보존·중복 키·동시 upsert·이전 결과 차단·캐시 복원 통과를 확인한다.
|
|
||||||
- [x] **REFACTOR:** 이번 변경의 중복만 정리하고 focused test와 공통 번역 회귀를 실행한다. 자동 검증 결과와 배포 후 DDL 확인 항목을 이 Task 아래에 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
```
|
|
||||||
|
|
||||||
DDL은 테스트 브랜치 배포 후 격리된 MySQL 테스트 DB에 적용해 기존 행의 언어 NULL/개정 0, 원문 유지, locale 유일 키를 확인한다.
|
|
||||||
운영 DB 적용은 이 Task의 검증으로 실행하지 않는다.
|
|
||||||
|
|
||||||
#### P1-T1 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- RED: `CreatorCommunityTranslationServiceTest.shouldRequireCreatorCommunityTranslationPersistenceContract`를 먼저 작성했다.
|
|
||||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest'`는
|
|
||||||
exit 1로 실패했고, `CreatorCommunityTranslationServiceTest.kt:13`의 `languageCode` 누락 assertion이 원인이었다.
|
|
||||||
- GREEN/REFACTOR: `CreatorCommunity`의 nullable 언어·0 시작 개정, 현재 개정/해시/언어/활성 상태를 잠금으로 재확인하는
|
|
||||||
materializer, 메모리 우선 복원 후 기존 scheduler를 쓰는 서비스, 일괄 표시 조회와 locale 유일 번역 저장을 추가했다.
|
|
||||||
같은 focused 명령은 exit 0, `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 동시성: H2 MySQL 모드 `@DataJpaTest`에서 두 worker를 같은 원문 메모리 재료화 지점에 동시에 진입시켜,
|
|
||||||
게시물 `PESSIMISTIC_WRITE` 잠금과 locale 유일 키 후 번역 행 1개·오류 0개를 확인했다.
|
|
||||||
- 공통 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- DDL: `schema.sql`에 기존 행의 `language_code` NULL 및 `content_revision` 0 기본값, locale 유일 키, 전 컬럼/테이블 COMMENT,
|
|
||||||
BaseEntity 시각 컬럼 규칙을 기록했다. 운영 DB에는 적용하지 않았다.
|
|
||||||
- 전체 회귀/수동 HTTP/Papago/MySQL 적용 검증은 이 Task의 지정 focused/common Gradle 검증 범위를 넘어 실행하지 않았다.
|
|
||||||
|
|
||||||
#### P1-T1 검토 수정 — 2026-09-10
|
|
||||||
|
|
||||||
- 검토 확인 RED: `CreatorCommunityTranslationServiceTest.shouldRejectStaleManagedCreatorCommunityAfterConcurrentCommit`를
|
|
||||||
실제 JPA transaction으로 추가했다. focused 명령은 exit 1, 20개 테스트 중 1개 실패였고,
|
|
||||||
stale managed `CreatorCommunity`로 이전 번역을 저장한 `CreatorCommunityTranslationServiceTest.kt:358` assertion이 원인이었다.
|
|
||||||
- GREEN: `EntityManager.refresh(..., PESSIMISTIC_WRITE)`로 잠긴 게시물의 최신 상태를 다시 읽고,
|
|
||||||
번역 행도 `findByCreatorCommunityIdAndLocaleForUpdate`의 잠금 현재 읽기로 조회하게 했다. provider 호출은 기존처럼
|
|
||||||
materializer transaction 밖의 worker에 남는다. 같은 focused 명령은 exit 0, `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 테스트 보강: 번역 없음·원문 언어 불일치·원문 해시 불일치의 원문 폴백, 실제 JPA stale persistence-context 차단,
|
|
||||||
A→B→A 기존 번역 행 갱신, repeatable-read snapshot 뒤 동시 upsert를 확인했다.
|
|
||||||
- H2 설정: `@AutoConfigureTestDatabase(replace = NONE)`와 `jdbc:h2:mem:creator-community-translation;MODE=MySQL`을
|
|
||||||
사용한다. 이는 H2 호환 모드일 뿐 실제 MySQL DDL·REPEATABLE READ 검증 증거가 아니다.
|
|
||||||
- 기록 정정: 이전 검증 기록의 H2 MySQL 모드 표기는 datasource replacement를 끄기 전에는 성립하지 않았다.
|
|
||||||
현재 H2 설정을 바로잡았지만, 과거 결과를 실제 MySQL 결과로 소급하지 않는다.
|
|
||||||
- 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 사용자 검증 정책 변경: local acceptance는 automatic Gradle test와 ktlint으로 한정한다. 위 자동 검증이 통과했으므로
|
|
||||||
Task 1.1과 GREEN 확인/REFACTOR 체크박스를 완료로 표시한다. Docker, 서버, HTTP, MySQL은 local에서 실행하지 않는다.
|
|
||||||
- 배포 후 보류: `schema.sql` 적용, 기존 행 NULL/0, locale 유일 키, 실제 MySQL REPEATABLE READ 동작은
|
|
||||||
테스트 브랜치 배포 후 아래 수동 체크리스트에서만 확인하며 아직 통과로 기록하지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: 미확정 원문 언어의 비동기 감지와 목표 언어 전달**
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P1-T2` — 본문 언어가 없으면 현재 본문을 감지하고 요청 범위의 번역으로 이어진다.
|
|
||||||
|
|
||||||
- 시작 조건: `P1-T1` 완료.
|
|
||||||
- 완료 증거: 한국어/영어/일본어 감지, 단일/전체 목표 언어 전달, 지연 감지·실패 테스트 통과.
|
|
||||||
- 범위 밖: 사용자 언어 입력 필드, 다른 리소스의 감지 정책 변경.
|
|
||||||
- 소비/생산: 기존 `LanguageDetectionCacheService.detectWithCache`를 소비하고 커뮤니티 감지 분기와 완성된 `requestTranslations`를 제공한다.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
|
||||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt` |
|
|
||||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` |
|
|
||||||
|
|
||||||
- [x] **RED:** 언어 NULL인 본문 감지 후 `targetLanguage=ja`는 일본어만, NULL은 원문 외 두 언어로 이어지는 실패 테스트를 작성한다.
|
|
||||||
빈 본문·미지원 원문 언어·감지 실패·감지 중 본문 수정·중복 이벤트도 포함한다.
|
|
||||||
- [x] **RED 확인:** 아래 focused 명령으로 목표 언어 유실과 오래된 감지 결과 적용의 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** 기존 감지 이벤트에 기본값이 있는 커뮤니티 메타데이터를 추가하고 개정 조건부 저장·커밋 후 예약을 연결한다.
|
|
||||||
다른 리소스 이벤트와 동작은 유지한다.
|
|
||||||
- [x] **GREEN 확인:** 외부 감지 provider 경계만 통제한 통합 테스트로 실제 커밋 후 이벤트/별도 트랜잭션을 확인한다.
|
|
||||||
테스트를 감싼 트랜잭션이 커밋되지 않아 AFTER_COMMIT이 실행되지 않는 문제를 피한다.
|
|
||||||
- [x] **REFACTOR:** focused test와 기존 감지 캐시/번역 scheduler 회귀 결과를 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P1-T2 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- RED: `CreatorCommunityLanguageDetectTest.shouldKeepLegacyDefaultsAndDeclareCreatorCommunityMetadata`를 먼저 작성했다.
|
|
||||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`는 exit 1,
|
|
||||||
`CreatorCommunityLanguageDetectTest.kt:14`의 `CREATOR_COMMUNITY` 누락 assertion으로 실패했다.
|
|
||||||
- 행위 RED: 커밋 후 단일/전체 목표 언어 전달, 빈 본문·미지원 감지값·provider 실패·stale 본문·중복 이벤트를 추가했다.
|
|
||||||
같은 명령은 exit 1, `CreatorCommunityLanguageDetectTest.kt:85`, `:106`, `:179`의
|
|
||||||
`CreatorCommunityTranslationService.requestTranslations(...)` `WantedButNotInvoked` assertion으로 실패했다.
|
|
||||||
- GREEN: `LanguageDetectEvent`의 기본값 있는 커뮤니티 메타데이터, 언어 미확정 본문의 현재 content/revision 이벤트 발행,
|
|
||||||
감지 외부 호출 전 무잠금 상태 확인과 잠금 후 active/revision/content 재확인, 저장 커밋 뒤 기존 번역 서비스 호출을 연결했다.
|
|
||||||
감지 언어가 지원 범위 밖이거나 비어 있으면 원문·작업 상태를 유지한다.
|
|
||||||
- AFTER_COMMIT: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` 테스트와 명시적 `TransactionTemplate` 커밋으로
|
|
||||||
이벤트를 발행했다. 테스트용 감지 provider 경계만 제어하고 실제 행 언어 저장 뒤 `requestTranslations(postId, "ja")` 또는
|
|
||||||
`requestTranslations(postId, null)` 호출을 검증했다.
|
|
||||||
- focused: 위 P1-T2 명령 — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 범위: local automatic 정책에 따라 Docker, HTTP, 실제 Papago, MySQL DDL/동시성은 실행하지 않았다.
|
|
||||||
|
|
||||||
#### Phase 1 Gate
|
|
||||||
|
|
||||||
- [x] **Goal `P1-GATE`: 현재 본문 기반의 감지·번역 저장 경계 판정**
|
|
||||||
|
|
||||||
- 시작 조건: `P1-T1`, `P1-T2` 체크박스와 검증 기록 완료.
|
|
||||||
- 완료 증거: 아래 명령 exit 0, DDL·엔티티 정적 대조, 개정/언어 불일치 미노출 기록.
|
|
||||||
- 범위 밖: API 연결 구현. TDD 예외 사유: 이미 작성한 동작과 DDL의 Gate 검증이며 별도 제품 코드가 아니다.
|
|
||||||
- 대체 검증: 기존 통합 테스트를 실행하고 DB의 현재 개정/번역 개정/언어를 대조한다.
|
|
||||||
- 확인 파일: 두 Task의 source/test 파일 및 `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P1-GATE 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- 자동 Gate: 위 명령 — exit 0, `BUILD SUCCESSFUL`. 현재 개정 번역 저장/메모리 복원/작업 예약과 커밋 후 감지 경계를 함께 회귀했다.
|
|
||||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 보류: 사용자 정책에 따라 실제 MySQL DDL·격리 수준, HTTP, Docker, 실제 Papago 검증은 실행하지 않았다.
|
|
||||||
테스트 브랜치 배포 후 수동 체크리스트는 모두 미완료 상태로 유지한다.
|
|
||||||
|
|
||||||
#### Task 1.R: P1-T2 리뷰 회귀 수정
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P1-R1` — 감지 커밋 후 독립 트랜잭션에서 현재 본문만 요청 범위대로 번역 작업으로 예약한다.
|
|
||||||
|
|
||||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-001~004` 확정, `P1-T2` 및 `P1-GATE` 기존 기록 확인.
|
|
||||||
- 완료 증거: 실제 Spring 감지 listener와 scheduler를 사용하는 실패 재현 test, 최소 수정 후 focused test와 P1 Gate 재검증, 검증 기록.
|
|
||||||
- 범위 밖: 다른 리소스 감지 정책, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
|
||||||
| 생성 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
|
||||||
|
|
||||||
- [x] **RED:** `REQUIRES_NEW`, 실제 `translation_job` 저장, stale 본문 차단, known-language 다중 target continuation을 실제 Spring 경계로 재현한다.
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`에서 7개 중 5개 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** 감지 listener의 잠금 현재 읽기와 커밋 후 독립 번역 요청 트랜잭션을 최소 수정한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
|
||||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P1-R1 진행 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- RED: 새 `CreatorCommunityLanguageDetectTest`는 초기 Kotlin generic 추론과 `AudioContentThemeQueryRepository` test bean 누락을 바로잡은 뒤 실행했다.
|
|
||||||
focused 명령은 exit 1, 7개 중 5개 assertion 실패였다. `REQUIRES_NEW` annotation 부재, 커밋 뒤 job 미저장,
|
|
||||||
stale 감지 결과의 언어 저장, 이미 언어가 있는 두 target 이벤트의 job 미저장을 확인했다.
|
|
||||||
- GREEN: `requestTranslations`를 `REQUIRES_NEW`로 분리하고, listener는 잠금 조회 뒤 `EntityManager.refresh(..., PESSIMISTIC_WRITE)`로
|
|
||||||
현재 본문을 확인한다. 이미 언어가 있는 이벤트도 감지 없이 after-commit에서 원 target을 요청한다.
|
|
||||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
실제 Spring listener와 scheduler를 통해 단일/전체 target job 저장, stale 본문 차단, known-language 두 target continuation을 확인했다.
|
|
||||||
- REFACTOR/Phase Gate 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`. focused/공통 번역 회귀, ktlint, bootJar를 함께 확인했다.
|
|
||||||
- 범위: 사용자 정책에 따라 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
|
||||||
|
|
||||||
#### Task 1.R3: P1-T2 동일 게시물 작업 예약 직렬화
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P1-R3` — 같은 게시물·target의 동시 `requestTranslations`가 누락 메모리에서도 하나의 translation job만 예약한다.
|
|
||||||
|
|
||||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-006` 확정, `P1-R2` 완료 기록 확인.
|
|
||||||
- 완료 증거: 실제 Spring/JPA service와 job repository의 concurrent missing-memory RED test, 최소 lock 수정 후 두 호출 정상 종료·job 1개, P1 Gate 재검증.
|
|
||||||
- 범위 밖: 공통 scheduler 동작, 다른 resource type, 분산 lock·의존성, P2/P3, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` |
|
|
||||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
|
||||||
|
|
||||||
- [x] **RED:** 같은 언어 확정 게시물·target의 두 요청을 동시에 시작해 실제 `translation_job` unique insert 경합을 재현한다.
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`에서 unique-key 오류 또는 정상 완료/job 1개 위반을 확인한다.
|
|
||||||
- [x] **GREEN:** `REQUIRES_NEW` request transaction 시작에 active post write lock과 refresh를 추가한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 focused test가 두 호출 정상 종료와 job 1개를 확인한다.
|
|
||||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P1-R3 진행 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- 원인: `requestTranslations`가 post lock 없이 memory lookup과 `TranslationJobScheduler`의 select-then-insert를 수행해,
|
|
||||||
동일 post·target의 두 request transaction이 unique key에 경합할 수 있다.
|
|
||||||
- RED: actual JPA `translation_job` 저장을 사용하는 두 `REQUIRES_NEW` transaction의 missing-job 조회 창을 동기화했다.
|
|
||||||
focused 명령은 exit 1이었고, H2 `SQLState 23505`의 `uk_translation_job_resource_field_target_hash` unique index 충돌을 확인했다.
|
|
||||||
- GREEN: `requestTranslations` 시작에서 active post를 `findByIdAndIsActiveTrueForUpdate`로 잠그고
|
|
||||||
`EntityManager.refresh(..., PESSIMISTIC_WRITE)`한 뒤 source extraction, memory lookup, job 조회·저장을 진행한다.
|
|
||||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`; 두 호출이 정상 종료하고 job 1개만 저장됨을 확인했다.
|
|
||||||
- REFACTOR/Phase Gate 재검증:
|
|
||||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 전체 `./gradlew test`는 private scheduling service의 직접 영향 회귀와 P1 Gate가 통과했으므로 실행하지 않았다.
|
|
||||||
Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증도 사용자 정책에 따라 실행하지 않았다.
|
|
||||||
|
|
||||||
#### Task 1.R2: P1-T2 동시 감지 target 보존
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P1-R2` — 두 언어 미확정 감지가 같은 현재 본문에서 경합해도 각 이벤트의 요청 target을 커밋 후 예약한다.
|
|
||||||
|
|
||||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-005` 확정, `P1-R1` 완료 기록 확인.
|
|
||||||
- 완료 증거: 두 감지가 언어 NULL에서 시작한 뒤 한 감지만 먼저 커밋하는 실제 Spring failure test, 최소 수정 후 focused test와 P1 Gate 재검증, 검증 기록.
|
|
||||||
- 범위 밖: 다른 리소스 감지 정책, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
|
||||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
|
||||||
|
|
||||||
- [x] **RED:** 두 감지가 `languageCode=NULL`에서 시작하고 첫 감지 커밋 뒤 두 번째가 lock을 얻는 실제 `ja`/`en` target 보존 test를 작성한다.
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`에서 두 번째 target job 미저장 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** lock/refresh 뒤 현재 revision/content가 유효하고 언어만 이미 설정된 경우 after-commit에 원 target을 등록한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
|
||||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P1-R2 진행 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- 원인: `handleCreatorCommunityLanguageDetect`는 lock과 refresh 뒤 다른 감지가 저장한 languageCode를 발견하면,
|
|
||||||
현재 revision/content가 일치해도 return하여 두 번째 이벤트의 target을 잃는다.
|
|
||||||
- RED: `shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage`는 두 감지가 languageCode NULL에서 시작한 뒤,
|
|
||||||
첫 감지의 `ja` 예약·커밋 후 두 번째 감지의 `en` 진행을 순서대로 제어했다. focused 명령은 exit 1, 8개 중 1개 assertion 실패였고,
|
|
||||||
`translation_job`의 `en` 누락이 원인이었다.
|
|
||||||
- GREEN: lock/refresh 뒤 revision/content가 같고 languageCode만 이미 설정되어 있으면 이를 덮어쓰지 않고,
|
|
||||||
기존 `requestTranslationsAfterCommit`에 두 번째 이벤트의 원 target을 등록한다. lock 보유 중 `REQUIRES_NEW` 호출은 하지 않는다.
|
|
||||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
실제 listener/scheduler/repository에서 `ja`와 `en` job 저장 및 source language `ko` 유지를 확인했다.
|
|
||||||
- REFACTOR/Phase Gate 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 범위: 사용자 정책에 따라 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
|
||||||
|
|
||||||
#### Task 1.R4: 최초 언어 감지 캐시 경합에서도 요청 target 보존
|
|
||||||
|
|
||||||
- [x] **Goal `P1-R4`: 동일 본문의 최초 감지 캐시 저장이 경합해도 각 이벤트의 목표 언어 번역을 예약한다**
|
|
||||||
|
|
||||||
- 시작 조건: [Phase 1 리뷰](reviews/phase-1-review.md)의 `REV-P1-007` 확인 및 후속 구현 요청.
|
|
||||||
기존 `P1-R1~R3` 완료 기록을 유지한다.
|
|
||||||
- 관련 요구사항: `CCT-002`, `CCT-005`, `CCT-010`, 기존 `P1-R2`의 요청 target 보존.
|
|
||||||
- 완료 증거: 실제 감지 캐시 INSERT 경합의 실패 회귀 테스트 → 최소 수정 → focused 성공 →
|
|
||||||
P1 감지/번역 및 P2 상세 영향 회귀 → 실행 결과와 리뷰 재판정 기록.
|
|
||||||
- 범위 밖: 외부 감지 중 게시물 행 잠금, 새 분산 잠금/큐/스케줄러, 번역 지원 언어·공개 API 변경,
|
|
||||||
감지 provider 실패를 무조건 성공으로 처리, 다른 리소스의 제품 정책 변경, 운영 DB 작업.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResult.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
|
||||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` |
|
|
||||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
|
||||||
| 기록 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-1-review.md` |
|
|
||||||
| 기록 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
|
||||||
|
|
||||||
설계 기준:
|
|
||||||
|
|
||||||
- 캐시 miss → detector → 무조건 INSERT를 같은 키의 원자적 저장으로 바꾸고 저장된 결과로 수렴시켰다.
|
|
||||||
MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`를 사용한다. DB 예외를 잡아 무시하지 않는다.
|
|
||||||
- 동일 해시/provider/normalizationVersion의 승자 결과를 사용하며, 반복 읽기 격리에서도 최초 miss 스냅샷에 갇히지 않도록
|
|
||||||
저장 후 current read를 보장한다. 기본 언어를 임의로 지정하거나 감지 결과를 최신 본문에 무조건 적용하지 않는다.
|
|
||||||
- 이미 rollback-only가 된 같은 JPA 트랜잭션에서 예외만 잡고 target 예약을 계속하는 방식은 금지한다.
|
|
||||||
공통 캐시 저장 수정이 다른 리소스의 기존 감지/캐시 재사용 동작을 바꾸지 않도록 회귀 검증한다.
|
|
||||||
- 새 테스트는 실제 `LanguageDetectionCacheService`와 repository를 사용한다. `detectWithCache` 전체를 override하지 않고
|
|
||||||
감지 HTTP 응답/provider 경계만 통제해 두 트랜잭션이 모두 캐시 miss를 읽는 순서를 제어한다.
|
|
||||||
|
|
||||||
- [x] **RED:** `shouldPreserveBothTargetsWhenInitialDetectionCacheWritesRace`를 추가한다.
|
|
||||||
언어 NULL/캐시 없음의 동일 게시물에서 `ja`, `en` 감지를 동시에 시작하고 두 cache miss 이후 첫 저장/커밋,
|
|
||||||
후발 저장 순서로 진행한다. 두 흐름의 정상 완료, 캐시 1행, `ja`/`en` 작업 모두 존재를 assertion으로 검증한다.
|
|
||||||
- [x] **RED 확인:** 아래 focused 명령으로 후발 중복 키 예외 또는 target 누락의 의도한 실패를 확인한다.
|
|
||||||
두 이벤트가 애초에 cache miss에 진입하지 않은 테스트를 경합 재현으로 인정하지 않는다.
|
|
||||||
- [x] **GREEN:** 원자적 캐시 저장과 저장 결과 재사용만 최소 수정한다. 감지 후 기존 개정/본문 검사와
|
|
||||||
커밋 후 요청 target 전달을 유지한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 focused 테스트를 통과시키고, 같은 본문의 서로 다른 게시물 생성도 캐시 1행을 공유하면서
|
|
||||||
각 게시물의 원문 외 두 언어 작업이 보존되는지 검증한다.
|
|
||||||
- [x] **REFACTOR:** 감지 캐시·listener·scheduler·materializer 및 실제 상세 프록시 영향 회귀를 실행한다.
|
|
||||||
선택한 DB 저장 문법의 MySQL 동작과 H2 테스트 지원 범위를 구분하고 실제 MySQL 미검증은 기존 수동 체크리스트에 남긴다.
|
|
||||||
관련 없는 변경 없이 아래 명령 결과와 `REV-P1-007` 수정 판정을 누적한다.
|
|
||||||
|
|
||||||
실행한 focused/영향 회귀 명령이다. focused의 fresh 재실행에는 `--rerun-tasks --no-parallel`을 추가했다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
공통 캐시 동작 변경의 영향이 위 회귀로 판정되지 않으면 전체 테스트로 확장한다. 전체 테스트 생략 시 대체 명령과 근거를 기록한다.
|
|
||||||
현재 상태: RED/GREEN·영향 회귀·수정 후 리뷰 완료. 실제 MySQL 검증은 미완료다.
|
|
||||||
|
|
||||||
#### P1-R4 실행 기록 · 2026-09-10
|
|
||||||
|
|
||||||
- RED: 위 focused 명령은 두 provider 진입을 모두 확인한 뒤 exit 1로 실패했다.
|
|
||||||
`ExecutionException → DataIntegrityViolationException → H2 23505`로 감지 캐시 유일 키 충돌을 재현했다.
|
|
||||||
- GREEN: native MySQL `ON DUPLICATE KEY UPDATE id=id`로 승자 행을 보존하고 scalar `SELECT ... FOR UPDATE`로
|
|
||||||
저장 결과를 현재 읽기한다. native INSERT의 audit 시각을 명시하며 DB 예외를 잡고 계속 진행하지 않는다.
|
|
||||||
- GREEN 확인: 실제 캐시 service/repository를 사용해 같은 게시물의 `ja`/`en` 이중 miss 및
|
|
||||||
서로 다른 게시물의 동일 본문 경합을 검증했다. 캐시 1행으로 수렴하며 각 요청의 목표 언어 작업을 보존한다.
|
|
||||||
- focused fresh 재실행: 위 첫 명령 + `--rerun-tasks --no-parallel`, `BUILD SUCCESSFUL` (5분 19초).
|
|
||||||
- P1 listener/scheduler/materializer 회귀: 위 두 번째 명령, `BUILD SUCCESSFUL` (1분 09초).
|
|
||||||
- P2 상세/API 회귀: 위 세 번째 명령, `BUILD SUCCESSFUL` (1분 42초).
|
|
||||||
- 형식: `./gradlew ktlintCheck`, `BUILD SUCCESSFUL` (44초).
|
|
||||||
- 수정 후 리뷰: spec `PASS` (`ses_f7657d350ffe3YUYhEHvHPuikK`), quality `APPROVED`
|
|
||||||
(`ses_f7656994dffeR2odTLswaw6RTo`). `REV-P1-007`은 로컬 자동 검증 범위에서 수정 완료로 재판정했다.
|
|
||||||
- 전체 `./gradlew test`는 생략했다. 공통 캐시 계약, 실제 repository 경합, 모든 직접 listener/scheduler/materializer
|
|
||||||
테스트와 P2 상세/API 소비자가 위 대체 명령으로 통과했고, 미해결 상태로 남은 별도의 공통 경계가 없었다.
|
|
||||||
- 범위: H2 `MODE=MySQL`은 실제 MySQL 8/InnoDB 검증이 아니다. 실제 MySQL/Papago/HTTP, Docker, 배포는
|
|
||||||
실행하지 않았다. 상세 명령·리뷰 출처는 [후속 증거](reviews/review-evidence.md)에 누적한다.
|
|
||||||
|
|
||||||
### Phase 2: 작성·수정·상세에서 번역 처리
|
|
||||||
|
|
||||||
#### 구현 항목
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 작성 커밋과 실제 본문 변경에 감지·번역 연결**
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P2-T1` — 생성과 본문 수정이 현재 원문 기준의 전체 목표 언어 번역으로 이어진다.
|
|
||||||
|
|
||||||
- 시작 조건: `P1-GATE` 완료, `CCT-003~004`, `CCT-007` 확인.
|
|
||||||
- 완료 증거: 신규·실제 변경·동일 본문·롤백·관리자 공용 경로·동시 수정 검증.
|
|
||||||
- 범위 밖: 이미지·오디오 업로드, 알림 발행, 소유권 정책 변경.
|
|
||||||
- 소비 인터페이스: `requestTranslations(postId, targetLanguage = null)`.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` |
|
|
||||||
| 확인/테스트 보강 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` |
|
|
||||||
| 확인/테스트 보강 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` |
|
|
||||||
|
|
||||||
- [x] **RED:** 생성 커밋 후 감지, 본문 변경 때만 언어 NULL/개정 증가, 롤백 시 작업 없음의 실패 테스트를 작성한다.
|
|
||||||
수정 후 새 요청에서 이전 번역 미노출, 실제 언어 변경, 관리자 공용 경로도 포함한다.
|
|
||||||
- [x] **RED 확인:** 아래 focused 명령으로 번역 연결 부재/개정 미변경의 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** 기존 커밋 후 실행 패턴으로 공유 서비스를 연결하고 본문 문자열이 다른 경우에만 언어·개정 상태를 갱신한다.
|
|
||||||
소유권 조건을 유지한 잠금 조회로 동시 본문 수정의 개정을 직렬화한다.
|
|
||||||
- [x] **GREEN 확인:** 테스트를 다시 실행하고 동일 본문/본문 외 변경은 감지·번역 요청 0건인지 확인한다.
|
|
||||||
- [x] **REFACTOR:** 공용 서비스 호출자 회귀를 확인한다. 관리자 facade의 중복 번역 연결은 추가하지 않는다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P2-T1 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- RED: focused 명령은 exit 1이었다. 생성 뒤 번역 요청 누락, 본문 변경 뒤 개정/언어 상태 미갱신의 assertion 실패를
|
|
||||||
`CreatorCommunityServiceTest.kt:333`, `AiCharacterAdminCommunityPostCreateTest.kt:93`,
|
|
||||||
`AiCharacterAdminCommunityPostUpdateTest.kt:91`에서 확인했다.
|
|
||||||
- GREEN: `CreatorCommunityService`는 생성과 실제 본문 변경 뒤 기존 after-commit 패턴으로
|
|
||||||
`requestTranslations(postId, null)`을 요청한다. 본문 변경은 `languageCode`를 NULL로 초기화하고
|
|
||||||
`contentRevision`을 증가시키며, 수정 조회는 소유권 조건의 `PESSIMISTIC_WRITE` 잠금 후 refresh한다.
|
|
||||||
- 동시성: 실제 H2/JPA 비관적 잠금 조회를 latch로 제어한 관리자 MockMvc 요청 두 건이 모두 200으로 종료하고,
|
|
||||||
최종 revision 2와 번역 요청 2건을 확인했다.
|
|
||||||
- focused: 위 명령과 `LegacyCommunityPostCharacterizationTest`를 포함한 실행 — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
생성·수정·동일 본문·롤백·관리자 공용 경로·동시 수정과 변경된 수동 생성자 호출을 함께 확인했다.
|
|
||||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 범위: local acceptance 정책에 따라 Docker, 서버 HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
|
||||||
|
|
||||||
#### Task 2.R: P2-T1 spec-review 실제 트랜잭션 증거
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P2-R1` — 실제 Spring transaction에서 생성 롤백과 본문 변경 뒤 stale 번역 폴백·감지 예약을 증명한다.
|
|
||||||
|
|
||||||
- 시작 조건: `P2-T1` 완료, spec-review의 실제 transaction 증거 보강 요구 확인.
|
|
||||||
- 완료 증거: proxied `CreatorCommunityService` 생성 롤백 뒤 post/job/detection 미영속과, 한글→영문 수정 커밋 뒤
|
|
||||||
revision·stale 번역 폴백·영문 감지·새 target job을 별도 transaction에서 확인한다.
|
|
||||||
- 범위 밖: P2-T2 상세/목록 API, production 리팩터링, Docker·HTTP·실제 Papago/MySQL 검증.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
|
||||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
|
||||||
|
|
||||||
- [x] **TEST-FIRST:** 실제 outer transaction rollback 뒤 생성 post와 감지/번역 작업이 남지 않는 test를 작성한다.
|
|
||||||
기존 한글 translation 행이 있는 post의 실제 영문 수정 뒤, 감지 전 stale 번역 폴백과 감지 후 언어·target job을 test한다.
|
|
||||||
- [x] **TEST-FIRST 확인:** 첫 실행에서 async 감지 완료 전 `languageCode`를 읽은 assertion 실패를 확인하고, 기존 `await` 방식의 완료 조건으로 test 경계를 바로잡는다.
|
|
||||||
- [x] **GREEN:** test가 production 결함을 드러내면 실패 test를 유지한 최소 수정만 적용하고, 아니면 실제 경계 증거만 보강한다.
|
|
||||||
- [x] **GREEN 확인:** 실제 post-commit 경로가 완료될 때까지 latch/새 transaction으로 대기한 assertion을 통과시킨다.
|
|
||||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, ktlint 결과를 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P2-R1 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- test-first: `CreatorCommunityLanguageDetectTest`에 실제 `CreatorCommunityService` bean을 등록하고 AOP proxy를 확인했다.
|
|
||||||
JPA repository, listener, translation service, scheduler는 실제 bean을 사용하며 S3·FCM 연관 의존성과 external detector만 test 경계로 제어했다.
|
|
||||||
- 생성 rollback: 실제 outer `TransactionTemplate`에서 `createCommunityPost`를 호출하고 rollback-only로 종료했다.
|
|
||||||
새 transaction에서 생성 post 부재, 해당 post ID의 translation read-model 및 `translation_job` 0건, detector query 0건을 확인했다.
|
|
||||||
- 영문 수정: 기존 한글 원문과 유효한 이전 영어 translation 행을 만들고 실제 `modifyCommunityPost`를 commit했다.
|
|
||||||
차단된 detector가 시작된 뒤 새 transaction에서 `languageCode=NULL`, revision 1, 현재 영문 원문 반환과 이전 영어 translation 행의
|
|
||||||
미사용을 확인했다. detector 해제 뒤 `languageCode=en`, `ko`·`ja` job 예약을 대기해 확인했다.
|
|
||||||
- 첫 실행: rollback test는 통과했고 수정 test는 async `@TransactionalEventListener` 완료 전 state를 읽어 `expected en but was null`로 실패했다.
|
|
||||||
이는 production 결함이 아니라 outer service future가 async 감지 완료를 기다리지 않는 test synchronization 문제였으며,
|
|
||||||
기존 조건 기반 `await`로 post-commit 완료를 기다리도록 바로잡았다. production 파일은 변경하지 않았다.
|
|
||||||
- focused: 위 P2-R1 명령에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
|
||||||
에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 형식: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 범위: Docker, 서버 HTTP, detail/list API, 실제 Papago/MySQL 검증은 실행하거나 변경하지 않았다.
|
|
||||||
|
|
||||||
#### Task 2.R: P2-T1 quality-review 고정 상태 경쟁 회귀
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P2-R2` — 관리자/레거시 고정 상태 변경이 최신 본문·개정·언어 상태를 덮어쓰지 않는다.
|
|
||||||
|
|
||||||
- 시작 조건: P2-T1 완료, quality-review의 stale flush 및 test pseudo-lock 지적 확정.
|
|
||||||
- 완료 증거: stale admin read 뒤 동시 본문 변경을 commit하고 고정+본문 요청을 재개하는 실제 JPA race test에서
|
|
||||||
최신 본문·revision·language와 fixed 상태를 확인한다. test는 production 소유권 조건 `PESSIMISTIC_WRITE` query를 사용한다.
|
|
||||||
- 범위 밖: P2-T2/P3, API schema, `@DynamicUpdate`/`@Version`, dependency, Docker·HTTP·실제 MySQL 검증.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` |
|
|
||||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
|
||||||
|
|
||||||
- [x] **RED:** stale admin read 뒤 별도 transaction의 본문/개정/언어 변경을 commit하고, 재개한 fixed+content 요청이 최신 상태에서 적용돼야 한다는 실제 JPA race test를 작성한다.
|
|
||||||
- [x] **RED 확인:** 현재 구현으로 최신 state 보존 assertion이 실패하는지 확인한다.
|
|
||||||
- [x] **GREEN:** `updateCommunityPostFixed`가 소유권 조건 post lock과 refresh를 통해 최신 행을 얻은 뒤에만 고정 상태를 변경하도록 최소 수정한다.
|
|
||||||
관리자 흐름의 member→post lock 순서는 유지한다.
|
|
||||||
- [x] **GREEN 확인:** production repository lock query를 사용하는 test에서 두 번째 요청의 pre-lock/lock 도착을 latch로 제어하고 executor 종료까지 확인한다.
|
|
||||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, ktlint, diff check 결과를 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P2-R2 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- RED: `shouldApplyFixedAndContentUpdateAfterConcurrentContentCommit`는 admin facade의 활성 post read 뒤 첫 요청을 멈추고,
|
|
||||||
별도 worker의 실제 `CreatorCommunityService.modifyCommunityPost`가 production `findByIdAndMemberIdForUpdate` 경로로
|
|
||||||
`after concurrent` 본문·revision 1·language NULL을 commit한 뒤 mixed `isFixed=true`/`after fixed` 요청을 재개했다.
|
|
||||||
현재 구현에서 `contentRevision` 2 assertion은 실제 1로 실패했다.
|
|
||||||
- GREEN: `updateCommunityPostFixed`의 비잠금 조회를 소유권 조건 `findByIdAndMemberIdForUpdate`와
|
|
||||||
`EntityManager.refresh(..., PESSIMISTIC_WRITE)`로 교체했다. facade의 기존 member lock 뒤 post lock 순서는 바꾸지 않았다.
|
|
||||||
- 동시성 test: 기존 본문 race의 test-only JPQL lock 대체를 제거하고 두 worker start gate와 실제 production repository query를 사용한다.
|
|
||||||
새 race는 concurrent content worker가 실제 lock 호출 직전임을 latch로 알리고 commit 완료 뒤 mixed request를 재개한다.
|
|
||||||
모든 executor는 release 뒤 `shutdown`과 `awaitTermination`으로 종료를 확인한다.
|
|
||||||
- focused: P2 명령에 `--rerun-tasks`를 추가해 재실행 — 67 tests, exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- P1 회귀: P1 명령에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 형식: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- diff: `GIT_MASTER=1 git diff --check` — exit 0.
|
|
||||||
- 범위: P2-T2/P3, API schema, `@DynamicUpdate`/`@Version`, dependency, Docker, HTTP, 실제 MySQL 검증은 변경하거나 실행하지 않았다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: 레거시·v2 상세의 저장 번역 표시와 누락 예약**
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P2-T2` — 상세에서 원문을 즉시 제공하고 요청 언어의 누락 번역만 예약한다.
|
|
||||||
|
|
||||||
- 시작 조건: `P2-T1` 완료, PRD §5 상세 계약 확인.
|
|
||||||
- 완료 증거: 두 상세 API의 기존 응답 구조·언어 처리·권한·별도 쓰기 트랜잭션·비동기 폴백 테스트 통과.
|
|
||||||
- 범위 밖: 목록 연결, 댓글 API에서 번역 예약, 번역 상태/원문 필드 추가.
|
|
||||||
- 소비 인터페이스: 권한 확인 후 `findDisplayContents`를 조회하고, 유효 번역이 없을 때만
|
|
||||||
`requestTranslations(postId, langContext.lang.code)`를 호출한다.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
|
|
||||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt` |
|
|
||||||
|
|
||||||
- [x] **RED:** `Accept-Language: ja`에서 기존 언어 미확정 글의 최초 상세는 원문, 감지 후 일본어만 예약,
|
|
||||||
완료 후 일본어 제공을 테스트한다. 같은 언어·반복 상세·차단·비활성·미구매·댓글 API도 포함한다.
|
|
||||||
- [x] **RED 확인:** 아래 focused 명령에서 저장 번역 미사용/누락 예약 부재를 확인한다.
|
|
||||||
- [x] **GREEN:** 기존 권한 확인 뒤 상세에서 유효 번역이 없을 때만 공유 쓰기 진입점을 호출한다. 읽기 전용 부모 트랜잭션과 분리하고
|
|
||||||
번역 선택은 기존 마스킹 전에 DTO/레코드 `copy(content = ...)`로 반영한다. 원문 엔티티는 조회 중 변경하지 않는다.
|
|
||||||
- [x] **GREEN 확인:** 실제 HTTP 계약 테스트와 중복 상세 경합에서 정상 응답·중복 작업 방지·원문 즉시 응답을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 같은 내부 조회를 사용하는 댓글/답글이 예약 진입점을 호출하지 않는지 회귀 검사한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P2-T2 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- RED: 상세 경로에 번역 선택과 예약 연결이 없던 상태에서 legacy/v2 focused test의 저장 번역 미사용과 누락 예약 assertion 실패를 확인했다.
|
|
||||||
컴파일 오류는 RED 증거로 사용하지 않았으며, 새 통합 test의 `viewerId` named argument를 실제 `memberId` 계약에 맞춰 바로잡았다.
|
|
||||||
- GREEN: `CreatorCommunityService`와 `CreatorChannelCommunityQueryService`는 권한/차단 확인 뒤
|
|
||||||
`findDisplayContents(...)[postId] ?: content`를 선택하고 `requestTranslations(postId, 요청 언어)`를 호출한다.
|
|
||||||
선택한 본문은 기존 유료 마스킹 전 DTO/record `copy(content = ...)`에만 반영하며, 엔티티 원문은 변경하지 않는다.
|
|
||||||
- 통합: `CreatorCommunityTranslationEndToEndTest`는 저장된 번역의 유료 마스킹 전 선택과, 언어 NULL 원문의 즉시 폴백 및
|
|
||||||
`targetLanguage=ja` 단일 감지 이벤트를 실제 JPA 경계에서 확인했다.
|
|
||||||
- HTTP 계약: legacy/v2 detail test는 번역 존재/누락, 구매자·소유자의 전체 본문, 미구매자의 기존 마스킹,
|
|
||||||
차단·비활성 경로, 댓글·답글의 예약 비진입을 확인했다. 같은 언어 예약 없음과 동시 예약의 유일성은 기존
|
|
||||||
`CreatorCommunityTranslationServiceTest`/통합 test로 회귀했다.
|
|
||||||
- focused: 위 명령 — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceIntegrationTest'`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 호출자 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest'`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
|
|
||||||
#### Task 2.R: P2-T2 실제 상세 파이프라인 증거 보강
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P2-R3` — 실제 Spring 프록시 상세 조회가 감지, 단일 작업 예약, 재료화와 유효 번역 재사용을 올바르게 연결한다.
|
|
||||||
|
|
||||||
- 시작 조건: `P2-T2` 완료, 상세 경로의 실제 transaction 증거 보강 요구 확인.
|
|
||||||
- 완료 증거: 원문 즉시 반환, 감지 후 요청 언어 하나의 job, 반복·동시 상세의 단일 job, memory 재료화 후 번역 반환,
|
|
||||||
유효 번역 상세의 예약 미호출을 확인한다.
|
|
||||||
- 범위 밖: 목록/홈, 외부 감지·번역 API, Docker, live HTTP, 실제 MySQL 검증.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
|
||||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
|
||||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
|
||||||
|
|
||||||
- [x] **RED:** 병렬 상세 응답 본문 검증은 `ExecutorService.submit { ... }`가 `Runnable` 오버로드를 선택해
|
|
||||||
`Future.get()`이 `null`을 반환하며 실패하는 것을 확인한다. 이는 production 동작이 아닌 수용 테스트의 반환값 폐기 문제다.
|
|
||||||
- [x] **GREEN:** `Callable`을 명시해 두 병렬 상세의 실제 원문 반환과 translation job 1개를 검증한다.
|
|
||||||
- [x] **GREEN 확인:** 실제 `CreatorCommunityService`, `LanguageDetectListener`, `ResourceTranslationJobScheduler`,
|
|
||||||
`TranslationReadModelMaterializer` 경로에서 원문 즉시 반환, `ko` job 하나, memory 재료화 후 번역 반환을 확인한다.
|
|
||||||
legacy/v2 unit test는 유효 번역의 문자열이 원문과 같아도 `requestTranslations`를 호출하지 않음을 확인한다.
|
|
||||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, Phase 2 Gate를 실행한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P2-R3 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- RED: 병렬 lambda의 반환값을 확인하는 assertion은 `expected: [detail source content, detail source content] but was: [null, null]`로 실패했다.
|
|
||||||
`submit`의 `Runnable` 오버로드가 반환값을 폐기한 것이 원인이었고, production 상세 응답이나 DB 본문 손실은 아니었다.
|
|
||||||
- GREEN: `Callable` 명시 뒤 실제 두 상세 응답은 원문을 반환하고, 감지 완료 후 `ko` translation job은 하나만 유지됐다.
|
|
||||||
수동으로 저장한 translation memory를 실제 materializer가 적용한 뒤 상세는 번역문을 반환했다.
|
|
||||||
- 유효 저장 번역: legacy/v2 상세 unit test는 `CreatorCommunityDisplayContent.hasValidTranslation`으로 판정하므로,
|
|
||||||
번역문이 원문과 같아도 `requestTranslations`를 호출하지 않는다. 실제 상세 파이프라인 test는 재료화 뒤 번역문을 반환하고
|
|
||||||
job 수가 증가하지 않음을 확인했다.
|
|
||||||
- focused: 첫 번째 명령 — exit 0, `BUILD SUCCESSFUL` (41초).
|
|
||||||
- P1 회귀: 두 번째 명령 — exit 0, `BUILD SUCCESSFUL` (26초).
|
|
||||||
- Phase 2 Gate: 세 번째 명령 — exit 0, `BUILD SUCCESSFUL` (1분 30초).
|
|
||||||
- latch RED: `CreatorCommunityLanguageDetectTest.shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned`에서 A를 멈춘 뒤 B가 다음 task의 latch를 준비하게 했다.
|
|
||||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned'`는
|
|
||||||
exit 1, `expected: <false> but was: <true>`로 실패했다. A 종료가 `completion.get()`으로 B의 latch를 감소시킨 것이 원인이었다.
|
|
||||||
- latch GREEN: decorator 생성 시점의 `taskCompletion`을 캡처해 해당 task 종료에만 사용하도록 했고, latch regression과 상세 파이프라인 detector는 `finally`에서 release하도록 정리했다.
|
|
||||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL` (20초).
|
|
||||||
- P2 재회귀: 첫 번째 명령 — exit 0, `BUILD SUCCESSFUL` (44초).
|
|
||||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (17초).
|
|
||||||
|
|
||||||
#### Phase 2 Gate
|
|
||||||
|
|
||||||
- [x] **Goal `P2-GATE`: 생성·수정·기존 글 상세 흐름 판정**
|
|
||||||
|
|
||||||
- 시작 조건: Phase 2 두 Task 완료.
|
|
||||||
- 완료 증거: 생성→커밋→감지→번역 저장과 기존 글 상세→요청 언어만 예약의 실제 통합 결과.
|
|
||||||
- 범위 밖: 목록/홈 구현. TDD 예외 사유: 앞선 Task의 사용자 흐름 검증 Gate다.
|
|
||||||
- 대체 검증: 테스트 HTTP 요청과 실제 커밋 후 DB 상태를 대조하고 롤백 케이스에 작업이 없는지 확인한다.
|
|
||||||
- 확인 파일: Phase 2의 두 EndToEndTest, 서비스 테스트, PRD §4~5.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### P2-GATE 검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- gate: 위 명령 — exit 0, `BUILD SUCCESSFUL`. 생성·수정·기존 글 상세, 레거시 서비스/JPA 및 v2 MockMvc 계약, 관리자 공용 경로를 함께 확인했다.
|
|
||||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- diff: `GIT_MASTER=1 git diff --check` — exit 0.
|
|
||||||
- 범위: local automatic 정책에 따라 Docker, live server/curl, 실제 Papago, 실제 MySQL DDL·동시성은 실행하지 않았다.
|
|
||||||
HTTP 동작은 자동 MockMvc 통합 테스트로만 검증했다.
|
|
||||||
|
|
||||||
### Phase 3: 목록·홈·미리보기 번역과 최종 검증
|
|
||||||
|
|
||||||
#### 구현 항목
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 레거시 목록과 v2 커뮤니티·채널 홈에 저장 번역 적용**
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P3-T1` — 커뮤니티 목록과 채널 홈이 작업 예약 없이 저장된 번역을 제공한다.
|
|
||||||
|
|
||||||
- 시작 조건: `P2-GATE` 완료.
|
|
||||||
- 완료 증거: 언어별 목록·최신 목록·고정/일반 채널 홈, 유료 마스킹, 추가 N+1 없음, 감지/작업 증가 0건.
|
|
||||||
- 범위 밖: 추천/팔로잉 소식, 정렬·페이지·고정 정책 변경.
|
|
||||||
- 소비 인터페이스: `findDisplayContents`만 호출. 레거시 DTO와 v2 레코드를 표시 직전에 복사해 기존 마스킹을 재사용한다.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt` |
|
|
||||||
|
|
||||||
- [x] **RED:** 번역 존재/누락·이전 개정 혼합 페이지와 무료·유료·소유자·구매자 응답을 테스트한다.
|
|
||||||
이모지 포함 유료 본문에서 기존 코드포인트 기준 미리보기를 확인한다.
|
|
||||||
- [x] **RED 확인:** 아래 명령으로 번역 미사용 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** 페이지별 번역 일괄 조회 후 기존 마스킹 전에 반영한다. 채널 홈은 이미 호출하는
|
|
||||||
`findHomeCommunityPosts` 공통 경로를 통해 고정/일반 게시물을 모두 처리한다.
|
|
||||||
- [x] **GREEN 확인:** 목록만 호출했을 때 감지 이벤트/번역 작업 생성 0건, 페이지·개수·순서 유지,
|
|
||||||
페이지 크기에 비례한 추가 번역 SELECT 증가 없음까지 확인한다.
|
|
||||||
- [x] **REFACTOR:** 기존 권한·홈 응답 회귀와 focused test 결과를 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
##### 실행·검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- 무엇을: 레거시 목록·최신 목록, v2 커뮤니티 탭, 채널 홈의 고정/일반 게시물에 저장된 표시 번역을 한 번에 조회해 본문에 복사했다.
|
|
||||||
상세 경로의 작업 예약 정책은 변경하지 않았고, 목록/홈에서는 `findDisplayContents`만 호출한다.
|
|
||||||
- 왜: 목록 응답은 번역 누락 시 원문으로 안전하게 폴백하면서도 감지·번역 작업을 만들지 않아야 한다.
|
|
||||||
- RED 확인: 새 테스트 6개를 대상으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldUseStoredTranslationsOnceForLegacyCommunityListBeforePaidMasking' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldUseStoredTranslationsForVisibleLatestCommunityPostsWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest.shouldUseStoredTranslationsOnceForCommunityTabBeforePaidMasking' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest.shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPostsWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.shouldUseStoredTranslationsForCommunityTabWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest.shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts'`를 실행했다.
|
|
||||||
exit 1의 기대된 assertion 실패로 번역 적용 전 원문이 반환됨을 확인했다.
|
|
||||||
- GREEN/영향 범위 회귀: 위 focused 명령 — exit 0, `BUILD SUCCESSFUL` (1분 3초). 저장 번역·원문 폴백·이전 개정 무시,
|
|
||||||
유료 미구매자/구매자/소유자 마스킹, 차단 필터 후 일괄 조회, 목록/홈 호출의 감지·번역 작업 미생성을 단위·MockMvc 통합 테스트로 확인했다.
|
|
||||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (45초).
|
|
||||||
- 전체 회귀와 수동 HTTP: 공통 번역 API 또는 모든 Phase를 변경하지 않았으므로 직접 영향받은 package/API 회귀까지만 실행했다.
|
|
||||||
운영과 분리된 DB·Redis·S3·Papago 및 테스트 계정이 준비되지 않아 live server/curl·실제 Papago 검증은 실행하지 않았고,
|
|
||||||
HTTP 응답은 MockMvc 통합 테스트로 검증했다.
|
|
||||||
- 추가 실제 통합 증거: `CreatorCommunityTranslationEndToEndTest`는 실제 `CreatorCommunityTranslationService`와 H2 저장소를 사용한다.
|
|
||||||
영속성 컨텍스트와 Hibernate 통계를 매 배치 전 초기화한 뒤 1개와 4개 ID를 각각 읽어, 게시글 `IN` 조회 1회와
|
|
||||||
`creator_community_translation` 조회 1회의 합계인 prepared statement 2회를 동일하게 확인했다.
|
|
||||||
따라서 페이지 크기는 번역 읽기 SELECT 수를 늘리지 않는다.
|
|
||||||
- 추가 부작용 증거: 같은 실제 읽기 서비스를 레거시 목록의 번역 누락 게시물에 연결하고, 실제 `translation_job` 행 수의 전후 동일성과
|
|
||||||
`ApplicationEventPublisher`·재료화기·resource scheduler의 무상호작용을 확인했다. 읽기 서비스 자체는 mock으로 대체하지 않았다.
|
|
||||||
v2 탭의 mock 경계 테스트는 별도로 반환 50개 ID만 전달하고 51번째 `fetchLimit + 1` ID를 제외함을 확인한다.
|
|
||||||
- TDD 예외: 이번 추가는 이미 올바른 production 동작을 직접 측정하는 증거 테스트뿐이므로 RED assertion을 만들 수 없었다.
|
|
||||||
새 테스트는 즉시 통과했으며 production P3-T1 파일은 변경하지 않았다.
|
|
||||||
- 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'` — exit 0, `BUILD SUCCESSFUL` (1분 18초).
|
|
||||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (17초).
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 홈 추천과 팔로잉 소식 본문에 저장 번역 적용**
|
|
||||||
|
|
||||||
**Goal ID / objective:** `P3-T2` — 홈 추천·팔로잉 소식의 커뮤니티 본문과 파생 미리보기가 요청 언어로 표시된다.
|
|
||||||
|
|
||||||
- 시작 조건: `P3-T1` 완료.
|
|
||||||
- 완료 증거: 두 API의 번역/원문 폴백·일괄 조회·작업 미생성·다른 소식 유형 불변 테스트.
|
|
||||||
- 범위 밖: 추천 스냅샷/순위 변경, FCM·알림 이력·오디오 소식 번역.
|
|
||||||
- 소비 인터페이스: `LangContext`의 언어와 `findDisplayContents`. 추천 상세 레코드/팔로잉 도메인의 커뮤니티 항목만 복사한다.
|
|
||||||
|
|
||||||
| 작업 | 정확한 파일 경로 |
|
|
||||||
|---|---|
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` |
|
|
||||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` |
|
|
||||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` |
|
|
||||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` |
|
|
||||||
|
|
||||||
- [x] **RED:** 일본어·영어 요청의 인기 커뮤니티/팔로잉 소식, 누락·이전 개정 폴백,
|
|
||||||
비커뮤니티 항목 유지, 조회만으로 작업이 늘지 않는 테스트를 작성한다.
|
|
||||||
- [x] **RED 확인:** 아래 focused 명령으로 해당 응답이 원문에 머무르는 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** 이미 필터링된 게시물 ID를 모아 공유 읽기 메서드로 처리한다. 팔로잉 서비스에 기존 `LangContext`를 전달한다.
|
|
||||||
본문은 DB 게시물의 현재 내용에서 가져오므로 과거 소식 발행 문자열을 번역 소스로 쓰지 않는다.
|
|
||||||
- [x] **GREEN 확인:** 언어 전환·익명 허용 추천·인증된 팔로잉 흐름을 검증하고 기존 필터/추천 순서를 대조한다.
|
|
||||||
- [x] **REFACTOR:** 변경된 생성자 호출 테스트를 갱신하고 두 기능의 직접 영향 회귀 결과를 기록한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
##### 실행·검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- 무엇을: 인기 커뮤니티 추천의 최종 노출 게시물과 팔로잉 최근 소식의 커뮤니티 게시물만 요청 언어의 저장 본문으로 복사했다.
|
|
||||||
추천 중복 제거·limit와 팔로잉 필터·순서·다른 소식 유형은 변경하지 않았다.
|
|
||||||
- RED 확인: 서비스·API 테스트 4개가 번역 연결 전 원문 본문 assertion으로 실패함을 확인했다.
|
|
||||||
- GREEN/회귀: 관련 5개 테스트 클래스에 `--rerun-tasks`를 적용해 exit 0, `BUILD SUCCESSFUL` (2분 38초)을 확인했다.
|
|
||||||
일본어 비회원 추천, 영어 인증 팔로잉, 현재 원문 폴백, 최종 ID 단일 배치, 비커뮤니티 불변을 검증했다.
|
|
||||||
- 리뷰: 명세 리뷰 `APPROVED` (`ses_f7757315dffeoDl3Ef2bhCO684`), 품질 리뷰 `APPROVED` (`ses_f774b8bf4ffehbjDvQWo6yE84S`).
|
|
||||||
- 형식/diff: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL` (26초).
|
|
||||||
`GIT_MASTER=1 git diff --check` — exit 0.
|
|
||||||
- 범위: Docker, live HTTP, 실제 Papago/MySQL은 실행하지 않았으며 테스트 브랜치 배포 후 수동 체크리스트에서 확인한다.
|
|
||||||
|
|
||||||
#### Task P3-R1: DDL 해시 컬럼 타입 정합화
|
|
||||||
|
|
||||||
- [x] **Goal `P3-R1`: `source_hash` DDL을 Hibernate 엔티티 매핑과 일치시킨다**
|
|
||||||
|
|
||||||
- 시작 조건: `P3-GATE` 코드 품질 리뷰에서 `CHAR(64)`와 `VARCHAR(64)` 불일치가 차단 이슈로 확정됨.
|
|
||||||
- 완료 증거: `schema.sql`의 `source_hash VARCHAR(64)` 확인, `./gradlew tasks --all`, `git diff --check`, 해당 delta 코드 품질 재검토 통과.
|
|
||||||
- 범위 밖: 엔티티 매핑 변경, 실제 MySQL DDL 적용, 비차단 로그·테스트 종료 개선.
|
|
||||||
- RED: `rg -n 'source_hash VARCHAR\(64\)' schema.sql`이 일치 항목 없이 실패하는지 확인한다.
|
|
||||||
- GREEN: DDL 한 줄만 `VARCHAR(64)`로 변경하고 동일 명령이 성공하는지 확인한다.
|
|
||||||
- REFACTOR: 추가 구조 변경 없이 문서 상태와 Gate 증거만 동기화한다.
|
|
||||||
- 수정 파일: `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql`, `prd.md`, `plan-task.md`.
|
|
||||||
|
|
||||||
##### 실행·검증 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- RED: `rg -n 'source_hash VARCHAR\(64\)' schema.sql` — 일치 항목 없이 exit 1.
|
|
||||||
- GREEN: `source_hash`를 엔티티의 `String` + `@Column(length = 64)`와 같은 `VARCHAR(64)`로 변경했고 동일 `rg`가 11행을 반환했다.
|
|
||||||
- 회귀: `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`; `GIT_MASTER=1 git diff --check` — exit 0.
|
|
||||||
- delta 품질 재검토: `PASS` (`ses_f772f064cffeKa532hsf1Pa3GU`). 실제 MySQL 적용과 `ddl-auto: validate` 기동 확인은 수동 검증에 유지한다.
|
|
||||||
|
|
||||||
#### Phase 3 Gate
|
|
||||||
|
|
||||||
- [x] **Goal `P3-GATE`: 전체 노출 표면과 비동기 정합성 최종 판정**
|
|
||||||
|
|
||||||
- 시작 조건: `P3-T1`, `P3-T2` 및 이전 Gate의 증거 완료.
|
|
||||||
- 완료 증거: 아래 자동 검증, PRD `CCT-001~010` 대조 및 5개 리뷰 레인 통과. HTTP·실제 MySQL/Papago는 배포 후 수동 체크리스트로 분리한다.
|
|
||||||
- 범위 밖: 테스트 삭제/완화, 운영 데이터 번역/배포, 다른 기능의 결함 수정.
|
|
||||||
- TDD 예외 사유: 기능 추가가 아닌 전체 수용 기준 검증 Gate다.
|
|
||||||
- 대체 검증: 자동 회귀, 격리된 실행 서버의 HTTP 응답/DB 작업 수 대조, 문서와 실제 동작 대조.
|
|
||||||
- 확인 파일: 모든 Task의 변경 파일, `prd.md`, `plan-task.md`, `schema.sql`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.home.following.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
./gradlew bootJar
|
|
||||||
```
|
|
||||||
|
|
||||||
기본 최종 검증은 위 직접 영향 범위다. 이번 변경이 공통 번역 API의 기존 리소스 동작까지 바꾸거나,
|
|
||||||
위 범위로 판정할 수 없는 실패가 발견되거나, 사용자/릴리스 기준이 전체 검증을 요구하면 `./gradlew test`를 추가한다.
|
|
||||||
전체 테스트 생략 시 공통 번역 및 영향받은 각 기능을 위 명령으로 검증했다는 근거와 실제 결과를 기록한다.
|
|
||||||
|
|
||||||
##### 최종 Gate 기록 — 2026-09-10
|
|
||||||
|
|
||||||
- 직접 영향 테스트 — clean 후 `--no-parallel` 순차 실행, exit 0, `BUILD SUCCESSFUL` (5분 20초). 53개 클래스 493개 테스트, 실패·오류·skip 0을 XML에서 확인했다.
|
|
||||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (20초). `./gradlew bootJar` — exit 0, `BUILD SUCCESSFUL` (1분 31초).
|
|
||||||
- `GIT_MASTER=1 git diff --check` — exit 0. 최초 병렬 Gradle의 kapt 출력 경합은 clean 후 순차 성공으로 대체했다.
|
|
||||||
- 최종 리뷰: 목표·제약 PASS, 자동 QA PASS, 보안 PASS, 문맥 PASS. 코드 품질의 DDL 타입 차단 이슈는 `P3-R1`로 수정 후 delta 재검토 PASS.
|
|
||||||
- 비차단 관찰: 번역 예약 실패 로그 분류, 일부 경합 테스트 executor 종료 확인, 언어 감지 완료 전 반복 상세 요청의 호출 증폭은 이번 범위에서 변경하지 않았다.
|
|
||||||
- 간접 소비자: `LiveApiService`, `ExplorerService`도 기존 목록 서비스를 통해 저장 번역을 사용하며 추가 예약은 하지 않는다.
|
|
||||||
- 로컬 자동 Gate는 완료했다. 실제 DDL·MySQL 격리 수준·HTTP·Papago 검증은 아래 테스트 서버 수동 체크리스트에서 미완료로 유지한다.
|
|
||||||
|
|
||||||
### 테스트 브랜치 배포 후 수동 체크리스트
|
|
||||||
|
|
||||||
수동 HTTP 검증은 운영과 분리된 DB·Redis·S3/Papago 설정 및 테스트 계정이 준비된 테스트 서버에서 실행한다.
|
|
||||||
기존 `./gradlew bootRun`을 사용하며 토큰·키·본문 전문은 기록물에서 제거한다.
|
|
||||||
|
|
||||||
1. `schema.sql`을 테스트 DB에 적용한다. 기존 행은 `language_code` NULL, `content_revision` 0, 원문이 유지되고,
|
|
||||||
`creator_community_id + locale` 유일 키가 생성됐는지 확인한다.
|
|
||||||
2. 같은 게시물·locale의 번역 요청을 동시에 보낸다. duplicate-key 오류나 API 실패 없이, 현재 원문과 일치하는 번역 행이 정확히 1개인지 확인한다.
|
|
||||||
3. 언어 NULL인 기존 무료 게시물로 PRD §5의 모든 목록/홈 API를 조회한다. 원문 표시, 감지/번역 작업 증가 0건을 확인한다.
|
|
||||||
4. 같은 글을 `Accept-Language: ja`로 상세 조회한다. 최초 원문 응답, 감지 후 일본어 작업만 예약,
|
|
||||||
워커 완료 후 상세/목록/채널 홈/추천/팔로잉에서 저장 번역 사용을 확인한다.
|
|
||||||
5. 한·영·일 원문의 신규 게시물과 실제 본문 수정을 실행한다. 다른 두 언어 생성,
|
|
||||||
수정 직후 현재 원문 폴백, 지연된 이전 감지/번역 결과 미노출, A→B→A 메모리 복원을 확인한다.
|
|
||||||
6. 유료 글을 미구매자/구매자/소유자로 조회하고 번역 선택 후 기존 마스킹이 적용되는지 확인한다.
|
|
||||||
차단·성인·비활성·댓글/답글 노출과 미디어 URL 규칙은 기존 결과와 대조한다.
|
|
||||||
7. 감지/번역 provider 실패와 반복 상세 요청을 재현한다. 원문 응답, 기존 재시도 정책, 작업 중복 방지,
|
|
||||||
페이지 크기 증가 시 추가 번역 조회 수가 선형으로 늘지 않음을 확인한다.
|
|
||||||
|
|
||||||
#### P1-R4 실제 MySQL 수동 확인 · 2026-09-10 추가
|
|
||||||
|
|
||||||
- [ ] 테스트 서버가 실제 MySQL 8/InnoDB인지 확인하고 버전·테이블 엔진·세션 격리 수준을 기록한다.
|
|
||||||
감지 캐시의 해시/provider/normalizationVersion 유일 키와 audit 컬럼의 타입·NULL/default 조건을 확인한다.
|
|
||||||
native INSERT 후 생성/수정 시각이 채워지고 중복 upsert가 승자 감지값과 생성 시각을 덮어쓰지 않는지 대조한다.
|
|
||||||
- [ ] 언어 NULL이고 해당 캐시 키가 없는 동일 한국어 게시물에 `ja`/`en` 상세 요청을 보낸다.
|
|
||||||
테스트용 provider 제어로 두 miss와 provider 진입을 확인한 뒤 첫 감지 저장·커밋·`ja` 예약을 완료하고
|
|
||||||
후발 감지를 해제한다. 실제 `REPEATABLE READ`에서 예외/rollback 없이 두 흐름이 끝나고 캐시 1행,
|
|
||||||
`ja`/`en` 작업이 모두 남는지 확인한다. 단순 동시 HTTP 요청만으로 이 실행 순서를 증명했다고 보지 않는다.
|
|
||||||
- [ ] 서로 다른 게시물을 동일 본문으로 생성하고 캐시 miss 두 건의 저장 순서를 같은 방식으로 제어한다.
|
|
||||||
공통 캐시 1행과 승자 결과 재사용, 각 게시물의 원문 외 두 언어 작업 보존을 확인한다.
|
|
||||||
- [ ] provider 감지 응답을 차단한 동안 별도 연결에서 해당 게시물 본문 수정을 커밋할 수 있는지 확인한다.
|
|
||||||
수정 후 개정 증가·언어 초기화와 현재 원문 폴백을 확인하고 이전 감지를 해제한다. 오래된 결과가
|
|
||||||
새 본문·개정·언어·번역을 덮어쓰거나 이전 원문의 작업을 새 개정에 적용하지 않는지 확인한다.
|
|
||||||
|
|
||||||
필요한 테스트 환경이 없으면 수동 검증은 미완료로 기록한다. MockMvc/provider 대체 테스트를 실제 Papago 검증으로 표기하지 않는다.
|
|
||||||
실제 Papago 확인은 비민감한 테스트 문장을 사용하며 수동 검증 환경에서만 수행한다.
|
|
||||||
|
|
||||||
## Progress와 검증 기록
|
|
||||||
|
|
||||||
### 문서 작성 — 2026-09-10
|
|
||||||
|
|
||||||
- 상태: PRD·구현 계획 작성. 구현 Task/Gate는 모두 미실행.
|
|
||||||
- 무엇을: 사용자 인터뷰 결정과 레거시/v2 생성·상세·목록·홈 경로를 이 계획의 6개 Task에 연결했다.
|
|
||||||
- 왜: 구현 전 모호한 범위를 없애고 정확한 변경 위치·수용 기준·TDD 순서를 제공하기 위해서다.
|
|
||||||
- 실행 확인: `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`. `test`, `bootRun`, `bootJar`, `ktlintCheck` task를 확인했다.
|
|
||||||
- 관찰: 기존 Gradle 설정에서 Gradle 9.0 비호환 예정 deprecation 안내가 출력됐다. 이번 문서 범위에서 변경하지 않는다.
|
|
||||||
- 미실행: 코드 구현, 신규 테스트 작성/실행, 서버 실행, Papago 호출, DDL 작성/적용, 운영 작업.
|
|
||||||
- 테스트 생략 근거: 제품 코드를 변경하지 않는 문서 작업이다. 미래 테스트 명령을 계획에 적은 것은 테스트 통과 증거가 아니다.
|
|
||||||
- 남은 실행 조건: 사용자의 별도 구현 요청과 각 Task의 검증 환경 확보.
|
|
||||||
|
|
||||||
### 문서 검증 — 2026-09-10
|
|
||||||
|
|
||||||
- `./gradlew tasks --all` — 문서 생성 후 재실행, exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 문서 경로·링크·템플릿 잔여값·공백 검사 — exit 0, `Document validation: PASS`.
|
|
||||||
기존 파일 참조가 존재하며 미래 생성 파일 7개는 생성 항목으로 구분했다.
|
|
||||||
- 요구사항/계획 대조 — 요구사항 10개, 구현 Task 6개, Gate 3개가 연결되어 있고 구현 완료 체크는 없다.
|
|
||||||
- `git diff --check` — exit 0. 신규 미추적 문서의 공백은 별도 문서 검사로 확인했다.
|
|
||||||
- `git -c core.quotePath=false status --short --untracked-files=all` — 이 작업 폴더의 `prd.md`, `plan-task.md` 두 파일만 추가됨을 확인했다.
|
|
||||||
|
|
||||||
### P2-R3 최종 검증 — 2026-09-10
|
|
||||||
|
|
||||||
- `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`; `test`, `ktlintCheck`, `build` task가 유효함을 확인했다.
|
|
||||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`. P2 변경 테스트의 import 순서와 `Callable` 호출 줄바꿈을 정정했다.
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler'`
|
|
||||||
— exit 0, `BUILD SUCCESSFUL`. 실제 Spring 상세 파이프라인에서 원문 즉시 반환, 단일 job, 재료화 후 번역 반환을 확인했다.
|
|
||||||
- `./gradlew build` — exit 0, `BUILD SUCCESSFUL` (8분 16초). 컴파일, 전체 테스트, ktlint, 패키징을 포함한다.
|
|
||||||
- `GIT_MASTER=1 git diff --check` — exit 0. Docker, live HTTP, 실제 Papago, 실제 MySQL 검증은 실행하지 않았다.
|
|
||||||
|
|
||||||
### Phase별 후속 정적 리뷰 — 2026-09-10
|
|
||||||
|
|
||||||
- 기준: 인터뷰를 반영한 `prd.md`와 이 계획, 현재 source/test/DDL. HEAD와 작업 트리 지문은
|
|
||||||
[리뷰 증거](reviews/review-evidence.md)에 기록했다.
|
|
||||||
- Phase 1: 감지 캐시 최초 INSERT 경합으로 후발 target 예약이 누락되는 정적 경로 1건 확정.
|
|
||||||
`REV-P1-007`을 신규 `P1-R4`로 추가했다. 기존 완료 기록과 이전 리뷰는 유지했다.
|
|
||||||
- Phase 2: 생성·실제 본문 수정·상세 단일 언어 예약·권한/마스킹 기준 충족. 자체 추가 수정 사항 없음.
|
|
||||||
Phase 1 결함의 상세 영향은 `P1-R4`에서 처리한다.
|
|
||||||
- Phase 3: 목록·채널 홈·추천·팔로잉 저장 번역 및 비예약 기준 충족. 신규 Task 없음.
|
|
||||||
- 실행 제약: 사용자 요청대로 테스트·컴파일·서버·HTTP·Papago·DB 재현은 실행하지 않았다.
|
|
||||||
기존 XML 성공 기록은 과거 실행 증거로만 읽었다.
|
|
||||||
- 검증 한계: 실제 캐시 저장 경합은 새 Task의 RED로 재현할 예정이다. 한·영·일 전체 생성→워커 통합 검증과
|
|
||||||
실제 MySQL/Papago/HTTP는 기존 테스트 서버 수동 확인 범위로 남겼다. 구성요소 테스트 통과를 실환경 전체 흐름 통과로 확대하지 않는다.
|
|
||||||
- 문서 검증: `./gradlew tasks --all` — exit 0(task 목록 조회만 수행, 테스트·컴파일 미실행).
|
|
||||||
`git diff --check`와 문서 링크/공백 검사 통과. 리뷰 전후 source/test/DDL 32개 파일 지문이 같음을 확인했다.
|
|
||||||
|
|
||||||
### P1-R4 문서 동기화 · 2026-09-10
|
|
||||||
|
|
||||||
- 완료된 RED/GREEN·P1/P2 영향 회귀·spec/quality 리뷰를 P1-R4 실행 기록과 리뷰 문서 두 곳에 누적했다.
|
|
||||||
기존 정적 리뷰 지문과 과거 기록은 당시 증거로 보존하며 실제 MySQL 수동 확인 네 항목은 미완료로 추가했다.
|
|
||||||
- 문서 검증: `./gradlew tasks --all`, exit 0, `BUILD SUCCESSFUL` (2초). `test`, `ktlintCheck` task를 확인했다.
|
|
||||||
`rg`로 P1-R4 Goal 및 RED/GREEN/REFACTOR 완료 체크, 실행 시간·리뷰 출처·실환경 검증 보류 표기를 대조했다.
|
|
||||||
- 이번 작업은 지정된 계획·리뷰 문서 3개만 수정했다. PRD·source·test·schema 변경이나 제품 테스트 재실행은 하지 않았다.
|
|
||||||
|
|
||||||
## 변경 관리
|
|
||||||
|
|
||||||
기술 선택은 PRD `DEC-009~010`, 제품 결정은 `DEC-001~008`을 따른다.
|
|
||||||
후속 요구사항은 PRD 결정 기록 → 요구사항/API 계약 → 이 계획의 파일·검증·체크박스 순서로 갱신한다.
|
|
||||||
미래 구현에서는 각 Task 아래에 실행일·무엇/왜/어떻게·정확한 명령·결과·남은 항목을 누적한다.
|
|
||||||
새 테스트 클래스를 만들 때는 이 문서의 focused 실행 예시가 실제 클래스와 일치하는지도 갱신한다.
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
# 크리에이터 커뮤니티 게시물 본문 번역 PRD
|
|
||||||
|
|
||||||
## 문서 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 상태 | 구현 및 로컬 자동 검증 완료 · 테스트 서버 수동 검증 대기 |
|
|
||||||
| 작성일 / 최종 수정일 | 2026-09-10 |
|
|
||||||
| 결정권자 | 요청 사용자 |
|
|
||||||
| 산출물 범위 | 코드·자동 테스트·MySQL DDL 작성. 실제 DDL 적용·배포·Papago/HTTP 검증은 테스트 서버에서 수행한다. |
|
|
||||||
| API 계약 | 별도 파일을 만들지 않고 이 문서의 API 계약 절에 통합 |
|
|
||||||
| 기준 템플릿 | `docs/sample/sample-prd.md` |
|
|
||||||
|
|
||||||
## 1. 목표와 현재 동작
|
|
||||||
|
|
||||||
크리에이터 커뮤니티 게시물의 본문을 한국어·영어·일본어로 제공한다. 작성한 원문은 보존하고,
|
|
||||||
원문 이외의 지원 언어 번역을 저장한다. 이용자는 요청 언어에 맞는 본문을 상세·목록·미리보기에서 읽는다.
|
|
||||||
|
|
||||||
현재 코드에서 확인한 사실:
|
|
||||||
|
|
||||||
| 근거 파일 | 확인한 동작 |
|
|
||||||
|---|---|
|
|
||||||
| `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` | 원문 `content`는 있으나 원문 언어와 번역 저장 구조는 없다. |
|
|
||||||
| `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` | 상세 조회에서 요청 언어의 번역을 조회한다. 번역이 없으면 해당 언어 작업을 예약하고 원문 응답을 유지한다. 원문 언어가 없는 경우에는 이 예약 분기에 들어가지 않는다. |
|
|
||||||
| `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` | 커밋 후 비동기 언어 감지, 언어 저장, 번역 이벤트 발행 흐름이 있다. |
|
|
||||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt` | 리소스의 원문 언어를 제외한 지원 언어 전체 또는 지정한 한 언어의 작업을 예약한다. |
|
|
||||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobScheduler.kt` | 원문 언어가 `ko`, `en`, `ja`인 비어 있지 않은 텍스트만 예약한다. 동일 리소스·필드·목표 언어·원문 해시 작업은 중복 생성하지 않는다. |
|
|
||||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt` | 저장된 작업을 처리하고 번역 메모리와 조회용 번역을 저장한다. 주기 기본값은 600000ms이며 실제 환경 설정에 따라 달라진다. |
|
|
||||||
|
|
||||||
이번 기능은 오디오 상세의 누락 번역 예약 방식을 따른다. 다만 기존 커뮤니티 게시물은 언어 정보 자체가 없으므로,
|
|
||||||
상세 조회에서 비동기 언어 감지까지 연결해야 한다. 오디오 상세가 현재 언어 미확정 데이터도 감지한다고 가정하지 않는다.
|
|
||||||
|
|
||||||
## 2. 포함 범위와 제외 범위
|
|
||||||
|
|
||||||
### 포함
|
|
||||||
|
|
||||||
- 본문 `content`만 번역하며 무료·유료 게시물에 같은 언어 정책을 적용한다.
|
|
||||||
- 신규 게시물 저장 후 자동 감지·번역, 실제 본문 변경 후 언어 재감지·번역 갱신.
|
|
||||||
- 기존 게시물은 상세 조회를 계기로 필요한 언어를 감지하고 요청 언어의 누락 번역을 예약한다.
|
|
||||||
- 기존 커뮤니티 API와 v2 상세·목록, 채널 홈의 고정/일반 게시물, 홈 추천 인기 커뮤니티, 팔로잉 소식의 본문·미리보기.
|
|
||||||
- 공유 생성·수정 서비스를 사용하는 AI 캐릭터 관리자 작성 경로도 같은 저장 처리를 적용한다.
|
|
||||||
이는 별도 관리자 기능 추가가 아니라 동일 게시물의 생성·수정 누락 방지다.
|
|
||||||
|
|
||||||
### 제외
|
|
||||||
|
|
||||||
- 댓글·답글·첫 댓글, 이미지 내부 문자, 첨부 오디오 음성의 번역.
|
|
||||||
- 기존 게시물 일괄 번역, 목록·홈·미리보기·댓글 조회에서 언어 감지 또는 번역 작업 예약.
|
|
||||||
- 번역 수동 편집·언어 선택 입력·원문 보기 토글·번역 상태 API·실시간 완료 알림.
|
|
||||||
- 푸시 알림 문구·알림 이력의 번역, 정산 화면과 관리자 편집용 원문 응답 변경.
|
|
||||||
- 공통 언어 지원 범위, 다른 콘텐츠의 수정 정책, 추천 순위/스냅샷 생성 방식의 변경.
|
|
||||||
|
|
||||||
## 3. 확정 요구사항
|
|
||||||
|
|
||||||
| ID | 요구사항 | 수용 기준 | 연결 Goal |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `CCT-001` | 원문을 보존하고 본문만 지원 언어로 번역해 저장한다. | 한국어·영어·일본어 각각을 원문으로 작성하면 다른 두 언어의 본문 번역이 저장되고 원문은 유지된다. 댓글과 첨부 파일은 바뀌지 않는다. | `P1-T1`, `P1-T2`, `P2-T1` |
|
|
||||||
| `CCT-002` | 원문 언어가 없으면 본문으로 자동 감지한다. | 요청자의 앱 언어를 원문 언어로 추정하지 않는다. 감지 결과를 저장하고 번역으로 이어진다. | `P1-T2` |
|
|
||||||
| `CCT-003` | 신규 게시물은 저장 성공 후 원문 이외의 지원 언어 번역을 예약한다. | 저장 롤백에는 감지/번역이 실행되지 않는다. 저장 응답이 외부 번역 완료를 기다리지 않는다. | `P2-T1` |
|
|
||||||
| `CCT-004` | 실제 본문 수정 시 언어를 다시 감지하고 번역을 갱신한다. | 한국어에서 영어로 변경해도 새 언어와 새 본문으로 처리한다. 동일 본문 제출·이미지·고정·댓글 허용 여부만 변경하면 재번역하지 않는다. | `P2-T1` |
|
|
||||||
| `CCT-005` | 기존 게시물은 상세 조회에서만 누락 번역을 예약한다. | 번역 미완료 상세는 원문을 반환한다. 원문 언어가 없으면 감지 후 해당 상세 요청 언어만 예약한다. 언어가 같으면 번역 작업은 없다. | `P2-T2` |
|
|
||||||
| `CCT-006` | 상세·목록·미리보기에서 저장된 유효한 번역을 사용한다. | 레거시 목록/최신 목록, v2 커뮤니티 목록, 채널 홈, 홈 추천, 팔로잉 소식에 같은 언어의 저장 번역이 적용된다. 목록 계열만 조회하면 감지/번역 작업은 0건이다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
|
||||||
| `CCT-007` | 번역이 없거나 실패했거나 현재 본문에 맞지 않으면 현재 원문을 제공한다. | 수정 커밋 후 새로 시작한 요청에서는 수정 전 번역을 반환하지 않는다. 늦게 도착한 이전 감지/번역 결과도 새 본문을 덮어쓰지 않는다. | `P1-T1`, `P1-T2`, `P2-T1`, `P3-GATE` |
|
|
||||||
| `CCT-008` | 기존 조회 권한과 유료 본문 제한을 번역문에도 적용한다. | 차단·성인·비활성 필터와 구매/소유자 판정은 유지한다. 번역문을 선택한 다음 기존 미리보기 제한을 적용해 미구매자에게 전체 본문이 노출되지 않는다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
|
||||||
| `CCT-009` | 기존 API 구조와 언어 결정 방식을 유지한다. | `Accept-Language`를 처리하는 `LangContext`를 사용한다. 응답 본문 문자열의 언어만 달라지고 필드·상태 코드·인증·페이지 규칙은 유지한다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
|
||||||
| `CCT-010` | 기존 감지 캐시·번역 메모리·큐·워커를 재사용한다. | 반복 상세 조회나 이전 본문으로의 복원에서 불필요한 번역 호출을 만들지 않는다. 목록 번역 조회는 게시물별 쿼리 대신 일괄 조회한다. | `P1-T1`, `P1-T2`, `P3-GATE` |
|
|
||||||
|
|
||||||
## 4. 처리 흐름
|
|
||||||
|
|
||||||
### 4.1 신규 작성과 본문 수정
|
|
||||||
|
|
||||||
1. 기존 검증·소유권 확인을 거쳐 원문을 저장한다. 본문 변경 시 이전 언어를 비우고 본문 개정 번호를 증가시킨다.
|
|
||||||
2. 저장 트랜잭션 커밋 후 해당 본문의 언어를 비동기로 감지한다. 원문 정보가 이미 있으면 감지를 생략한다.
|
|
||||||
3. 감지 결과가 현재 본문의 결과임을 확인하고 원문 언어를 저장한다.
|
|
||||||
4. 지원 언어 중 원문을 제외한 언어를 예약한다. 기존 메모리를 재사용할 수 있으면 조회용 번역을 복원한다.
|
|
||||||
5. 워커가 완료하면 다음 조회부터 번역문을 제공한다. 완료 전에는 현재 원문을 제공한다.
|
|
||||||
|
|
||||||
### 4.2 기존 게시물 상세 조회
|
|
||||||
|
|
||||||
1. 기존 접근 권한과 노출 정책을 확인한다.
|
|
||||||
2. 요청 언어의 현재 본문에 대응하는 번역이 있으면 사용한다.
|
|
||||||
3. 번역이 없으면 원문 응답을 유지하고 누락 처리만 예약한다. 언어 미확정이면 감지 요청에 상세 요청 언어를 전달한다.
|
|
||||||
4. 감지 후 원문과 요청 언어가 다를 때 해당 언어만 예약한다. 신규 작성의 전체 언어 예약과 구분한다.
|
|
||||||
5. 외부 감지/번역은 HTTP 응답을 기다리게 하지 않는다. 다음 조회부터 완성된 번역을 사용한다.
|
|
||||||
|
|
||||||
### 4.3 목록·홈·미리보기
|
|
||||||
|
|
||||||
권한 필터와 페이지 선정 → 페이지 내 게시물의 유효한 번역 일괄 조회 → 번역 또는 현재 원문 선택 →
|
|
||||||
기존 유료 미리보기·표시 규칙 적용 순서로 처리한다. 이 경로는 언어 감지, 메모리에서 조회 모델 재생성,
|
|
||||||
번역 작업 예약을 하지 않는다. 저장된 번역이 없는 기존 게시물은 상세 조회 또는 본문 수정 전까지 원문으로 남는다.
|
|
||||||
|
|
||||||
## 5. API 계약과 조회 표면
|
|
||||||
|
|
||||||
공개 필드는 추가하지 않는다. `content`와 여기서 파생되는 게시물 미리보기 문자열을 요청 언어로 반환한다.
|
|
||||||
언어는 `LangInterceptor` → `Lang.fromAcceptLanguage` → `LangContext`를 그대로 사용한다.
|
|
||||||
지원 언어는 `ko`, `en`, `ja`이고 헤더 누락/미지원 언어 처리는 기존 한국어 기본값을 유지한다.
|
|
||||||
자동 번역문을 원문 `CreatorCommunity.content`에 덮어쓰지 않는다.
|
|
||||||
|
|
||||||
| API | 대상 | 누락 처리 예약 |
|
|
||||||
|---|---|---|
|
|
||||||
| `POST /creator-community`, `PUT /creator-community` | 신규 작성·실제 본문 수정 후 처리. multipart 계약 유지 | 커밋 후 전체 목표 언어 |
|
|
||||||
| `GET /creator-community/{id}` | 레거시 상세 `content` | 요청 언어만 |
|
|
||||||
| `GET /creator-community`, `GET /creator-community/latest` | 레거시 목록·팔로우 최신 목록 `content` | 없음 |
|
|
||||||
| `GET /api/v2/creator-channels/community-posts/{postId}` | v2 상세 게시물 `content` | 요청 언어만 |
|
|
||||||
| `GET /api/v2/creator-channels/{creatorId}/community` | 커뮤니티 탭 게시물 `content` | 없음 |
|
|
||||||
| `GET /api/v2/creator-channels/{creatorId}/home` | 고정/일반 커뮤니티 항목 `content` | 없음 |
|
|
||||||
| `GET /api/v2/home/recommendations` | 인기 커뮤니티 항목 본문·파생 미리보기 | 없음 |
|
|
||||||
| `GET /api/v2/home/following` | `recentNews`의 커뮤니티 게시물 본문 | 없음 |
|
|
||||||
|
|
||||||
관리자 게시물 조회는 편집 원문을 유지한다. 같은 작성 서비스를 호출하는 관리자 생성·수정은 `CCT-003~004`를 따른다.
|
|
||||||
댓글 전용 조회와 구매 응답은 번역 예약 진입점으로 추가하지 않는다.
|
|
||||||
|
|
||||||
## 6. 데이터·비동기 정합성 설계 기준
|
|
||||||
|
|
||||||
다음 기술 설계는 현재 구현과 DDL에 반영됐다. 실제 MySQL 적용 여부는 테스트 서버 수동 검증에서 확인한다.
|
|
||||||
|
|
||||||
| 저장 대상 | 계획 |
|
|
||||||
|---|---|
|
|
||||||
| `creator_community` | nullable `language_code`, 본문 변경에만 증가하는 `content_revision`을 추가한다. 기존 행은 언어 NULL, 개정 번호 0으로 시작한다. |
|
|
||||||
| `creator_community_translation` | 게시물 ID, locale, 번역 본문, 번역 기준 개정 번호, 원문 해시·언어를 저장한다. `(creator_community_id, locale)` 유일성을 보장한다. |
|
|
||||||
| 공통 감지/번역 저장소 | 기존 감지 캐시와 번역 메모리의 정규화·키, `translation_job` 처리 흐름을 유지한다. 커뮤니티 대상 enum과 원문 추출/조회 모델 저장 분기를 추가한다. |
|
|
||||||
|
|
||||||
- 감지 이벤트에 본문 개정 번호와 필요한 경우 목표 언어를 전달한다. 본문이 변경되었거나 비활성화되면 이전 결과를 적용하지 않는다.
|
|
||||||
- 번역문은 현재 본문의 개정 번호·원문 언어가 일치할 때만 조회한다. 오래된 작업의 결과에 현재 개정 번호를 임의로 붙이지 않는다.
|
|
||||||
- 감지 결과 저장과 번역 upsert의 개정 확인은 DB 갱신과 원자적으로 처리한다. 외부 API 호출 동안 행 잠금을 유지하지 않는다.
|
|
||||||
- `updatedAt`은 고정·이미지 등 수정에도 변하므로 본문 개정 판단에 사용하지 않는다.
|
|
||||||
- 원문 A → B → A 복원 시 기존 COMPLETED 작업 때문에 번역이 영구 누락되지 않도록 현재 메모리로 조회 모델을 먼저 복원한다.
|
|
||||||
- 기존 정규화는 공백·개행을 합친다. 원문 표시의 개행은 원문 그대로 보존하고, 번역 결과는 기존 번역기의 출력 정책을 따른다.
|
|
||||||
|
|
||||||
## 7. 예외·권한·성능
|
|
||||||
|
|
||||||
| 상황 | 처리 |
|
|
||||||
|---|---|
|
|
||||||
| 빈 본문 또는 공백만 있는 본문 | 기존 저장 검증은 바꾸지 않는다. 감지·번역은 생략하고 원문을 반환한다. |
|
|
||||||
| 감지 실패 또는 지원하지 않는 원문 언어 | 원문 유지. 지원하지 않는 언어의 번역 작업은 기존 scheduler 정책에 따라 생성하지 않는다. 언어 미확정 게시물의 다음 상세에서 감지를 다시 시도할 수 있다. |
|
|
||||||
| 번역 실패 | 원문 유지. 기존 워커의 재시도/최종 FAILED 정책을 재사용한다. 반복 상세 조회가 최종 실패 작업을 무한 재생성하지 않는다. |
|
|
||||||
| 번역 도중 본문 변경·비활성화 | 오래된 결과를 현재 게시물의 번역으로 노출하지 않는다. 삭제/비활성 게시물은 기존 조회 정책대로 숨긴다. |
|
|
||||||
| 유료 게시물 미구매 | 선택된 번역 또는 원문에 기존 코드포인트 기준 제한을 적용한다. 기존 15자 기준·짧은 본문 절반·말줄임 처리는 유지한다. |
|
|
||||||
| 목록에 여러 게시물 | 페이지 단위 번역 일괄 조회. 기존 정렬·개수·페이지·추천 후보를 변경하지 않고 추가 N+1을 만들지 않는다. |
|
|
||||||
|
|
||||||
외부 감지/번역 실패는 작성·조회 성공을 번역 완료 여부에 종속시키지 않는다. 인증 실패나 DB 장애까지 성공으로 숨기지는 않는다.
|
|
||||||
본문·자격증명·유료 콘텐츠 전문을 새 로그에 남기지 않는다. 기존 감지/번역 저장소의 텍스트 저장 정책은 재사용한다.
|
|
||||||
번역 완료 시간 SLA나 워커 주기 조정은 이번 요청에 포함하지 않는다.
|
|
||||||
|
|
||||||
## 8. 성공 기준과 추적성
|
|
||||||
|
|
||||||
| 수용 시나리오 | 완료 증거 |
|
|
||||||
|---|---|
|
|
||||||
| 한·영·일 원문으로 신규 작성 후 다른 두 언어 제공 | `P2-GATE`의 생성→감지→작업→저장 통합 검증 |
|
|
||||||
| 언어 없는 기존 글: 목록은 원문/작업 0건, 상세는 원문/감지 후 요청 언어 예약 | `P2-T2` 통합 테스트와 배포 후 수동 HTTP 검증 |
|
|
||||||
| 한국어 본문을 영어로 수정하고 이전 비동기 작업을 늦게 완료 | 현재 원문 폴백과 오래된 결과 차단 테스트 |
|
|
||||||
| 상세 번역 저장 후 목록·채널 홈·추천·팔로잉 모두 요청 언어 제공 | `P3-T1~T2`, `P3-GATE`의 표면별 검증 |
|
|
||||||
| 미구매자는 번역 미리보기만, 구매자·소유자는 기존 권한에 따른 본문 제공 | 레거시/v2 권한·코드포인트 경계 회귀 검증 |
|
|
||||||
| 언어 감지/번역 실패, 반복 조회, 본문 복원 | 원문 유지·작업 중복 방지·번역 메모리 재사용 검증 |
|
|
||||||
|
|
||||||
구현 완료는 위 자동 검증 증거가 기록된 뒤에 판정했다. 실제 MySQL·Papago·HTTP 검증은 테스트 서버 수동 검증으로 분리한다.
|
|
||||||
|
|
||||||
## 9. 인터뷰 결과와 Decision Log
|
|
||||||
|
|
||||||
최종 모호성 점수: **0.04**. 차원별 명확성: Goal 1.00, Scope 1.00, Constraints 0.95, Success 0.95, Context 0.85.
|
|
||||||
이는 누락 확인용 판단 지표이며, 코드 검증이나 구현 완료를 의미하지 않는다.
|
|
||||||
열린 제품 질문은 없다. 테스트 환경 자격증명과 DDL 적용 절차는 테스트 서버 배포 단계에서 확인할 실행 조건이다.
|
|
||||||
|
|
||||||
| ID | 상태 | 결정 | 근거 / 영향 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `DEC-001` | 확정 | 본문만 번역, 댓글 제외 | 사용자 인터뷰 / `CCT-001` |
|
|
||||||
| `DEC-002` | 확정 | 기존 글은 오디오 상세처럼 누락 번역 예약 | 사용자 제안 및 후속 상세 한정 답변 / `CCT-005` |
|
|
||||||
| `DEC-003` | 확정 | 목록 본문·미리보기도 저장된 번역 적용 | 사용자 답변 / `CCT-006` |
|
|
||||||
| `DEC-004` | 확정 | 언어 정보가 없으면 자동 감지 | 사용자 답변 / `CCT-002` |
|
|
||||||
| `DEC-005` | 확정 | 본문 수정 시 언어 재감지·번역 갱신 | 사용자 yes / `CCT-004` |
|
|
||||||
| `DEC-006` | 확정 | 목록에서는 예약하지 않고 상세에서만 예약 | 사용자 답변 / `CCT-005~006` |
|
|
||||||
| `DEC-007` | 확정 | 새 번역 전에는 수정된 원문 표시 | 사용자 yes / `CCT-007` |
|
|
||||||
| `DEC-008` | 확정 | 채널 홈·홈 추천·팔로잉 소식 포함 | 사용자 yes / `CCT-006` |
|
|
||||||
| `DEC-009` | 기술 설계 | 기존 API 구조·권한 유지, 언어별 저장 번역을 본문 필드에 적용 | 저장소 규칙 및 최소 변경 원칙 / `CCT-008~009` |
|
|
||||||
| `DEC-010` | 기술 설계 | 본문 개정 번호와 기존 캐시·큐 재사용 | 수정 직후 원문 표시와 비동기 경합 방지 / `CCT-007`, `CCT-010` |
|
|
||||||
|
|
||||||
결정 날짜는 모두 2026-09-10이다. 범위 변경 시 이 결정 기록과 요구사항을 먼저 갱신하고 `plan-task.md`를 동기화한다.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# P1-T2 리뷰 보고서
|
|
||||||
|
|
||||||
## 리뷰 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 리뷰 대상 | Phase 1 / `P1-T2`, `P1-GATE` |
|
|
||||||
| 기준 working tree | P1-T2 구현 및 실제 Spring regression test 추가 상태 |
|
|
||||||
| 리뷰 일자 | 2026-09-10 |
|
|
||||||
| 리뷰어 | automated review |
|
|
||||||
| 기준 문서 | `prd.md`, `plan-task.md` |
|
|
||||||
| 리뷰 상태 | 추가 수정 및 회귀 검증 완료 |
|
|
||||||
|
|
||||||
## 범위
|
|
||||||
|
|
||||||
- 코드: `LanguageDetectEvent.kt`, `CreatorCommunityTranslationService.kt`
|
|
||||||
- 테스트: `CreatorCommunityLanguageDetectTest.kt`
|
|
||||||
- 제외: 다른 리소스 감지, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증
|
|
||||||
|
|
||||||
## 확정 발견 사항
|
|
||||||
|
|
||||||
| ID | 심각도 | 상태 | 제목 | 후속 goal |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `REV-P1-T2-001` | Blocker | 수정 완료 | 커밋 후 번역 예약이 독립 트랜잭션으로 커밋되지 않는다 | `P1-R1` |
|
|
||||||
| `REV-P1-T2-002` | High | 수정 완료 | 잠금 조회가 stale managed 게시물 상태를 다시 읽지 않는다 | `P1-R1` |
|
|
||||||
| `REV-P1-T2-003` | High | 수정 완료 | 이미 언어가 있는 이벤트가 요청 target을 버린다 | `P1-R1` |
|
|
||||||
| `REV-P1-T2-004` | High | 수정 완료 | 이전 mock 검증은 실제 `translation_job` 저장을 증명하지 않는다 | `P1-R1` |
|
|
||||||
| `REV-P1-T2-005` | High | 수정 완료 | 동시 언어 미확정 감지의 두 번째 target이 lock 뒤 유실된다 | `P1-R2` |
|
|
||||||
| `REV-P1-T2-006` | Blocker | 수정 완료 | 동시 missing-memory 예약이 translation job unique key에 경합한다 | `P1-R3` |
|
|
||||||
|
|
||||||
## 근거와 판정
|
|
||||||
|
|
||||||
### REV-P1-T2-001
|
|
||||||
|
|
||||||
- 코드: `CreatorCommunityTranslationService.requestTranslations`는 기본 `@Transactional`이고, listener는 after-commit callback에서 이를 호출한다.
|
|
||||||
- 재현: `shouldExposeProxiedRequiresNewTranslationRequestEntryPoint`는 `Propagation.REQUIRES_NEW`가 아닌 annotation으로 실패했다.
|
|
||||||
- 영향: 감지 언어 저장 뒤에도 job 예약이 실제로 커밋되지 않아 번역 흐름이 진행되지 않는다.
|
|
||||||
- 조치: 공유 서비스의 public 요청 진입점을 `REQUIRES_NEW`로 선언하고 실제 job 저장 test로 검증한다.
|
|
||||||
|
|
||||||
### REV-P1-T2-002
|
|
||||||
|
|
||||||
- 코드: listener는 `findByIdAndIsActiveTrueForUpdate` 뒤 persistence context의 entity를 refresh하지 않는다.
|
|
||||||
- 재현: `shouldRejectStalePostAfterBlockedDetection`는 감지 동안 본문을 변경한 뒤에도 이전 언어가 저장되어 실패했다.
|
|
||||||
- 영향: 오래된 감지 결과가 새 본문 언어로 저장될 수 있다.
|
|
||||||
- 조치: 잠금 획득 뒤 최신 entity 상태를 refresh하고 revision/content를 재검사한다.
|
|
||||||
|
|
||||||
### REV-P1-T2-003
|
|
||||||
|
|
||||||
- 코드: listener는 이미 languageCode가 설정된 커뮤니티 이벤트를 감지 전 return한다.
|
|
||||||
- 재현: `shouldContinueBothRequestedTargetsWhenLanguageIsAlreadyKnown`는 `ja`, `en` target job이 저장되지 않아 실패했다.
|
|
||||||
- 영향: 상세 요청별 번역 예약 범위가 사라진다.
|
|
||||||
- 조치: 현재 본문 검증 뒤 known-language 이벤트도 after-commit에서 원 target으로 shared service를 호출한다.
|
|
||||||
|
|
||||||
### REV-P1-T2-004
|
|
||||||
|
|
||||||
- 코드/테스트: 기존 테스트는 `CreatorCommunityTranslationService` mock의 호출만 검증했다.
|
|
||||||
- 재현: 실제 Spring listener와 scheduler를 연결한 `CreatorCommunityLanguageDetectTest`에서 저장된 `translation_job` target을 확인하자 job이 없었다.
|
|
||||||
- 영향: mock interaction만 통과해도 사용자 흐름의 persistence 실패를 놓친다.
|
|
||||||
- 조치: 감지 provider만 제어하고 scheduler/repository는 실제 bean으로 사용한다.
|
|
||||||
|
|
||||||
### REV-P1-T2-005
|
|
||||||
|
|
||||||
- 코드: lock과 refresh 뒤 languageCode가 이미 있으면 revision/content 재검증 전에 return한다.
|
|
||||||
- 재현: 같은 언어 미확정 본문으로 두 감지를 시작해 첫 감지를 먼저 커밋한 뒤 두 번째 감지를 진행하면 두 번째 target job이 없다.
|
|
||||||
- 영향: 동시 상세 요청이 서로 다른 언어를 요청할 때 한 요청의 번역 예약이 유실된다.
|
|
||||||
- 조치: 현재 revision/content가 유효하면 이미 저장된 언어를 보존하고, 두 번째 이벤트의 원 target을 after-commit에 등록한다.
|
|
||||||
|
|
||||||
### REV-P1-T2-006
|
|
||||||
|
|
||||||
- 코드: `requestTranslations`는 post lock 없이 materializer의 missing-memory 반환 뒤 scheduler의 job 존재 조회와 insert를 실행한다.
|
|
||||||
- 재현: 같은 언어 확정 post와 target으로 두 request transaction을 동시에 시작하면 두 transaction이 missing job을 보고 insert를 시도할 수 있다.
|
|
||||||
- 영향: job unique key 예외가 호출자 transaction까지 전파돼 상세 예약 흐름이 실패할 수 있다.
|
|
||||||
- 조치: request transaction 시작에서 active post write lock과 refresh를 수행해 source extraction, memory lookup, job 존재 조회와 insert를 직렬화한다.
|
|
||||||
|
|
||||||
## 실행 증거
|
|
||||||
|
|
||||||
| 명령 | 결과 | 핵심 증거 |
|
|
||||||
|---|---|---|
|
|
||||||
| `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` | 실패 | 초기 test bean 보정 후 7개 중 5개 assertion 실패 |
|
|
||||||
|
|
||||||
## 결론
|
|
||||||
|
|
||||||
`REV-P1-T2-001~006`은 `plan-task.md`의 `P1-R1~R3` 회귀 수정 goal로 반영했고 모두 수정·검증을 완료했다.
|
|
||||||
|
|
||||||
## 수정 후 검증 기록
|
|
||||||
|
|
||||||
### 1차 수정 검증 — 2026-09-10
|
|
||||||
|
|
||||||
- 무엇을: `REQUIRES_NEW` 번역 요청, stale entity refresh, known-language target continuation과 실제 `translation_job` 검증을 추가했다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 남은 항목: 사용자 정책상 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
|
||||||
|
|
||||||
### 2차 수정 검증 — 2026-09-10
|
|
||||||
|
|
||||||
- 대상: `REV-P1-T2-005`.
|
|
||||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 1, 8개 중 1개 실패.
|
|
||||||
두 감지가 언어 NULL에서 시작한 뒤 첫 감지의 `ja` job 저장 후 두 번째 `en` job이 누락됐다.
|
|
||||||
- GREEN: lock과 refresh 뒤 revision/content가 유효하면 이미 저장된 languageCode를 유지하고,
|
|
||||||
원 target을 after-commit에 등록하도록 수정했다.
|
|
||||||
- 검증:
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 남은 항목: Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 사용자 정책상 실행하지 않았다.
|
|
||||||
|
|
||||||
### 3차 수정 검증 — 2026-09-10
|
|
||||||
|
|
||||||
- 대상: `REV-P1-T2-006`.
|
|
||||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`
|
|
||||||
— exit 1. 실제 `translation_job` insert가 H2 `SQLState 23505`,
|
|
||||||
`uk_translation_job_resource_field_target_hash` unique index 충돌로 실패했다.
|
|
||||||
- GREEN: `requestTranslations` transaction 시작에서 active post write lock과 `PESSIMISTIC_WRITE` refresh를 수행하도록 수정했다.
|
|
||||||
- 검증:
|
|
||||||
- 같은 focused test — exit 0, `BUILD SUCCESSFUL`; 두 호출 정상 종료와 job 1개 저장을 확인했다.
|
|
||||||
- P1 직접 영향 회귀와 P1 Gate — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
|
||||||
- 남은 항목: Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 사용자 정책상 실행하지 않았다.
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# Phase 1 리뷰 보고서
|
|
||||||
|
|
||||||
## 1. 리뷰 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 대상 | Phase 1 `P1-T1`, `P1-T2`, `P1-R1~R4`, `P1-GATE` |
|
|
||||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
|
||||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
|
||||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 목표·품질·보안·QA 증거 리뷰어 |
|
|
||||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
|
||||||
| 상태 | `P1-R4` 수정·자동 회귀·후속 리뷰 완료 · 실제 MySQL/Papago/HTTP 수동 검증 대기 |
|
|
||||||
|
|
||||||
## 2. 목적과 범위
|
|
||||||
|
|
||||||
§2~7의 정적 근거와 지문은 최초 리뷰 당시 기록이다. 수정 후 코드를 같은 지문으로 검증했다고 보지 않는다.
|
|
||||||
현재 판정은 §8과 2026-09-10 후속 재판정에 기록하며, 아래 최초 리뷰의 실행 제약은 그대로 보존한다.
|
|
||||||
|
|
||||||
원문 언어 감지, 번역 저장·재사용, 본문 개정 정합성, 동시 요청의 목표 언어 보존을 기준 문서와 대조한다.
|
|
||||||
코드·테스트 소스·기존 검증 기록만 확인한다. 사용자 지시에 따라 테스트/컴파일/서버/HTTP/DB 실행은 하지 않는다.
|
|
||||||
현재 테스트 통과는 사용자 보고 및 기존 기록으로 구분하며 신규 실행 성공으로 표현하지 않는다.
|
|
||||||
|
|
||||||
## 3. 판정 기준
|
|
||||||
|
|
||||||
확정 요구사항과 실제 코드 경로가 일치하면 충족으로 판정한다. 결함 후보는 발생 순서·예외 전달·기존 테스트의 경계를
|
|
||||||
대조한 후 확정/오탐/보류로 분리한다. 이번 확정 항목은 제한된 동시 요청 조건에서 발생하고 재조회로 복구할 수 있어
|
|
||||||
**Medium**으로 판정한다. 단순 외부 감지 호출 중복 자체는 계획에서 허용한 범위이므로 결함으로 세지 않는다.
|
|
||||||
|
|
||||||
## 4. 검토 근거와 충족 항목
|
|
||||||
|
|
||||||
아래 source 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
|
||||||
|
|
||||||
| 요구사항 / 경계 | 코드 근거 | 판정 |
|
|
||||||
|---|---|---|
|
|
||||||
| `CCT-001` 원문과 번역 분리 | `v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt:17`, `schema.sql` | 충족 |
|
|
||||||
| `CCT-007` 현재 개정·언어·해시 일치 | `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt:95`, `i18n/translation/TranslationReadModelMaterializer.kt:194` | 충족 |
|
|
||||||
| 이전 감지 결과 배제 | `content/LanguageDetectEvent.kt:383`, 감지 전 확인 및 잠금/refresh 후 재검사 | 충족 |
|
|
||||||
| `CCT-010` A→B→A 메모리 복원 | `CreatorCommunityTranslationService.requestTranslations`에서 materialize 후 누락 예약 | 충족 |
|
|
||||||
| 같은 게시물 작업/번역 행 저장 직렬화 | 요청 진입점의 post lock, materializer의 post/translation lock | 충족 |
|
|
||||||
| 언어 미확정 상태의 실제 캐시 INSERT 경합 | 최초 근거: `content/LanguageDetectionCacheService.kt:21`, `:29` | 당시 수정 필요, `P1-R4` 자동 검증으로 수정 확인 (§9) |
|
|
||||||
|
|
||||||
읽은 테스트에는 `CreatorCommunityTranslationServiceTest`의
|
|
||||||
`shouldReturnOnlyCurrentTranslationFromBatchedRead`, `shouldFallbackToOriginalWhenTranslationIsMissingOrDoesNotMatchCurrentSource`,
|
|
||||||
`shouldRematerializeAtoBtoAIntoExistingTranslationRow`, `shouldSerializeConcurrentMissingMemoryTranslationJobScheduling`,
|
|
||||||
`shouldUpdateExistingTranslationAfterRepeatableReadSnapshotWaitsForFirstWriter`가 포함된다.
|
|
||||||
기존 P1 리뷰의 수정 사항을 신규 결함으로 중복 등록하지 않았다.
|
|
||||||
|
|
||||||
실행한 검증은 `git diff`, `rg`, `sed`, `nl`을 통한 정적 대조와 파일 SHA256 계산이다.
|
|
||||||
기존 테스트 XML과 리뷰 레인 출처는 [증거 기록](review-evidence.md)에 있다. 새 테스트나 런타임 재현은 실행하지 않았다.
|
|
||||||
|
|
||||||
## 5. 발견 사항 요약
|
|
||||||
|
|
||||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 Goal |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `REV-P1-007` | Medium | 수정 완료 · `P1-R4` 자동 증거 (§9), 최초 정적 확정 이력 유지 | 최초 감지 캐시 저장 경합으로 후발 목표 언어의 예약 유실 | `P1-T2`, `P1-R2` | `P1-R4` 완료, 실제 MySQL 수동 확인 |
|
|
||||||
|
|
||||||
## 6. REV-P1-007 상세
|
|
||||||
|
|
||||||
- 관련 요구사항: `CCT-005`, `CCT-010`, 계획 `P1-R2`의 각 요청 target 보존 목표.
|
|
||||||
- 관련 계약: PRD §4.2 상세 요청 언어만 예약, §6 기존 감지 캐시 재사용.
|
|
||||||
- 근거 파일:
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt:21`: 캐시를 먼저 조회한다.
|
|
||||||
- 같은 파일 `:28`, `:29`: 외부 감지 후 같은 키에 무조건 `save`한다.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResult.kt:15`: 해시/provider/version 유일 키.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:19`: IDENTITY 저장 식별자.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt:398`: 캐시 예외가 후속 post lock보다 먼저 전파된다.
|
|
||||||
- 같은 파일 `:411`, `:417`: 목표 언어 후속 예약은 감지 성공 이후에만 등록된다.
|
|
||||||
|
|
||||||
### 정적 검증 순서
|
|
||||||
|
|
||||||
이 순서는 코드를 따라 확인한 결함 조건이며, 이번 턴에서 실제로 실행한 재현 결과가 아니다.
|
|
||||||
|
|
||||||
1. 원문 언어 NULL이고 감지 캐시가 없는 동일 한국어 게시물에 일본어·영어 상세 요청을 보낸다.
|
|
||||||
2. 두 감지 트랜잭션이 모두 언어 NULL과 캐시 miss를 읽은 뒤 각 detector 호출을 진행한다.
|
|
||||||
3. 첫 detector가 반환하면 캐시 INSERT → 게시물 언어 저장 → 커밋 → 일본어 작업 예약이 진행된다.
|
|
||||||
4. 뒤늦게 반환한 detector는 캐시를 다시 읽거나 원자적으로 합류하지 않고 같은 유일 키를 INSERT한다.
|
|
||||||
5. 중복 키 예외로 두 번째 감지 트랜잭션이 종료되어 영어 목표를 예약하는 콜백에 도달하지 못한다.
|
|
||||||
|
|
||||||
기대 결과는 양쪽 목표 언어의 작업 보존이다. 코드 경로상 결과는 후발 목표의 작업 누락이다.
|
|
||||||
다음 영어 상세 조회로 다시 예약할 수 있으므로 영구적인 데이터 손실이라고 주장하지 않는다.
|
|
||||||
서로 다른 게시물을 같은 본문으로 동시에 신규 작성할 때도 동일한 전역 캐시 키 경합이 발생할 수 있다.
|
|
||||||
|
|
||||||
### 기존 테스트가 통과하는 이유
|
|
||||||
|
|
||||||
`src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt:335`의
|
|
||||||
`shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage`는 `ControlledLanguageDetectionCacheService`를 사용한다.
|
|
||||||
같은 파일 `:743`의 `detectWithCache` override는 실제 캐시 SELECT/INSERT 없이 제어된 언어를 반환한다.
|
|
||||||
따라서 이 테스트의 목표 보존 성공은 실제 캐시 유일 키 저장 경합까지 보장하지 않는다.
|
|
||||||
|
|
||||||
### 권장 조치와 판정
|
|
||||||
|
|
||||||
동일 캐시 키 저장을 원자적으로 처리해 이미 저장한 결과로 수렴시키고, 후발 감지 트랜잭션이 rollback-only가 되지 않게 한다.
|
|
||||||
이미 실패한 트랜잭션 내부에서 예외만 잡고 계속 진행하는 방식은 사용하지 않는다.
|
|
||||||
외부 API 호출 동안 게시물 행 잠금을 잡지 않는 현재 기준을 유지한다.
|
|
||||||
실제 캐시 service/repository를 사용하고 detector만 통제하는 두 cache miss 경합 회귀 테스트를 추가한다.
|
|
||||||
|
|
||||||
2026-09-10 — 품질 리뷰, 목표 리뷰, 루트 리뷰에서 위 정적 경로를 교차 확인하여 확정했다.
|
|
||||||
실행 재현은 사용자 지시에 따라 수행하지 않았으며 `P1-R4`의 RED 단계로 남긴다.
|
|
||||||
|
|
||||||
## 7. 계획 전환
|
|
||||||
|
|
||||||
`plan-task.md`의 Phase 1에 신규 `P1-R4`를 추가한다. 기존 `P1-R2`와 `P1-GATE` 완료 체크 및 검증 기록은 유지한다.
|
|
||||||
새 목표: 실제 최초 감지 캐시 경합에서도 두 요청 target을 보존하고 해당 회귀를 방지한다.
|
|
||||||
상세 검증 명령·파일·RED/GREEN/REFACTOR·재검토 조건은 신규 Task에 기록한다.
|
|
||||||
|
|
||||||
## 8. 리뷰 종료 판정
|
|
||||||
|
|
||||||
| 항목 | 결과 |
|
|
||||||
|---|---|
|
|
||||||
| Phase 범위와 기존 수정 확인 | 충족 |
|
|
||||||
| 후보 판정 | 확정 1건. afterCommit 후 재잠금 교착 후보는 DB 커밋 후 잠금 해제로 오탐 제외 |
|
|
||||||
| 확정 항목 계획 반영 | 최초 `P1-R4` 신규 등록, 이후 구현·자동 검증·후속 리뷰 완료 |
|
|
||||||
| 실행 범위 명시 | 최초 리뷰는 테스트 미실행. 이후 `P1-R4` 로컬 자동 검증 증거를 §9에 별도 누적 |
|
|
||||||
|
|
||||||
**최초 결론 기록: 수정 Goal 필요.** 당시 최초 감지 캐시 경합 수정과 수정 후 검증이 필요했다.
|
|
||||||
**현재 최종 결론: `REV-P1-007`은 `P1-R4` 로컬 자동 검증 범위에서 수정 완료.**
|
|
||||||
실제 MySQL/Papago/HTTP 검증은 기존 배포 후 체크리스트에서 미완료로 유지한다.
|
|
||||||
|
|
||||||
## 9. P1-R4 수정 후 재판정 · 2026-09-10
|
|
||||||
|
|
||||||
- RED는 실제 캐시 service/repository와 제어된 provider를 사용했다. 두 provider 진입 후 focused 명령이
|
|
||||||
exit 1로 실패했고 `ExecutionException → DataIntegrityViolationException → H2 23505` 캐시 유일 키 충돌을 확인했다.
|
|
||||||
- GREEN은 native MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`로 승자 결과에
|
|
||||||
수렴시킨다. audit 시각을 명시하고 DB 예외를 잡아 같은 트랜잭션에서 계속하는 방식은 사용하지 않는다.
|
|
||||||
- 같은 게시물 `ja`/`en` 이중 miss 및 다른 게시물의 동일 본문 경합에서 캐시 1행과 각 target 작업 보존을 확인했다.
|
|
||||||
focused fresh 재실행은 `--rerun-tasks --no-parallel`로 `BUILD SUCCESSFUL` (5분 19초)이었다.
|
|
||||||
- P1 listener/scheduler/materializer 회귀 (1분 09초), P2 상세/API 회귀 (1분 42초), `ktlintCheck` (44초)는
|
|
||||||
모두 `BUILD SUCCESSFUL`이다. 정확한 명령은 [후속 증거](review-evidence.md)에 기록한다.
|
|
||||||
- spec `PASS`: `ses_f7657d350ffe3YUYhEHvHPuikK`. quality `APPROVED`: `ses_f7656994dffeR2odTLswaw6RTo`.
|
|
||||||
- `REV-P1-007`의 실제 캐시 경합 테스트 공백은 위 자동 증거로 보완됐다. H2 `MODE=MySQL`은 MySQL 8/InnoDB의
|
|
||||||
실제 격리·잠금·DDL 증거가 아니다. 실제 MySQL의 순서 제어 경합, audit 컬럼, 감지 중 수정 가능 여부와 stale 차단,
|
|
||||||
실제 Papago/HTTP는 [수동 체크리스트](../plan-task.md)에서 확인해야 한다.
|
|
||||||
- 전체 `./gradlew test`는 생략했다. 공통 캐시 계약·실제 repository 경합·모든 직접 listener/scheduler/materializer
|
|
||||||
테스트·P2 상세/API 소비자가 통과했고 별도의 미해결 공통 경계가 없다는 영향 범위 판단에 따른다.
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Phase 2 리뷰 보고서
|
|
||||||
|
|
||||||
## 1. 리뷰 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 대상 | Phase 2 `P2-T1`, `P2-T2`, `P2-R1~R3`, `P2-GATE` |
|
|
||||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
|
||||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
|
||||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 목표·품질·보안·QA 증거 리뷰어 |
|
|
||||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
|
||||||
| 상태 | 판정 완료 |
|
|
||||||
|
|
||||||
## 2. 목적과 범위
|
|
||||||
|
|
||||||
생성 커밋 후 번역, 실제 본문 수정 시 재감지, 상세의 요청 언어 예약, 기존 권한/응답 보존을 점검한다.
|
|
||||||
관리자 공용 mutation 경로와 고정 상태 경합의 기존 수정도 확인한다. 사용자 지시에 따라 테스트/컴파일/HTTP 실행은 하지 않는다.
|
|
||||||
소스·테스트·과거 검증 기록을 기준으로 판단하며 새로운 기능을 요구하거나 미실행을 결함으로 만들지 않는다.
|
|
||||||
|
|
||||||
## 3. 판정 기준과 검토 근거
|
|
||||||
|
|
||||||
source 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/`, test 경로는 같은 패키지의 `src/test/kotlin/` 기준이다.
|
|
||||||
|
|
||||||
| 기준 | 근거 | 판정 |
|
|
||||||
|---|---|---|
|
|
||||||
| `CCT-003` 생성 커밋 후 예약 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:152`, `:175`, `:192` | 충족. 커밋 후 공유 쓰기 진입점을 호출하고 외부 감지는 비동기 listener에서 실행 |
|
|
||||||
| `CCT-004` 실제 본문 변경만 재감지 | 같은 파일 `:214`, `:221` | 충족. 소유권 잠금 조회/refresh 후 문자열 비교, 언어 NULL, 개정 증가 |
|
|
||||||
| 본문 외 고정 상태 변경의 정합성 | 같은 파일 `:264`의 잠금/refresh 및 관리자 공용 경로 | 기존 `P2-R2` 수정 반영 확인 |
|
|
||||||
| `CCT-005` 상세만 요청 언어 예약 | 같은 파일 `:398`, `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt:128` | 충족. 유효 번역이 없을 때 요청 locale만 전달 |
|
|
||||||
| `CCT-007` 최신 원문 폴백 | 공유 조회 결과를 DTO에 복사하며 유효하지 않은 번역은 사용하지 않음 | 충족 |
|
|
||||||
| `CCT-008` 권한·유료 미리보기 | 레거시 차단 확인 `CreatorCommunityService.kt:390`, v2 접근 검사 `CreatorChannelCommunityQueryService.kt:121`, 기존 마스킹 `:253` | 충족. 번역 선택 후 마스킹 |
|
|
||||||
| `CCT-009` API/언어 규칙 | 기존 `LangContext`를 사용하고 공개 DTO에는 `content`만 복사 | 충족 |
|
|
||||||
|
|
||||||
관리자 생성/수정은 `v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt:47`, `:75`에서
|
|
||||||
기존 공용 서비스를 호출한다. 관리용 조회는 원문을 유지한다. 댓글/답글/구매를 번역 예약 진입점으로 추가하지 않았다.
|
|
||||||
|
|
||||||
확인한 테스트:
|
|
||||||
|
|
||||||
- `content/CreatorCommunityLanguageDetectTest.kt:192` — `shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler`.
|
|
||||||
- 같은 파일 `:433` — `shouldLeaveNoPostOrTranslationArtifactsWhenProxiedCreateRollsBack`.
|
|
||||||
- 같은 파일 `:461` — `shouldHideStaleTranslationAndScheduleNewTargetsAfterCommittedEnglishModification`.
|
|
||||||
- `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` — 동일 본문/본문 외 수정의 비예약, 유료·소유자·구매자·차단 조건.
|
|
||||||
- `v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` — 관리자 공용 mutation과 최신 본문 보존.
|
|
||||||
|
|
||||||
## 4. 실행 및 증거 한계
|
|
||||||
|
|
||||||
`git diff`, `rg`, `sed`와 테스트 소스 읽기로 호출·트랜잭션·권한 경계를 확인했다.
|
|
||||||
기존 XML 성공 기록은 [증거 기록](review-evidence.md)에 분리했다. 테스트/컴파일/서버/외부 호출은 실행하지 않았다.
|
|
||||||
|
|
||||||
`shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler`는 실제 프록시·listener·scheduler를 연결하지만
|
|
||||||
번역 메모리를 직접 저장한 뒤 materializer를 호출한다. 워커 테스트는 별도의 mock 기반 구성요소 검증이다.
|
|
||||||
따라서 한·영·일 각각의 신규 작성부터 실제 provider/워커/번역 행 저장까지 하나로 연결한 실환경 검증을 완료했다고 해석하지 않는다.
|
|
||||||
이는 새 제품 결함으로 확정하지 않고 기존 테스트 서버 수동 체크리스트의 실행 범위로 유지한다.
|
|
||||||
|
|
||||||
## 5. 발견 사항 요약
|
|
||||||
|
|
||||||
**Phase 2 자체 구현의 추가 확정 발견 사항 없음.**
|
|
||||||
|
|
||||||
Phase 1의 `REV-P1-007`은 상세를 통한 최초 언어 감지에도 영향을 준다. 같은 원인을 Phase 2 결함으로 중복 등록하지 않고
|
|
||||||
[Phase 1 리뷰](phase-1-review.md)의 `P1-R4`에서 수정한다. 이 의존성까지 포함한 전체 번역 흐름을 무조건 PASS로 표현하지 않는다.
|
|
||||||
|
|
||||||
## 6. 후보 판정
|
|
||||||
|
|
||||||
| 후보 | 판정 / 근거 |
|
|
||||||
|---|---|
|
|
||||||
| afterCommit의 REQUIRES_NEW 진입이 이전 post lock과 교착 | 오탐. DB commit 후 콜백이므로 이전 트랜잭션의 DB 잠금은 해제된 상태 |
|
|
||||||
| 관리자 별도 생성/수정에 번역 연결 누락 | 오탐. 공용 레거시 서비스를 호출하므로 중복 연결 불필요 |
|
|
||||||
| 테스트 재실행이 없으므로 구현 실패 | 해당 없음. 사용자 지시이며 정적 리뷰의 실행 한계로만 기록 |
|
|
||||||
|
|
||||||
## 7. 계획 전환
|
|
||||||
|
|
||||||
**전환 항목 없음.** 자체 수정 Task를 억지로 추가하지 않는다. Phase 1 `P1-R4`의 직접 영향 회귀에 상세 파이프라인을 포함한다.
|
|
||||||
|
|
||||||
## 8. 리뷰 종료 판정
|
|
||||||
|
|
||||||
| 항목 | 결과 |
|
|
||||||
|---|---|
|
|
||||||
| Phase 2 자체 요구사항 확인 | 충족 |
|
|
||||||
| 추가 확정 결함 | 없음 |
|
|
||||||
| 다른 Phase 의존 결함 기록 | `REV-P1-007` / `P1-R4` |
|
|
||||||
| 새 수정 Task | 해당 없음 |
|
|
||||||
| 검증 범위 명시 | 충족. 소스·기존 테스트/기록 대조, 직접 실행 없음 |
|
|
||||||
|
|
||||||
**최종 결론: Phase 2 자체 기준 충족, 추가 확정 발견 사항 없음.**
|
|
||||||
남은 항목은 Phase 1 수정의 영향 재검토와 기존 테스트 서버 수동 확인이다. 이번 리뷰에서 수정한 제품 코드는 없다.
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Phase 3 리뷰 보고서
|
|
||||||
|
|
||||||
## 1. 리뷰 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 대상 | Phase 3 `P3-T1`, `P3-T2`, `P3-R1`, `P3-GATE` |
|
|
||||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
|
||||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
|
||||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 문맥·목표·보안·QA 증거 리뷰어 |
|
|
||||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
|
||||||
| 상태 | 판정 완료 |
|
|
||||||
|
|
||||||
## 2. 목적과 범위
|
|
||||||
|
|
||||||
`CCT-006~010`의 목록·미리보기·홈 표면, 저장 번역만 읽는 경계, 권한·페이지·공개 계약 유지를 확인한다.
|
|
||||||
기존 최종 Gate의 실제 실행 미완료 범위도 대조한다. 사용자 지시에 따라 테스트·컴파일·서버·HTTP는 실행하지 않는다.
|
|
||||||
|
|
||||||
## 3. 판정 기준과 코드 대조
|
|
||||||
|
|
||||||
아래 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
|
||||||
|
|
||||||
| 표면 / 기준 | 코드 근거 | 판정 |
|
|
||||||
|---|---|---|
|
|
||||||
| 레거시 일반·최신 목록 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:305`, `:359`, `:641`, `:679` | 충족. 표시할 ID를 모아 번역 조회 후 본문만 복사 |
|
|
||||||
| v2 탭 | `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt:74` | 충족. 반환 페이지 확정 후 번역 조회, 다음 페이지 확인용 추가 행 제외 |
|
|
||||||
| 채널 홈 고정·일반 | `v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt:104`, `:133` → 공용 `findHomeCommunityPosts` | 충족. 두 그룹 모두 같은 읽기 연결 |
|
|
||||||
| 홈 추천 | `v2/recommendation/application/HomeRecommendationQueryService.kt:158` | 충족. 후보 정렬·중복 제거·limit 이후 최종 ID만 처리 |
|
|
||||||
| 팔로잉 소식 | `v2/home/following/application/HomeFollowingQueryService.kt:32`, `:60` | 충족. 커뮤니티 유형만 변경하고 다른 소식 유지 |
|
|
||||||
| 목록 계열 예약 금지 | `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt:33` | 충족. `findDisplayContents`는 읽기 전용이며 감지·예약·materialize 호출 없음 |
|
|
||||||
| 공개 스키마 | DTO/도메인 `copy(content = ...)` | 충족. 내부 `CreatorCommunityDisplayContent`를 공개 응답으로 추가하지 않음 |
|
|
||||||
| DDL 타입 정합성 | `schema.sql:11`, `CreatorCommunityTranslation.kt`의 sourceHash 매핑 | 기존 `P3-R1`의 VARCHAR(64) 수정 반영 확인 |
|
|
||||||
|
|
||||||
`LiveApiService`와 `ExplorerService`의 간접 목록 호출도 기존 서비스에 연결되어 저장 번역을 사용한다.
|
|
||||||
팔로잉 본문은 `DefaultHomeFollowingQueryRepository.kt:160`, `:251`에서 현재 게시물 내용을 읽으므로
|
|
||||||
소식 발행 당시 저장한 미리보기를 번역 원문으로 사용하지 않는다.
|
|
||||||
|
|
||||||
## 4. 테스트·기록 대조
|
|
||||||
|
|
||||||
test 경로는 `src/test/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
|
||||||
|
|
||||||
| 검증 대상 | 읽은 기존 테스트 |
|
|
||||||
|---|---|
|
|
||||||
| 배치 크기 1/4에서 SELECT 2회 유지 | `explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt:66`, `shouldReadTranslationsWithConstantSelectCountAcrossBatchSizes` |
|
|
||||||
| 목록 누락 번역의 작업/이벤트 미생성 | 같은 파일 `:98`, `shouldNotPublishOrScheduleWhenLegacyListReadsMissingTranslation` |
|
|
||||||
| 레거시 필터·마스킹·일괄 조회 | `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt:579` 이후 관련 테스트 |
|
|
||||||
| v2 페이지 추가 행 제외·홈 두 그룹 | `v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt:180` 이후 관련 테스트 |
|
|
||||||
| 채널 홈 저장 번역 | `v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt:89`, `shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts` |
|
|
||||||
| 추천 최종 배치·팔로잉 커뮤니티 유형만 처리 | `v2/recommendation/application/HomeRecommendationQueryServiceTest.kt:705`, `v2/home/following/application/HomeFollowingQueryServiceTest.kt:123` |
|
|
||||||
|
|
||||||
`git diff`, `rg`, `sed`로 호출부와 테스트를 대조했다. 기존 XML과 레인 결과는 [증거 기록](review-evidence.md)에 있다.
|
|
||||||
이 표는 테스트를 새로 실행했다는 의미가 아니다. 기존 계획의 로컬 성공 기록과 실제 MySQL/Papago/HTTP 미완료 표기는
|
|
||||||
서로 구분되어 있으며, 후자를 완료한 것으로 평가하지 않는다.
|
|
||||||
|
|
||||||
## 5. 발견 사항 요약
|
|
||||||
|
|
||||||
**확정 발견 사항 없음.** 목록·채널 홈·추천·팔로잉의 저장 번역 적용은 인터뷰에서 확정한 범위에 부합한다.
|
|
||||||
|
|
||||||
## 6. 후보 판정
|
|
||||||
|
|
||||||
| 후보 | 판정 / 근거 |
|
|
||||||
|---|---|
|
|
||||||
| 추천·팔로잉 본문 복사가 유료 전체 본문을 노출 | 오탐. `DefaultHomeRecommendationQueryRepository.kt:893`, `DefaultHomeFollowingQueryRepository.kt:461`의 기존 무료 조건 유지 |
|
|
||||||
| 레거시/v2 마스킹 전에 원문 길이를 사용 | 오탐. 번역 본문을 복사한 뒤 `SelectCommunityPostResponse.kt:44`, `CreatorChannelCommunityQueryService.kt:253`의 기존 마스킹 실행 |
|
|
||||||
| 번역 배치 조회가 작성자별 N+1을 추가 | 오탐. `member` LAZY이며 읽기에서 접근하지 않는다. 서로 다른 작성자의 배치 크기 테스트가 기존에 존재 |
|
|
||||||
| 목록 번역 누락을 처리하기 위해 자동 작업 예약 | 오탐. 공용 읽기 메서드만 사용하고 예약 진입점을 호출하지 않음 |
|
|
||||||
|
|
||||||
## 7. 계획 전환
|
|
||||||
|
|
||||||
**전환 항목 없음.** 추가 수정 Task는 만들지 않는다. Phase 1 결함으로 번역이 아직 생성되지 않은 경우에는
|
|
||||||
이 Phase의 요구대로 원문을 표시한다. 예약 결함은 `P1-R4`에서 처리한다.
|
|
||||||
|
|
||||||
## 8. 리뷰 종료 판정
|
|
||||||
|
|
||||||
| 항목 | 결과 |
|
|
||||||
|---|---|
|
|
||||||
| 모든 합의된 목록·미리보기 표면 확인 | 충족 |
|
|
||||||
| 확정 결함 | 없음 |
|
|
||||||
| 후보 판정 완료 | 충족, 위 후보는 오탐 |
|
|
||||||
| 확정 항목 계획 반영 | 해당 없음 |
|
|
||||||
| 실행 범위·기존 미완료 검증 구분 | 충족 |
|
|
||||||
|
|
||||||
**최종 결론: Phase 3 기준 충족, 확정 발견 사항 없음.**
|
|
||||||
실제 DDL·MySQL 동시성·HTTP·Papago 확인은 기존 수동 체크리스트대로 남긴다. 이번 리뷰에서 제품 코드를 수정하지 않았다.
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# 2026-09-10 Phase별 정적 리뷰 증거
|
|
||||||
|
|
||||||
## 대상과 검증 범위
|
|
||||||
|
|
||||||
아래 지문부터 리뷰 산출물 검증까지는 최초 정적 리뷰 당시 기록이다. `P1-R4` 이후 변경에는 이 지문과 PASS를
|
|
||||||
소급 적용하지 않는다. 현재 최종 판정과 후속 자동 증거는 문서 끝에 별도로 누적한다.
|
|
||||||
|
|
||||||
- 기준 HEAD: `50409e41c0c469529572f5d77033c3cb23d67d22`.
|
|
||||||
- 리뷰 대상은 HEAD 자체가 아닌 현재 수정/미추적 source·test와 `schema.sql` 32개 파일이다.
|
|
||||||
- 위 파일의 경로순 `경로 + NUL + SHA256 + LF`를 결합한 SHA256:
|
|
||||||
`3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7`.
|
|
||||||
- 사용자 지시: 컴파일·테스트는 이미 통과했으므로 테스트를 직접 실행하지 않는다.
|
|
||||||
- 이번 판정은 소스·테스트·기존 기록의 정적 대조다. 테스트/컴파일/서버/HTTP/Papago/MySQL 실행 판정이 아니다.
|
|
||||||
- 이전 리뷰의 PASS를 재사용하지 않고 현재 작업 트리를 다시 읽었다. 아래 PASS는 정적 리뷰 범위에만 적용한다.
|
|
||||||
|
|
||||||
## 레인 기록
|
|
||||||
|
|
||||||
모든 기록의 기준은 위 전체 HEAD와 작업 트리 지문이다. 소스가 바뀌면 이 결과를 그대로 재사용하지 않는다.
|
|
||||||
|
|
||||||
| 레인 | 판정 | 근거 / 결과 출처 |
|
|
||||||
|---|---|---|
|
|
||||||
| 보안·권한 `security_review` | PASS | 현재 본문 정합성, 번역 선택 후 유료 마스킹, 추천/팔로잉 무료 필터, 관리자 원문 보존 확인. `/root/security_review` 최종 보고, 2026-09-10. |
|
|
||||||
| 목표·제약 `goal_review` | 수정 필요 | Phase 1 캐시 경합으로 요청 target 유실을 독립 확인. Phase 2·3 추가 결함 없음. `/root/goal_review` 최종 보고, 2026-09-10. |
|
|
||||||
| 코드 품질·동시성 `code_review` | 수정 필요 | 감지 캐시 최초 INSERT 경합으로 후발 target 예약 유실. `/root/code_review` 최종 보고, 2026-09-10. Phase 1 보고서에 상세 근거를 기록한다. |
|
|
||||||
| 기존 QA 증거 `qa_evidence_review` | 정적 증거 확인 완료 | 기존 테스트 소스/XML 성공 기록을 확인했다. 실제 캐시 INSERT 경합과 한·영·일 전체 파이프라인의 직접 통합 증거 한계는 별도로 남긴다. `/root/qa_evidence_review` 최종 보고, 2026-09-10. |
|
|
||||||
| 문맥·통합 `context_review` | PASS — Phase 3 범위 | 목록/홈 및 간접 소비자, 공개 스키마·무료 필터·읽기 전용 경계 확인. `/root/context_review` 최종 보고, 2026-09-10. Phase 1·2 전체 판정으로 확대하지 않는다. |
|
|
||||||
|
|
||||||
동적 QA/debugging 감사는 사용자 지시에 따라 재실행하지 않았다. 이를 PASS나 신규 실패로 분류하지 않는다.
|
|
||||||
|
|
||||||
## 기존 실행 증거 표본
|
|
||||||
|
|
||||||
아래는 `build/test-results/test/`의 기존 XML을 읽은 결과이며 현재 턴에서 실행한 테스트가 아니다.
|
|
||||||
|
|
||||||
| XML 파일명 | 테스트 수 | 실패 / 오류 / skip | 기록 timestamp |
|
|
||||||
|---|---:|---|---|
|
|
||||||
| `TEST-kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.xml` | 12 | 0 / 0 / 0 | 2026-09-10T02:43:16 |
|
|
||||||
| `TEST-kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest.xml` | 16 | 0 / 0 / 0 | 2026-09-10T02:50:05 |
|
|
||||||
| `TEST-kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest.xml` | 4 | 0 / 0 / 0 | 2026-09-10T02:44:03 |
|
|
||||||
| `TEST-kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest.xml` | 7 | 0 / 0 / 0 | 2026-09-10T02:44:03 |
|
|
||||||
| `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.xml` | 15 | 0 / 0 / 0 | 2026-09-10T02:48:44 |
|
|
||||||
| `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest.xml` | 2 | 0 / 0 / 0 | 2026-09-10T02:49:09 |
|
|
||||||
|
|
||||||
XML에 source 지문이 포함되어 있다고 가정하지 않는다. 사용자 보고와 계획의 과거 성공 기록을 보조하는 자료로만 사용한다.
|
|
||||||
|
|
||||||
## 최종 정적 판정
|
|
||||||
|
|
||||||
- [Phase 1](phase-1-review.md): 확정 수정 사항 1건 → `P1-R4`.
|
|
||||||
- [Phase 2](phase-2-review.md): 자체 구현 범위 충족, 추가 확정 발견 사항 없음. Phase 1 결함의 상세 예약 영향은 남는다.
|
|
||||||
- [Phase 3](phase-3-review.md): 저장 번역 표시·목록 비예약 범위 충족, 추가 확정 발견 사항 없음.
|
|
||||||
- 이번 리뷰는 테스트·제품 코드·DDL을 변경하지 않았다. Phase 1 수정과 실제 실행 검증은 후속 작업이다.
|
|
||||||
|
|
||||||
## 리뷰 산출물 검증
|
|
||||||
|
|
||||||
- `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`. 저장소 문서 가이드에 따른 task 목록 확인만 수행했다.
|
|
||||||
이 명령으로 컴파일이나 테스트를 실행하지 않았다.
|
|
||||||
- `git diff --check` — exit 0. 새 미추적 리뷰 문서는 별도 공백/개행 검사로 확인했다.
|
|
||||||
- 문서 링크·공백·템플릿 잔여값 검사 — 5개 문서 PASS. `P1-R4`는 Phase 1 안에 한 번만 미완료 Goal로 등록됐다.
|
|
||||||
- 리뷰 전후 source/test/DDL 32개 파일 SHA256 대조 — 동일 지문으로 PASS. 제품 코드·테스트·DDL 수정 없음.
|
|
||||||
|
|
||||||
## P1-R4 수정 후 증거 · 2026-09-10
|
|
||||||
|
|
||||||
이 절은 완료된 구현의 RED/GREEN·자동 회귀·리뷰 결과를 전달받아 동기화한 기록이다.
|
|
||||||
이번 문서 작업에서 제품 테스트를 다시 실행한 결과가 아니며, 위 최초 정적 리뷰의 지문을 새 코드의 지문으로 재사용하지 않는다.
|
|
||||||
|
|
||||||
### 구현과 RED/GREEN
|
|
||||||
|
|
||||||
- RED: 아래 focused 명령은 두 provider 진입 이후 exit 1로 실패했다.
|
|
||||||
`ExecutionException → DataIntegrityViolationException → H2 23505`로 감지 캐시 유일 키 충돌을 확인했다.
|
|
||||||
- GREEN: native MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`로 승자 행을
|
|
||||||
현재 읽기한다. native INSERT에 audit 시각을 명시하며 DB 예외를 잡고 후속 예약을 계속하지 않는다.
|
|
||||||
- 실제 캐시 service/repository를 통한 같은 게시물 `ja`/`en` 이중 miss와 다른 게시물의 동일 본문 경합을 검증했다.
|
|
||||||
캐시 1행으로 수렴하고 각 요청의 목표 언어 작업을 보존한다.
|
|
||||||
|
|
||||||
### 자동 검증 명령과 결과
|
|
||||||
|
|
||||||
RED focused 명령 (exit 1, 위 유일 키 충돌):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
GREEN focused fresh 재실행, `BUILD SUCCESSFUL` (5분 19초):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --rerun-tasks --no-parallel
|
|
||||||
```
|
|
||||||
|
|
||||||
P1 listener/scheduler/materializer 영향 회귀, `BUILD SUCCESSFUL` (1분 09초):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
|
||||||
```
|
|
||||||
|
|
||||||
P2 상세/API 소비자 회귀, `BUILD SUCCESSFUL` (1분 42초):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*'
|
|
||||||
```
|
|
||||||
|
|
||||||
형식 검사, `BUILD SUCCESSFUL` (44초):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
전체 `./gradlew test`는 생략했다. 공통 캐시 계약, 실제 repository 경합, 모든 직접 listener/scheduler/materializer
|
|
||||||
테스트와 P2 상세/API 소비자가 위 명령으로 통과했고 별도로 미해결된 공통 경계가 없었다.
|
|
||||||
과거 전체 빌드 성공을 이번 변경의 전체 테스트 실행으로 간주하지 않는다.
|
|
||||||
|
|
||||||
### 수정 후 리뷰와 현재 최종 판정
|
|
||||||
|
|
||||||
| 리뷰 | 판정 | 출처 |
|
|
||||||
|---|---|---|
|
|
||||||
| spec | PASS | `ses_f7657d350ffe3YUYhEHvHPuikK` |
|
|
||||||
| quality | APPROVED | `ses_f7656994dffeR2odTLswaw6RTo` |
|
|
||||||
|
|
||||||
**현재 최종 판정: 기존 Phase 1 결함 `REV-P1-007`은 `P1-R4` 로컬 자동 검증 범위에서 수정 완료다.**
|
|
||||||
H2 `MODE=MySQL` 결과는 실제 MySQL 8/InnoDB의 유일 키·격리 수준·audit 컬럼·잠금 동작 검증이 아니다.
|
|
||||||
테스트 서버의 순서 제어 이중 miss, 다른 게시물의 공유 캐시 수렴, provider 차단 중 본문 수정과 stale 결과 차단은
|
|
||||||
[계획의 수동 체크리스트](../plan-task.md)에 미완료로 남긴다. 실제 MySQL/Papago/live HTTP, Docker, 배포는
|
|
||||||
검증하지 않았으며 기존 정적 리뷰의 실환경 검증 한계도 유지한다.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
ALTER TABLE creator_community
|
|
||||||
ADD COLUMN language_code VARCHAR(10) NULL COMMENT '본문 원문 언어 코드',
|
|
||||||
ADD COLUMN content_revision BIGINT NOT NULL DEFAULT 0 COMMENT '본문 개정 번호';
|
|
||||||
|
|
||||||
CREATE TABLE creator_community_translation (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '식별자',
|
|
||||||
creator_community_id BIGINT NOT NULL COMMENT '크리에이터 커뮤니티 게시물 식별자',
|
|
||||||
locale VARCHAR(10) NOT NULL COMMENT '표시 대상 언어 코드',
|
|
||||||
content TEXT NOT NULL COMMENT '번역된 본문',
|
|
||||||
source_revision BIGINT NOT NULL COMMENT '번역 기준 본문 개정 번호',
|
|
||||||
source_hash VARCHAR(64) NOT NULL COMMENT '번역 기준 정규화 원문 해시',
|
|
||||||
source_language VARCHAR(10) NOT NULL COMMENT '번역 기준 원문 언어 코드',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT uk_creator_community_translation_post_locale UNIQUE (creator_community_id, locale),
|
|
||||||
CONSTRAINT fk_creator_community_translation_post
|
|
||||||
FOREIGN KEY (creator_community_id) REFERENCES creator_community (id)
|
|
||||||
) COMMENT = '크리에이터 커뮤니티 게시물 본문 번역';
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# OCI Blue/Green 배포 검증 endpoint 구현 계획
|
|
||||||
|
|
||||||
| 문서 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 상태 | 구현 완료 |
|
|
||||||
| 작성일 | 2026-09-14 |
|
|
||||||
| 요구사항 기준 | `docs/20260914_배포검증readiness및deployment엔드포인트/prd.md` |
|
|
||||||
| 현재 Phase | Phase 1 배포 검증 endpoint |
|
|
||||||
| 현재 활성 Goal | 없음 |
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
배포 스크립트가 애플리케이션 포트에서 readiness를, management 포트에서 readiness와 배포 식별자를 확인할 수 있다.
|
|
||||||
|
|
||||||
## 현재 상태
|
|
||||||
|
|
||||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
|
||||||
|---:|---|---:|---|---|
|
|
||||||
| 1 | 완료 | `4/4` | 없음 | 없음 |
|
|
||||||
|
|
||||||
## 범위
|
|
||||||
|
|
||||||
### 포함
|
|
||||||
|
|
||||||
- `spring-boot-starter-actuator` 의존성 추가.
|
|
||||||
- `application.yml`(main/test)의 포트·probe·exposure·deployment property 설정.
|
|
||||||
- `DeploymentEndpoint` custom Actuator endpoint 구현.
|
|
||||||
- `SecurityConfig`의 `/readyz` permitAll 최소 변경.
|
|
||||||
- 위 동작을 검증하는 통합 test.
|
|
||||||
|
|
||||||
### 제외
|
|
||||||
|
|
||||||
- Kubernetes 의존성·설정.
|
|
||||||
- 배포 스크립트(`scripts/`, `appspec.yml`) 변경.
|
|
||||||
- 신규 추상화/라이브러리, 기존 API 변경.
|
|
||||||
|
|
||||||
## 기술적 제약
|
|
||||||
|
|
||||||
- 기술 스택: Kotlin 1.6.21, Spring Boot 2.7.14, Gradle Kotlin DSL, JUnit 5.
|
|
||||||
- 배포 식별자는 property placeholder만 사용하고 별도 환경변수 파싱·fallback 코드를 만들지 않는다.
|
|
||||||
- 응답 필드명은 snake_case 고정(`application_artifact_version`, `config_commit`).
|
|
||||||
- 검증: 신규 test 우선 실행 후 `./gradlew test` 전체 회귀(공통 보안·설정 변경이므로 전체 회귀 필요).
|
|
||||||
|
|
||||||
## Phase 1 배포 검증 endpoint
|
|
||||||
|
|
||||||
**Phase 결과:** readiness와 deployment endpoint가 지정된 포트 경계에 맞게 동작한다.
|
|
||||||
|
|
||||||
**선행조건:** 없음.
|
|
||||||
|
|
||||||
**Phase 완료 조건:** `P1-T1`~`P1-T4` 완료, `./gradlew test`와 `./gradlew bootJar` 성공 기록.
|
|
||||||
|
|
||||||
### 구현 항목
|
|
||||||
|
|
||||||
#### Task 1.1 Actuator 의존성과 기본 설정
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T1`:** Actuator를 추가하고 포트·exposure·deployment property 계약을 설정에 반영한다.
|
|
||||||
|
|
||||||
- **시작 조건:** PRD `DEPLOY-001`, `DEPLOY-002`, `DEPLOY-005`.
|
|
||||||
- **완료 증거:** 설정 파일 diff와 애플리케이션 컨텍스트 기동 test 통과.
|
|
||||||
- **범위 밖:** endpoint 구현, security 변경.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `build.gradle.kts`
|
|
||||||
- Modify: `src/main/resources/application.yml`
|
|
||||||
- Modify: `src/test/resources/application.yml`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointInjectedValueIntegrationTest.kt`
|
|
||||||
|
|
||||||
- [x] **RED:** `DeploymentEndpointIntegrationTest`에서 management 포트 readiness/deployment 호출 test를 작성했다.
|
|
||||||
- [x] **RED 확인:** Actuator 부재로 `Could not resolve placeholder 'local.management.port'` 실패를 확인했다.
|
|
||||||
- [x] **GREEN:** `spring-boot-starter-actuator`와 포트·exposure·deployment 설정을 추가했다.
|
|
||||||
- [x] **GREEN 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.deployment.*'` 통과.
|
|
||||||
- [x] **REFACTOR:** deprecated `LocalServerPort` import 정리, `./gradlew ktlintCheck` 성공.
|
|
||||||
|
|
||||||
#### Task 1.2 readiness 접근 허용
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T2`:** `/readyz`와 management readiness가 인증 없이 200을 반환한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P1-T1` 완료.
|
|
||||||
- **완료 증거:** readiness test 통과.
|
|
||||||
- **범위 밖:** 기타 endpoint의 security 정책 변경.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
|
|
||||||
|
|
||||||
- [x] **RED:** `/readyz` 200 기대 test 작성(`DeploymentEndpointIntegrationTest`).
|
|
||||||
- [x] **RED 확인:** Actuator/보안 미적용 상태 실패 확인.
|
|
||||||
- [x] **GREEN:** `SecurityConfig`에 `/readyz` permitAll과 `EndpointRequest.toAnyEndpoint()` permitAll 추가.
|
|
||||||
- [x] **GREEN 확인:** readiness test 2건 통과.
|
|
||||||
- [x] **REFACTOR:** 추가 변경 없음.
|
|
||||||
|
|
||||||
#### Task 1.3 deployment custom Actuator endpoint
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T3`:** `/actuator/deployment`가 snake_case 배포 식별자를 반환한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P1-T1` 완료.
|
|
||||||
- **완료 증거:** 기본값·주입값 test 통과.
|
|
||||||
- **범위 밖:** 추가 필드.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpoint.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
|
|
||||||
|
|
||||||
- [x] **RED:** 기본값(`local`, 40자리 zero)과 주입값 검증 test 작성.
|
|
||||||
- [x] **RED 확인:** endpoint 미구현 실패 확인.
|
|
||||||
- [x] **GREEN:** `@Endpoint(id = "deployment")` + `@ReadOperation` 구현.
|
|
||||||
- [x] **GREEN 확인:** 기본값·주입값 test 통과.
|
|
||||||
- [x] **REFACTOR:** `./gradlew ktlintCheck` 성공.
|
|
||||||
|
|
||||||
#### Task 1.4 포트 접근 경계 검증
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T4`:** 애플리케이션 포트에서 `/actuator/deployment`가 노출되지 않음을 검증한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P1-T3` 완료.
|
|
||||||
- **완료 증거:** 애플리케이션 포트 접근 test 통과.
|
|
||||||
- **범위 밖:** 그 외 경로 노출 정책.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
|
|
||||||
|
|
||||||
- [x] **RED:** 애플리케이션 포트 접근이 200이 아님을 기대하는 test 작성.
|
|
||||||
- [x] **RED 확인:** 초기 404 기대 단정이 `401 UNAUTHORIZED`로 실패함을 확인.
|
|
||||||
- [x] **GREEN:** management 포트 분리로 애플리케이션 포트에서는 endpoint가 매핑되지 않고 401로 차단됨을 확인, 단정을 "200 아님 + 배포 식별자 미포함" 계약으로 정정.
|
|
||||||
- [x] **GREEN 확인:** 해당 test 통과.
|
|
||||||
- [x] **REFACTOR:** `./gradlew test` 전체 회귀 성공.
|
|
||||||
|
|
||||||
### Phase Gate `P1-GATE`
|
|
||||||
|
|
||||||
- [x] `./gradlew test` 성공.
|
|
||||||
- [x] `./gradlew bootJar` 성공 및 실행 가능한 JAR 생성 확인.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 2026-09-14 `./gradlew test --tests 'kr.co.vividnext.sodalive.deployment.*'` → RED 5 failed → GREEN 5 passed.
|
|
||||||
- 2026-09-14 `./gradlew ktlintCheck` → BUILD SUCCESSFUL.
|
|
||||||
- 2026-09-14 `./gradlew test` → BUILD SUCCESSFUL (전체 회귀).
|
|
||||||
- 2026-09-14 `./gradlew bootJar` → `build/libs/sodalive-0.0.1-SNAPSHOT.jar`(141MB), `Main-Class: org.springframework.boot.loader.JarLauncher` 확인.
|
|
||||||
- 참고: 기존 test 실행에는 `JWT_SECRET` 환경변수가 필요하며 이는 이번 변경 이전부터 동일한 전제다.
|
|
||||||
- 참고: 애플리케이션 포트에서 `/actuator/deployment`는 404가 아니라 401로 차단된다. `EndpointRequest` matcher가 management 포트 분리 시 애플리케이션 포트 요청에 매칭되지 않기 때문이며, 노출되지 않는다는 요구는 충족한다.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# OCI Blue/Green 배포 검증용 readiness 및 deployment endpoint PRD
|
|
||||||
|
|
||||||
## 문서 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 문서 상태 | 구현 완료 |
|
|
||||||
| 작성일 | 2026-09-14 |
|
|
||||||
| 최종 수정일 | 2026-09-14 |
|
|
||||||
| 대상 제품 | sodalive server 배포 검증 endpoint |
|
|
||||||
| 작성자·결정권자 | 요청 사용자 |
|
|
||||||
| 관련 구현 계획 | `docs/20260914_배포검증readiness및deployment엔드포인트/plan-task.md` |
|
|
||||||
| 관련 review | 없음 |
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
OCI Blue/Green 배포에서 배포 스크립트가 새로 기동한 인스턴스의 기동 완료 여부와, 실제로 어떤 artifact/config가 올라갔는지를
|
|
||||||
HTTP로 확인할 수 있어야 한다. 이를 위해 Spring Boot Actuator의 readiness probe와 배포 식별 정보를 반환하는 custom Actuator
|
|
||||||
endpoint를 제공한다.
|
|
||||||
|
|
||||||
## 2. Problem Statement
|
|
||||||
|
|
||||||
- 현재 프로젝트에는 Actuator 의존성과 health endpoint가 없어 배포 스크립트가 기동 완료를 판정할 방법이 없다.
|
|
||||||
- 어떤 artifact 버전과 config commit이 배포되었는지 런타임에서 확인할 수 없다.
|
|
||||||
- 모든 요청이 Spring Security의 `anyRequest().authenticated()`로 보호되어 있어 인증 없는 probe 호출이 불가능하다.
|
|
||||||
|
|
||||||
해결 판단 기준: 배포 스크립트가 인증 없이 `GET :8080/readyz`로 기동을 판정하고, 운영 전용 포트에서
|
|
||||||
`GET :8082/actuator/deployment`로 배포 식별자를 확인할 수 있다.
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
|
|
||||||
- 애플리케이션 포트(8080)에서 인증 없이 readiness 확인 가능.
|
|
||||||
- Management 포트(8082)에서 readiness와 배포 식별 정보 확인 가능.
|
|
||||||
- 배포 식별자는 환경변수로 주입하고, 환경변수가 없어도 로컬/테스트 기동이 가능해야 한다.
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
|
|
||||||
- Kubernetes 관련 의존성·설정 추가.
|
|
||||||
- liveness 기반 자동 재기동 정책, 배포 스크립트 자체 변경.
|
|
||||||
- health details/components 공개, 신규 추상화 계층 도입.
|
|
||||||
|
|
||||||
## 5. 권한
|
|
||||||
|
|
||||||
- 인증 주체: 없음(배포 스크립트/로드밸런서).
|
|
||||||
- `/readyz`: 애플리케이션 포트에서 인증 없이 허용.
|
|
||||||
- `/actuator/**`: management 포트(8082)에서만 노출. 애플리케이션 포트에서는 노출하지 않는다.
|
|
||||||
- `/readyz` 응답에는 민감 정보나 배포 식별자를 포함하지 않는다.
|
|
||||||
|
|
||||||
## 6. 기능 요구사항
|
|
||||||
|
|
||||||
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `DEPLOY-001` | 확정 | `spring-boot-starter-actuator` 의존성을 추가한다(중복 추가 금지). | `build.gradle.kts`에 1회만 존재 | `P1-T1` |
|
|
||||||
| `DEPLOY-002` | 확정 | 애플리케이션 포트 8080, management 포트 8082로 분리한다. | `application.yml`에 `server.port: 8080`, `management.server.port: 8082` | `P1-T1` |
|
|
||||||
| `DEPLOY-003` | 확정 | health probes와 additional path를 활성화해 `/readyz`를 제공한다. | `GET :8080/readyz` → 200 | `P1-T2` |
|
|
||||||
| `DEPLOY-004` | 확정 | management 포트에서 readiness를 제공한다. | `GET :8082/actuator/health/readiness` → 200 | `P1-T2` |
|
|
||||||
| `DEPLOY-005` | 확정 | web exposure는 `health`, `deployment`만 포함하고 health details/components는 공개하지 않는다. | `management.endpoints.web.exposure.include: health,deployment`, `show-details: never`, `show-components: never` | `P1-T1` |
|
|
||||||
| `DEPLOY-006` | 확정 | `DEPLOY_ARTIFACT_VERSION`/`DEPLOY_CONFIG_COMMIT`을 property placeholder로 연결한다. | 환경변수 없으면 `local`, 40자리 zero commit | `P1-T3` |
|
|
||||||
| `DEPLOY-007` | 확정 | `/actuator/deployment`를 custom Actuator endpoint로 구현한다. | REST controller가 아닌 `@Endpoint` 구현체 존재 | `P1-T3` |
|
|
||||||
| `DEPLOY-008` | 확정 | 응답 필드는 정확히 `application_artifact_version`, `config_commit`이다. | snake_case JSON 검증 test 통과 | `P1-T3` |
|
|
||||||
| `DEPLOY-009` | 확정 | 애플리케이션 포트에서 `/actuator/deployment`가 노출되지 않는다. | `GET :8080/actuator/deployment` → 200 아님 | `P1-T4` |
|
|
||||||
| `DEPLOY-010` | 확정 | Security 정책 최소 변경으로 readiness가 401/403/redirect 되지 않는다. | `SecurityConfig`에 `/readyz` permitAll 추가 | `P1-T2` |
|
|
||||||
|
|
||||||
## 7. API 계약
|
|
||||||
|
|
||||||
### 7.1 `GET :8080/readyz`
|
|
||||||
|
|
||||||
- 인증 불필요. 응답 body는 Actuator 기본 health 응답(`{"status":"UP"}`), 세부 정보 비공개.
|
|
||||||
|
|
||||||
### 7.2 `GET :8082/actuator/health/readiness`
|
|
||||||
|
|
||||||
- 응답: `{"status":"UP"}`.
|
|
||||||
|
|
||||||
### 7.3 `GET :8082/actuator/deployment`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"application_artifact_version": "local",
|
|
||||||
"config_commit": "0000000000000000000000000000000000000000"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 성공 기준
|
|
||||||
|
|
||||||
- [x] `GET :8080/readyz`가 200을 반환한다. (`DEPLOY-003`, `DEPLOY-010`)
|
|
||||||
- [x] `GET :8082/actuator/health/readiness`가 200을 반환한다. (`DEPLOY-004`)
|
|
||||||
- [x] `GET :8082/actuator/deployment`가 주입값을 snake_case JSON으로 반환한다. (`DEPLOY-006`~`DEPLOY-008`)
|
|
||||||
- [x] `GET :8080/actuator/deployment`가 200을 반환하지 않는다(401로 차단). (`DEPLOY-009`)
|
|
||||||
- [x] `./gradlew test`, `./gradlew bootJar` 성공.
|
|
||||||
|
|
||||||
## 9. Open Questions
|
|
||||||
|
|
||||||
없음.
|
|
||||||
|
|
||||||
## 10. Decision Log
|
|
||||||
|
|
||||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 2026-09-14 | `DEC-001` | 확정 | Kubernetes 의존성 없이 Actuator 기본 probe만 사용 | 사용자 요구사항 범위 제한 | `DEPLOY-003`, `DEPLOY-004` |
|
|
||||||
| 2026-09-14 | `DEC-002` | 확정 | 배포 식별자는 별도 파싱 코드 없이 Spring property placeholder로만 연결 | 사용자 요구사항, 단순성 유지 | `DEPLOY-006` |
|
|
||||||
| 2026-09-14 | `DEC-003` | 확정 | `/actuator/deployment`는 `@Endpoint` 기반 custom Actuator endpoint로 구현 | 사용자 요구사항(REST controller 금지) | `DEPLOY-007` |
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
# 크리에이터 시작 DM 방 생성 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** `POST /api/v2/user-creator-chat/rooms/create`가 기존 리스너 시작 DM 생성과 새 크리에이터 시작 DM 생성을 모두 지원한다.
|
|
||||||
|
|
||||||
**Architecture:** 기존 endpoint와 응답 DTO를 유지하고 request DTO만 `recipientId` 중심으로 확장한다. Controller에서 `request.recipientMemberId()`로 단일 상대 회원 ID를 산출하고, 기존 `UserCreatorChatService.createOrGetRoom(member, recipientId)` 흐름과 `validateRecipient` 정책을 재사용한다. 메시지 저장/전달/푸시는 기존 WebSocket 및 메시지 발송 흐름에 맡긴다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, JUnit 5, Mockito, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
| 문서 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 상태 | 구현 중 |
|
|
||||||
| 작성일 | 2026-09-14 |
|
|
||||||
| 요구사항 기준 | `docs/20260914_크리에이터_리스너_DM방_생성/prd.md` |
|
|
||||||
| API 기준 | PRD §8 |
|
|
||||||
| 현재 Phase | Phase 1 |
|
|
||||||
| 현재 활성 Goal | 없음 |
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
기존 `creatorId` 요청을 깨지 않으면서 신규 `recipientId` 요청으로 발신자 역할과 무관하게 유저-크리에이터 DM 방을 생성/조회할 수 있게 한다.
|
|
||||||
|
|
||||||
## 현재 상태
|
|
||||||
|
|
||||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
|
||||||
|---:|---|---:|---|---|
|
|
||||||
| 1 | 진행 중 | `3/3` | `P1-GATE` | 전체 `./gradlew build`가 `:test` 단계에서 timeout 됨 |
|
|
||||||
|
|
||||||
## 범위
|
|
||||||
|
|
||||||
### 포함
|
|
||||||
|
|
||||||
- `POST /api/v2/user-creator-chat/rooms/create` request DTO 확장
|
|
||||||
- `recipientId` 표준 필드 추가
|
|
||||||
- 기존 `creatorId` alias 유지
|
|
||||||
- `recipientId`/`creatorId` 누락·불일치 validation
|
|
||||||
- 기존 방 재사용 및 새 방 생성 회귀 검증
|
|
||||||
- `/create`가 메시지를 만들지 않는다는 회귀 검증
|
|
||||||
|
|
||||||
### 제외
|
|
||||||
|
|
||||||
- 첫 메시지 발송 request 추가
|
|
||||||
- WebSocket 프로토콜 변경
|
|
||||||
- FCM 푸시 정책 변경
|
|
||||||
- DB 스키마 변경
|
|
||||||
- 새 endpoint 추가
|
|
||||||
- 기존 `creatorId` alias 제거
|
|
||||||
|
|
||||||
## 기술적 제약
|
|
||||||
|
|
||||||
- 공개 API 응답 `CreateUserCreatorChatRoomResponse(roomId)`는 변경하지 않는다.
|
|
||||||
- 신규 클라이언트는 `recipientId`를 사용하지만, 기존 클라이언트의 `creatorId` 요청도 허용한다.
|
|
||||||
- `recipientId`와 `creatorId`가 모두 있고 값이 다르면 `common.error.invalid_request`로 거부한다.
|
|
||||||
- 권한/상태 검증은 기존 `UserCreatorChatService.validateRecipient` 정책을 재사용한다.
|
|
||||||
- 새 공용 abstraction, 새 dependency, DB migration은 만들지 않는다.
|
|
||||||
- 구현 Task는 RED → GREEN → REFACTOR 순서로 수행한다.
|
|
||||||
|
|
||||||
## 파일 구조 계획
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
|
||||||
- `CreateUserCreatorChatRoomRequest`에 `recipientId` nullable 필드와 기존 `creatorId` nullable alias를 둔다.
|
|
||||||
- 단일 상대 ID 산출 함수를 DTO 내부에 둔다.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
|
||||||
- `request.recipientMemberId()` 결과를 `service.createOrGetRoom(member, recipientId)`에 전달한다.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- 함수 파라미터명만 의미에 맞게 `recipientId`로 바꾸는 것을 검토한다. 동작은 유지한다.
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- 새 방 생성, 기존 방 재사용, 메시지 미생성, validation 회귀를 검증한다.
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt`
|
|
||||||
- request alias 해석 단위 테스트 또는 controller mapping 테스트를 보강한다.
|
|
||||||
|
|
||||||
## Phase 1: `/create` request 일반화
|
|
||||||
|
|
||||||
**Phase 결과:** 기존 클라이언트와 신규 클라이언트 모두 같은 `/create` endpoint로 DM 방을 생성/조회할 수 있다.
|
|
||||||
|
|
||||||
**선행조건:** PRD `DEC-001`~`DEC-003` 확정.
|
|
||||||
|
|
||||||
**Phase 완료 조건:** `P1-T1`~`P1-T3`과 `P1-GATE` 완료, 검증 기록 누적.
|
|
||||||
|
|
||||||
### 구현 항목
|
|
||||||
|
|
||||||
#### Task 1.1 request DTO alias 계약 추가
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T1`:** `CreateUserCreatorChatRoomRequest`가 `recipientId`와 기존 `creatorId` alias에서 하나의 상대 회원 ID를 산출한다.
|
|
||||||
|
|
||||||
- **시작 조건:** PRD `DMROOM-001~004`, `DEC-003` 확인.
|
|
||||||
- **완료 증거:** DTO/Controller focused test 통과와 request 계약 문서 일치.
|
|
||||||
- **범위 밖:** 방 생성 repository 동작 변경, 메시지 발송.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: request JSON `{ "recipientId": Long? }`, `{ "creatorId": Long? }`
|
|
||||||
- Produces: `CreateUserCreatorChatRoomRequest.recipientMemberId(): Long`
|
|
||||||
|
|
||||||
- [x] **RED:** `UserCreatorChatControllerMappingTest`에 `recipientId`만 보낸 요청이 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldCreateRoomWithRecipientId() {
|
|
||||||
val service = Mockito.mock(UserCreatorChatService::class.java)
|
|
||||||
val controller = UserCreatorChatController(service)
|
|
||||||
val member = Member(email = "creator@test.com", password = "pw", nickname = "creator")
|
|
||||||
member.id = 10L
|
|
||||||
Mockito.`when`(service.createOrGetRoom(member, 20L))
|
|
||||||
.thenReturn(CreateUserCreatorChatRoomResponse(roomId = 30L))
|
|
||||||
|
|
||||||
val response = controller.createOrGetRoom(
|
|
||||||
member,
|
|
||||||
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = null)
|
|
||||||
)
|
|
||||||
|
|
||||||
Mockito.verify(service).createOrGetRoom(member, 20L)
|
|
||||||
assertEquals(30L, response.data!!.roomId)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **RED:** 기존 `creatorId`만 보낸 요청도 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldCreateRoomWithLegacyCreatorId() {
|
|
||||||
val request = CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = 20L)
|
|
||||||
|
|
||||||
assertEquals(20L, request.recipientMemberId())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **RED:** 두 필드가 모두 없거나 서로 다르면 `common.error.invalid_request`가 발생하는 실패 테스트를 작성한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldRejectMissingOrConflictingRecipientIds() {
|
|
||||||
val missing = assertThrows(SodaException::class.java) {
|
|
||||||
CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = null).recipientMemberId()
|
|
||||||
}
|
|
||||||
assertEquals("common.error.invalid_request", missing.messageKey)
|
|
||||||
|
|
||||||
val conflict = assertThrows(SodaException::class.java) {
|
|
||||||
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = 21L).recipientMemberId()
|
|
||||||
}
|
|
||||||
assertEquals("common.error.invalid_request", conflict.messageKey)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest`를 실행해 `recipientId` 생성자 파라미터 또는 `recipientMemberId()` 부재로 실패하는 것을 확인한다.
|
|
||||||
- [x] **GREEN:** DTO를 최소 확장한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class CreateUserCreatorChatRoomRequest(
|
|
||||||
val recipientId: Long? = null,
|
|
||||||
val creatorId: Long? = null
|
|
||||||
) {
|
|
||||||
fun recipientMemberId(): Long {
|
|
||||||
if (recipientId != null && creatorId != null && recipientId != creatorId) {
|
|
||||||
throw SodaException(messageKey = "common.error.invalid_request")
|
|
||||||
}
|
|
||||||
return recipientId ?: creatorId ?: throw SodaException(messageKey = "common.error.invalid_request")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **GREEN:** Controller에서 `request.recipientMemberId()`를 service에 전달한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
ApiResponse.ok(service.createOrGetRoom(member, request.recipientMemberId()))
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 테스트 helper 중 이번 Task가 만든 중복만 정리하고 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
|
|
||||||
|
|
||||||
#### Task 1.2 service 방 생성 정책 회귀 보강
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T2`:** 발신자 역할과 무관하게 기존 수신자 검증, 기존 방 재사용, 메시지 미생성 정책을 유지한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P1-T1` 완료.
|
|
||||||
- **완료 증거:** `UserCreatorChatServiceTest` focused test 통과.
|
|
||||||
- **범위 밖:** `validateRecipient` 정책 변경, WebSocket 발송 구현.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `createOrGetRoom(member: Member, recipientId: Long)`
|
|
||||||
- Produces: `CreateUserCreatorChatRoomResponse(roomId: Long)`
|
|
||||||
|
|
||||||
- [x] **RED:** 크리에이터 역할 회원이 일반 회원에게 방을 생성할 수 있는 실패 테스트를 작성한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldCreateRoomWhenCreatorStartsDmToUser() {
|
|
||||||
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
|
|
||||||
val user = member(2L, "user")
|
|
||||||
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
|
|
||||||
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null)
|
|
||||||
Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenReturn(room(10L))
|
|
||||||
|
|
||||||
val response = service.createOrGetRoom(creator, 2L)
|
|
||||||
|
|
||||||
assertEquals(10L, response.roomId)
|
|
||||||
Mockito.verify(participantRepository).save(Mockito.argThat { it.member == creator })
|
|
||||||
Mockito.verify(participantRepository).save(Mockito.argThat { it.member == user })
|
|
||||||
Mockito.verifyNoInteractions(messageRepository)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **RED:** 기존 활성 방이 있으면 새 방과 메시지를 만들지 않는 회귀 테스트를 작성한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldReturnExistingRoomWithoutCreatingMessage() {
|
|
||||||
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
|
|
||||||
val user = member(2L, "user")
|
|
||||||
val existingRoom = room(10L)
|
|
||||||
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
|
|
||||||
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(existingRoom)
|
|
||||||
|
|
||||||
val response = service.createOrGetRoom(creator, 2L)
|
|
||||||
|
|
||||||
assertEquals(10L, response.roomId)
|
|
||||||
Mockito.verify(roomRepository, Mockito.never()).save(Mockito.any(UserCreatorChatRoom::class.java))
|
|
||||||
Mockito.verifyNoInteractions(messageRepository)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`를 실행해 누락된 동작 또는 helper compile 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** 필요하면 `createOrGetRoom(member, creatorId)` 파라미터명을 `recipientId`로 변경한다. 함수 내부 로직은 기존 `memberRepository.findById`, `validateRecipient`, `findActiveRoomByParticipantMemberIds`, participant 저장 흐름을 유지한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Transactional
|
|
||||||
fun createOrGetRoom(member: Member, recipientId: Long): CreateUserCreatorChatRoomResponse {
|
|
||||||
val recipient = memberRepository.findById(recipientId).orElseThrow {
|
|
||||||
SodaException(messageKey = "message.error.recipient_not_found")
|
|
||||||
}
|
|
||||||
validateRecipient(member, recipient)
|
|
||||||
|
|
||||||
val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, recipient.id!!)
|
|
||||||
if (existingRoom != null) {
|
|
||||||
return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
val room = roomRepository.save(UserCreatorChatRoom())
|
|
||||||
participantRepository.save(UserCreatorChatParticipant(room, member))
|
|
||||||
participantRepository.save(UserCreatorChatParticipant(room, recipient))
|
|
||||||
return CreateUserCreatorChatRoomResponse(roomId = room.id!!)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 파라미터명 변경으로 테스트/문서와 의미가 맞는지 확인하고 새 abstraction 없이 종료한다.
|
|
||||||
|
|
||||||
#### Task 1.3 통합 회귀 검증 보강
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T3`:** 기존 리스너 시작 흐름과 신규 크리에이터 시작 흐름이 통합 환경에서 모두 동작한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
|
|
||||||
- **완료 증거:** 통합 테스트와 focused 회귀 명령 통과.
|
|
||||||
- **범위 밖:** 전체 메시징 E2E, 푸시 발송 검증.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`
|
|
||||||
|
|
||||||
- [x] **RED:** 리스너가 `creatorId` alias로 기존처럼 방을 만들 수 있는 통합 회귀 테스트를 작성한다.
|
|
||||||
- [x] **RED:** 크리에이터가 일반 회원 ID로 방을 만들 수 있는 통합 테스트를 작성한다.
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`를 실행해 현재 계약 미지원 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** `P1-T1`, `P1-T2` 구현만으로 통합 테스트를 통과시킨다. 추가 production 코드를 만들지 않는다.
|
|
||||||
- [x] **GREEN 확인:** 같은 통합 테스트를 다시 실행해 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 통합 테스트 fixture 중 이번 Task가 만든 중복만 정리한다.
|
|
||||||
|
|
||||||
### 완료 조건
|
|
||||||
|
|
||||||
- [x] `P1-T1`, `P1-T2`, `P1-T3`의 체크박스와 완료 증거가 모두 충족됐다.
|
|
||||||
- [x] PRD `DMROOM-001~007`이 구현 또는 명시적 제외로 추적된다.
|
|
||||||
- [x] 기존 `creatorId` 요청과 신규 `recipientId` 요청의 차이가 문서와 테스트에 남아 있다.
|
|
||||||
|
|
||||||
### 검증 방법
|
|
||||||
|
|
||||||
#### Phase 1 Gate
|
|
||||||
|
|
||||||
**Goal 실행 `P1-GATE`:** `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책을 최종 판정한다.
|
|
||||||
|
|
||||||
- **시작 조건:** Phase 1의 모든 Task goal 완료.
|
|
||||||
- **완료 증거:** 아래 명령 통과와 Progress 기록.
|
|
||||||
- **범위 밖:** 실패와 무관한 채팅방 목록, openRoom 응답, WebSocket 기능 수정.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest
|
|
||||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest
|
|
||||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:** 모든 명령이 `BUILD SUCCESSFUL`이고, `/create` 호출만으로 메시지 저장/전달/푸시가 발생하지 않는 테스트가 통과한다.
|
|
||||||
|
|
||||||
## 실행 순서와 의존성
|
|
||||||
|
|
||||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
|
||||||
|---:|---|---|---|---|
|
|
||||||
| 1 | `P1-T1` | PRD 확정 | 아니요 | request 계약 재확인 |
|
|
||||||
| 2 | `P1-T2` | `P1-T1` | 아니요 | 기존 service 정책 대조 |
|
|
||||||
| 3 | `P1-T3` | `P1-T1`, `P1-T2` | 아니요 | 통합 fixture 보정 |
|
|
||||||
| 4 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task로 회귀 수정 |
|
|
||||||
|
|
||||||
```text
|
|
||||||
P1-T1 → P1-T2 → P1-T3 → P1-GATE
|
|
||||||
```
|
|
||||||
|
|
||||||
## 변경 금지 항목
|
|
||||||
|
|
||||||
- `/create`에서 메시지를 저장하거나 발송하지 않는다.
|
|
||||||
- 기존 `creatorId` request를 제거하지 않는다.
|
|
||||||
- `CreateUserCreatorChatRoomResponse` 필드를 바꾸지 않는다.
|
|
||||||
- DB schema, WebSocket message type, FCM event 계약을 변경하지 않는다.
|
|
||||||
- test를 삭제·skip·완화하지 않는다.
|
|
||||||
- 요청 범위 밖 리팩터링과 공용 abstraction을 추가하지 않는다.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
|
||||||
|
|
||||||
### P1-T1 1차 실행 — 2026-09-14
|
|
||||||
|
|
||||||
- 상태: 완료
|
|
||||||
- 무엇을: `CreateUserCreatorChatRoomRequest`에 `recipientId` 표준 필드와 `creatorId` alias를 추가하고, controller가 `recipientMemberId()` 결과를 service에 전달하도록 구현했다.
|
|
||||||
- 왜: `DMROOM-001~004`, `DEC-003` 기준으로 신규 클라이언트와 기존 클라이언트 request를 모두 지원하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — RED 확인, `recipientId` 생성자 파라미터와 `recipientMemberId()` 부재로 `compileTestKotlin` 실패.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — GREEN 확인, `BUILD SUCCESSFUL`.
|
|
||||||
- 남은 항목: 없음.
|
|
||||||
- 다음 행동: `P1-T2` 진행.
|
|
||||||
|
|
||||||
### P1-T2 1차 실행 — 2026-09-14
|
|
||||||
|
|
||||||
- 상태: 완료
|
|
||||||
- 무엇을: 크리에이터 시작 DM 방 생성, 기존 방 재사용, `/create` 메시지 미생성 정책을 `UserCreatorChatServiceTest`로 고정했다. production service는 파라미터명만 `recipientId`로 정리했다.
|
|
||||||
- 왜: `DMROOM-005~007`, `DEC-001`, `DEC-002` 기준으로 기존 수신자 검증과 메시지 발송 분리 정책을 유지하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` — `BUILD SUCCESSFUL`.
|
|
||||||
- 남은 항목: 없음.
|
|
||||||
- 다음 행동: `P1-T3` 진행.
|
|
||||||
|
|
||||||
### P1-T3 1차 실행 — 2026-09-14
|
|
||||||
|
|
||||||
- 상태: 완료
|
|
||||||
- 무엇을: 리스너 시작 기존 흐름과 크리에이터 시작 신규 흐름을 `UserCreatorChatServiceIntegrationTest`에 추가했다.
|
|
||||||
- 왜: 실제 JPA 통합 환경에서 양방향 방 생성과 메시지 미생성을 확인하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`.
|
|
||||||
- 남은 항목: 없음.
|
|
||||||
- 다음 행동: `P1-GATE` 진행.
|
|
||||||
|
|
||||||
### P1-GATE 1차 실행 — 2026-09-14
|
|
||||||
|
|
||||||
- 상태: 차단 감사 중
|
|
||||||
- 무엇을: Phase 1 focused test와 lint를 검증하고 전체 build를 시도했다.
|
|
||||||
- 왜: `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책과 공통 품질 기준을 판정하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew ktlintCheck` — 최초 import 정렬 오류로 실패 후 수정, 재실행 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew build` — 120초 timeout, 재시도 600초 timeout. 두 번 모두 `:test` 단계에서 종료되지 않아 전체 build 성공 증거는 확보하지 못했다.
|
|
||||||
- 남은 항목: 전체 build timeout 원인 분리 또는 별도 승인.
|
|
||||||
- 다음 행동: 변경 범위 리뷰와 전체 suite hang 원인 보고.
|
|
||||||
|
|
||||||
## Decision Log
|
|
||||||
|
|
||||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리한다. | 사용자 선택 A | `P1-T2`, PRD `DEC-001` |
|
|
||||||
| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole` 제한 없이 기존 수신자 검증 정책을 따른다. | 사용자 선택 B | `P1-T2`, PRD `DEC-002` |
|
|
||||||
| 2026-09-14 | `DEC-003` | 확정 | `recipientId`를 표준 필드로 추가하고 `creatorId` alias를 유지한다. | 기존 클라이언트 호환 필요 | `P1-T1`, PRD `DEC-003` |
|
|
||||||
|
|
||||||
## 발견된 문제
|
|
||||||
|
|
||||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 없음 | - | - | - | - | - |
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# PRD: 크리에이터 시작 DM 방 생성
|
|
||||||
|
|
||||||
## 문서 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 문서 상태 | 구현 기준 확정 |
|
|
||||||
| 작성일 | 2026-09-14 |
|
|
||||||
| 최종 수정일 | 2026-09-14 |
|
|
||||||
| 대상 제품 | 유저-크리에이터 DM |
|
|
||||||
| 작성자·결정권자 | 사용자 인터뷰 기준 |
|
|
||||||
| 관련 구현 계획 | `docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md` |
|
|
||||||
| 관련 review | 없음 |
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
현재 `POST /api/v2/user-creator-chat/rooms/create`는 일반 유저가 크리에이터에게 DM을 시작할 때 방을 생성하거나 기존 방을 반환한다. 이번 요구사항은 같은 DM 도메인에서 크리에이터도 상대 회원에게 먼저 DM 방을 만들 수 있게 하는 것이다.
|
|
||||||
|
|
||||||
첫 메시지 저장, 실시간 전달, 푸시는 이번 API에서 처리하지 않는다. 방 생성 후 메시지는 기존 WebSocket 텍스트 발송 또는 기존 메시지 발송 흐름을 사용한다.
|
|
||||||
|
|
||||||
## 2. Problem Statement
|
|
||||||
|
|
||||||
- 현재 생성 요청 DTO는 `creatorId`만 받기 때문에 API 의미가 “일반 유저가 크리에이터에게 DM 시작”에 고정되어 있다.
|
|
||||||
- 크리에이터가 먼저 상대 회원에게 연락하려면 같은 유저-크리에이터 DM 방을 만들 수 있는 서버 계약이 필요하다.
|
|
||||||
- 기존 클라이언트는 이미 `creatorId`를 보내고 있으므로, 새 필드만 강제하면 기존 리스너 시작 DM 생성 흐름이 깨진다.
|
|
||||||
|
|
||||||
문제 해결 여부는 신규 클라이언트가 `recipientId`로 방을 만들 수 있고, 기존 클라이언트가 `creatorId`로 같은 API를 계속 사용할 수 있는지로 판단한다.
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
|
|
||||||
- `POST /api/v2/user-creator-chat/rooms/create`가 발신자 역할과 무관하게 상대 회원 ID로 DM 방을 생성하거나 기존 방을 반환한다.
|
|
||||||
- 신규 표준 request 필드는 `recipientId`로 한다.
|
|
||||||
- 기존 클라이언트 호환을 위해 `creatorId` request 필드는 alias로 유지한다.
|
|
||||||
- 기존 리스너 → 크리에이터 방 생성 흐름은 계속 동작한다.
|
|
||||||
- 크리에이터 → 상대 회원 방 생성 흐름도 같은 참여자/중복 방 정책을 따른다.
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
|
|
||||||
- `/create`에서 첫 텍스트 메시지를 저장하거나 발송하지 않는다.
|
|
||||||
- `/create`에서 WebSocket 메시지를 대신 보내지 않는다.
|
|
||||||
- FCM 푸시 발송 정책은 변경하지 않는다.
|
|
||||||
- DB 스키마를 변경하지 않는다.
|
|
||||||
- 새 endpoint를 만들지 않는다.
|
|
||||||
- 기존 `roomId` 응답 구조를 변경하지 않는다.
|
|
||||||
- `creatorId` alias 제거 시점이나 클라이언트 마이그레이션 일정은 이번 범위에서 정하지 않는다.
|
|
||||||
|
|
||||||
## 5. Target Users and Permissions
|
|
||||||
|
|
||||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 리스너/일반 유저 | 크리에이터에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 |
|
|
||||||
| 크리에이터 | 상대 회원에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 |
|
|
||||||
|
|
||||||
권한과 거부 조건:
|
|
||||||
|
|
||||||
- 인증 주체: 로그인 `Member`
|
|
||||||
- 허용 역할: 별도 `MemberRole` 제한 없음
|
|
||||||
- 수신자 조건: 활성 회원, AI 캐릭터 아님, 자기 자신 아님, 차단 정책 통과
|
|
||||||
- 미인증: 기존처럼 `common.error.bad_credentials`
|
|
||||||
- 수신자 없음 또는 AI 캐릭터: 기존 정책에 맞춰 `message.error.recipient_not_found`
|
|
||||||
- 비활성 수신자: 기존처럼 `message.error.recipient_inactive`
|
|
||||||
- 자기 자신: 기존처럼 `common.error.invalid_request`
|
|
||||||
- 상대가 발신자를 차단한 경우: 기존처럼 `message.error.blocked_by_recipient`
|
|
||||||
|
|
||||||
## 6. 핵심 사용자 흐름
|
|
||||||
|
|
||||||
1. 인증 회원이 DM을 시작할 상대 회원을 선택한다.
|
|
||||||
2. 클라이언트는 `POST /api/v2/user-creator-chat/rooms/create`에 `recipientId`를 보낸다.
|
|
||||||
3. 서버는 기존 alias인 `creatorId`만 온 요청도 허용한다.
|
|
||||||
4. 서버는 발신자와 수신자 사이의 활성 DM 방이 있으면 기존 `roomId`를 반환한다.
|
|
||||||
5. 활성 DM 방이 없으면 방과 두 참여자를 생성하고 새 `roomId`를 반환한다.
|
|
||||||
6. 클라이언트는 반환된 `roomId`로 기존 방 입장 및 메시지 발송 흐름을 진행한다.
|
|
||||||
|
|
||||||
## 7. 기능 요구사항
|
|
||||||
|
|
||||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `DMROOM-001` | 확정 | `/create`는 `recipientId`를 표준 상대 회원 ID로 받아 방을 생성/조회한다. | `recipientId`만 보낸 요청이 기존 `roomId` 응답을 받는다. | `P1-T1` |
|
|
||||||
| `DMROOM-002` | 확정 | 기존 클라이언트 호환을 위해 `creatorId`만 보낸 요청도 계속 허용한다. | 기존 `creatorId` request가 깨지지 않고 동일한 서비스 흐름을 탄다. | `P1-T1` |
|
|
||||||
| `DMROOM-003` | 확정 | `recipientId`와 `creatorId`가 모두 없으면 잘못된 요청으로 거부한다. | `common.error.invalid_request`로 실패한다. | `P1-T1` |
|
|
||||||
| `DMROOM-004` | 확정 | 두 필드가 모두 있고 값이 다르면 모호한 요청으로 거부한다. | `common.error.invalid_request`로 실패하고 방을 만들지 않는다. | `P1-T1` |
|
|
||||||
| `DMROOM-005` | 확정 | 수신자 권한/상태 검증은 기존 `validateRecipient` 정책을 재사용한다. | 비활성, AI 캐릭터, 자기 자신, 차단 관계의 기존 오류 key가 유지된다. | `P1-T2` |
|
|
||||||
| `DMROOM-006` | 확정 | 기존 활성 방이 있으면 새 방을 만들지 않고 기존 `roomId`를 반환한다. | 같은 두 회원으로 두 번 호출해도 활성 방은 1개다. | `P1-T2` |
|
|
||||||
| `DMROOM-007` | 확정 | `/create`는 첫 메시지 저장/전달/푸시를 수행하지 않는다. | 방 생성 후 `UserCreatorChatMessage`가 생성되지 않는다. | `P1-T2` |
|
|
||||||
|
|
||||||
## 8. API 계약
|
|
||||||
|
|
||||||
### 8.1 Endpoint
|
|
||||||
|
|
||||||
- Method: `POST`
|
|
||||||
- Path: `/api/v2/user-creator-chat/rooms/create`
|
|
||||||
- Auth: 로그인 회원 필수
|
|
||||||
- Success envelope: 기존 `ApiResponse.ok(...)`
|
|
||||||
|
|
||||||
### 8.2 Request
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"recipientId": 200,
|
|
||||||
"creatorId": 200
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `recipientId`: 신규 표준 필드. 발신자 반대편 회원 ID다.
|
|
||||||
- `creatorId`: 기존 클라이언트 호환용 alias. 신규 클라이언트는 `recipientId`를 사용한다.
|
|
||||||
- 둘 중 하나만 보내는 요청을 허용한다.
|
|
||||||
- 둘 다 보내는 경우 값이 같으면 허용한다.
|
|
||||||
- 둘 다 보내는 경우 값이 다르면 `common.error.invalid_request`로 거부한다.
|
|
||||||
- 둘 다 없으면 `common.error.invalid_request`로 거부한다.
|
|
||||||
|
|
||||||
### 8.3 Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"roomId": 123
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 응답 DTO `CreateUserCreatorChatRoomResponse`의 필드는 변경하지 않는다.
|
|
||||||
|
|
||||||
## 9. 보안과 데이터 취급
|
|
||||||
|
|
||||||
- 수신자 ID는 요청 본문 외 로그에 별도 기록하지 않는다.
|
|
||||||
- 차단 정책은 기존 `BlockMemberRepository.isBlocked(blockedMemberId = sender.id, memberId = recipient.id)` 기준을 유지한다.
|
|
||||||
- AI 캐릭터용 `Member`와의 DM 방은 계속 생성하지 않는다.
|
|
||||||
- 공개 API 스키마에서 기존 필드 제거는 금지한다.
|
|
||||||
|
|
||||||
## 10. 성공 기준
|
|
||||||
|
|
||||||
- [x] 신규 `recipientId` 요청으로 크리에이터가 상대 회원과 DM 방을 만들 수 있다.
|
|
||||||
- [x] 기존 `creatorId` 요청으로 리스너가 크리에이터와 DM 방을 만들 수 있다.
|
|
||||||
- [x] 같은 두 회원의 중복 요청은 같은 활성 `roomId`를 반환한다.
|
|
||||||
- [x] `/create` 호출만으로 메시지, WebSocket 전달, FCM 푸시가 발생하지 않는다.
|
|
||||||
- [x] 오류 key는 기존 정책과 호환된다.
|
|
||||||
|
|
||||||
## 11. Open Questions
|
|
||||||
|
|
||||||
없음. 인터뷰로 아래 결정을 확정했다.
|
|
||||||
|
|
||||||
## 12. 요구사항 추적표
|
|
||||||
|
|
||||||
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
|
||||||
|---|---:|---|---|---|
|
|
||||||
| `DMROOM-001~004` | 1 | `P1-T1` | `UserCreatorChatControllerMappingTest`, `UserCreatorChatServiceTest` | request alias 계약 대조 |
|
|
||||||
| `DMROOM-005~007` | 1 | `P1-T2` | `UserCreatorChatServiceTest`, `UserCreatorChatServiceIntegrationTest` | 기존 메시지 발송 흐름 분리 확인 |
|
|
||||||
|
|
||||||
## 13. Decision Log
|
|
||||||
|
|
||||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리하고 첫 메시지는 발송하지 않는다. | 사용자 선택 A | `DMROOM-007`, `P1-T2` |
|
|
||||||
| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole`로 제한하지 않고 활성/AI 아님/자기 자신 아님/차단 정책으로 제한한다. | 사용자 선택 B, 기존 `validateRecipient` 정책 | `DMROOM-005`, `P1-T2` |
|
|
||||||
| 2026-09-14 | `DEC-003` | 확정 | 신규 표준 필드는 `recipientId`로 하고 기존 `creatorId`는 alias로 유지한다. | 기능 의미는 A가 맞지만 기존 클라이언트 동작 보존 필요 | `DMROOM-001~004`, `P1-T1` |
|
|
||||||
|
|
||||||
## 14. 변경 관리
|
|
||||||
|
|
||||||
- 코드 구현 전 `plan-task.md`의 체크박스와 Goal 단위를 따른다.
|
|
||||||
- 구현 중 API 계약이 바뀌면 이 PRD의 Decision Log를 먼저 갱신한다.
|
|
||||||
- 기존 `creatorId` alias를 제거하려면 별도 PRD와 클라이언트 마이그레이션 계획을 작성한다.
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
# 라이브 크리에이터 입장 제한 구현 계획
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
| 문서 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 상태 | Phase 1~3 구현 및 검증 완료 |
|
|
||||||
| 작성일 | `2026-09-17` |
|
|
||||||
| 요구사항 기준 | `docs/20260917_라이브_크리에이터_입장제한/prd.md` |
|
|
||||||
| API 기준 | 기존 API 응답 스키마 변경 없음 |
|
|
||||||
| 현재 Phase | 완료 |
|
|
||||||
| 현재 활성 Goal | 없음 |
|
|
||||||
| 다음 Goal | 없음 |
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
다른 크리에이터가 `isAvailableJoinCreator = false` 라이브 방을 볼 수도, 직접 입장할 수도 없게 하고, 입장 가능 성별(`genderRestriction`)도 조회와 입장 경계에서 일관되게 적용한다.
|
|
||||||
|
|
||||||
## 현재 상태
|
|
||||||
|
|
||||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
|
||||||
|---:|---|---:|---|---|
|
|
||||||
| 1 | 완료 | `2/2` | 없음 | 없음 |
|
|
||||||
| 2 | 완료 | `1/1` | 없음 | 없음 |
|
|
||||||
| 3 | 완료 | `2/2` | 없음 | 없음 |
|
|
||||||
|
|
||||||
## 범위
|
|
||||||
|
|
||||||
### 포함
|
|
||||||
|
|
||||||
- `v2` 홈 추천/온에어 라이브 조회의 `isAvailableJoinCreator` 및 `genderRestriction` 필터 누락 보정.
|
|
||||||
- `v2` 크리에이터 채널 라이브 탭의 조회자 크리에이터 판정 보정.
|
|
||||||
- `/live/room/enter`에서 다른 크리에이터의 제한 방 직접 입장 차단과 기존 성별 입장 차단 회귀 검증.
|
|
||||||
- focused test와 직접 영향 회귀 검증.
|
|
||||||
- 후속 리뷰 `REV-001`: `/live/room/info/{id}`의 토큰 발급 전 크리에이터·성별 제한 적용.
|
|
||||||
- 후속 리뷰 `REV-002`: 채널 소유자 본인의 제한 방 조회를 repository 회귀 테스트로 고정.
|
|
||||||
|
|
||||||
### 제외
|
|
||||||
|
|
||||||
- 공개 API DTO 필드 추가/삭제.
|
|
||||||
- DB schema 변경.
|
|
||||||
- 기존 성인/성별/차단/강퇴/비공개/결제 정책 재설계.
|
|
||||||
- 관리자용 라이브 조회 정책 변경.
|
|
||||||
|
|
||||||
## 기술적 제약
|
|
||||||
|
|
||||||
- 기술 스택: Kotlin, Spring Boot 2.7.14, QueryDSL, Gradle Wrapper.
|
|
||||||
- 기존 QueryDSL 조건 패턴을 재사용하고 새 abstraction은 만들지 않는다.
|
|
||||||
- 크리에이터 제한 조건은 `isViewerCreator && memberId != null`일 때만 적용하며, 방 생성자는 `liveRoom.member.id.eq(memberId)`로 예외 처리한다.
|
|
||||||
- 성별 제한 조건은 기존 조회 경로와 같은 정책을 따른다. `Gender.MALE`은 `ALL`, `MALE_ONLY`, `Gender.FEMALE`은 `ALL`, `FEMALE_ONLY`, `Gender.NONE` 또는 비로그인/null 유효 성별은 필터 없음이다.
|
|
||||||
- 방 생성자 본인은 성별 제한 조건에서도 `liveRoom.member.id.eq(memberId)`로 예외 처리한다.
|
|
||||||
- 모든 production 변경 Task는 `RED → GREEN → REFACTOR` 순서로 진행한다.
|
|
||||||
- 공개 API schema 변경은 금지한다.
|
|
||||||
|
|
||||||
## Phase 1: 리스트 노출 제한 보정
|
|
||||||
|
|
||||||
**Phase 결과:** 다른 크리에이터가 문제 의심 리스트 경로에서 제한 방을 받지 않고, 성별 제한에 맞지 않는 사용자가 v2 홈 추천/온에어 라이브에서 제한 방을 받지 않는다.
|
|
||||||
|
|
||||||
**선행조건:** PRD 확정.
|
|
||||||
|
|
||||||
**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-GATE` 완료 및 검증 기록 누적.
|
|
||||||
|
|
||||||
### 구현 항목
|
|
||||||
|
|
||||||
#### Task 1.1 v2 홈 추천/온에어 라이브 필터 적용
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T1`:** `findLiveRecommendations()` 계열 호출에서 조회자 크리에이터 여부와 유효 성별을 전달하고 QueryDSL 필터를 적용한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `LCR-001` 확정.
|
|
||||||
- **완료 증거:** 다른 크리에이터가 제한 방을 받지 않고, 성별 제한에 맞지 않는 사용자가 제한 방을 받지 않으며, 방 생성자 본인은 예외인 focused test 통과.
|
|
||||||
- **범위 밖:** `/live/room/enter` 입장 차단.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
|
||||||
- Test: repository/service 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- `findLiveRecommendations(offset, limit, memberId, includeAdultLives, isViewerCreator, effectiveViewerGender)` 형태로 파라미터를 추가한다.
|
|
||||||
- `isViewerCreator` 기본값은 `false`로 둬 snapshot refresh 등 기존 호출을 보존한다.
|
|
||||||
- `effectiveViewerGender` 기본값은 `null`로 둬 비로그인/성별 미설정과 snapshot refresh 등 기존 호출을 보존한다.
|
|
||||||
- `HomeRecommendationFacade`와 `HomeOnAirLiveFacade`는 `member.auth?.gender` 우선, 없으면 `member.gender`로 기존 경로와 같은 유효 성별을 계산해 전달한다.
|
|
||||||
|
|
||||||
- [x] **RED:** 크리에이터 조회자에게 `isAvailableJoinCreator = false` 라이브가 제외되는 테스트, 성별 제한에 맞지 않는 조회자에게 `genderRestriction` 제한 라이브가 제외되는 테스트, 방 생성자 본인은 포함되는 테스트를 작성한다.
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest'`를 실행해 필터 미구현으로 크리에이터 제한 방 또는 성별 제한 방이 포함되는 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** `HomeRecommendationQueryPort`, `HomeRecommendationQueryService`, `DefaultHomeRecommendationQueryRepository`, `HomeRecommendationFacade`, `HomeOnAirLiveFacade`에 `isViewerCreator`, `effectiveViewerGender` 전달과 조건을 최소 추가한다.
|
|
||||||
- [x] **GREEN 확인:** `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest'`를 다시 실행해 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 `./gradlew ktlintCheck` 및 focused test 결과를 Progress에 기록한다.
|
|
||||||
|
|
||||||
#### Task 1.2 v2 크리에이터 채널 라이브 탭 판정 보정
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T2`:** 크리에이터 채널 라이브 탭에서 “조회자가 크리에이터인지” 기준으로 제한 방 노출을 막는다.
|
|
||||||
|
|
||||||
- **시작 조건:** `LCR-002` 확정.
|
|
||||||
- **완료 증거:** 다른 크리에이터는 제한 방을 받지 않고 방 생성자 본인은 받을 수 있는 focused test 통과.
|
|
||||||
- **범위 밖:** 크리에이터 채널 홈은 이미 `viewer.role == MemberRole.CREATOR`를 사용하므로 변경하지 않는다.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
|
|
||||||
- Test: 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- `queryPort.findCurrentLive(..., isViewerCreator = viewer.role == MemberRole.CREATOR, ...)`로 전달한다.
|
|
||||||
- repository의 기존 `creatorJoinLiveCondition(viewerId, isViewerCreator)`와 본인 예외는 유지한다.
|
|
||||||
|
|
||||||
- [x] **RED:** 다른 크리에이터 viewer가 `isAvailableJoinCreator = false` 현재 라이브를 받지 않는 service test를 작성한다.
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests '*CreatorChannelLiveQueryServiceTest'`를 실행해 기존 `viewerId == creatorId` 판정 때문에 제한 방이 반환되는 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** `CreatorChannelLiveQueryService`의 `isViewerCreator` 계산을 `viewer.role == MemberRole.CREATOR`로 변경한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 중복 정리 없이 끝낼 수 있으면 그대로 두고 focused test 및 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
|
|
||||||
|
|
||||||
### 검증 방법
|
|
||||||
|
|
||||||
#### Phase 1 Gate
|
|
||||||
|
|
||||||
**Goal 실행 `P1-GATE`:** 리스트 노출 제한과 성별 제한 경로가 PRD 요구사항과 일치하는지 판정한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
|
|
||||||
- **완료 증거:** 아래 명령 통과와 수동 대조 기록.
|
|
||||||
- **범위 밖:** 입장 차단 구현.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:** focused test와 ktlint가 0 exit code로 종료한다.
|
|
||||||
|
|
||||||
수동 대조:
|
|
||||||
|
|
||||||
- [x] `HomeFollowingQueryService`와 `CreatorChannelHomeQueryService`의 기존 정상 조건은 변경하지 않았음을 확인한다.
|
|
||||||
- [x] `HomeRecommendationFacade.getHomeRecommendations`, `HomeRecommendationFacade.getLives`, `HomeOnAirLiveFacade.getOnAirLives`가 모두 조회자 크리에이터 여부와 유효 성별을 전달함을 확인한다.
|
|
||||||
|
|
||||||
## Phase 2: 직접 입장 차단
|
|
||||||
|
|
||||||
**Phase 결과:** 다른 크리에이터가 제한 방 ID를 알고 있어도 `/live/room/enter`로 입장할 수 없고, 성별 제한에 맞지 않는 사용자의 직접 입장도 기존 정책대로 차단된다.
|
|
||||||
|
|
||||||
**선행조건:** `P1-GATE` 완료.
|
|
||||||
|
|
||||||
**Phase 완료 조건:** `P2-T1`, `P2-GATE` 완료 및 검증 기록 누적.
|
|
||||||
|
|
||||||
### 구현 항목
|
|
||||||
|
|
||||||
#### Task 2.1 `/live/room/enter` 크리에이터 제한 차단 및 성별 제한 회귀 검증
|
|
||||||
|
|
||||||
**Goal 실행 `P2-T1`:** `LiveRoomService.enterLive()`에서 다른 크리에이터의 제한 방 입장을 결제/roomInfo 변경 전 차단하고, 기존 성별 제한 입장 차단을 회귀 테스트로 고정한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `LCR-003` 확정, `P1-GATE` 완료.
|
|
||||||
- **완료 증거:** 다른 크리에이터는 예외, 성별 제한에 맞지 않는 사용자는 `live.room.gender_restricted` 예외, 방 생성자 본인과 일반 유저는 기존 정책 유지 test 통과.
|
|
||||||
- **범위 밖:** error envelope schema 변경.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt`
|
|
||||||
- Test: 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt`
|
|
||||||
- Optional Modify: 메시지 key 추가가 필요하다고 확인될 때만 `src/main/resources/messages*.properties` 계열 파일
|
|
||||||
|
|
||||||
**Implementation rule:**
|
|
||||||
|
|
||||||
`enterLive()`에서 room 조회 후, 결제나 `LiveRoomInfo` 변경 전에 아래 의미의 조건을 추가한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
if (
|
|
||||||
member.role == MemberRole.CREATOR &&
|
|
||||||
room.member!!.id!! != member.id!! &&
|
|
||||||
!room.isAvailableJoinCreator
|
|
||||||
) {
|
|
||||||
throw SodaException(messageKey = "live.room.not_found")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
기존 message key 재사용이 부적절하다고 확인되면 PRD와 plan-task를 먼저 갱신한 뒤 새 message key를 추가한다.
|
|
||||||
|
|
||||||
성별 제한은 현재 구현된 아래 의미의 조건을 유지한다. 이 작업에서는 해당 조건을 약화하지 말고 회귀 테스트로 고정한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
|
|
||||||
throw SodaException(messageKey = "live.room.gender_restricted")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **RED:** 다른 크리에이터가 `isAvailableJoinCreator = false` 방에 입장하면 예외가 발생하고 `roomInfoRepository.save`와 `canPaymentService.spendCan`이 호출되지 않는 테스트를 작성한다. 성별 제한에 맞지 않는 사용자가 입장하면 `live.room.gender_restricted` 예외가 발생하는 회귀 테스트도 작성한다.
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests '*LiveRoomServiceTest'`를 실행해 기존 구현이 입장을 허용하거나 후속 저장을 호출하는 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** `LiveRoomService.enterLive()`에 최소 조건을 추가한다.
|
|
||||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
|
||||||
- [x] **REFACTOR:** 방 생성자 본인, 일반 유저, `Gender.NONE` 사용자 회귀 테스트를 함께 실행하고 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
|
|
||||||
|
|
||||||
### 검증 방법
|
|
||||||
|
|
||||||
#### Phase 2 Gate
|
|
||||||
|
|
||||||
**Goal 실행 `P2-GATE`:** 입장 차단, 리스트 노출 차단, 성별 제한 전체 흐름을 최종 판정한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P2-T1` 완료.
|
|
||||||
- **완료 증거:** 아래 명령 통과와 계획 문서 Progress 기록.
|
|
||||||
- **범위 밖:** 실패 test 삭제·완화, 무관한 리팩터링.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
./gradlew test
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:** focused test, ktlint, 전체 test가 0 exit code로 종료한다. 크리에이터 제한과 성별 제한이 모두 조회/입장 경계에서 검증된다. 전체 test가 환경 문제로 실패하면 실패 원인과 focused/영향 범위 대체 검증을 Progress에 기록한다.
|
|
||||||
|
|
||||||
## Phase 3: 후속 리뷰 항목 구현
|
|
||||||
|
|
||||||
**근거:** `reviews/현재변경사항-review.md`의 `REV-001`, `REV-002` 및 두 항목을 후속 구현사항으로 추가하라는 사용자 요청.
|
|
||||||
|
|
||||||
**Phase 결과:** 제한 사용자는 방 정보 API로 토큰을 발급받지 못하고, 채널 소유자 본인의 제한 방 조회는 실제 repository 테스트로 보장된다.
|
|
||||||
|
|
||||||
**선행조건:** Phase 1~2 완료 및 후속 리뷰 확인. 기존 완료 체크박스와 검증 기록은 유지한다.
|
|
||||||
|
|
||||||
**Phase 완료 조건:** `P3-T1`, `P3-T2`, `P3-GATE` 완료와 실행 증거 기록.
|
|
||||||
|
|
||||||
### Task 3.1 토큰 발급 경계의 입장 제한 보강
|
|
||||||
|
|
||||||
- [x] **Task 3.1 완료**
|
|
||||||
|
|
||||||
**Goal 실행 `P3-T1`:** `REV-001`에 따라 `getRoomInfo()`에서 토큰 생성 전에 크리에이터·성별 제한을 적용하고 허용 사용자 회귀를 검증한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `REV-001`의 제한 검사 누락 확인. 실제 RTC 우회 접속은 아직 재현되지 않았으므로 서버의 토큰 생성 호출 여부부터 검증한다.
|
|
||||||
- **완료 증거:** 제한 사용자에 대한 실패 테스트 → 최소 수정 후 예외 key 및 RTC/RTM 생성기 무호출 검증 → 소유자·허용 사용자 회귀 통과.
|
|
||||||
- **범위 밖:** 새로운 입장 완료 여부 검사, 성인/비공개/결제 정책 재설계, 기존 발급 토큰 회수, Agora SDK 변경, API/DB 스키마 변경.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `docs/20260917_라이브_크리에이터_입장제한/prd.md` — LCR-003 및 성공 기준에 방 정보 API의 토큰 발급 경계를 명시.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt` — `getRoomInfo()`.
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt`.
|
|
||||||
- Read: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt`.
|
|
||||||
|
|
||||||
**구현 기준:** 기존 방/roomInfo 존재 및 상호 차단 검사를 유지하고 첫 토큰 생성 호출 전에 `/enter`와 동일한 조건을 적용한다. 다른 크리에이터의 제한 방 접근은 `live.room.not_found`, 다른 사용자의 성별 불일치는 `live.room.gender_restricted`로 거절한다. 두 제한이 겹치면 크리에이터 제한을 먼저 판정한다. 생성자는 두 제한의 예외이며 성별은 `Member.canEnter()`를 재사용해 인증 성별 우선 및 `Gender.NONE` 허용 정책을 유지한다. RTC, RTM, v2v 토큰 모두 제한 검사를 통과한 뒤에만 생성한다.
|
|
||||||
|
|
||||||
- [x] **문서 정합성:** production 수정 전에 기존 PRD의 LCR-003, 관련 흐름·성공 기준에 토큰 발급 제한을 반영하고 후속 검증 항목은 미완료로 둔다.
|
|
||||||
- [x] **RED:** roomInfo와 room이 존재하고 차단 관계가 없는 fixture에서 다른 크리에이터의 제한 방 접근 및 성별 불일치 접근을 테스트한다. 토큰 생성기는 테스트용 문자열을 반환하도록 준비하고 정상 응답 구성에 필요한 의존성도 설정해, 무관한 null 오류가 아닌 예상 예외 미발생으로 실패하는지 확인한다.
|
|
||||||
- [x] **GREEN:** 위 조건을 최소 추가하고 예외 key, `rtcTokenBuilder`와 `rtmTokenBuilder`의 무호출을 검증한다. `/enter` 거절 후 `/info/{id}`에 해당하는 서비스 호출도 토큰을 생성하지 않는지 확인한다.
|
|
||||||
- [x] **회귀:** 소유자는 크리에이터·성별 제한이 모두 걸려도 허용되고, 일반 사용자와 허용 방의 다른 크리에이터는 성별이 맞으면 응답을 받는다. 인증 성별 우선, `Gender.NONE` 허용, 기존 상호 차단 거절을 확인한다.
|
|
||||||
- [x] **REFACTOR/기록:** 불필요한 추상화 없이 기존 스타일을 유지하고 아래 명령의 결과와 무엇을/왜/어떻게 검증했는지를 이 Task 아래에 누적한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:** RED 단계는 제한 검사 누락으로 실패하고, GREEN 이후는 exit 0. 차단 경로에서는 토큰이 생성되지 않는다.
|
|
||||||
|
|
||||||
**검증 기록 (2026-09-17):** `LiveRoomServiceTest`에 정상 응답 fixture와 제한·허용 회귀 10건을 추가했다. RED에서 다른 크리에이터, 성별 불일치, 중첩 제한, `/enter` 거절 후 정보 조회 테스트가 모두 예상 `SodaException` 미발생으로 실패했다. `getRoomInfo()`의 기존 상호 차단 검사 뒤와 모든 RTC/RTM/v2v 토큰 생성 앞에 `/enter`와 동일한 크리에이터·성별 조건을 추가한 뒤 `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'`가 20건 모두 통과하며 `BUILD SUCCESSFUL`로 종료했다. 거절 경로의 token builder 무호출과 소유자·성별 일치 사용자·허용 방 크리에이터·인증 성별 우선·`Gender.NONE`·기존 상호 차단 회귀를 확인했다. 실제 HTTP/RTC 연결은 검증하지 않았다. P3-T1 리뷰 결과 Blocker는 0건이다.
|
|
||||||
|
|
||||||
### Task 3.2 채널 소유자 제한 방 조회 회귀 테스트
|
|
||||||
|
|
||||||
- [x] **Task 3.2 완료**
|
|
||||||
|
|
||||||
**Goal 실행 `P3-T2`:** `REV-002`에 따라 채널 소유자 본인이 `isAvailableJoinCreator = false`인 현재 방을 조회할 수 있음을 H2 repository 테스트로 고정한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `REV-002` 확인. 기본 실행 순서는 `P3-T1` 이후이며 파일 변경은 독립적이다.
|
|
||||||
- **완료 증거:** 실제 repository의 `findCurrentLive()` 결과가 생성한 제한 방 ID와 일치하고 기존 타인 필터 테스트도 통과한다.
|
|
||||||
- **범위 밖:** production 조회 조건 수정, 채널 API/DTO 변경, 조회 정책 재설계.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`.
|
|
||||||
- Read: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`.
|
|
||||||
|
|
||||||
**TDD 예외 사유:** 소유자 예외는 이미 구현돼 있고 이번 Task는 누락된 테스트만 추가한다. RED를 만들기 위해 정상 production 코드를 변경하지 않는다. 대체 검증은 실제 H2 조회 결과 assertion과 기존 타인 제외 테스트의 동시 통과다.
|
|
||||||
|
|
||||||
- [x] **테스트 추가:** 기존 fixture로 활성·비성인·유효 channelName의 현재 방을 생성하고 `isAvailableJoinCreator = false`로 설정한다. `flushAndClear()` 후 `viewerId == creatorId`, `isViewerCreator = true`, 성별 제한과 일치하는 유효 성별로 조회해 방 ID를 단정한다.
|
|
||||||
- [x] **GREEN:** 아래 focused test로 소유자 조회와 기존 타인 제외 동작이 함께 통과하는지 확인한다.
|
|
||||||
- [x] **REFACTOR/기록:** 기존 fixture를 재사용하고 이 Task 아래에 명령과 검증 결과를 누적한다. 예상 밖 실패는 원인을 판정한 뒤 production 수정이 필요하면 먼저 Task 범위를 갱신한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:** exit 0. 소유자에게 제한 방이 반환되고 다른 크리에이터에게는 기존대로 제외된다.
|
|
||||||
|
|
||||||
**검증 기록 (2026-09-17):** production 조건은 변경하지 않고 `DefaultCreatorChannelLiveQueryRepositoryTest.shouldReturnRestrictedCurrentLiveForChannelOwner`를 추가했다. 활성·비성인·유효 channelName·`isAvailableJoinCreator = false`·`MALE_ONLY` 방을 실제 H2에 저장하고 `viewerId == creatorId`, `isViewerCreator = true`, `Gender.MALE`로 조회해 생성한 방 ID를 단정했다. `./gradlew test --rerun-tasks --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'`가 기존 다른 크리에이터 제외 테스트와 함께 `BUILD SUCCESSFUL`로 종료했다. `git diff --check`도 통과했다.
|
|
||||||
|
|
||||||
### Phase 3 Gate
|
|
||||||
|
|
||||||
**Goal 실행 `P3-GATE`:** 후속 두 항목과 기존 목록·입장 정책의 회귀 검증을 완료한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P3-T1`, `P3-T2` 완료.
|
|
||||||
- **완료 증거:** 아래 명령 exit 0, 토큰 생성 전 제한 검사 수동 대조, 소유자 조회 assertion 확인, PRD·리뷰·계획의 상태 및 검증 기록 갱신.
|
|
||||||
- **범위 밖:** 실제 RTC 접속을 검증하지 않고 검증했다고 기록하는 행위, 무관한 리팩터링.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest'
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
./gradlew tasks --all
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] 위 명령을 실행하고 각 Task의 완료 증거를 대조한다.
|
|
||||||
- [x] 실제 HTTP/RTC 연결 검증 여부와 환경 한계를 별도로 기록한다. 서비스 테스트 통과를 실제 RTC 접속 검증으로 간주하지 않는다.
|
|
||||||
- [x] 전체 회귀는 공통 인증·예외·설정까지 변경하거나 focused test로 영향 범위를 판단할 수 없는 경우 또는 사용자 요청 시 실행한다. 생략 시 이유와 위 대체 회귀 명령을 Progress에 남긴다.
|
|
||||||
- [x] `reviews/현재변경사항-review.md`에 수정 후 검증을 누적하고, PRD의 후속 성공 기준 및 Phase 3 상태를 실제 결과에 맞춰 갱신한다.
|
|
||||||
|
|
||||||
## 실행 순서와 의존성
|
|
||||||
|
|
||||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
|
||||||
|---:|---|---|---|---|
|
|
||||||
| 1 | `P1-T1` | PRD 확정 | 아니요 | 호출부/port signature 대조 후 문서 갱신 |
|
|
||||||
| 2 | `P1-T2` | PRD 확정 | 예, `P1-T1`과 파일 충돌 없음 | service test 작성 방식 확정 |
|
|
||||||
| 3 | `P1-GATE` | `P1-T1`, `P1-T2` | 아니요 | 실패 소유 Task로 회귀 수정 |
|
|
||||||
| 4 | `P2-T1` | `P1-GATE` | 아니요 | 오류 message key 결정 후 문서 갱신 |
|
|
||||||
| 5 | `P2-GATE` | `P2-T1` | 아니요 | 실패 소유 Task로 회귀 수정 |
|
|
||||||
| 6 | `P3-T1` | `P2-GATE`, `REV-001` | 기본 순차 | 토큰 생성 전 거절 재현과 PRD 정합성 확인 |
|
|
||||||
| 7 | `P3-T2` | `REV-002` | `P3-T1`과 파일 독립 | 기존 fixture·현재 방 조건 확인 |
|
|
||||||
| 8 | `P3-GATE` | `P3-T1`, `P3-T2` | 아니요 | 실패 소유 Task로 회귀 수정 |
|
|
||||||
|
|
||||||
```text
|
|
||||||
P1-T1 ─┐
|
|
||||||
├→ P1-GATE → P2-T1 → P2-GATE
|
|
||||||
P1-T2 ─┘
|
|
||||||
|
|
||||||
P2-GATE → P3-T1 ─┐
|
|
||||||
P3-T2 ─┴→ P3-GATE
|
|
||||||
```
|
|
||||||
|
|
||||||
## 변경 금지 항목
|
|
||||||
|
|
||||||
- 공개 API 응답 DTO를 변경하지 않는다.
|
|
||||||
- `isAvailableJoinCreator`의 DB schema나 기본값을 변경하지 않는다.
|
|
||||||
- 기존 성인/성별/차단/강퇴/비공개/결제 조건을 완화하지 않는다.
|
|
||||||
- 테스트를 삭제·skip·완화하지 않는다.
|
|
||||||
- `as any`, 타입 억제, 불필요한 새 abstraction을 만들지 않는다.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-09-17: PRD와 계획 문서 작성. 구현은 아직 시작하지 않음.
|
|
||||||
- 2026-09-17: v2 홈 추천/온에어 라이브의 `genderRestriction` 조회 필터와 `/live/room/enter` 성별 제한 회귀 검증을 PRD/계획에 반영. 구현은 아직 시작하지 않음.
|
|
||||||
- 2026-09-17: `P1-T1` RED 확인. 신규 인자 추가 전 `compileTestKotlin`에서 `isViewerCreator`/`effectiveViewerGender` 미정의로 실패했고, 시그니처 연결 후 repository focused test 79건 중 크리에이터 제한 1건과 성별 제한 2건이 assertion 실패했다. facade 전달 전 `HomeRecommendationControllerTest.shouldLogHomeRecommendationPageFailure`도 신규 인자 불일치로 실패했다.
|
|
||||||
- 2026-09-17: `P1-T1` GREEN 확인. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest'`가 `BUILD SUCCESSFUL`로 종료했다. 컴파일 경고는 기존 deprecated API 및 기존 테스트 경고이며 신규 실패는 없다.
|
|
||||||
- 2026-09-17: `P1-T1` REFACTOR 확인. 추가 추상화 없이 기존 QueryDSL 패턴을 재사용했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
|
|
||||||
- 2026-09-17: `P1-T2` RED 확인. 다른 ID의 `MemberRole.CREATOR` viewer 테스트에서 `currentLiveIsViewerCreator`가 기대값 `true`, 실제값 `false`로 실패했다.
|
|
||||||
- 2026-09-17: `P1-T2` GREEN 확인. `CreatorChannelLiveQueryService`가 `viewer.role == MemberRole.CREATOR`를 전달하도록 최소 수정한 뒤 `./gradlew test --rerun-tasks --tests '*CreatorChannelLiveQueryServiceTest'`가 `BUILD SUCCESSFUL`로 종료했다.
|
|
||||||
- 2026-09-17: `P1-T2` REFACTOR 확인. 별도 추상화 없이 기존 역할 enum을 사용했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
|
|
||||||
- 2026-09-17: `P1-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest' --tests '*CreatorChannelLiveQueryServiceTest'`와 `./gradlew ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 종료했다. 출력된 deprecated API 및 unchecked cast 경고는 변경 범위 밖의 기존 경고다.
|
|
||||||
- 2026-09-17: `P1-GATE` 수동 대조. `HomeFollowingQueryService`와 `CreatorChannelHomeQueryService`는 변경하지 않았고, 홈 추천·라이브·온에어 facade 세 경로가 조회자 역할과 유효 성별을 전달함을 확인했다.
|
|
||||||
- 2026-09-17: `P1-GATE` 리뷰 승인. Phase 1 전체 변경 범위의 PRD/계획 위반과 기존 필터 약화가 없음을 확인했으며 Blocker는 0건이다.
|
|
||||||
- 2026-09-17: `P2-T1` RED 확인. 다른 크리에이터의 제한 방 입장 테스트가 `SodaException`을 기대했지만 예외가 발생하지 않아 실패했다.
|
|
||||||
- 2026-09-17: `P2-T1` GREEN 확인. `LiveRoomService.enterLive()`의 결제 및 room-info 접근 전에 역할·방 생성자·`isAvailableJoinCreator` 조건을 추가한 뒤 `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest'`가 5건 모두 통과하며 `BUILD SUCCESSFUL`로 종료했다.
|
|
||||||
- 2026-09-17: `P2-T1` REFACTOR 확인. 방 생성자, 일반 사용자, `Gender.NONE`, 성별 불일치 회귀 테스트와 차단 경로의 무부수효과 검증을 유지했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
|
|
||||||
- 2026-09-17: `P2-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest'`, `./gradlew ktlintCheck`, `./gradlew test --rerun-tasks`가 모두 `BUILD SUCCESSFUL`로 종료했다.
|
|
||||||
- 2026-09-17: `P2-GATE` 최종 리뷰 승인. 구현·테스트·문서의 PRD/계획 일치와 API/스키마 유지 여부를 재검토했으며 Blocker는 0건이다.
|
|
||||||
- 2026-09-17: 현재 변경사항 후속 리뷰. focused 140건 및 직접 영향 회귀 42건, 총 182건 통과. `./gradlew ktlintCheck tasks --all` 성공(ktlint UP-TO-DATE). 소스 수정 없이 목록·입장 경계 중심의 검토이므로 전체 회귀는 재실행하지 않았다. 명시된 구현 범위는 적합하나, 기존 `/live/room/info/{id}` 토큰 발급 경계의 제한 누락과 채널 소유자 조회 테스트 보완점을 `reviews/현재변경사항-review.md`에 기록했다. 실제 HTTP/RTC 연결은 미검증이며, 제품 전체의 입장 차단 완결성 승인은 보류한다. 기존 완료 기록은 유지한다.
|
|
||||||
- 2026-09-17: 사용자 요청에 따라 `REV-001`을 토큰 발급 제한 보강 `P3-T1`, `REV-002`를 채널 소유자 조회 테스트 `P3-T2`로 구체화하고 `P3-GATE`를 추가했다. 파일·조건·예외 key·회귀 시나리오·명령·완료 증거를 명시했으며 기존 Phase 1~2 완료 기록은 유지했다. `./gradlew tasks --all`은 exit 0, `BUILD SUCCESSFUL`; `git diff --check`도 통과했다. 이번 변경은 계획 문서만 갱신하므로 코드 테스트는 실행하지 않았다. 후속 구현은 미착수이며 다음 Goal은 `P3-T1`이다.
|
|
||||||
- 2026-09-17: `P3-T1` 시작 전 문서 정합성 완료. PRD의 관련 review, 문제·목표·사용자 흐름, `LCR-003`, API 계약, 보안 기준과 미완료 성공 기준에 `/live/room/info/{id}` 토큰 발급 경계 및 `REV-002` 회귀 검증을 반영했다.
|
|
||||||
- 2026-09-17: `P3-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest'`가 119건, 실패·오류·skip 0으로 `BUILD SUCCESSFUL` 종료했다. `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`도 모두 성공했다.
|
|
||||||
- 2026-09-17: 토큰 발급 경계는 보안 관련 변경이므로 조건부 전체 회귀를 실행했다. `./gradlew test --rerun-tasks`가 `BUILD SUCCESSFUL`로 종료했다. 실제 HTTP 인증/직렬화 및 Agora RTC/RTM 연결은 환경상 실행하지 않았고, 실제 서비스 메서드 호출과 token builder 무호출 assertion 및 H2 repository 조회를 대체 실행 증거로 사용했다.
|
|
||||||
- 2026-09-17: `P3-GATE` 최종 reviewer gate 승인. Phase 3 구현·테스트·PRD·계획·후속 리뷰 해소 기록을 전체 diff 기준으로 검토했으며 Blocker는 0건이다.
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# 라이브 크리에이터 입장 제한 PRD
|
|
||||||
|
|
||||||
## 문서 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 문서 상태 | Phase 1~3 구현 및 검증 완료 |
|
|
||||||
| 작성일 | `2026-09-17` |
|
|
||||||
| 최종 수정일 | `2026-09-17` |
|
|
||||||
| 대상 제품 | 라이브 방 목록 노출 및 입장 제한 |
|
|
||||||
| 작성자·결정권자 | Sisyphus / 사용자 |
|
|
||||||
| 관련 API Contract | 기존 API 응답 스키마 변경 없음 |
|
|
||||||
| 관련 구현 계획 | `docs/20260917_라이브_크리에이터_입장제한/plan-task.md` |
|
|
||||||
| 관련 review | `reviews/현재변경사항-review.md` (`REV-001`, `REV-002`) |
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
라이브 방 생성자가 `isAvailableJoinCreator = false`로 설정한 방은 다른 크리에이터 계정에게 노출되거나 입장 가능하면 안 된다. 또한 `genderRestriction`으로 입장 가능 성별이 제한된 방은 조회 단계에서도 입장 가능한 사용자에게만 노출돼야 한다. 기존 `/live/room` 계열 조회는 대부분 이 정책을 적용하지만, v2 홈 추천/온에어 라이브와 일부 크리에이터 채널 라이브 경로에서 정책 누락 가능성이 확인됐다. 후속 리뷰에서 `/live/room/info/{id}`가 같은 제한을 확인하지 않고 RTC/RTM 토큰을 발급하는 경로도 확인됐다.
|
|
||||||
|
|
||||||
## 2. Problem Statement
|
|
||||||
|
|
||||||
- `v2` 홈 추천/온에어 라이브 조회는 `findLiveRecommendations()`에서 조회자의 크리에이터 여부와 유효 성별을 받지 않아 `isAvailableJoinCreator = false`인 방도 크리에이터에게 노출될 수 있고, 성별 제한 방도 입장 불가능한 사용자에게 노출될 수 있다.
|
|
||||||
- `v2` 크리에이터 채널 라이브 탭은 조회자가 크리에이터인지가 아니라 조회자가 해당 채널 주인인지로만 `isViewerCreator`를 계산해, 다른 크리에이터에게 제한 방이 노출될 수 있다.
|
|
||||||
- `/live/room/enter`는 성인, 비공개, 차단, 강퇴, 성별, 정원, 결제 조건은 확인하지만 크리에이터 입장 제한을 최종 차단하지 않는다.
|
|
||||||
- `/live/room/info/{id}`는 방 정보와 상호 차단만 확인한 뒤 RTC/RTM 토큰을 생성하므로 `/enter`에서 거절된 사용자도 토큰 발급을 요청할 수 있다.
|
|
||||||
|
|
||||||
문제를 해결했다는 판단은 다른 크리에이터가 `isAvailableJoinCreator = false` 방을 리스트에서 보지 못하고, 성별 제한에 맞지 않는 사용자가 제한 방을 리스트에서 보지 못하며, 직접 `enter` 요청을 보내도 차단되는 것으로 한다.
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
|
|
||||||
- 다른 크리에이터 계정은 `isAvailableJoinCreator = false` 라이브 방을 문제 의심 경로에서 볼 수 없다.
|
|
||||||
- 다른 크리에이터 계정은 `isAvailableJoinCreator = false` 라이브 방에 직접 입장할 수 없다.
|
|
||||||
- 입장 가능 성별이 제한된 라이브 방은 성별이 맞는 사용자 또는 방 생성자 본인에게만 조회된다.
|
|
||||||
- 입장 가능 성별이 제한된 라이브 방은 성별이 맞는 사용자 또는 방 생성자 본인만 입장할 수 있다.
|
|
||||||
- 방을 만든 크리에이터 본인은 예외로 계속 조회 및 입장 가능하다.
|
|
||||||
- 제한 사용자는 `/live/room/info/{id}`에서 RTC/RTM/v2v 토큰을 발급받을 수 없다.
|
|
||||||
- 기존 응답 DTO와 공개 API 스키마는 변경하지 않는다.
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
|
|
||||||
- `isAvailableJoinCreator` 필드명, 의미, DB 스키마 변경은 하지 않는다.
|
|
||||||
- 일반 유저, 관리자, 봇, 에이전트의 기존 노출/입장 정책은 변경하지 않는다.
|
|
||||||
- 기존 성인/성별/차단/강퇴/비공개/결제 정책은 재설계하지 않는다.
|
|
||||||
- `Gender.NONE` 사용자의 기존 정책은 변경하지 않는다. 기존 `Member.canEnter()`와 리스트 조건처럼 성별 미설정 사용자는 성별 제한을 통과한다.
|
|
||||||
- 새 API endpoint나 새 응답 필드는 만들지 않는다.
|
|
||||||
|
|
||||||
## 5. Target Users and Permissions
|
|
||||||
|
|
||||||
| 사용자 | 목표 | 정책 |
|
|
||||||
|---|---|---|
|
|
||||||
| 방 생성 크리에이터 | 본인이 만든 방 조회 및 입장 | `isAvailableJoinCreator = false`여도 허용 |
|
|
||||||
| 다른 크리에이터 | 크리에이터 입장 제한 방 접근 방지 | `isAvailableJoinCreator = false`면 목록 미노출 및 입장 차단 |
|
|
||||||
| 일반 유저 | 기존 라이브 조회 및 입장 | `genderRestriction`에 맞으면 허용, `Gender.NONE`은 기존처럼 허용 |
|
|
||||||
|
|
||||||
## 6. 핵심 사용자 흐름
|
|
||||||
|
|
||||||
1. 일반 유저 또는 방 생성자가 `isAvailableJoinCreator = false` 방을 조회하면 기존 정책에 따라 표시된다.
|
|
||||||
2. 성별 제한에 맞지 않는 사용자가 `genderRestriction` 제한 방을 v2 홈 추천/온에어 라이브에서 조회하면 표시되지 않는다.
|
|
||||||
3. 다른 크리에이터가 같은 방을 v2 홈 추천/온에어 라이브 또는 크리에이터 채널 라이브 경로에서 조회하면 표시되지 않는다.
|
|
||||||
4. 성별 제한에 맞지 않는 사용자가 방 ID를 알고 `/live/room/enter`를 호출하면 입장이 거부된다.
|
|
||||||
5. 다른 크리에이터가 방 ID를 알고 `/live/room/enter`를 호출하면 입장이 거부된다.
|
|
||||||
6. 방 생성 크리에이터가 본인 방에 입장하면 기존처럼 허용된다.
|
|
||||||
7. `/enter`에서 거절되는 다른 크리에이터 또는 성별 불일치 사용자가 `/live/room/info/{id}`를 호출하면 토큰 생성 전에 같은 정책으로 거절된다.
|
|
||||||
8. 방 생성자, 성별이 맞는 일반 사용자, 크리에이터 입장이 허용된 방의 다른 크리에이터는 기존처럼 방 정보와 토큰을 받는다.
|
|
||||||
|
|
||||||
## 7. 기능 요구사항
|
|
||||||
|
|
||||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `LCR-001` | 확정 | v2 홈 추천/온에어 라이브 조회에서 다른 크리에이터에게 `isAvailableJoinCreator = false` 방을 숨기고, 성별 제한에 맞지 않는 사용자에게 `genderRestriction` 제한 방을 숨긴다. | `HomeRecommendationQueryService.findLiveRecommendations()` 호출 경로가 조회자 크리에이터 여부와 유효 성별을 전달한다. repository는 `liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(memberId))`와 `Gender.MALE -> ALL/MALE_ONLY`, `Gender.FEMALE -> ALL/FEMALE_ONLY`, `Gender.NONE/null -> 필터 없음` 조건을 적용한다. | `P1-T1` |
|
|
||||||
| `LCR-002` | 확정 | v2 크리에이터 채널 라이브 탭에서 다른 크리에이터에게 제한 방을 숨긴다. | `CreatorChannelLiveQueryService`가 `viewer.role == MemberRole.CREATOR` 기준으로 조회자 크리에이터 여부를 전달한다. 방 생성자 본인은 예외다. | `P1-T2` |
|
|
||||||
| `LCR-003` | 확정 | `/live/room/enter`와 `/live/room/info/{id}`에서 다른 크리에이터의 제한 방 입장·토큰 발급과 성별 제한에 맞지 않는 접근을 차단한다. | 다른 크리에이터 제한은 `live.room.not_found`, 성별 제한은 `live.room.gender_restricted`로 결제·입장 상태 변경 또는 RTC/RTM/v2v 토큰 생성 전에 거절한다. 생성자는 예외이며 성별 판정은 `Member.canEnter()`를 재사용한다. | `P2-T1`, `P3-T1` |
|
|
||||||
| `LCR-004` | 확정 | 기존 정상 경로는 유지한다. | 일반 유저와 방 생성자 본인의 조회/입장 동작은 유지되고 기존 테스트 또는 신규 회귀 테스트로 확인된다. | `P2-GATE` |
|
|
||||||
|
|
||||||
## 8. API 계약
|
|
||||||
|
|
||||||
- 변경되는 공개 request/response 필드는 없다.
|
|
||||||
- `/live/room/enter`는 기존 오류 envelope를 사용한다.
|
|
||||||
- `/live/room/info/{id}`도 기존 오류 envelope와 message key를 재사용하며 응답 필드는 변경하지 않는다.
|
|
||||||
- 신규 message key 추가 여부는 구현 단계에서 기존 `live.room.gender_restricted`, `common.error.invalid_request`, `live.room.not_found` 패턴을 확인해 결정한다. 새 key가 필요하면 메시지 리소스와 테스트를 함께 갱신한다.
|
|
||||||
|
|
||||||
## 9. 보안과 데이터 취급
|
|
||||||
|
|
||||||
- 숨김 정책은 클라이언트 표시만으로 끝내지 않고 서버 입장 경계에서 한 번 더 차단한다.
|
|
||||||
- 제한 방 존재 여부를 다른 크리에이터에게 과도하게 노출하지 않는 오류 메시지를 우선한다.
|
|
||||||
- 민감정보, 결제 정보, password, token은 로그나 테스트 fixture에 기록하지 않는다.
|
|
||||||
- 제한 검사는 RTC, RTM, v2v 토큰 생성 호출보다 먼저 수행한다.
|
|
||||||
|
|
||||||
## 10. 성공 기준
|
|
||||||
|
|
||||||
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방을 v2 홈 추천/온에어 라이브에서 받지 않는다. (`LCR-001`)
|
|
||||||
- [x] 성별 제한에 맞지 않는 사용자는 `genderRestriction` 제한 방을 v2 홈 추천/온에어 라이브에서 받지 않는다. (`LCR-001`)
|
|
||||||
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방을 v2 크리에이터 채널 라이브 탭에서 받지 않는다. (`LCR-002`)
|
|
||||||
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방에 직접 입장할 수 없다. (`LCR-003`)
|
|
||||||
- [x] 성별 제한에 맞지 않는 사용자는 제한 방에 직접 입장할 수 없다. (`LCR-003`)
|
|
||||||
- [x] 방 생성 크리에이터 본인은 본인 방을 조회하고 입장할 수 있다. (`LCR-001`~`LCR-003`)
|
|
||||||
- [x] 공개 API 스키마 변경 없이 focused test와 영향 범위 회귀가 통과한다.
|
|
||||||
- [x] 다른 크리에이터와 성별 불일치 사용자는 `/live/room/info/{id}`에서 토큰 생성 전에 거절되고 토큰 생성기가 호출되지 않는다. (`LCR-003`)
|
|
||||||
- [x] 방 생성자, 성별이 맞는 일반 사용자, 크리에이터 입장이 허용된 방의 다른 크리에이터, `Gender.NONE` 사용자는 기존처럼 방 정보와 토큰을 받는다. (`LCR-003`, `LCR-004`)
|
|
||||||
- [x] 채널 소유자 본인은 `isAvailableJoinCreator = false`인 현재 방을 repository 조회에서 받는다. (`LCR-002`, `LCR-004`)
|
|
||||||
|
|
||||||
## 11. Decision Log
|
|
||||||
|
|
||||||
| 일시 | 결정 | 근거 |
|
|
||||||
|---|---|---|
|
|
||||||
| 2026-09-17 | `isAvailableJoinCreator = false`는 다른 크리에이터만 제한하고 방 생성자 본인은 예외로 둔다. | 기존 QueryDSL 조건들이 `liveRoom.member.id.eq(viewerId)` 예외를 두는 패턴과 사용자 선택 A |
|
|
||||||
| 2026-09-17 | 이 작업은 문서가 필요한 구현 작업으로 분류한다. | 저장소 `AGENTS.md`와 `docs/agent-guides/작업절차.md`가 모든 구현 작업 전 PRD와 plan-task를 요구 |
|
|
||||||
| 2026-09-17 | v2 홈 추천/온에어 라이브에도 기존 조회 경로와 같은 `genderRestriction` 필터를 적용한다. | `/live/room`, 팔로잉, 크리에이터 채널 조회 경로가 이미 성별 조건을 조회 단계에서 적용하고 있으며 사용자가 문서 반영을 요청 |
|
|
||||||
| 2026-09-17 | `/live/room/enter`의 성별 제한은 기존 `Member.canEnter()` 정책을 유지한다. | 현재 `LiveRoomService.enterLive()`가 이미 `room.member.id != member.id && !member.canEnter(room.genderRestriction)`을 차단하고, `Gender.NONE`은 `canEnter()`에서 허용됨 |
|
|
||||||
| 2026-09-17 | `/live/room/info/{id}`에서도 `/enter`와 같은 크리에이터·성별 제한을 토큰 생성 전에 적용한다. | `REV-001`에서 `/enter` 거절 후에도 RTC/RTM/v2v 토큰 생성 경로가 열려 있음을 코드로 확인 |
|
|
||||||
| 2026-09-17 | 채널 소유자의 제한 방 조회는 production 변경 없이 H2 repository 회귀 테스트로 고정한다. | `REV-002`와 기존 `creatorJoinLiveCondition()`의 소유자 예외 |
|
|
||||||
|
|
||||||
## 12. 열린 질문
|
|
||||||
|
|
||||||
- 없음. 구현 중 기존 메시지 key 재사용이 부적절하다고 확인되면 plan-task 범위를 먼저 갱신한다.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# 라이브 크리에이터 입장 제한 현재 변경사항 리뷰
|
|
||||||
|
|
||||||
## 1. 리뷰 정보
|
|
||||||
|
|
||||||
- 일자: 2026-09-17
|
|
||||||
- 기준: `62c834d28f0d142e81f2d303273eb1220cab0611` 위의 현재 staged/working tree 변경사항. commit 자체에 대한 승인이 아니다.
|
|
||||||
- 소스 diff SHA-256 (`git diff HEAD -- src`): `7b951c79f1575832ac65870a479005909da22e640772c5609a3cbdd983209b98`
|
|
||||||
- 기준 문서: `../prd.md`, `../plan-task.md`
|
|
||||||
- 범위: LCR-001~004, 변경 production 7개 파일과 관련 테스트, 기존 조회/입장 정책.
|
|
||||||
- 상태: 판정 완료. production 및 테스트 코드는 수정하지 않았다.
|
|
||||||
|
|
||||||
## 2. 검토 기준 및 근거
|
|
||||||
|
|
||||||
- 홈 추천·라이브·온에어의 역할/인증 성별 전달, QueryDSL 필터와 생성자 예외를 대조한다.
|
|
||||||
- 크리에이터 채널 라이브의 역할 판정과 기존 repository 조건을 대조한다.
|
|
||||||
- 직접 입장 거부가 결제 및 roomInfo 접근보다 먼저 발생하는지 확인한다.
|
|
||||||
- 공개 DTO, 일반 사용자, Gender.NONE, 기존 성인/차단/결제 정책의 회귀 여부를 확인한다.
|
|
||||||
- `docs/sample/sample-review.md` 기준으로 확정 결함과 검증 한계를 구분한다.
|
|
||||||
|
|
||||||
## 3. 검토별 증거 기록
|
|
||||||
|
|
||||||
| 검토 | 기준 HEAD | 판정 | 근거 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 코드 품질 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/quality`: SQL WHERE 적용 후 페이지네이션, 호출 3곳 인자 전달, 기본값 및 생성자 예외, 부수효과 전 차단 확인. 이전 XML은 이번 실행 증거로 사용하지 않음. |
|
|
||||||
| 요구사항 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/requirements`: LCR-001~004 구현 일치. 채널 소유자 조회의 직접 회귀 테스트는 보완 여지 있음. |
|
|
||||||
| 주변 문맥 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/context`: 기존 팔로잉·채널 홈 조건 및 Member.canEnter와 일치. 기존 상세 조회의 제한 미검사는 이번 diff 밖임. |
|
|
||||||
| 보안 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | FAIL | `/root/security`: getRoomInfo의 기존 RTC 토큰 발급 경로가 입장 제한을 검사하지 않음. 코드 추적 근거이며 실제 RTC 연결은 미검증. |
|
|
||||||
| 자동 QA | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/qa`: focused 140건 + 인접 회귀 42건 통과. 실제 HTTP/RTC QA는 INCONCLUSIVE. |
|
|
||||||
| 런타임 감사 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | INCONCLUSIVE | H2 쿼리 결과, 서비스 거부 예외 및 무부수효과 assertion은 실행 확인. 실제 RTC 토큰을 사용한 연결 우회는 실행하지 않음. |
|
|
||||||
|
|
||||||
## 4. 실행 검증
|
|
||||||
|
|
||||||
환경: macOS, Java 17, Gradle Wrapper, repository 테스트 H2. 전체 회귀 대신 변경 파일 focused test 및 직접 영향받는 기능 회귀를 실행한다. 공개 DTO·공통 인증·설정 변경은 없으며 이 리뷰에서 소스를 수정하지 않아 전체 테스트를 재실행하지 않았다. 기존 계획의 전체 회귀 성공 기록은 이번 실행 결과로 간주하지 않는다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest'
|
|
||||||
```
|
|
||||||
|
|
||||||
- `/root/qa` 실행: BUILD SUCCESSFUL, 140건, 실패/오류/skip 0, 3분 19초.
|
|
||||||
- `./gradlew ktlintCheck tasks --all`: BUILD SUCCESSFUL. ktlint 작업은 UP-TO-DATE이며 강제 재실행은 하지 않았다.
|
|
||||||
- `git diff --check`: exit 0.
|
|
||||||
- `./gradlew test --tests '*HomeRecommendationControllerTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*HomeOnAirLiveControllerTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'`: exit 0, 42건, 실패/오류/skip 0, 41초.
|
|
||||||
- 합계: 182건 통과. 추천 repository 81, 추천 service 35, 추천 facade 6, 온에어 facade 4, 채널 service 9, 입장 service 5, 추천 controller 27, 온에어 controller 2, 채널 repository 8, 기존 성인 정책 5.
|
|
||||||
- 실제 HTTP/RTC 연결은 미검증. localhost:8080에 실행 중 서버가 없고 운영 데이터·외부 서비스에 연결하지 않았다. 서비스 Mockito 테스트 및 H2 repository 테스트는 입장 거부/조회 필터의 실행 증거이며 실제 RTC 우회 재현 증거는 아니다.
|
|
||||||
|
|
||||||
## 5. 발견 사항 및 종료 판정
|
|
||||||
|
|
||||||
### REV-001 — 기존 방 정보 API의 토큰 발급 경계에 제한 검사 없음
|
|
||||||
|
|
||||||
- 심각도: High. 상태: 제한 검사 누락은 코드로 확정, 실제 RTC 접속 우회는 미검증.
|
|
||||||
- 관련 목표: PRD §3의 다른 크리에이터·성별 불일치 사용자의 입장 차단.
|
|
||||||
- 이번 diff가 도입한 결함은 아니며, 계획에 명시된 목록 및 `/enter` 수정 범위 밖의 기존 경로다.
|
|
||||||
- `LiveRoomController.kt:129`의 `GET /live/room/info/{id}`는 로그인 확인 후 `getRoomInfo()`를 호출한다.
|
|
||||||
- `LiveRoomService.kt:972`는 roomInfo·room 존재와 상호 차단만 검사한다. 크리에이터 제한, 성별 제한, 호출자의 입장 완료 여부는 확인하지 않는다.
|
|
||||||
- 같은 파일 `:991`부터 RTC 토큰 생성기를 호출하고 `:1065`부터 채널명과 토큰을 응답에 넣는다. `RtcTokenBuilder.kt:83`은 JoinChannel 권한을 추가한다.
|
|
||||||
- 확인 시나리오: roomInfo가 존재하는 제한 방에서, 호스트와 차단 관계가 없는 다른 크리에이터가 방 ID로 `/info/{id}`를 요청한다. `/enter`에서 거절됐더라도 이 경로의 토큰 발급 전 제한 검사가 없다.
|
|
||||||
- 권장 조치: 토큰 발급 경계에도 생성자 예외를 포함한 크리에이터·성별 제한을 적용하고, 제한 사용자는 토큰 생성기가 호출되지 않는 회귀 테스트로 고정한다. 전체 입장 정책 재설계는 별도 범위다.
|
|
||||||
|
|
||||||
### REV-002 — 채널 소유자 조회의 직접 회귀 테스트 보완
|
|
||||||
|
|
||||||
- 심각도: Low, 비차단 검증 보완점.
|
|
||||||
- `CreatorChannelLiveQueryServiceTest.kt:67`은 다른 크리에이터의 `isViewerCreator` 전달을 검증한다.
|
|
||||||
- `DefaultCreatorChannelLiveQueryRepositoryTest.kt:206`은 다른 크리에이터 조회 필터를 검증하지만, 소유자 본인의 제한 방 반환을 직접 검증하지 않는다.
|
|
||||||
- 소유자 허용 조건 자체는 `DefaultCreatorChannelLiveQueryRepository.kt:353`에 존재하므로 구현 누락으로 판정하지 않는다.
|
|
||||||
- 권장 조치: viewerId == creatorId이고 isAvailableJoinCreator == false인 현재 방이 반환되는 repository 회귀 테스트 1건으로 P1-T2 완료 증거를 보강한다.
|
|
||||||
|
|
||||||
## 6. 후속 처리
|
|
||||||
|
|
||||||
코드 수정은 수행하지 않았다. 명시된 구현 범위의 기능 결함은 발견하지 못했으나, 제품 전체의 입장 차단 완료 판정에는 REV-001 확인·보강이 필요하다. 구현을 진행할 경우 기존 계획에 토큰 발급 경계 보강 Task를 추가한 뒤 실패 재현 → 최소 수정 → 회귀 검증 순서로 진행한다. 기존 완료 체크박스와 검증 기록은 유지한다.
|
|
||||||
|
|
||||||
**종합 판정:** 명시된 목록·`/enter` 구현 범위는 적합하다. 보안 검토 FAIL 및 실제 API/RTC 검증 한계 때문에 제품 전체의 입장 차단에 대한 무조건 승인은 보류한다. 이번 diff에서 새로 도입된 확정 결함은 발견하지 못했다.
|
|
||||||
|
|
||||||
**다음 행동:** REV-001의 토큰 발급 경계를 후속 검증·수정 범위로 반영한다.
|
|
||||||
|
|
||||||
## 7. Phase 3 후속 구현 검증
|
|
||||||
|
|
||||||
- 일자: 2026-09-17
|
|
||||||
- 대상: `REV-001`, `REV-002` 후속 구현과 Phase 3 Gate.
|
|
||||||
- [x] `REV-001` 해소: `LiveRoomService.getRoomInfo()`가 기존 room/roomInfo 존재 및 상호 차단 검사 뒤, RTC/RTM/v2v 토큰 생성 전에 다른 크리에이터와 성별 불일치 사용자를 각각 `live.room.not_found`, `live.room.gender_restricted`로 거절한다.
|
|
||||||
- [x] `REV-001` 회귀: 제한 경로의 token builder 무호출, `/enter` 거절 후 정보 조회 거절, 소유자·성별 일치 사용자·허용 방 크리에이터·인증 성별 우선·`Gender.NONE` 허용, 기존 상호 차단 거절을 서비스 테스트로 확인했다.
|
|
||||||
- [x] `REV-002` 해소: 실제 H2 repository 테스트가 채널 소유자에게 `isAvailableJoinCreator = false`인 본인 현재 방 ID를 반환하고, 기존 다른 크리에이터 제외 테스트도 통과했다.
|
|
||||||
- [x] Phase 3 focused 회귀: 5개 테스트 클래스 119건이 실패·오류·skip 0으로 통과했다.
|
|
||||||
- [x] 품질 및 명령 검증: `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 성공했다.
|
|
||||||
- [x] 전체 회귀: 보안 경계 변경을 반영해 `./gradlew test --rerun-tasks`를 실행했고 `BUILD SUCCESSFUL`로 종료했다.
|
|
||||||
- [x] 검증 한계 기록: 실제 HTTP 인증/직렬화 및 Agora RTC/RTM 연결은 실행하지 않았으며 서비스 메서드 호출, token builder 무호출 assertion, 실제 H2 조회를 대체 실행 증거로 사용했다.
|
|
||||||
- [x] 최종 reviewer gate: Phase 3 구현·테스트·문서 정합성을 전체 diff 기준으로 재검토했고 Blocker 0건으로 승인됐다.
|
|
||||||
|
|
||||||
**후속 판정:** `REV-001`, `REV-002`는 구현 및 회귀 검증으로 해소됐다. 기존 리뷰의 보안 FAIL은 토큰 발급 전 서버 제한 검사 누락 기준에서 해소됐으며, 실제 RTC 연결 검증을 수행했다는 주장은 하지 않는다.
|
|
||||||
|
|
||||||
## 8. Phase 1~3 문서·코드 정적 재대조 — 2026-09-17
|
|
||||||
|
|
||||||
- 사용자 요청: 전체 자동 테스트는 이미 수행됐으므로 재실행하지 않고 문서와 실제 코드의 스펙 일치 여부를 확인한다.
|
|
||||||
- 기준 HEAD: `62c834d28f0d142e81f2d303273eb1220cab0611` 및 현재 staged/working tree 변경.
|
|
||||||
- 소스 diff SHA-256 (`git diff HEAD -- src`): `4d90f90b2a262d1ee081c8e9fc78085ce6d04199d9c50a3d5a7506ceaeed0fbe`.
|
|
||||||
- 범위: LCR-001~004, P1~P3, REV-001/002, 호출 경로와 테스트 assertion. 이번 검토에서 테스트·애플리케이션은 실행하지 않는다. 기존 실행 성공 기록과 이번 정적 검토 판정은 구분한다.
|
|
||||||
|
|
||||||
| 검토 | 정적 판정 | 근거 |
|
|
||||||
|---|---|---|
|
|
||||||
| 요구사항 | PASS | `/root/spec_v3`: 전체 호출 3곳 역할·성별 전달, 목록 필터 및 생성자 예외, 입장/토큰 발급 전 거절, 채널 소유자 repository assertion까지 LCR-001~004 구현 확인. |
|
|
||||||
| 보안 경계 | PASS | `/root/security_v3`: LiveRoomService.kt:986~995 검사 뒤 :1001/:1009/:1016 토큰 생성. 기존 존재·양방향 차단 검사 및 /enter의 결제 전 차단 유지. |
|
|
||||||
| 코드 품질 | PASS | `/root/quality_v3`: 모든 호출부 인자 전달, WHERE 필터 후 페이지네이션, 내부 기본값 및 공개 API/DB 스키마 유지. |
|
|
||||||
| 테스트 소스 | PASS | `/root/tests_v3`: LiveRoomServiceTest.kt:180~326 제한/허용/우선순위/무호출 assertion, :368 세 토큰 응답 및 호출 횟수, 채널 repository 테스트 :244~266 실제 소유자 방 ID assertion 확인. 실행 QA 판정은 아님. |
|
|
||||||
| 문서·주변 정책 | PASS | `/root/context_v3`: Member.canEnter 및 기존 채널 조건과 일치. 이전 FAIL/미착수는 보존된 이력이며 후속 완료 기록으로 해소됨. |
|
|
||||||
|
|
||||||
위 판정은 명시된 HEAD와 소스 diff 조합에 한정한다.
|
|
||||||
|
|
||||||
**요구사항 대조:**
|
|
||||||
|
|
||||||
- LCR-001: 홈 추천·라이브·온에어의 역할/유효 성별 전달과 조회 필터 일치. 생성자 예외와 NONE/null 정책 유지.
|
|
||||||
- LCR-002: 채널 조회자의 실제 CREATOR 역할 판정 및 생성자 예외 일치. REV-002의 소유자 H2 테스트 추가 확인.
|
|
||||||
- LCR-003: /enter는 결제·상태 변경 전, /info는 모든 토큰 생성 전에 동일 제한 검사. 예외 key·우선순위·생성자 예외·Member.canEnter 재사용 일치. REV-001 해소 확인.
|
|
||||||
- LCR-004: 허용 사용자·생성자·인증 성별 우선·Gender.NONE 회귀 assertion 존재. 공개 DTO/DB 스키마 및 기존 제한 조건 유지.
|
|
||||||
|
|
||||||
**최종 정적 판정: PASS.** 문서에 명시된 Phase 1~3 구현 및 후속 두 항목의 누락·스펙 위반은 발견하지 못했다. 새 수정 Task로 전환할 확정 항목은 없다. 코드·테스트는 변경하지 않았으며 이 절만 누적했다. 사용자 요청에 따라 테스트와 Gradle 작업을 재실행하지 않고 문서·소스·diff를 대조했다. 전체 자동 테스트 성공은 사용자 확인 및 기존 문서 기록으로 구분하며, 이번에 HTTP/RTC 실행 또는 과거 RED→GREEN을 재검증했다고 주장하지 않는다.
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# 성인 콘텐츠 노출 정책 Deprecated 함수 제거 구현 계획
|
|
||||||
|
|
||||||
| 문서 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 상태 | Phase 1~3 완료, P3-R1 구현 완료·실행 검증 대기 |
|
|
||||||
| 작성일 | `2026-09-17` |
|
|
||||||
| 요구사항 기준 | `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/prd.md` |
|
|
||||||
| API 기준 | 기존 API 응답 스키마 변경 없음 |
|
|
||||||
| 현재 Phase | Phase 3 후속 실행 검증 대기 |
|
|
||||||
| 현재 활성 Goal | `P3-R1` 실행 검증 |
|
|
||||||
| 다음 Goal | 없음 |
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
`MemberContentPreferencePolicy.isAdultVisibleByPolicy(...)` / `resolveCountryCodeByPolicy(...)` Deprecated 경로를 `MemberContentPreferenceService` 정식 경로로 이전하고, Deprecated 함수와 전용 테스트를 제거한다. 사용자가 확정한 JP 강제 매핑의 `2L` 제거를 제외하고 성인 콘텐츠 노출 판정 결과는 유지한다.
|
|
||||||
|
|
||||||
## 현재 상태
|
|
||||||
|
|
||||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
|
||||||
|---:|---|---:|---|---|
|
|
||||||
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
|
||||||
| 2 | 완료 | `4/4` | 없음 | 없음 |
|
|
||||||
| 3 | 완료 | `1/1` | 없음 | 없음 |
|
|
||||||
| 3 후속 | 진행 중 | `0/1` | 활성: `P3-R1` 실행 검증 | 회귀 테스트 구현·소스 검토 완료, 자동 실행 대기 |
|
|
||||||
|
|
||||||
## 범위
|
|
||||||
|
|
||||||
### 포함
|
|
||||||
|
|
||||||
- `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)` 추가.
|
|
||||||
- production 12개 파일 27곳의 `isAdultVisibleByPolicy(...)` 호출 치환 및 `MemberContentPreferenceService` 생성자 주입.
|
|
||||||
- `MemberContentPreferencePolicy.kt`, `MemberContentPreferencePolicyTest.kt` 제거와 고유 검증 이전.
|
|
||||||
- 영향 범위 focused test 및 ktlint 검증.
|
|
||||||
|
|
||||||
### 제외
|
|
||||||
|
|
||||||
- `isAdult` 판정 알고리즘 변경 및 JP 강제 매핑의 `2L` 제거 외 국가 강제 매핑 회원 ID 변경.
|
|
||||||
- 각 서비스 public 메서드 시그니처 변경, 공개 API 스키마 변경.
|
|
||||||
- `CanController`의 통화 강제 로직.
|
|
||||||
|
|
||||||
## 기술적 제약
|
|
||||||
|
|
||||||
- 기술 스택: Kotlin, Spring Boot 2.7.14, Gradle Wrapper, ktlint.
|
|
||||||
- 신규 abstraction을 만들지 않는다. 기존 `MemberContentPreferenceService`의 `resolveCountryCode` + `calculateIsAdultForQuery`를 재사용한다.
|
|
||||||
- `CountryContext`는 `@RequestScope`이므로 마이그레이션 대상은 웹 요청 경로에서만 호출되는 서비스로 한정한다(`RISK-001`).
|
|
||||||
- 모든 production 변경 Task는 `RED → GREEN → REFACTOR` 순서로 진행한다.
|
|
||||||
- 테스트 실행은 변경 범위 focused test를 우선하고, 전체 회귀는 실행하지 않으며 생략 근거를 검증 기록에 남긴다.
|
|
||||||
|
|
||||||
## Phase 1: 정식 경로 단일 진입점 확보
|
|
||||||
|
|
||||||
**Phase 결과:** `MemberContentPreferenceService.isAdultVisibleForQuery(...)`가 Deprecated 함수와 동일한 판정을 제공한다.
|
|
||||||
|
|
||||||
**선행조건:** PRD 확정.
|
|
||||||
|
|
||||||
**Phase 완료 조건:** `P1-T1` 완료 및 검증 기록 누적.
|
|
||||||
|
|
||||||
### 구현 항목
|
|
||||||
|
|
||||||
#### Task 1.1 `isAdultVisibleForQuery` 추가
|
|
||||||
|
|
||||||
**Goal 실행 `P1-T1`:** `MemberContentPreferenceService`에 접속 국가 계산과 성인 노출 판정을 묶은 단일 메서드를 추가한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `DEPREM-001` 확정.
|
|
||||||
- **완료 증거:** KR + 미인증 → `false`, KR + 인증 → 전달값, 비KR → 전달값, 강제 매핑 회원은 헤더보다 강제 국가 우선인 focused test 통과.
|
|
||||||
- **범위 밖:** 호출부 치환.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- `fun isAdultVisibleForQuery(member: Member, isAdultContentVisible: Boolean): Boolean`
|
|
||||||
- 구현은 `calculateIsAdultForQuery(member, resolveCountryCode(member), isAdultContentVisible)`로 한다. DB 조회를 추가하지 않는다.
|
|
||||||
|
|
||||||
- [x] **RED:** `MemberContentPreferenceServiceTest`에 `isAdultVisibleForQuery` 판정 테스트 4건(KR 미인증/KR 인증/비KR/강제 매핑)을 작성한다.
|
|
||||||
- [x] **RED 확인:** `MemberContentPreferenceServiceTest` 실행 결과 `Unresolved reference: isAdultVisibleForQuery` 컴파일 실패를 확인했다.
|
|
||||||
- [x] **GREEN:** `MemberContentPreferenceService`에 `isAdultVisibleForQuery`를 추가했다.
|
|
||||||
- [x] **GREEN 확인:** `MemberContentPreferenceServiceTest` 25/25 통과.
|
|
||||||
- [x] **REFACTOR:** `resolveCountryCode` + `calculateIsAdultForQuery` 재사용만으로 구현해 중복 계산이 없음을 확인했고 ktlint 통과.
|
|
||||||
|
|
||||||
## Phase 2: production 호출부 마이그레이션
|
|
||||||
|
|
||||||
**Phase 결과:** production 코드에서 `isAdultVisibleByPolicy(...)` 호출이 0건이 된다.
|
|
||||||
|
|
||||||
**선행조건:** Phase 1 완료.
|
|
||||||
|
|
||||||
**Phase 완료 조건:** `P2-T1`~`P2-T4`와 `P2-GATE` 완료 및 검증 기록 누적.
|
|
||||||
|
|
||||||
### 구현 항목
|
|
||||||
|
|
||||||
#### Task 2.1 메인 탭 서비스 7개 치환
|
|
||||||
|
|
||||||
**Goal 실행 `P2-T1`:** 콘텐츠 메인 탭 서비스 7개가 `MemberContentPreferenceService`로 성인 노출 여부를 계산한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P1-T1` 완료.
|
|
||||||
- **완료 증거:** 7개 파일에서 `isAdultVisibleByPolicy` import/호출 0건, 컴파일 및 영향 범위 테스트 통과.
|
|
||||||
- **범위 밖:** `AudioContentService`, `ContentSeriesService`.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- 각 서비스 생성자에 `private val memberContentPreferenceService: MemberContentPreferenceService`를 추가한다.
|
|
||||||
- `isAdultVisibleByPolicy(member, isAdultContentVisible)` → `memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)`.
|
|
||||||
- `AudioContentMainTabHomeService`의 nullable member 처리(`member?.let { ... } ?: false`)는 유지한다.
|
|
||||||
|
|
||||||
- [x] **RED:** 7개 서비스가 각각 대응 `*Controller`에서만 주입됨을 확인해 `RISK-001` 위반 경로가 없음을 검증했다.
|
|
||||||
- [x] **GREEN:** 7개 파일의 호출부와 생성자를 치환했다.
|
|
||||||
- [x] **GREEN 확인:** 테스트 컴파일과 `src/test/kotlin/kr/co/vividnext/sodalive/content` 42/42 통과로 확인했다.
|
|
||||||
- [x] **REFACTOR:** `isAdultVisibleByPolicy` import를 `MemberContentPreferenceService` import로 교체했고 ktlint 통과.
|
|
||||||
|
|
||||||
#### Task 2.2 메인/큐레이션/테마 서비스 치환
|
|
||||||
|
|
||||||
**Goal 실행 `P2-T2`:** `AudioContentMainService`, `AudioContentCurationService`, `AudioContentThemeService`를 정식 경로로 치환한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P2-T1` 완료.
|
|
||||||
- **완료 증거:** 3개 파일에서 `isAdultVisibleByPolicy` 0건, 컴파일 및 영향 범위 테스트 통과.
|
|
||||||
- **범위 밖:** 다른 Task 대상 파일.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- `P2-T1`과 동일한 치환 규칙을 적용한다.
|
|
||||||
|
|
||||||
- [x] **RED:** 3개 서비스의 치환 대상이 모두 `member`를 받는 웹 조회 메서드임을 확인했다.
|
|
||||||
- [x] **GREEN:** 3개 파일을 치환했다.
|
|
||||||
- [x] **GREEN 확인:** 테스트 컴파일 성공 및 `content` 패키지 회귀 통과.
|
|
||||||
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
|
|
||||||
|
|
||||||
#### Task 2.3 `AudioContentService` 치환
|
|
||||||
|
|
||||||
**Goal 실행 `P2-T3`:** `AudioContentService`의 3곳을 정식 경로로 치환한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P2-T2` 완료.
|
|
||||||
- **완료 증거:** `AudioContentServiceTest`가 갱신된 생성자로 통과한다.
|
|
||||||
- **범위 밖:** `ContentSeriesService`.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- `P2-T1`과 동일한 치환 규칙을 적용하고, 테스트에서는 `MemberContentPreferenceService`를 mock으로 주입한다.
|
|
||||||
|
|
||||||
- [x] **RED:** `AudioContentServiceTest.kt:118`에서 `No value passed for parameter 'memberContentPreferenceService'` 컴파일 실패를 확인했다.
|
|
||||||
- [x] **GREEN:** `getDetail`, `getLatestCreatorAudioContent`, `getAudioContentList` 3곳을 치환하고 테스트에 mock을 주입했다.
|
|
||||||
- [x] **GREEN 확인:** `AudioContentServiceTest` 16/16 통과.
|
|
||||||
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
|
|
||||||
|
|
||||||
#### Task 2.4 `ContentSeriesService` 치환
|
|
||||||
|
|
||||||
**Goal 실행 `P2-T4`:** `ContentSeriesService`의 5곳을 정식 경로로 치환한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P2-T3` 완료.
|
|
||||||
- **완료 증거:** 시리즈 관련 영향 범위 테스트 통과, `isAdultVisibleByPolicy` 0건.
|
|
||||||
- **범위 밖:** Deprecated 함수 제거.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt`
|
|
||||||
- Test: 필요 시 `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt` 등 영향 테스트 갱신
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- `P2-T1`과 동일한 치환 규칙을 적용한다.
|
|
||||||
|
|
||||||
- [x] **RED:** 테스트 컴파일을 실행해 `ContentSeriesService`를 직접 생성하는 테스트가 없어 추가 수정이 필요 없음을 확인했다.
|
|
||||||
- [x] **GREEN:** 5곳(`getSeriesList`, `getSeriesListByGenre`, `getSeriesDetail`, `getSeriesContentList`, `getRecommendSeriesList`)을 치환했다.
|
|
||||||
- [x] **GREEN 확인:** 테스트 컴파일 성공 및 Spring 컨텍스트 로딩 테스트 통과.
|
|
||||||
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
|
|
||||||
|
|
||||||
### Phase 2 Gate
|
|
||||||
|
|
||||||
**Goal 실행 `P2-GATE`:** production에서 Deprecated 호출이 0건임을 확인한다.
|
|
||||||
|
|
||||||
- [x] `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 검색 결과가 `src/main/kotlin`에서 0건이다.
|
|
||||||
- [x] Phase 2 대상 파일의 영향 범위 테스트가 통과한다(`content` 42/42).
|
|
||||||
|
|
||||||
## Phase 3: Deprecated 함수 제거와 테스트 정리
|
|
||||||
|
|
||||||
**Phase 결과:** Deprecated 함수와 전용 테스트가 제거되고 고유 검증이 정식 경로 테스트에 남는다.
|
|
||||||
|
|
||||||
**선행조건:** Phase 2 완료.
|
|
||||||
|
|
||||||
**Phase 완료 조건:** `P3-T1`, `P3-GATE` 완료 및 검증 기록 누적.
|
|
||||||
|
|
||||||
### 구현 항목
|
|
||||||
|
|
||||||
#### Task 3.1 Deprecated 정의 제거와 검증 이전
|
|
||||||
|
|
||||||
**Goal 실행 `P3-T1`:** `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 제거하고 고유 검증을 이전한다.
|
|
||||||
|
|
||||||
- **시작 조건:** `P2-GATE` 완료.
|
|
||||||
- **완료 증거:** 두 파일이 삭제되고 `member/contentpreference` 테스트가 모두 통과한다.
|
|
||||||
- **범위 밖:** `MemberContentPreferenceCountryResolver.kt` 변경.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt`
|
|
||||||
- Delete: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- 이전 대상 검증: 헤더 공백/소문자 정규화(`" us "` → `US`), 국가 컨텍스트 없음 → `KR` fallback, 비KR에서 저장 `countryCode` 무시.
|
|
||||||
- 이미 정식 경로 테스트에 존재하는 검증은 중복 추가하지 않는다.
|
|
||||||
|
|
||||||
- [x] **RED:** 로그인 회원의 헤더 공백/소문자 정규화와 KR fallback 검증을 `MemberContentPreferenceServiceTest`에 추가했다. 나머지 항목(비로그인 정규화, 저장 `countryCode` 미사용)은 기존 테스트가 이미 커버함을 확인해 중복 추가하지 않았다.
|
|
||||||
- [x] **GREEN:** `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 삭제했다.
|
|
||||||
- [x] **GREEN 확인:** `member/contentpreference` 36/36 통과.
|
|
||||||
- [x] **REFACTOR:** ktlint 통과 및 잔여 참조 0건 확인.
|
|
||||||
|
|
||||||
### Phase 3 Gate
|
|
||||||
|
|
||||||
**Goal 실행 `P3-GATE`:** 전체 정리 상태를 확인한다.
|
|
||||||
|
|
||||||
- [x] `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 참조가 `src/main/kotlin`, `src/test/kotlin`에서 0건이다.
|
|
||||||
- [x] `member/contentpreference` 테스트와 Phase 2 영향 범위 테스트가 통과한다.
|
|
||||||
- [x] `./gradlew ktlintMainSourceSetCheck ktlintTestSourceSetCheck`가 통과한다.
|
|
||||||
- [x] PRD 성공 기준 체크박스를 갱신했다.
|
|
||||||
|
|
||||||
## Phase 3 후속: 리뷰 검증 보강
|
|
||||||
|
|
||||||
### Task 3.R1 저장 국가 무시 검증 이전
|
|
||||||
|
|
||||||
**Goal 실행 `P3-R1`:** 저장 국가가 KR인 미인증 회원이 US에서 조회할 때 저장 국가를 무시하는 기존 정책을 정식 서비스 테스트로 검증한다.
|
|
||||||
|
|
||||||
- **시작 조건:** DEPREM-004 및 `reviews/문서대비구현-review.md`의 REV-002 확정. REV-001은 사용자 확인으로 결함 판정 철회; `2L` 제거 유지.
|
|
||||||
- **완료 증거:** 아래 테스트 추가 및 조건·assertion 소스 검토, 후속 실행이 허용된 경우 focused test 결과 기록. 실행하지 않았으면 실행 검증 대기로 남기고 완료 처리하지 않는다.
|
|
||||||
- **범위 밖:** production 정책·resolver·매핑 변경, API 변경, 테스트 helper 리팩터링, 전체 회귀 실행.
|
|
||||||
- **TDD 예외:** 현재 production은 올바르게 저장 국가를 무시하므로 신규 테스트는 처음부터 통과하는 것이 정상이다. 실패를 만들려고 정상 구현을 변경하지 않는다. 삭제된 테스트와 새 테스트의 입력·assertion 대조로 누락 검증 이전을 확인한다.
|
|
||||||
- **실행 제약:** 이번 문서 갱신에서는 테스트를 실행하지 않는다. 기존 자동 테스트 미실행 지시가 유지되는 동안 후속 작업도 소스 검토까지만 진행하고 실행 상태를 별도로 남긴다.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
|
|
||||||
- Update: `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/plan-task.md`
|
|
||||||
- Update: `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/reviews/문서대비구현-review.md`
|
|
||||||
|
|
||||||
**추가할 테스트:** 기존 `createMember`, `countryContext`, `service`, JUnit import를 재사용한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
@DisplayName("비KR 요청에서는 회원의 저장 국가와 무관하게 전달한 성인 노출값을 사용한다")
|
|
||||||
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
|
|
||||||
val member = createMember(id = 2401L, withAuth = false).apply {
|
|
||||||
countryCode = "KR"
|
|
||||||
}
|
|
||||||
countryContext.setCountryCode("US")
|
|
||||||
|
|
||||||
assertEquals("US", service.resolveCountryCode(member))
|
|
||||||
assertTrue(service.isAdultVisibleForQuery(member, isAdultContentVisible = true))
|
|
||||||
assertFalse(service.isAdultVisibleForQuery(member, isAdultContentVisible = false))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **누락 확인:** 삭제된 정책 테스트와 비교해 저장 KR·요청 US·미인증 조건이 기존 정식 테스트에 없음을 확인했다(TDD 예외 적용).
|
|
||||||
- [x] **최소 구현:** 위 테스트 1건만 추가했다. 강제 매핑이 없는 ID 2401을 사용하고 production은 변경하지 않았다.
|
|
||||||
- [x] **소스 검토:** 국가 결과 US 및 노출 전달값 true/false 보존 assertion을 확인했다. 저장 국가 KR을 우선하면 국가 assertion과 `true` 전달값 assertion이 실패한다.
|
|
||||||
- [ ] **실행 검증:** 자동 테스트 실행이 허용되는 후속 단계에서 아래 focused test와 ktlint를 실행하고 결과를 기록한다. 미실행이면 완료 표시하지 않는다.
|
|
||||||
- [x] **기록 갱신:** P3-T1의 기존 완료 기록을 유지하고, REV-002 보강 결과와 실행 대기 상태를 본 Task와 리뷰에 누적했다.
|
|
||||||
|
|
||||||
**후속 실행용 명령(이번에는 미실행):** 저장소와 호환되는 JDK 환경에서 실행한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest'
|
|
||||||
./gradlew ktlintTestSourceSetCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
기대 결과: 신규 테스트를 포함한 서비스 테스트 및 테스트 소스 ktlint 성공. 테스트 한 파일의 검증 보강이므로 전체 회귀는 생략한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 2026-09-17 Phase 3 후속 P3-R1: 삭제된 정책 테스트와 현재 서비스 테스트를 대조해 저장 KR·요청 US·미인증 조건의 누락을 확인하고 `MemberContentPreferenceServiceTest.shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr` 1건을 추가했다. 국가 결과 US와 전달값 true/false 보존 assertion을 소스로 검토했다. production은 변경하지 않았으며 기존 자동 테스트 미실행 지시에 따라 focused test와 ktlint는 실행하지 않아 Task 완료 처리는 보류했다.
|
|
||||||
- 2026-09-17 리뷰 후속 문서 갱신: 사용자가 JP 매핑의 `2L` 제거를 의도한 변경으로 확정했다. REV-002 검증 누락은 `P3-R1`로 추가하고 REV-003 호출 수는 27곳으로 정정했다. 문서와 테스트 소스의 식별자·조건을 대조했으며 사용자 지시에 따라 자동 테스트 및 Gradle 명령은 실행하지 않았다.
|
|
||||||
|
|
||||||
- 2026-09-17 Phase 1: `MemberContentPreferenceServiceTest` 실행 → RED에서 `Unresolved reference: isAdultVisibleForQuery` 확인, GREEN 후 25/25 통과. 검증 이유: 신규 판정 메서드가 Deprecated 함수와 동일한 결과를 내는지 확인.
|
|
||||||
- 2026-09-17 Phase 2: `AudioContentServiceTest.kt:118` 컴파일 실패로 생성자 변경 영향을 확인한 뒤 mock 주입. `src/test/kotlin/kr/co/vividnext/sodalive/content` 42/42 통과, `AudioContentServiceTest` 16/16 통과.
|
|
||||||
- 2026-09-17 Phase 3: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference` 36/36 통과(Deprecated 테스트 5건 삭제, 신규 3건 추가 반영).
|
|
||||||
- 2026-09-17 빈 주입 회귀: `SpringBootIntegrationSampleTest` 1/1 통과로 12개 서비스에 추가된 `MemberContentPreferenceService` 주입이 애플리케이션 컨텍스트에서 정상 해석됨을 확인(순환 의존 없음).
|
|
||||||
- 2026-09-17 스타일: `JAVA_HOME=<Android Studio JBR 21> ./gradlew ktlintMainSourceSetCheck ktlintTestSourceSetCheck` BUILD SUCCESSFUL. JetBrains JBR 25는 Gradle 8.1.1과 비호환이어서 Java 21 JBR을 사용했다.
|
|
||||||
- 2026-09-17 전체 회귀 생략: `./gradlew test` 전체 회귀는 실행하지 않았다. 근거는 변경이 `isAdult` 계산 위임 경로 치환으로 한정되고 공개 스키마/DB/보안 설정 변경이 없으며, 대신 변경 범위 focused test(`content` 42, `member/contentpreference` 36, `AudioContentServiceTest` 16)와 컨텍스트 로딩 테스트, ktlint 전체 소스셋 검사를 실행했다.
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# 성인 콘텐츠 노출 정책 Deprecated 함수 제거 PRD
|
|
||||||
|
|
||||||
## 문서 정보
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 문서 상태 | Phase 1~3 완료, 리뷰 후 고유 검증 보강 대기 |
|
|
||||||
| 작성일 | `2026-09-17` |
|
|
||||||
| 최종 수정일 | `2026-09-17` |
|
|
||||||
| 대상 제품 | 성인 콘텐츠 노출 여부 / 접속 국가 코드 계산 정책 |
|
|
||||||
| 작성자·결정권자 | Junie / 사용자 |
|
|
||||||
| 관련 API Contract | 기존 API 응답 스키마 변경 없음 |
|
|
||||||
| 관련 구현 계획 | `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/plan-task.md` |
|
|
||||||
| 관련 review | `reviews/문서대비구현-review.md` |
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
성인 콘텐츠 노출 여부(`isAdult`)와 접속 국가 코드 계산 정책은 현재 두 경로에 중복 구현되어 있다. 하나는 `@Deprecated`로 표시된 top-level 함수 `MemberContentPreferencePolicy.isAdultVisibleByPolicy(...)` / `resolveCountryCodeByPolicy(...)`이고, 다른 하나는 정식 경로인 `MemberContentPreferenceService`다. Deprecated 경로가 production 12개 파일에서 여전히 27곳 호출되고 있어 정책 변경 시 두 곳을 모두 고쳐야 하는 위험이 남아 있다. 이 작업은 Deprecated 경로 호출부를 `MemberContentPreferenceService`로 이전하고 Deprecated 함수와 그 전용 테스트를 제거한다.
|
|
||||||
|
|
||||||
## 2. Problem Statement
|
|
||||||
|
|
||||||
- `MemberContentPreferencePolicy.kt`의 두 함수는 `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`에서 `@Deprecated` 처리만 되고 호출부 이전은 완료되지 않았다.
|
|
||||||
- 접속 국가 판정 기준이 두 경로에서 다르게 조달된다. Deprecated 경로는 `RequestContextHolder`에서 `CloudFront-Viewer-Country` 헤더를 직접 읽고, 정식 경로는 `CountryContext`(요청 스코프)에서 읽는다. 값 출처(같은 헤더, `CountryInterceptor`가 주입)는 같지만 경로가 둘이다.
|
|
||||||
- Deprecated 함수의 유일한 테스트인 `MemberContentPreferencePolicyTest`의 5개 검증은 `MemberContentPreferenceServiceTest` / `MemberContentPreferenceIntegrationTest`에 이미 동등하게 존재해, 회원 ID 강제 매핑 정책을 변경할 때마다 같은 상수를 3~4개 파일에서 함께 고쳐야 한다.
|
|
||||||
- 결과적으로 정책 변경(예: 국가 강제 매핑 회원 ID 추가/삭제)이 중복 지점 누락으로 회귀를 만들 수 있다.
|
|
||||||
|
|
||||||
문제를 해결했다는 판단은 `isAdultVisibleByPolicy` / `resolveCountryCodeByPolicy` 참조가 `src/main/kotlin`과 `src/test/kotlin`에서 0건이 되고, 기존 legacy 콘텐츠 조회 API의 `isAdult` 계산 결과가 그대로 유지되는 것으로 한다.
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
|
|
||||||
- 성인 콘텐츠 노출 여부 계산의 단일 진입점을 `MemberContentPreferenceService`로 만든다.
|
|
||||||
- `MemberContentPreferencePolicy.kt`의 Deprecated 함수 2개와 파일, 전용 테스트를 제거한다.
|
|
||||||
- 기존 legacy 콘텐츠 조회 API의 `isAdult` / 국가 코드 계산 동작을 유지하되, 사용자가 확정한 JP 강제 매핑의 `2L` 제거는 예외로 반영한다.
|
|
||||||
- 공개 API request/response 스키마를 변경하지 않는다.
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
|
|
||||||
- `isAdult` 판정 정책 자체(KR + 본인인증 여부, 해외 전달값 사용)를 변경하지 않는다.
|
|
||||||
- JP 강제 매핑의 `2L` 제거 외에는 회원 ID 국가 강제 매핑(`FORCED_KR_MEMBER_IDS`, `FORCED_JP_MEMBER_IDS`) 값을 변경하지 않는다.
|
|
||||||
- `isAdultContentVisible`을 서비스 파라미터에서 제거하거나 저장값 조회로 대체하는 리팩터링은 하지 않는다. 호출부 시그니처는 유지한다.
|
|
||||||
- `CanController`의 통화(currency) 강제 지정 로직은 국가 강제 매핑과 무관하므로 건드리지 않는다.
|
|
||||||
- `MemberContentPreferenceCountryResolver.kt`의 강제 매핑 알고리즘은 유지하고, JP 대상에서 `2L`만 제외한다.
|
|
||||||
|
|
||||||
## 5. 영향 범위
|
|
||||||
|
|
||||||
### 5.1 Deprecated 함수 정의
|
|
||||||
|
|
||||||
| 파일 | 대상 |
|
|
||||||
|---|---|
|
|
||||||
| `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt` | `resolveCountryCodeByPolicy(member)`, `isAdultVisibleByPolicy(member, isAdultContentVisible)` |
|
|
||||||
|
|
||||||
### 5.2 production 호출부
|
|
||||||
|
|
||||||
| 파일 | 호출 수 |
|
|
||||||
|---|---:|
|
|
||||||
| `content/series/ContentSeriesService.kt` | 5 |
|
|
||||||
| `content/AudioContentService.kt` | 3 |
|
|
||||||
| `content/main/AudioContentMainService.kt` | 2 |
|
|
||||||
| `content/main/curation/AudioContentCurationService.kt` | 2 |
|
|
||||||
| `content/theme/AudioContentThemeService.kt` | 2 |
|
|
||||||
| `content/main/tab/free/AudioContentMainTabFreeService.kt` | 4 |
|
|
||||||
| `content/main/tab/alarm/AudioContentMainTabAlarmService.kt` | 2 |
|
|
||||||
| `content/main/tab/content/AudioContentMainTabContentService.kt` | 2 |
|
|
||||||
| `content/main/tab/home/AudioContentMainTabHomeService.kt` | 2 |
|
|
||||||
| `content/main/tab/asmr/AudioContentMainTabAsmrService.kt` | 1 |
|
|
||||||
| `content/main/tab/replay/AudioContentMainTabLiveReplayService.kt` | 1 |
|
|
||||||
| `content/main/tab/series/AudioContentMainTabSeriesService.kt` | 1 |
|
|
||||||
|
|
||||||
12개 파일, 총 27곳이다. 어떤 파일도 현재 `MemberContentPreferenceService`를 주입받지 않는다.
|
|
||||||
|
|
||||||
### 5.3 테스트 호출부
|
|
||||||
|
|
||||||
| 파일 | 처리 |
|
|
||||||
|---|---|
|
|
||||||
| `src/test/kotlin/.../member/contentpreference/MemberContentPreferencePolicyTest.kt` | 삭제. 동등 검증이 `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`에 이미 존재하며, 부족한 항목은 `MemberContentPreferenceServiceTest`로 이전한다. |
|
|
||||||
|
|
||||||
## 6. 기능 요구사항
|
|
||||||
|
|
||||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `DEPREM-001` | 확정 | `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)`를 추가해 `resolveCountryCode(member)` + `calculateIsAdultForQuery(...)` 조합을 단일 메서드로 제공한다. | KR + `auth == null`이면 `false`, KR + `auth != null`이면 전달값, 비KR이면 전달값. 회원 ID 강제 매핑이 접속 국가 헤더보다 우선한다. | `P1-T1` |
|
|
||||||
| `DEPREM-002` | 확정 | production 12개 파일의 `isAdultVisibleByPolicy(...)` 27곳을 `memberContentPreferenceService.isAdultVisibleForQuery(...)`로 치환하고 `MemberContentPreferenceService`를 생성자 주입한다. | 각 파일에서 `isAdultVisibleByPolicy` import와 호출이 사라지고, `isAdult` 계산 결과가 기존과 동일하다. | `P2-T1`~`P2-T4` |
|
|
||||||
| `DEPREM-003` | 확정 | `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 제거한다. | `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 검색 결과가 `src/main/kotlin`, `src/test/kotlin`에서 0건이다. | `P3-T1` |
|
|
||||||
| `DEPREM-004` | 확정 | Deprecated 테스트에서 제거되는 검증 항목 중 정식 경로 테스트에 없는 것은 `MemberContentPreferenceServiceTest`로 이전한다. | 헤더 공백/소문자 정규화(`" us "` → `US`), 국가 컨텍스트 없음 → `KR` fallback, 비KR에서 저장 `countryCode` 무시 검증이 정식 경로 테스트에 존재한다. | `P3-T1` |
|
|
||||||
| `DEPREM-005` | 확정 | 기존 legacy 콘텐츠 조회 동작을 회귀 없이 유지한다. | `AudioContentServiceTest` 등 영향 범위 테스트와 `member/contentpreference` 테스트가 모두 통과하고 ktlint가 통과한다. | `P3-GATE` |
|
|
||||||
|
|
||||||
## 7. API 계약
|
|
||||||
|
|
||||||
- 변경되는 공개 request/response 필드는 없다.
|
|
||||||
- 신규 message key, 신규 endpoint는 없다.
|
|
||||||
- 각 서비스의 public 메서드 시그니처(`isAdultContentVisible` 파라미터 포함)는 유지한다. 변경되는 것은 생성자 의존성뿐이다.
|
|
||||||
|
|
||||||
## 8. 보안과 데이터 취급
|
|
||||||
|
|
||||||
- 접속 국가 판정은 계속 `CloudFront-Viewer-Country` 헤더를 `CountryInterceptor`가 `CountryContext`에 주입한 값만 사용한다.
|
|
||||||
- 성인 콘텐츠 노출은 KR에서 본인인증(`member.auth != null`)이 있는 경우에만 허용하는 기존 정책을 유지한다.
|
|
||||||
- 회원 ID, 이메일 등 개인정보는 로그에 추가하지 않는다.
|
|
||||||
|
|
||||||
## 9. 리스크
|
|
||||||
|
|
||||||
| ID | 리스크 | 대응 |
|
|
||||||
|---|---|---|
|
|
||||||
| `RISK-001` | `CountryContext`는 `@RequestScope`이므로 비웹 스레드에서 호출되면 예외가 발생할 수 있다. Deprecated 함수는 `RequestContextHolder`가 없을 때 `KR`로 fallback했다. | 대상 12개 서비스가 controller 요청 경로에서만 호출되는지 Phase 2에서 파일별로 확인한다. 스케줄러/비동기 호출 경로가 발견되면 해당 Task를 진행하지 않고 계획을 먼저 갱신한다. |
|
|
||||||
| `RISK-002` | 생성자 파라미터 추가로 기존 서비스 테스트가 컴파일 실패할 수 있다. | 해소됨. `AudioContentServiceTest` 1곳만 영향받아 mock 주입으로 해결했다. |
|
|
||||||
| `RISK-003` | 순환 의존 발생 가능성. | `MemberContentPreferenceService`는 `MemberContentPreferenceRepository`, `MemberRepository`, `CountryContext`, `CacheManager`만 의존하므로 콘텐츠 서비스와 순환이 발생하지 않음을 확인했다. |
|
|
||||||
|
|
||||||
## 10. 성공 기준
|
|
||||||
|
|
||||||
- [x] `MemberContentPreferenceService.isAdultVisibleForQuery(...)`가 기존 `isAdultVisibleByPolicy(...)`와 동일한 판정을 한다. (`DEPREM-001`)
|
|
||||||
- [x] production 12개 파일 27곳이 모두 정식 경로를 호출한다. (`DEPREM-002`)
|
|
||||||
- [x] `MemberContentPreferencePolicy.kt`, `MemberContentPreferencePolicyTest.kt`가 제거되고 참조가 0건이다. (`DEPREM-003`)
|
|
||||||
- [x] Deprecated 테스트의 고유 검증이 `MemberContentPreferenceServiceTest`에 남아 있다. (`DEPREM-004`)
|
|
||||||
- [x] 영향 범위 focused test와 ktlint가 통과한다. (`DEPREM-005`)
|
|
||||||
|
|
||||||
## 11. Decision Log
|
|
||||||
|
|
||||||
기존 성공 기준 체크박스는 당시 완료 기록으로 유지한다. 후속 리뷰에서 DEPREM-004의 저장 국가 충돌 검증 누락을 확인했으며, `plan-task.md`의 `P3-R1` 완료 전까지 해당 검증 보강은 미완료다.
|
|
||||||
|
|
||||||
| 일시 | 결정 | 근거 |
|
|
||||||
|---|---|---|
|
|
||||||
| 2026-09-17 | Deprecated 함수 제거를 위해 production 호출부를 모두 마이그레이션하는 방식(선택지 3)을 택한다. | 사용자 선택. 테스트만 정식 경로로 바꾸면 production에서 쓰이는 Deprecated 경로가 커버리지 0이 되므로 부적절 |
|
|
||||||
| 2026-09-17 | 호출부 1:1 치환을 위해 `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)` wrapper를 추가한다. | 호출부마다 `resolveCountryCode(...)` + `calculateIsAdultForQuery(...)` 2단계를 반복하면 중복이 27곳으로 늘어남 |
|
|
||||||
| 2026-09-17 | `MemberContentPreferencePolicyTest`는 삭제하고 고유 검증만 `MemberContentPreferenceServiceTest`로 이전한다. | 5개 검증 중 대부분이 정식 경로 테스트와 중복이며, 함수 제거 후에는 테스트 대상이 사라짐 |
|
|
||||||
| 2026-09-17 | Phase를 서비스 묶음 단위로 나눠 진행한다. | 27곳 동시 변경 시 실패 원인 추적이 어려움. 저장소 규칙("작은 단위로 안전하게 수정") 준수 |
|
|
||||||
| 2026-09-17 | `RISK-001`은 해소로 판정한다. | 치환한 27곳이 모두 controller 진입 조회 경로임을 파일별로 확인했고, 스케줄러(`Recommendation/Ranking/ChargeEvent`)는 이 경로를 호출하지 않음 |
|
|
||||||
|
|
||||||
- 2026-09-17 사용자 확정: JP 강제 매핑에서 `2L`을 제거한 것은 의도된 정책 변경이다. REV-001은 결함 판정을 철회하며 `2L`을 복원하지 않는다. 나머지 매핑은 유지한다.
|
|
||||||
- 2026-09-17 후속 계획: REV-002의 저장 국가 무시 검증을 `P3-R1`로 추가한다. 이번 작업은 문서 갱신이며 테스트 구현·실행은 하지 않는다.
|
|
||||||
|
|
||||||
## 12. 열린 질문
|
|
||||||
|
|
||||||
- 없음. Phase 2에서 비웹 호출 경로(`RISK-001`)가 발견되면 계획을 먼저 갱신하고 사용자에게 확인한다.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# 문서 대비 구현 리뷰
|
|
||||||
|
|
||||||
## 리뷰 정보와 범위
|
|
||||||
|
|
||||||
- 일자: 2026-09-17
|
|
||||||
- 기준: HEAD `8c87286acd69174f6024d30a267458e838e35cf5` 및 리뷰 시작 시 존재한 미커밋 변경
|
|
||||||
- 요구사항: `../prd.md`, `../plan-task.md`의 Phase 1~3
|
|
||||||
- 방법: 문서, production 코드, 테스트 소스, 현재 diff와 호출 경로 정적 대조
|
|
||||||
- 사용자 지시에 따라 자동 테스트·빌드·ktlint·애플리케이션 실행은 하지 않았다. 기존 실행 성공 기록을 재검증한 것으로 취급하지 않는다.
|
|
||||||
- 구현 및 기존 PRD/계획은 수정하지 않았다. 아래 판정은 현재 작업 트리에 대한 것이며 변경 작성자나 의도를 추정하지 않는다.
|
|
||||||
|
|
||||||
## 발견 사항
|
|
||||||
|
|
||||||
### REV-001 — 강제 JP 매핑 값이 변경됨
|
|
||||||
|
|
||||||
- 심각도: High / 상태: 확정
|
|
||||||
- 요구사항: PRD §3 기존 동작 유지, §4 강제 매핑 값 변경 금지; 계획 P3-T1의 resolver 변경 제외
|
|
||||||
- 근거: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt:6`에서 `FORCED_JP_MEMBER_IDS`의 `2L`이 제거됐다.
|
|
||||||
- 코드 추적: 회원 ID 2, 인증 없음, 요청 국가 KR 또는 헤더 없음, `isAdultContentVisible=true`이면 기존 JP 판정/성인 노출 true에서 KR 판정/false로 바뀐다.
|
|
||||||
- 관련 테스트도 `MemberContentPreferenceServiceTest.kt:64`, `MemberContentPreferenceIntegrationTest.kt:187`에서 ID 2를 29721로 교체해 기존 매핑 회귀를 검출하지 못한다.
|
|
||||||
- 권장 조치: 문서대로라면 기존 매핑과 해당 테스트 대상을 복원한다. 별도로 의도한 정책 변경이라면 해당 결정과 범위를 문서에 명시해야 한다.
|
|
||||||
|
|
||||||
### REV-002 — 저장 국가 무시 검증이 이전되지 않음
|
|
||||||
|
|
||||||
- 심각도: Medium / 상태: 확정
|
|
||||||
- 요구사항: DEPREM-004, P3-T1
|
|
||||||
- 근거: 삭제된 PolicyTest는 저장 `member.countryCode="KR"`, 요청 국가 US 조건을 검증했다. 현재 `MemberContentPreferenceServiceTest.kt:469`의 해외 노출 테스트와 `:536`의 회원 생성 함수는 저장 국가를 설정하지 않는다. IntegrationTest에도 이 충돌 조건이 없다.
|
|
||||||
- 영향: 현재 resolver는 저장 국가를 참조하지 않아 구현 자체는 맞지만, 문서에서 이전하도록 요구한 고유 회귀 검증이 빠졌다. `plan-task.md:224`의 기존 테스트가 이미 커버한다는 기록은 부정확하다.
|
|
||||||
- 권장 조치: 일반 미인증 회원의 저장 국가를 KR로 설정하고 요청 국가는 US로 두어, 정식 진입점의 국가 US/성인 노출 true를 확인하는 검증을 추가한다.
|
|
||||||
|
|
||||||
### REV-003 — 호출부 합계 오기
|
|
||||||
|
|
||||||
- 심각도: Low / 상태: 확정
|
|
||||||
- 근거: PRD §5.2 표의 합계와 실제 치환은 모두 12개 파일 27곳이다. PRD와 계획에는 반복해서 24곳으로 기록돼 있다.
|
|
||||||
- 영향: 호출 치환 누락은 없으며 문서 수치가 틀렸다.
|
|
||||||
- 권장 조치: 두 문서의 합계를 27곳으로 정정한다.
|
|
||||||
|
|
||||||
## 충족 항목과 한계
|
|
||||||
|
|
||||||
| 항목 | 정적 검토 결과 |
|
|
||||||
|---|---|
|
|
||||||
| DEPREM-001 단일 진입점 | 기존 resolveCountryCode + calculateIsAdultForQuery 재사용, 추가 DB 조회 없음 |
|
|
||||||
| DEPREM-002 호출부 이전 | 12개 파일 27곳 이전, 인자 및 홈의 member=null → false 유지 |
|
|
||||||
| DEPREM-003 정의/참조 제거 | 두 파일 삭제, src/main/kotlin 및 src/test/kotlin의 두 Deprecated 함수 이름 검색 0건 |
|
|
||||||
| DEPREM-004 고유 검증 이전 | 정규화/null 국가값 fallback은 존재, 저장 국가 충돌 검증 누락 |
|
|
||||||
| DEPREM-005 실행 검증 | 사용자 요청에 따라 재실행 제외 |
|
|
||||||
| API 계약 | 기존 공개 메서드 인자와 request/response 스키마 변경 없음 |
|
|
||||||
| 요청 경로/의존성 | 변경된 조회 메서드의 비웹 호출 및 새 순환 의존 발견 없음 |
|
|
||||||
|
|
||||||
국가값 null 검증은 요청 스코프 자체가 없는 상황과 다르다. 새 경로는 요청 스코프와 회원 ID를 요구하므로 모든 입력에서 구 함수와 동등하다고 일반화할 수 없다. 다만 검토한 변경 호출 경로에서 해당 비웹/ID 없는 입력은 확인하지 못해 별도 운영 결함으로 확정하지 않았다.
|
|
||||||
|
|
||||||
## 리뷰 종료 판정과 후속 작업
|
|
||||||
|
|
||||||
요구사항, 테스트 소스, 코드 품질, 보안, 주변 호출 맥락의 검토 결과를 종합했다. 확정 불일치 3건으로 문서와 완전히 일치하는 구현이라고 판정할 수 없다.
|
|
||||||
|
|
||||||
이번 요청은 검토이므로 수정 goal은 실행하지 않았다. 후속 구현 시 기존 완료 기록을 유지하면서 REV-001 매핑 일치, REV-002 고유 검증 이전, REV-003 문서 수치 정정을 별도 회귀 Task로 계획에 추가한다. 자동 검증은 이번 검토에서 수행하지 않았다.
|
|
||||||
|
|
||||||
## 후속 판정 정정 — 2026-09-17
|
|
||||||
|
|
||||||
위 내용은 최초 검토 당시 기록이며, 사용자 확인 이후 현재 판정은 다음과 같다.
|
|
||||||
|
|
||||||
- **REV-001: 결함 판정 철회.** 사용자가 `2L` 제거를 의도한 변경으로 확정했다. PRD·계획에 해당 예외를 반영했으며 매핑과 테스트 대상을 복원하지 않는다.
|
|
||||||
- **REV-002: 구현 완료 / 실행 검증 대기.** `MemberContentPreferenceServiceTest.shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr`를 추가해 저장 KR·요청 US·미인증 회원 조건에서 국가 결과 US와 전달값 true/false 보존을 검증한다. production은 변경하지 않았고, 기존 자동 테스트 미실행 지시에 따라 focused test와 ktlint는 실행하지 않았다.
|
|
||||||
- **REV-003: 문서 정정 완료.** PRD·계획의 24곳 표기를 실제 합계 27곳으로 정정했다.
|
|
||||||
- **현재 결론:** `P3-R1` 테스트 구현과 소스 검토는 완료했으며, 남은 항목은 focused test와 ktlint 실행 검증이다.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 캔 조회 회원 2 강제 통화 제거 구현 계획
|
|
||||||
|
|
||||||
- 상태: 구현 완료
|
|
||||||
- 작성일: 2026-09-18
|
|
||||||
- 요구사항: [prd.md](prd.md)
|
|
||||||
- 현재 Phase: 1 완료
|
|
||||||
- 활성 Goal: 없음
|
|
||||||
- 실행 순서: CAN-P1-T1 → CAN-P1-GATE
|
|
||||||
|
|
||||||
### Phase 1: 회원 2의 기본 통화 적용과 회귀 확인
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 회원 2의 강제 통화 제거** (`CAN-P1-T1`)
|
|
||||||
- Objective: 회원 2가 다른 일반 회원처럼 요청 국가에 따른 통화의 캔 상품을 조회한다.
|
|
||||||
- 시작 조건: PRD 확정, 기존 컨트롤러·서비스 및 테스트 확인 완료.
|
|
||||||
- 완료 증거: RED/GREEN 결과, 실제 컨트롤러·서비스·저장소 통합 테스트와 캔 패키지 회귀 통과.
|
|
||||||
- 범위 밖: 다른 회원 예외 제거, 국가 결정·API 스키마·서비스 리팩터링.
|
|
||||||
- 수정: `src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt`
|
|
||||||
- 생성: `src/test/kotlin/kr/co/vividnext/sodalive/can/CanControllerIntegrationTest.kt`
|
|
||||||
- 확인: `src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt`
|
|
||||||
- [x] **RED:** 회원 2의 KRW/USD, 유지할 예외·일반 회원·비로그인·국가 누락 통합 테스트를 작성한다.
|
|
||||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanControllerIntegrationTest'`로 회원 2의 JPY 반환 assertion 실패를 확인한다.
|
|
||||||
- [x] **GREEN:** 컨트롤러 조건에서 `member.id == 2L ||`만 제거한다.
|
|
||||||
- [x] **GREEN 확인:** 동일 focused test가 통과하는지 확인한다.
|
|
||||||
- [x] **REFACTOR:** 불필요한 정리 없이 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.*'` 및 변경 파일 lint로 회귀를 확인한다.
|
|
||||||
|
|
||||||
#### Task 검증 기록
|
|
||||||
- 2026-09-18: 기존 `getCans` 및 `/can` 직접 테스트가 없음을 확인했다. 새 테스트는 `@SpringBootTest`와 H2, 요청 범위의 실제 CountryContext를 사용하고 트랜잭션 롤백으로 상품 데이터를 격리한다.
|
|
||||||
- 2026-09-18 RED: 테스트 작성 중 `Member.password` 필수 인자 누락을 수정한 뒤 production/test 컴파일이 통과했다. 이후 공통 JWT 기본 키의 길이 부족으로 발생한 컨텍스트 로딩 실패는 테스트 실행에만 임시 키를 제공하여 해결했다. 이 두 실패는 동작 RED에 포함하지 않았다.
|
|
||||||
- 2026-09-18 RED 확인: 아래 환경 설정 후 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanControllerIntegrationTest' --console=plain` 실행. 13개 중 회원 2의 4개만 assertion 실패(exit 1): KR은 KRW, US/JP/국가 누락은 USD 기대였으나 실제 JPY. 나머지 9개 통과.
|
|
||||||
- 2026-09-18 GREEN 확인: 조건 제거 후 같은 focused 명령으로 13개 전부 통과(exit 0), production/test 컴파일 성공.
|
|
||||||
- 2026-09-18 회귀 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.*' --console=plain` 성공(exit 0). 신규 13개와 기존 14개, 총 5개 클래스 27개 테스트 통과(실패·오류·skip 0). 기존 테스트를 완화하거나 수정할 필요가 없었다.
|
|
||||||
|
|
||||||
- [x] **Phase Gate: 변경 범위와 검증 기록 확인** (`CAN-P1-GATE`)
|
|
||||||
- Objective: 요구사항과 최소 변경, 검증 증거가 일치함을 확인한다.
|
|
||||||
- 시작 조건: CAN-P1-T1 완료.
|
|
||||||
- 완료 증거: 캔 패키지 테스트 통과, 문서 명령·diff 확인 및 PRD 체크박스 완료.
|
|
||||||
- 범위 밖: 전체 서비스 실행과 관련 없는 기능 수정.
|
|
||||||
- 확인 파일: 이 문서, `docs/20260918_캔조회_회원2_강제통화제거/prd.md`, 위 Task의 코드·테스트.
|
|
||||||
- TDD 예외 사유: 구현 없는 최종 검증 단계.
|
|
||||||
- 대체 검증 방법: `./gradlew tasks --all`, `git diff --check`, 변경 파일 대조.
|
|
||||||
|
|
||||||
## 검증 범위
|
|
||||||
- 단일 루트 프로젝트이며 변경은 컨트롤러의 조건 하나에 한정된다. 전체 회귀 실행 조건(공통 코드·여러 도메인 변경·영향 불명확 실패·사용자 명시 요청)이 발생하지 않으면 전체 테스트는 생략하고 캔 패키지 전체를 실행한다.
|
|
||||||
- 테스트 실행으로 production/test 컴파일을 함께 확인한다. 새 API나 실행 환경 변경이 없어 별도 애플리케이션 구동은 하지 않는다.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
- 2026-09-18: 요구사항 및 TDD 계획 작성, CAN-P1-T1 준비.
|
|
||||||
- 2026-09-18: CAN-P1-T1 RED 확인 완료, 회원 2 조건 제거 진행.
|
|
||||||
- 2026-09-18: CAN-P1-T1 완료. 조건 한 항만 제거했고 신규 13개 및 기존 14개 테스트가 통과했다.
|
|
||||||
- 2026-09-18: CAN-P1-GATE 완료. API 스키마·다른 회원 예외가 유지되는 diff와 PRD 수용 기준, 검증 기록을 대조했다. 남은 작업 없음.
|
|
||||||
|
|
||||||
## 공통 검증 기록
|
|
||||||
- 셸에 기본 Java가 없어 기존 Gradle 캐시의 Java 17을 사용했다. 설정 파일은 변경하지 않았다.
|
|
||||||
- 테스트 JWT 기본값은 디코딩 후 키 길이가 부족하므로 실행 프로세스에만 임시 키를 전달했다. 키 값은 저장·출력하지 않는다. 아래 환경 설정 뒤 문서의 검증 명령을 실행한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export JAVA_HOME=~/.gradle/jdks/eclipse_adoptium-17-x86_64-os_x.2/jdk-17.0.19+10/Contents/Home
|
|
||||||
export JWT_SECRET="$(openssl rand -base64 64 | tr -d '\n')"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 2026-09-18: `./gradlew tasks --all --console=plain`, `git diff --check` 성공(exit 0). 변경 Kotlin 파일의 IDE 정적 검사에서 오류 없음. 신규 테스트는 경고도 없으며 기존 컨트롤러의 수정하지 않은 인증 SpEL 식에는 변수 해석 경고 8개가 있다.
|
|
||||||
- 2026-09-18: 전체 테스트·전체 ktlint 및 별도 서버 실행은 생략했다. 변경은 컨트롤러 조건 한 곳이고 다른 도메인·공통 코드·하위 모듈 변경이 없어 focused 통합 테스트, 캔 패키지 전체 회귀, 변경 파일 정적 검사로 검증했다.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 캔 조회 회원 2 강제 통화 제거
|
|
||||||
|
|
||||||
## 문서 정보
|
|
||||||
- 상태: 구현 완료
|
|
||||||
- 작성일: 2026-09-18
|
|
||||||
- 관련 계획: [plan-task.md](plan-task.md)
|
|
||||||
|
|
||||||
## 문제와 목표
|
|
||||||
`GET /can`의 `getCans`는 회원 2·4·44144에게 JPY를 강제 적용한다.
|
|
||||||
사용자 요청에 따라 회원 2만 제외하여 기존 국가별 통화 선택을 따르게 한다.
|
|
||||||
|
|
||||||
## 확정 요구사항과 수용 기준
|
|
||||||
| ID | 요구사항 | 수용 기준 | Goal |
|
|
||||||
|---|---|---|---|
|
|
||||||
| CAN-CURRENCY-001 | 회원 2의 JPY 강제 조건 제거 | KR 요청은 KRW, 그 외 국가는 USD 상품 반환 | CAN-P1-T1 |
|
|
||||||
| CAN-CURRENCY-002 | 나머지 동작 유지 | 회원 4·44144는 JPY, 일반 회원·비로그인은 기존 국가별 통화 적용 | CAN-P1-T1 |
|
|
||||||
| CAN-CURRENCY-003 | 관련 테스트 검증 | 변경 전 실패 재현, 변경 후 캔 패키지 테스트 통과 | CAN-P1-GATE |
|
|
||||||
|
|
||||||
## 범위 밖
|
|
||||||
- 다른 진입점의 회원 ID 조건, 국가 판정 및 성인콘텐츠 정책 변경.
|
|
||||||
- 서비스의 KR → KRW / 그 외 → USD 정책 변경.
|
|
||||||
- 공개 API 요청·응답 스키마, 인증 및 상품 판매 상태 필터 변경.
|
|
||||||
|
|
||||||
## 성공 기준
|
|
||||||
- [x] 회원 2에 대한 회귀 테스트가 변경 전 실패하고 변경 후 통과한다.
|
|
||||||
- [x] 회원 4·44144, 일반 회원, 비로그인 및 국가 누락의 기존 동작이 유지된다.
|
|
||||||
- [x] 관련 기존 테스트가 통과하고 검증 결과를 계획 문서에 기록한다.
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
# 선물 관리자 페이지 구현 프롬프트
|
|
||||||
|
|
||||||
이 문서는 관리자 페이지의 선물 관련 4개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
|
|
||||||
|
|
||||||
공통 전제:
|
|
||||||
|
|
||||||
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 4개 하위 메뉴가 있다.
|
|
||||||
- `선물함 리스트` → `/gift/list`
|
|
||||||
- `받을 주소` → `/gift/mailbox`
|
|
||||||
- `선물 카테고리` → `/gift/category`
|
|
||||||
- `선물 사이즈` → `/gift/size`
|
|
||||||
- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다.
|
|
||||||
- 인증/권한 처리는 기존 관리자 페이지의 API 클라이언트, 토큰 저장 방식, 에러 처리 방식을 따른다.
|
|
||||||
- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 테이블, 필터, 버튼, 모달, 폼, 토스트, 확인창 패턴을 재사용한다.
|
|
||||||
- 화면 문구는 한국어로 작성한다.
|
|
||||||
- API 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 선물함 리스트 페이지 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
관리자 선물함 리스트 페이지를 구현해줘.
|
|
||||||
|
|
||||||
목표:
|
|
||||||
- 관리자 메뉴 `선물함 관리 > 선물함 리스트`에서 전체 선물 신청 목록을 조회한다.
|
|
||||||
- 상태, 신청번호, 닉네임으로 필터링할 수 있다.
|
|
||||||
- 목록 row에서 선물 상세를 확인하고, 현재 상태에서 가능한 운영 액션을 실행할 수 있다.
|
|
||||||
- 기존 관리자 페이지의 테이블/필터/페이지네이션/모달/확인창 스타일을 그대로 따른다.
|
|
||||||
|
|
||||||
라우트:
|
|
||||||
- `/gift/list`
|
|
||||||
|
|
||||||
필수 화면 구성:
|
|
||||||
- 상단 제목: `선물함 리스트`
|
|
||||||
- 필터 영역:
|
|
||||||
- 상태 select: 전체, 접수 완료, 발송 확인, 사서함 도착, 검수 완료, 전달 완료, 전달 불가, 신청 취소
|
|
||||||
- 신청번호 검색 input: `applicationNo` 부분 검색
|
|
||||||
- 닉네임 검색 input: 발송인 또는 수취인 닉네임 부분 검색
|
|
||||||
- 검색 버튼, 초기화 버튼
|
|
||||||
- 목록 테이블 컬럼:
|
|
||||||
- 신청번호 `applicationNo`
|
|
||||||
- 발송인 닉네임 `senderNickname`
|
|
||||||
- 수취인 닉네임 `recipientNickname`
|
|
||||||
- 사이즈 `sizeName` / `sizeCode`
|
|
||||||
- 카테고리 `categoryName`
|
|
||||||
- 분류번호 `classificationNumber`
|
|
||||||
- 택배사 `courierCompanyName`
|
|
||||||
- 운송장번호 `trackingNumber`
|
|
||||||
- 상태 `statusName`
|
|
||||||
- 액션 버튼
|
|
||||||
- 페이지네이션:
|
|
||||||
- 서버의 `page`, `size`, `totalCount`, `hasNext` 기준으로 기존 관리자 페이지 패턴에 맞춘다.
|
|
||||||
|
|
||||||
목록 조회 API:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/admin/gifts`
|
|
||||||
- Query:
|
|
||||||
- `status?: GiftStatus`
|
|
||||||
- `applicationNo?: string`
|
|
||||||
- `nickname?: string`
|
|
||||||
- `page?: number` 기본 `0`
|
|
||||||
- `size?: number` 기본 `20`
|
|
||||||
- 필터 조합:
|
|
||||||
- 선택 필터는 AND 조건이다.
|
|
||||||
- `nickname`은 발송인/수취인 닉네임 중 하나에 포함되면 매칭된다.
|
|
||||||
|
|
||||||
목록 조회 response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftListResponse = {
|
|
||||||
totalCount: number;
|
|
||||||
items: AdminGiftListItemResponse[];
|
|
||||||
page: number;
|
|
||||||
size: number;
|
|
||||||
hasNext: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AdminGiftListItemResponse = {
|
|
||||||
applicationNo: string;
|
|
||||||
senderNickname: string;
|
|
||||||
recipientNickname: string;
|
|
||||||
sizeCode: string;
|
|
||||||
sizeName: string;
|
|
||||||
categoryName: string;
|
|
||||||
classificationNumber: string;
|
|
||||||
courierCompanyName: string | null;
|
|
||||||
trackingNumber: string | null;
|
|
||||||
status: GiftStatus;
|
|
||||||
statusName: string;
|
|
||||||
availableActions: AdminGiftAction[];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
상태 enum:
|
|
||||||
```ts
|
|
||||||
type GiftStatus =
|
|
||||||
| "RECEIVED"
|
|
||||||
| "TRACKING_REGISTERED"
|
|
||||||
| "ARRIVED_AT_MAILBOX"
|
|
||||||
| "INSPECTION_COMPLETED"
|
|
||||||
| "DELIVERED"
|
|
||||||
| "UNDELIVERABLE"
|
|
||||||
| "CANCELED";
|
|
||||||
```
|
|
||||||
|
|
||||||
상태 표시명:
|
|
||||||
- `RECEIVED`: `접수 완료`
|
|
||||||
- `TRACKING_REGISTERED`: `발송 확인`
|
|
||||||
- `ARRIVED_AT_MAILBOX`: `사서함 도착`
|
|
||||||
- `INSPECTION_COMPLETED`: `검수 완료`
|
|
||||||
- `DELIVERED`: `전달 완료`
|
|
||||||
- `UNDELIVERABLE`: `전달 불가`
|
|
||||||
- `CANCELED`: `신청 취소`
|
|
||||||
|
|
||||||
상세 조회 API:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/admin/gifts/{applicationNo}`
|
|
||||||
- 용도:
|
|
||||||
- 목록 row 클릭 또는 `상세` 버튼 클릭 시 상세 모달/상세 패널을 연다.
|
|
||||||
|
|
||||||
상세 response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftDetailResponse = {
|
|
||||||
applicationNo: string;
|
|
||||||
senderInfo: AdminGiftMemberInfoResponse;
|
|
||||||
recipientInfo: AdminGiftMemberInfoResponse;
|
|
||||||
productInfo: AdminGiftProductInfoResponse;
|
|
||||||
inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse;
|
|
||||||
status: GiftStatus;
|
|
||||||
statusName: string;
|
|
||||||
availableActions: AdminGiftAction[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type AdminGiftMemberInfoResponse = {
|
|
||||||
nickname: string;
|
|
||||||
name: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
address: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AdminGiftProductInfoResponse = {
|
|
||||||
sizeCode: string;
|
|
||||||
sizeName: string;
|
|
||||||
categoryName: string;
|
|
||||||
classificationNumber: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AdminGiftInboundDeliveryInfoResponse = {
|
|
||||||
courierCompanyName: string | null;
|
|
||||||
trackingNumber: string | null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
상세 표시 요구사항:
|
|
||||||
- 발송인 정보: 닉네임, 이름, 전화번호, 주소
|
|
||||||
- 수취인 정보: 닉네임, 이름, 전화번호, 주소
|
|
||||||
- 상품 정보: 사이즈, 카테고리, 분류번호
|
|
||||||
- 입고 배송 정보: 택배사, 운송장번호
|
|
||||||
- 현재 상태와 가능한 액션
|
|
||||||
- 수취인이 배송지를 아직 입력하지 않은 경우 `recipientInfo.nickname`은 표시하고 `name`, `phoneNumber`, `address`는 빈 문자열로 온다. 화면에서는 `미입력`으로 표시해도 된다.
|
|
||||||
|
|
||||||
운영 액션 enum:
|
|
||||||
```ts
|
|
||||||
type AdminGiftAction =
|
|
||||||
| "ARRIVE_MAILBOX"
|
|
||||||
| "COMPLETE_INSPECTION"
|
|
||||||
| "COMPLETE_DELIVERY"
|
|
||||||
| "MARK_UNDELIVERABLE";
|
|
||||||
```
|
|
||||||
|
|
||||||
상태별 `availableActions`:
|
|
||||||
- `RECEIVED`: 없음
|
|
||||||
- `TRACKING_REGISTERED`: `ARRIVE_MAILBOX`, `MARK_UNDELIVERABLE`
|
|
||||||
- `ARRIVED_AT_MAILBOX`: `COMPLETE_INSPECTION`, `MARK_UNDELIVERABLE`
|
|
||||||
- `INSPECTION_COMPLETED`: `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`
|
|
||||||
- `DELIVERED`: 없음
|
|
||||||
- `UNDELIVERABLE`: 없음
|
|
||||||
- `CANCELED`: 없음
|
|
||||||
|
|
||||||
액션 버튼 문구:
|
|
||||||
- `ARRIVE_MAILBOX`: `사서함 도착 처리`
|
|
||||||
- `COMPLETE_INSPECTION`: `검수 완료 처리`
|
|
||||||
- `COMPLETE_DELIVERY`: `전달 완료 처리`
|
|
||||||
- `MARK_UNDELIVERABLE`: `전달 불가 처리`
|
|
||||||
|
|
||||||
액션 API:
|
|
||||||
- 사서함 도착 처리
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox`
|
|
||||||
- Body: 없음
|
|
||||||
- 검수 완료 처리
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-inspection`
|
|
||||||
- Body: 없음
|
|
||||||
- 전달 완료 처리
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-delivery`
|
|
||||||
- Body: 없음
|
|
||||||
- 전달 불가 처리
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable`
|
|
||||||
- Body:
|
|
||||||
```ts
|
|
||||||
type AdminGiftMarkUndeliverableRequest = {
|
|
||||||
reason: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
액션 response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftOperationStatusResponse = {
|
|
||||||
applicationNo: string;
|
|
||||||
status: GiftStatus;
|
|
||||||
statusName: string;
|
|
||||||
occurredAt: string; // UTC ISO string, e.g. 2026-09-30T12:00:00Z
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
액션 UX:
|
|
||||||
- 모든 상태 변경 액션은 확인창을 띄운다.
|
|
||||||
- `MARK_UNDELIVERABLE`은 사유 입력 모달을 띄운다.
|
|
||||||
- 전달 불가 사유는 공백만 입력할 수 없고 255자 이내로 제한한다.
|
|
||||||
- 액션 성공 후:
|
|
||||||
- 상세 모달이 열려 있으면 상세를 다시 조회한다.
|
|
||||||
- 목록도 현재 필터/페이지 기준으로 다시 조회한다.
|
|
||||||
- 성공 토스트를 표시한다.
|
|
||||||
- 액션 실패 시 기존 관리자 페이지 에러 토스트/알림 패턴을 따른다.
|
|
||||||
|
|
||||||
빈 값 표시:
|
|
||||||
- `courierCompanyName`, `trackingNumber`가 null이면 `-`로 표시한다.
|
|
||||||
- 수취인 개인정보가 빈 문자열이면 `미입력`으로 표시한다.
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- API 클라이언트 함수 단위 테스트 또는 페이지 테스트에서 query parameter가 올바르게 전달되는지 확인한다.
|
|
||||||
- `availableActions`에 따라 버튼 노출이 달라지는지 확인한다.
|
|
||||||
- 전달 불가 사유 빈 값 validation을 확인한다.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 받을 주소 설정 페이지 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
관리자 선물 받을 주소 설정 페이지를 구현해줘.
|
|
||||||
|
|
||||||
목표:
|
|
||||||
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
|
|
||||||
- 이 주소는 팬의 보낸 선물 상세 API에서 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 `mailbox`로 노출된다.
|
|
||||||
- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다.
|
|
||||||
|
|
||||||
라우트:
|
|
||||||
- `/gift/mailbox`
|
|
||||||
|
|
||||||
필수 화면 구성:
|
|
||||||
- 상단 제목: `받을 주소`
|
|
||||||
- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.`
|
|
||||||
- 입력 폼:
|
|
||||||
- 받을 사람 이름 `name`
|
|
||||||
- 연락처 `phoneNumber`
|
|
||||||
- 우편번호 `zipCode`
|
|
||||||
- 주소 `address`
|
|
||||||
- 상세주소 `addressDetail`
|
|
||||||
- 저장 버튼: `저장`
|
|
||||||
|
|
||||||
초기 조회 API:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/admin/gift-mailbox`
|
|
||||||
- response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftMailboxResponse = {
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
} | null;
|
|
||||||
```
|
|
||||||
- `data`가 null이면 빈 폼을 표시한다.
|
|
||||||
|
|
||||||
저장 API:
|
|
||||||
- Method: `PUT`
|
|
||||||
- URL: `/api/v2/admin/gift-mailbox`
|
|
||||||
- Request:
|
|
||||||
```ts
|
|
||||||
type AdminGiftMailboxRequest = {
|
|
||||||
name: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
zipCode: string;
|
|
||||||
address: string;
|
|
||||||
addressDetail: string | null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
- Response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftMailboxResponse = {
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- `name`, `phoneNumber`, `zipCode`, `address`는 필수다.
|
|
||||||
- 공백만 입력할 수 없다.
|
|
||||||
- 저장 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
|
|
||||||
|
|
||||||
저장 성공 UX:
|
|
||||||
- 성공 토스트를 표시한다.
|
|
||||||
- 저장 API response 기준으로 화면 값을 갱신한다.
|
|
||||||
- `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식으로 표시해도 되고, 입력 필드는 사용자가 입력한 값을 유지해도 된다.
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- 초기 조회 시 null이면 빈 폼을 표시하는지 확인한다.
|
|
||||||
- 저장 시 PUT body가 정확히 전달되는지 확인한다.
|
|
||||||
- 필수값 공백 validation을 확인한다.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 선물 카테고리 CRUD 페이지 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
관리자 선물 카테고리 CRUD 페이지를 구현해줘.
|
|
||||||
|
|
||||||
목표:
|
|
||||||
- 관리자 메뉴 `선물함 관리 > 선물 카테고리`에서 선물 카테고리를 조회, 등록, 수정, 비활성화한다.
|
|
||||||
- 기존 관리자 페이지의 CRUD 테이블, 등록/수정 모달, 삭제 확인창, 토스트 패턴을 그대로 따른다.
|
|
||||||
|
|
||||||
라우트:
|
|
||||||
- `/gift/category`
|
|
||||||
|
|
||||||
필수 화면 구성:
|
|
||||||
- 상단 제목: `선물 카테고리`
|
|
||||||
- 상단 우측 `카테고리 등록` 버튼
|
|
||||||
- 목록 테이블 컬럼:
|
|
||||||
- 카테고리 ID `categoryId`
|
|
||||||
- 분류번호 `classificationNumber`
|
|
||||||
- 카테고리 코드 `categoryCode`
|
|
||||||
- 카테고리명 `name`
|
|
||||||
- 접수 코드 `receiptCode`
|
|
||||||
- 대표 품목 `representativeItem`
|
|
||||||
- 파손면책 동의 필요 여부 `requiresDamageWaiver`
|
|
||||||
- 활성 여부 `isActive`
|
|
||||||
- 관리 버튼: 수정, 비활성화
|
|
||||||
- 등록/수정 폼 필드:
|
|
||||||
- `classificationNumber`
|
|
||||||
- `categoryCode`
|
|
||||||
- `name`
|
|
||||||
- `receiptCode`
|
|
||||||
- `representativeItem`
|
|
||||||
- `requiresDamageWaiver`
|
|
||||||
- `isActive`
|
|
||||||
|
|
||||||
목록 조회 API:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/admin/gift-categories`
|
|
||||||
- Request: 없음
|
|
||||||
- Response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftCategoryResponse = {
|
|
||||||
categoryId: number;
|
|
||||||
classificationNumber: string;
|
|
||||||
categoryCode: string;
|
|
||||||
name: string;
|
|
||||||
receiptCode: string;
|
|
||||||
representativeItem: string;
|
|
||||||
requiresDamageWaiver: boolean;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
등록 API:
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/admin/gift-categories`
|
|
||||||
- Body:
|
|
||||||
```ts
|
|
||||||
type AdminGiftCategoryRequest = {
|
|
||||||
classificationNumber: string;
|
|
||||||
categoryCode: string;
|
|
||||||
name: string;
|
|
||||||
receiptCode: string;
|
|
||||||
representativeItem: string;
|
|
||||||
requiresDamageWaiver: boolean;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
- Response `data`: `AdminGiftCategoryResponse`
|
|
||||||
|
|
||||||
수정 API:
|
|
||||||
- Method: `PUT`
|
|
||||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
|
||||||
- Body: `AdminGiftCategoryRequest`
|
|
||||||
- Response `data`: `AdminGiftCategoryResponse`
|
|
||||||
|
|
||||||
비활성화 API:
|
|
||||||
- Method: `DELETE`
|
|
||||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
|
||||||
- Body: 없음
|
|
||||||
- Response `data`: `AdminGiftCategoryResponse`
|
|
||||||
- 서버는 실제 삭제가 아니라 `isActive=false` 논리 삭제로 처리한다.
|
|
||||||
|
|
||||||
폼 validation:
|
|
||||||
- `classificationNumber`: 필수, 숫자 3자리. 예: `100`
|
|
||||||
- `categoryCode`: 필수, 50자 이하. 예: `DOLL`
|
|
||||||
- `name`: 필수, 50자 이하. 예: `인형`
|
|
||||||
- `receiptCode`: 필수, 영문 대문자 1~9자. 예: `A`
|
|
||||||
- `representativeItem`: 필수, 100자 이하. 예: `피규어`
|
|
||||||
- `requiresDamageWaiver`: boolean
|
|
||||||
- `isActive`: boolean
|
|
||||||
|
|
||||||
UX 요구사항:
|
|
||||||
- 등록 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
|
||||||
- 수정 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
|
||||||
- 비활성화 버튼은 확인창을 띄운 뒤 호출한다.
|
|
||||||
- 이미 비활성화된 row는 비활성화 버튼을 disabled 처리한다.
|
|
||||||
- boolean 값은 기존 관리자 페이지 표현에 맞춰 `예/아니오`, `활성/비활성` 또는 badge로 표시한다.
|
|
||||||
- `categoryCode`는 수정 폼에 표시하되 서버 쪽 엔티티는 코드 변경을 실제로 반영하지 않을 수 있으므로, 가능하면 수정 시 읽기 전용으로 두고 나머지 운영 필드만 수정하도록 UX를 구성한다.
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- 목록 조회가 `GET /api/v2/admin/gift-categories`를 호출하는지 확인한다.
|
|
||||||
- 등록/수정 body가 `AdminGiftCategoryRequest` 형태와 일치하는지 확인한다.
|
|
||||||
- validation 실패 시 API를 호출하지 않고 폼 에러를 표시한다.
|
|
||||||
- 비활성화 성공 후 해당 row의 `isActive=false`가 반영되는지 확인한다.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 선물 사이즈 가격 CRUD 페이지 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘.
|
|
||||||
|
|
||||||
목표:
|
|
||||||
- 관리자 메뉴 `선물함 관리 > 선물 사이즈`에서 선물 사이즈별 기본가/판매가/활성 여부를 조회하고 수정한다.
|
|
||||||
- 현재 API는 사이즈 가격의 목록 조회와 수정만 제공한다. 신규 생성과 물리 삭제 기능은 만들지 않는다.
|
|
||||||
- 기존 관리자 페이지의 테이블, 수정 모달, 저장 확인/토스트 패턴을 그대로 따른다.
|
|
||||||
|
|
||||||
라우트:
|
|
||||||
- `/gift/size`
|
|
||||||
|
|
||||||
필수 화면 구성:
|
|
||||||
- 상단 제목: `선물 사이즈`
|
|
||||||
- 목록 테이블 컬럼:
|
|
||||||
- 사이즈 코드 `sizeCode`
|
|
||||||
- 사이즈명 `name`
|
|
||||||
- 기본가 `basePriceCan`
|
|
||||||
- 판매가 `salePriceCan`
|
|
||||||
- 활성 여부 `isActive`
|
|
||||||
- 관리 버튼: 수정
|
|
||||||
- 수정 폼 필드:
|
|
||||||
- `sizeCode`: 읽기 전용
|
|
||||||
- `name`: 읽기 전용
|
|
||||||
- `basePriceCan`: number input
|
|
||||||
- `salePriceCan`: number input
|
|
||||||
- `isActive`: checkbox/switch
|
|
||||||
|
|
||||||
목록 조회 API:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/admin/gift-size-prices`
|
|
||||||
- Request: 없음
|
|
||||||
- Response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftSizePriceResponse = {
|
|
||||||
sizeCode: GiftSize;
|
|
||||||
name: string;
|
|
||||||
basePriceCan: number;
|
|
||||||
salePriceCan: number;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
수정 API:
|
|
||||||
- Method: `PUT`
|
|
||||||
- URL: `/api/v2/admin/gift-size-prices/{sizeCode}`
|
|
||||||
- Body:
|
|
||||||
```ts
|
|
||||||
type AdminGiftSizePriceRequest = {
|
|
||||||
basePriceCan: number;
|
|
||||||
salePriceCan: number;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
- Response `data`: `AdminGiftSizePriceResponse`
|
|
||||||
|
|
||||||
사이즈 enum:
|
|
||||||
```ts
|
|
||||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
|
||||||
```
|
|
||||||
|
|
||||||
서버 validation:
|
|
||||||
- `salePriceCan`은 0보다 커야 한다.
|
|
||||||
- `salePriceCan`은 `basePriceCan`보다 클 수 없다.
|
|
||||||
|
|
||||||
프론트 validation:
|
|
||||||
- `basePriceCan`: 필수, 1 이상 정수
|
|
||||||
- `salePriceCan`: 필수, 1 이상 정수
|
|
||||||
- `salePriceCan <= basePriceCan`
|
|
||||||
- validation 실패 시 API 호출을 막고 필드 에러를 표시한다.
|
|
||||||
|
|
||||||
UX 요구사항:
|
|
||||||
- 가격은 숫자 입력이지만 테이블 표시에서는 기존 관리자 페이지의 숫자 포맷을 따른다.
|
|
||||||
- 수정 버튼 클릭 시 현재 row 값을 폼 초기값으로 넣는다.
|
|
||||||
- 저장 전 확인창은 기존 관리자 페이지 패턴이 있으면 따른다.
|
|
||||||
- 저장 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
|
||||||
- 신규 생성/삭제 버튼은 만들지 않는다.
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- 목록 조회가 `GET /api/v2/admin/gift-size-prices`를 호출하는지 확인한다.
|
|
||||||
- 수정 저장 시 `PUT /api/v2/admin/gift-size-prices/{sizeCode}`와 request body가 정확한지 확인한다.
|
|
||||||
- `salePriceCan > basePriceCan`일 때 API를 호출하지 않고 validation 메시지를 표시하는지 확인한다.
|
|
||||||
- 저장 성공 후 목록 refresh가 일어나는지 확인한다.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 구현 시 참고할 공통 타입
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type ApiResponse<T> = {
|
|
||||||
success: boolean;
|
|
||||||
data: T;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GiftStatus =
|
|
||||||
| "RECEIVED"
|
|
||||||
| "TRACKING_REGISTERED"
|
|
||||||
| "ARRIVED_AT_MAILBOX"
|
|
||||||
| "INSPECTION_COMPLETED"
|
|
||||||
| "DELIVERED"
|
|
||||||
| "UNDELIVERABLE"
|
|
||||||
| "CANCELED";
|
|
||||||
|
|
||||||
type AdminGiftAction =
|
|
||||||
| "ARRIVE_MAILBOX"
|
|
||||||
| "COMPLETE_INSPECTION"
|
|
||||||
| "COMPLETE_DELIVERY"
|
|
||||||
| "MARK_UNDELIVERABLE";
|
|
||||||
|
|
||||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
|
||||||
```
|
|
||||||
|
|
||||||
## 구현 순서 추천
|
|
||||||
|
|
||||||
1. API client 함수와 TypeScript 타입을 먼저 추가한다.
|
|
||||||
2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다.
|
|
||||||
3. `/gift/mailbox` 받을 주소 설정 화면을 구현한다.
|
|
||||||
4. `/gift/category` 카테고리 CRUD를 구현한다.
|
|
||||||
5. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
|
|
||||||
6. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
|
|
||||||
7. 각 페이지별 loading/empty/error/success 상태를 확인한다.
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
# 선물하기 클라이언트 API 요약
|
|
||||||
|
|
||||||
## 문서 목적
|
|
||||||
|
|
||||||
- 이 문서는 클라이언트 구현 프롬프트 작성용 빠른 참조다.
|
|
||||||
- 상세 request/response 계약은 `prd.md`의 `8. API 계약`을 기준으로 한다.
|
|
||||||
- 여기서는 사용자 역할별 URI, 역할, 핵심 request/response 필드만 요약한다.
|
|
||||||
|
|
||||||
## 문서 분리 결정
|
|
||||||
|
|
||||||
PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한다. 클라이언트 프롬프트용 역할별 인벤토리는 별도 문서로 둔다.
|
|
||||||
|
|
||||||
- 이유: PRD에 같은 URI 정보를 역할별로 다시 넣으면 상세 계약과 요약표를 동시에 갱신해야 한다.
|
|
||||||
- 기준: PRD는 제품 요구사항과 API 계약의 원본, 이 문서는 클라이언트 구현을 위한 탐색용 색인이다.
|
|
||||||
|
|
||||||
## 공통/조회 API
|
|
||||||
|
|
||||||
| 사용자 | Method | URI | 역할 | Request | Response |
|
|
||||||
|---|---:|---|---|---|---|
|
|
||||||
| 비회원/회원 | GET | `/api/v2/gifts/form-options` | 선물 신청 폼에 필요한 활성 카테고리와 사이즈별 가격 옵션을 조회한다. | 없음 | `sizes[]`, `categories[]` |
|
|
||||||
| 팬/크리에이터 | GET | `/api/v2/gifts?type=ALL&page=0&size=20` | 내 선물함 목록을 조회하고 `type=ALL|SENT|RECEIVED`로 전체/보낸/받은 선물을 필터링한다. | query: `type`, `page`, `size` | `items[]`, `page`, `size`, `hasNext` |
|
|
||||||
| 팬/크리에이터 | GET | `/api/v2/gifts/{applicationNo}` | 선물 상세를 조회하며 로그인 회원이 팬인지 크리에이터인지에 따라 노출 정보와 CTA flag가 달라진다. | path: `applicationNo` | `applicationNo`, `direction`, `status`, `statusName`, `giftInfo`, `senderInfo`, `recipientAddress`, `mailbox`, `trackingRequired`, `recipientAddressRequired`, `delivery`, `statusTimeline`, `review` |
|
|
||||||
|
|
||||||
## 팬 API
|
|
||||||
|
|
||||||
| 사용자 | Method | URI | 역할 | Request | Response |
|
|
||||||
|---|---:|---|---|---|---|
|
|
||||||
| 팬 | POST | `/api/v2/gifts` | 크리에이터에게 선물을 신청하고 최종 결제 금액만큼 캔을 차감한다. | body: `recipientMemberId`, `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `sizeCode`, `categoryId`, `senderTermsAgreed`, `senderPrivacyAgreed`, `damageWaiverAgreed` | `applicationNo`, `status`, `statusName`, `priceCan`, `trackingDeadlineAt` |
|
|
||||||
| 팬 | POST | `/api/v2/gifts/{applicationNo}/cancel` | 운송장 등록 전 선물 신청을 취소하고 전액 환불한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `priceCan`, `canceledAt` |
|
|
||||||
| 팬 | POST | `/api/v2/gifts/{applicationNo}/tracking` | 발송 후 택배사와 운송장 번호를 등록한다. | path: `applicationNo`, body: `courierCompanyName`, `trackingNumber` | `applicationNo`, `status`, `statusName`, `courierCompanyName`, `trackingNumber`, `trackingRegisteredAt`, `recipientAddressDeadlineAt` |
|
|
||||||
| 팬 | POST | `/api/v2/gifts/{applicationNo}/review` | 전달 완료된 선물에 리뷰를 작성한다. | path: `applicationNo`, body: `rating`, `keywords`, `comment` | `reviewId`, `applicationNo`, `rating`, `keywords`, `comment`, `createdAt` |
|
|
||||||
|
|
||||||
## 크리에이터 API
|
|
||||||
|
|
||||||
| 사용자 | Method | URI | 역할 | Request | Response |
|
|
||||||
|---|---:|---|---|---|---|
|
|
||||||
| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/recipient-address` | 운송장 등록 이후 배송지와 수령 약관 동의를 등록한다. | path: `applicationNo`, body: `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `recipientTermsAgreed`, `recipientPrivacyAgreed` | `applicationNo`, `status`, `statusName`, `recipientAddressRegisteredAt` |
|
|
||||||
| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/delivery-complete` | 검수 완료된 선물을 직접 수령 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `deliveredAt` |
|
|
||||||
|
|
||||||
## 관리자 API
|
|
||||||
|
|
||||||
| 사용자 | Method | URI | 역할 | Request | Response |
|
|
||||||
|---|---:|---|---|---|---|
|
|
||||||
| 관리자 | GET | `/api/v2/admin/gift-categories` | 선물 카테고리 목록을 조회한다. | 없음 | `AdminGiftCategoryResponse[]` |
|
|
||||||
| 관리자 | GET | `/api/v2/admin/gifts` | 전체 선물함 목록을 조회하고 상태/신청번호/닉네임으로 검색한다. | query: `status`, `applicationNo`, `nickname`, `page`, `size` | `totalCount`, `items[]`, `page`, `size`, `hasNext` |
|
|
||||||
| 관리자 | GET | `/api/v2/admin/gifts/{applicationNo}` | 선물 상세와 발송인/수취인 개인정보, 상품/배송/상태 정보를 조회한다. | path: `applicationNo` | `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions` |
|
|
||||||
| 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` |
|
|
||||||
| 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` |
|
|
||||||
| 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive=false` |
|
|
||||||
| 관리자 | GET | `/api/v2/admin/gift-size-prices` | 사이즈별 기본가와 판매가를 조회한다. | 없음 | `AdminGiftSizePriceResponse[]` |
|
|
||||||
| 관리자 | PUT | `/api/v2/admin/gift-size-prices/{sizeCode}` | 사이즈별 기본가와 판매가를 수정한다. | path: `sizeCode`, body: `basePriceCan`, `salePriceCan`, `isActive` | `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` |
|
|
||||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` | 발송 확인 선물을 사서함 도착 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
|
|
||||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | 사서함 도착 선물을 검수 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
|
|
||||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | 진행 중 선물을 전달 불가 처리하고 사유를 저장한다. | path: `applicationNo`, body: `reason` | `applicationNo`, `status`, `statusName`, `occurredAt` |
|
|
||||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | 검수 완료 선물을 운영자가 전달 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
|
|
||||||
|
|
||||||
관리자 응답 DTO 요약:
|
|
||||||
|
|
||||||
- `AdminGiftCategoryResponse`: `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive`
|
|
||||||
- `AdminGiftListResponse`: `totalCount`, `items`, `page`, `size`, `hasNext`
|
|
||||||
- `AdminGiftListItemResponse`: `applicationNo`, `senderNickname`, `recipientNickname`, `sizeCode`, `sizeName`, `categoryName`, `classificationNumber`, `courierCompanyName`, `trackingNumber`, `status`, `statusName`, `availableActions`
|
|
||||||
- `AdminGiftDetailResponse`: `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions`
|
|
||||||
- `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive`
|
|
||||||
- `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt`
|
|
||||||
|
|
||||||
## Kotlin DTO 형태
|
|
||||||
|
|
||||||
아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. 응답 시각 필드는 UTC ISO 문자열이다. 예: `2026-09-30T12:00:00Z`.
|
|
||||||
|
|
||||||
### 사용자 Request DTO
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class GiftApplicationRequest(
|
|
||||||
val recipientMemberId: Long,
|
|
||||||
val senderName: String,
|
|
||||||
val senderPhoneNumber: String,
|
|
||||||
val senderZipCode: String,
|
|
||||||
val senderAddress: String,
|
|
||||||
val senderAddressDetail: String?,
|
|
||||||
val sizeCode: GiftSize,
|
|
||||||
val categoryId: Long,
|
|
||||||
val senderTermsAgreed: Boolean,
|
|
||||||
val senderPrivacyAgreed: Boolean,
|
|
||||||
val damageWaiverAgreed: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftTrackingRegistrationRequest(
|
|
||||||
val courierCompanyName: String,
|
|
||||||
val trackingNumber: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftRecipientAddressRegistrationRequest(
|
|
||||||
val recipientName: String,
|
|
||||||
val recipientPhoneNumber: String,
|
|
||||||
val recipientZipCode: String,
|
|
||||||
val recipientAddress: String,
|
|
||||||
val recipientAddressDetail: String?,
|
|
||||||
val recipientTermsAgreed: Boolean,
|
|
||||||
val recipientPrivacyAgreed: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftReviewRequest(
|
|
||||||
val rating: Int,
|
|
||||||
val keywords: List<String>,
|
|
||||||
val comment: String?
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 사용자 Response DTO
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class GiftFormOptionsResponse(
|
|
||||||
val sizes: List<GiftSizeOptionResponse>,
|
|
||||||
val categories: List<GiftCategoryOptionResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftSizeOptionResponse(
|
|
||||||
val sizeCode: String,
|
|
||||||
val name: String,
|
|
||||||
val basePriceCan: Int,
|
|
||||||
val salePriceCan: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftCategoryOptionResponse(
|
|
||||||
val categoryId: Long,
|
|
||||||
val name: String,
|
|
||||||
val requiresDamageWaiver: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
// name은 사용자 폼 옵션에서만 "분류명/대표품목" 형태로 조합된다.
|
|
||||||
|
|
||||||
data class GiftListResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val items: List<GiftListItemResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftListItemResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val direction: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val priceCan: Int,
|
|
||||||
val categoryName: String,
|
|
||||||
val sizeName: String,
|
|
||||||
val createdAt: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftDetailResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val direction: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val giftInfo: GiftDetailInfoResponse,
|
|
||||||
val senderInfo: GiftAddressResponse?,
|
|
||||||
val recipientAddress: GiftAddressResponse?,
|
|
||||||
val mailbox: GiftMailboxResponse?,
|
|
||||||
val trackingRequired: Boolean,
|
|
||||||
val recipientAddressRequired: Boolean,
|
|
||||||
val recipientAddressDeadlineAt: String?,
|
|
||||||
val delivery: GiftDeliveryInfoResponse,
|
|
||||||
val statusTimeline: List<GiftStatusTimelineResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftDetailInfoResponse(
|
|
||||||
val recipientCreatorNickname: String?,
|
|
||||||
val senderNickname: String?,
|
|
||||||
val sizeName: String,
|
|
||||||
val categoryName: String,
|
|
||||||
val applicationNo: String?,
|
|
||||||
val paidCan: Int?,
|
|
||||||
val tracking: String?,
|
|
||||||
val shippingRequestedAt: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftAddressResponse(
|
|
||||||
val name: String,
|
|
||||||
val phoneNumber: String,
|
|
||||||
val address: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftMailboxResponse(
|
|
||||||
val name: String,
|
|
||||||
val address: String,
|
|
||||||
val phoneNumber: String
|
|
||||||
)
|
|
||||||
|
|
||||||
// mailbox는 direction=SENT이고 status가 RECEIVED, TRACKING_REGISTERED,
|
|
||||||
// ARRIVED_AT_MAILBOX, INSPECTION_COMPLETED일 때만 값이 있다.
|
|
||||||
// DELIVERED, UNDELIVERABLE, CANCELED 또는 direction=RECEIVED이면 null이다.
|
|
||||||
|
|
||||||
data class GiftDeliveryInfoResponse(
|
|
||||||
val canceledAt: String?,
|
|
||||||
val undeliverableAt: String?,
|
|
||||||
val undeliverableReason: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftStatusTimelineResponse(
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val occurredAt: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftApplicationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val priceCan: Int,
|
|
||||||
val trackingDeadlineAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftCancellationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val priceCan: Int,
|
|
||||||
val canceledAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftTrackingRegistrationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val courierCompanyName: String,
|
|
||||||
val trackingNumber: String,
|
|
||||||
val trackingRegisteredAt: String,
|
|
||||||
val recipientAddressDeadlineAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftRecipientAddressRegistrationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val recipientAddressRegisteredAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftDeliveryConfirmationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val deliveredAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftReviewResponse(
|
|
||||||
val reviewId: Long,
|
|
||||||
val applicationNo: String,
|
|
||||||
val rating: Int,
|
|
||||||
val keywords: List<String>,
|
|
||||||
val comment: String?,
|
|
||||||
val createdAt: String?
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 관리자 Request DTO
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class AdminGiftCategoryRequest(
|
|
||||||
val classificationNumber: String,
|
|
||||||
val categoryCode: String,
|
|
||||||
val name: String,
|
|
||||||
val receiptCode: String,
|
|
||||||
val representativeItem: String,
|
|
||||||
val requiresDamageWaiver: Boolean,
|
|
||||||
val isActive: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftSizePriceRequest(
|
|
||||||
val basePriceCan: Int,
|
|
||||||
val salePriceCan: Int,
|
|
||||||
val isActive: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftMarkUndeliverableRequest(
|
|
||||||
val reason: String
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 관리자 Response DTO
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class AdminGiftCategoryResponse(
|
|
||||||
val categoryId: Long,
|
|
||||||
val classificationNumber: String,
|
|
||||||
val categoryCode: String,
|
|
||||||
val name: String,
|
|
||||||
val receiptCode: String,
|
|
||||||
val representativeItem: String,
|
|
||||||
val requiresDamageWaiver: Boolean,
|
|
||||||
val isActive: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftListResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val items: List<AdminGiftListItemResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftListItemResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val senderNickname: String,
|
|
||||||
val recipientNickname: String,
|
|
||||||
val sizeCode: String,
|
|
||||||
val sizeName: String,
|
|
||||||
val categoryName: String,
|
|
||||||
val classificationNumber: String,
|
|
||||||
val courierCompanyName: String?,
|
|
||||||
val trackingNumber: String?,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val availableActions: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftDetailResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val senderInfo: AdminGiftMemberInfoResponse,
|
|
||||||
val recipientInfo: AdminGiftMemberInfoResponse,
|
|
||||||
val productInfo: AdminGiftProductInfoResponse,
|
|
||||||
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val availableActions: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftMemberInfoResponse(
|
|
||||||
val nickname: String,
|
|
||||||
val name: String,
|
|
||||||
val phoneNumber: String,
|
|
||||||
val address: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftProductInfoResponse(
|
|
||||||
val sizeCode: String,
|
|
||||||
val sizeName: String,
|
|
||||||
val categoryName: String,
|
|
||||||
val classificationNumber: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftInboundDeliveryInfoResponse(
|
|
||||||
val courierCompanyName: String?,
|
|
||||||
val trackingNumber: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftSizePriceResponse(
|
|
||||||
val sizeCode: String,
|
|
||||||
val name: String,
|
|
||||||
val basePriceCan: Int,
|
|
||||||
val salePriceCan: Int,
|
|
||||||
val isActive: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminGiftOperationStatusResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: GiftStatus,
|
|
||||||
val statusName: String,
|
|
||||||
val occurredAt: String
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 클라이언트 구현 참고
|
|
||||||
|
|
||||||
- 선물 푸시 딥링크는 `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`를 사용한다.
|
|
||||||
- 딥링크 진입 후에는 `GET /api/v2/gifts/{applicationNo}`로 상세를 조회해 `direction`, `trackingRequired`, `recipientAddressRequired`를 기준으로 화면과 CTA를 결정한다.
|
|
||||||
- 운송장 미등록 자동취소는 서버 스케줄러가 처리하며, 팬 클라이언트는 상세 조회의 `status=CANCELED`와 푸시를 기준으로 표시한다.
|
|
||||||
- 모든 응답은 기존 `ApiResponse.ok(...)` envelope 안의 `data`로 내려간다.
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
# 보낸 선물 상세 및 운송장 등록 페이지 생성 프롬프트
|
|
||||||
|
|
||||||
이 문서는 Android/iOS 앱의 `선물함` 리스트에서 보낸 선물 상세 페이지와 운송장 등록 페이지를 생성할 때 사용하는 프롬프트다.
|
|
||||||
|
|
||||||
참고 Figma:
|
|
||||||
|
|
||||||
- 선물 보내기 신청 상세 운송장 등록 전 UI: `2481:19011`
|
|
||||||
- 선물 보내기 신청 상세 운송장 등록 후 UI: `2481:19152`
|
|
||||||
- 선물 보내기 신청 상세 전달 완료 UI: `2481:19320`
|
|
||||||
- 전달 불가 UI: `2481:19456`
|
|
||||||
- 취소 버튼 다이얼로그 UI: `2481:19500`
|
|
||||||
- 취소 완료 페이지 UI: `2481:19521`
|
|
||||||
- 운송장 등록 UI: `2481:19066`
|
|
||||||
- 운송장 정보를 채우고 난 후 UI: `2481:19109`
|
|
||||||
|
|
||||||
전제:
|
|
||||||
|
|
||||||
- 선물함 리스트 item 탭 시 `applicationNo`를 전달받아 상세 페이지로 이동한다.
|
|
||||||
- 새 디자인 시스템을 만들지 말고 Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
|
|
||||||
- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `response.data`다.
|
|
||||||
- Figma의 진행 상태는 4단계처럼 보이지만 실제 구현은 서버의 `statusTimeline` 5개 상태를 모두 사용한다.
|
|
||||||
- 운송장 등록의 택배사 선택지는 서버 API가 없으므로 앱 로컬 상수로 관리한다.
|
|
||||||
- 취소 확인 팝업은 앱의 `v2 dialog` 컴포넌트를 기본으로 사용한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 모바일 앱 구현 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
Android/iOS 앱의 선물 상세 페이지와 운송장 등록 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘.
|
|
||||||
|
|
||||||
범위:
|
|
||||||
- 선물함 리스트에서 `applicationNo`를 받아 선물 상세 페이지로 이동한다.
|
|
||||||
- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}` 응답으로 렌더링한다.
|
|
||||||
- `trackingRequired=true`인 보낸 선물 상세에서는 운송장 등록 CTA를 표시하고 운송장 등록 페이지로 이동한다.
|
|
||||||
- 운송장 등록 페이지는 같은 상세 API를 조회해 신청 요약과 보낼 주소를 표시한 뒤 `POST /api/v2/gifts/{applicationNo}/tracking`으로 운송장 정보를 등록한다.
|
|
||||||
|
|
||||||
중요 전제:
|
|
||||||
- Figma의 visual structure와 문구를 우선 따르되, 상태 개수와 데이터 노출은 API 계약을 우선한다.
|
|
||||||
- 상세 진행 단계는 Figma처럼 4단계로 고정하지 말고 API `statusTimeline`의 5개 정상 진행 상태를 모두 렌더링한다.
|
|
||||||
- 택배사 목록은 API로 조회하지 않는다. 앱 로컬 상수로 관리하고 선택한 한글 표시명을 `courierCompanyName`으로 전송한다.
|
|
||||||
- 운송장 등록 완료 후 수정 기능은 만들지 않는다.
|
|
||||||
|
|
||||||
상세 페이지 진입:
|
|
||||||
- 선물함 리스트 item에서 받은 `applicationNo`로 진입한다.
|
|
||||||
- 화면 진입 시 `GET /api/v2/gifts/{applicationNo}`를 호출한다.
|
|
||||||
- 조회 결과 `status=CANCELED`이면 일반 신청 상세 화면을 렌더링하지 말고 취소 완료 페이지 UI를 보여준다.
|
|
||||||
- 로딩 중에는 기존 앱 상세 화면 로딩/스켈레톤 패턴을 사용한다.
|
|
||||||
- 실패 시 기존 앱 toast/dialog와 재시도 패턴을 사용한다.
|
|
||||||
|
|
||||||
상세 페이지 공통 레이아웃:
|
|
||||||
1. 상단 고정 내비게이션
|
|
||||||
- 타이틀: `신청 상세`
|
|
||||||
- 뒤로가기 버튼은 기존 앱 패턴을 사용한다.
|
|
||||||
2. 스크롤 콘텐츠
|
|
||||||
- 배송 상태 hero
|
|
||||||
- 진행 상태 progress
|
|
||||||
- 상태별 action button 영역
|
|
||||||
- 선물 정보
|
|
||||||
- 필요한 경우 보내는 사람, 받는 사서함, 사유 섹션
|
|
||||||
3. 하단 safe area
|
|
||||||
- 기존 앱 패턴을 따른다.
|
|
||||||
|
|
||||||
상태별 hero 문구:
|
|
||||||
| status | chip | title | body |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `RECEIVED` | 없음 또는 `접수 완료` | `선물 신청 완료!` | `선물이 정상적으로 접수되었어요!` |
|
|
||||||
| `TRACKING_REGISTERED` | `발송 확인` | `사서함으로 이동 중이에요` | 없음 |
|
|
||||||
| `ARRIVED_AT_MAILBOX` | `사서함 도착` | `선물이 사서함에 도착했어요` | 없음 |
|
|
||||||
| `INSPECTION_COMPLETED` | `검수 완료` | `선물 검수를 완료했어요` | 없음 |
|
|
||||||
| `DELIVERED` | `전달 완료` | `크리에이터에게 선물을 전달했어요` | 없음 |
|
|
||||||
| `UNDELIVERABLE` | `전달 불가` | `전달할 수 없는 품목입니다` | `보내주신 선물은 SODALIVE 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다.` |
|
|
||||||
| `CANCELED` | `신청 취소` | `선물 신청이 취소되었어요` | `사용한 캔은 환불 처리됩니다.` |
|
|
||||||
|
|
||||||
진행 상태 progress:
|
|
||||||
- `statusTimeline` 배열을 그대로 사용한다.
|
|
||||||
- 항상 아래 5개 단계를 이 순서로 표시한다.
|
|
||||||
1. `RECEIVED`: `신청 접수`
|
|
||||||
2. `TRACKING_REGISTERED`: `발송 확인`
|
|
||||||
3. `ARRIVED_AT_MAILBOX`: `사서함 도착`
|
|
||||||
4. `INSPECTION_COMPLETED`: `검수 완료`
|
|
||||||
5. `DELIVERED`: `전달 완료`
|
|
||||||
- `occurredAt`이 있으면 해당 단계는 완료 색상으로 표시하고 `MM.DD`를 표시한다.
|
|
||||||
- `occurredAt`이 null이면 비활성 색상으로 표시하고 날짜는 표시하지 않는다.
|
|
||||||
- `UNDELIVERABLE`, `CANCELED`는 정상 진행 단계가 아니므로 progress step으로 추가하지 않는다.
|
|
||||||
- 종료 상태에서도 `statusTimeline`에 있는 정상 진행 이력은 그대로 표시한다.
|
|
||||||
|
|
||||||
상세 페이지 섹션 표시 규칙:
|
|
||||||
- `선물 정보`는 항상 표시한다.
|
|
||||||
- `direction=SENT`이면 크리에이터명은 `giftInfo.recipientCreatorNickname`을 사용한다.
|
|
||||||
- `direction=RECEIVED`이면 보낸 팬명은 `giftInfo.senderNickname`을 사용한다.
|
|
||||||
- `senderInfo`가 있으면 `보내는 사람` 섹션을 표시한다.
|
|
||||||
- `mailbox`가 있으면 `받는 사서함` 또는 `보내실 주소` 섹션을 표시한다.
|
|
||||||
- `recipientAddress`가 있으면 `받는 사람` 또는 `배송지` 섹션을 표시한다.
|
|
||||||
- `delivery.undeliverableReason`이 있으면 `사유` 섹션을 표시한다.
|
|
||||||
|
|
||||||
선물 정보 표시 필드:
|
|
||||||
- 크리에이터 또는 보낸 팬 닉네임
|
|
||||||
- 선물 사이즈: `giftInfo.sizeName`
|
|
||||||
- 카테고리: `giftInfo.categoryName`
|
|
||||||
- 신청번호: `applicationNo` 또는 `giftInfo.applicationNo`
|
|
||||||
- 캔: `giftInfo.paidCan`이 null이 아닐 때만 표시
|
|
||||||
- 운송장 번호: `giftInfo.tracking`이 null이 아닐 때 표시
|
|
||||||
- 검수일: `statusTimeline`에서 `INSPECTION_COMPLETED.occurredAt`이 있으면 표시
|
|
||||||
- 전달 완료일: `statusTimeline`에서 `DELIVERED.occurredAt`이 있으면 표시
|
|
||||||
- 취소일: `delivery.canceledAt`이 있으면 표시
|
|
||||||
|
|
||||||
받는 사서함/보내실 주소 표시:
|
|
||||||
- `mailbox.name`을 사서함/받는 분으로 표시한다.
|
|
||||||
- `mailbox.address`를 주소로 표시한다.
|
|
||||||
- `mailbox.phoneNumber`를 연락처로 표시한다.
|
|
||||||
- `mailbox`는 관리자가 등록한 전역 단일 주소다. 상대방 주소가 아니다.
|
|
||||||
- `mailbox`는 `direction=SENT`이고 `status`가 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`일 때 내려온다.
|
|
||||||
- `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 또는 `direction=RECEIVED`에서는 `mailbox=null`이다.
|
|
||||||
- `mailbox=null`이면 해당 섹션을 표시하지 않는다.
|
|
||||||
|
|
||||||
보내는 사람 표시:
|
|
||||||
- `senderInfo.name`
|
|
||||||
- `senderInfo.phoneNumber`
|
|
||||||
- `senderInfo.address`
|
|
||||||
- 이 정보는 보낸 팬 본인이 신청 시 입력한 정보다.
|
|
||||||
|
|
||||||
전달 불가 표시:
|
|
||||||
- `status=UNDELIVERABLE`이면 Figma `2481:19456` 패턴을 따른다.
|
|
||||||
- 사유 섹션의 제목은 `delivery.undeliverableReason`을 사용한다.
|
|
||||||
- `delivery.undeliverableReason`이 null이면 사유 섹션 제목은 `전달 불가`로 표시한다.
|
|
||||||
- 안내 문구는 `해당 선물은 정책에 따라 폐기되며 반송되지 않습니다.`를 사용한다.
|
|
||||||
|
|
||||||
상세 페이지 action:
|
|
||||||
- `trackingRequired=true`이면 하단 주요 CTA `운송장 작성하기`를 표시하고 운송장 등록 페이지로 이동한다.
|
|
||||||
- `direction=SENT && status=RECEIVED`이면 보조 action `신청 취소`를 표시할 수 있다.
|
|
||||||
- 신청 취소 버튼을 누르면 즉시 API를 호출하지 말고 `v2 dialog` 확인 팝업을 먼저 표시한다.
|
|
||||||
- `status=DELIVERED && direction=SENT`이면 `리뷰 남기기` 버튼을 표시하고 기존 리뷰 작성 화면/플로우로 이동한다.
|
|
||||||
- `문의하기`, `선물 정책 확인` 버튼은 기존 앱에 해당 이동 경로가 있으면 연결하고, 없으면 기존 정책에 맞춰 숨기거나 비활성 처리한다.
|
|
||||||
|
|
||||||
신청 취소 확인 다이얼로그:
|
|
||||||
- Figma `2481:19500`을 참고한다.
|
|
||||||
- 앱의 기본 `v2 dialog` 컴포넌트를 사용한다. 새 dialog 컴포넌트를 만들지 않는다.
|
|
||||||
- 제목: `신청 취소`
|
|
||||||
- 본문:
|
|
||||||
- `{크리에이터}에게 보내는 선물을 취소할까요?`
|
|
||||||
- `선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다.`
|
|
||||||
- 왼쪽 action: `나가기`
|
|
||||||
- dialog만 닫고 상세/운송장 등록 화면 상태를 유지한다.
|
|
||||||
- 오른쪽 destructive action: `취소하기`
|
|
||||||
- `POST /api/v2/gifts/{applicationNo}/cancel`을 호출한다.
|
|
||||||
- 호출 중에는 중복 탭을 막고 loading 상태를 표시한다.
|
|
||||||
- 실패 시 dialog를 닫지 말고 기존 앱 에러 toast/dialog 패턴으로 안내한다.
|
|
||||||
- 성공 시 취소 완료 페이지로 replace navigation 한다.
|
|
||||||
|
|
||||||
취소 완료 페이지:
|
|
||||||
- Figma `2481:19521`을 참고한다.
|
|
||||||
- 취소 API 성공 직후뿐 아니라 상세 페이지 접근 시 `GET /api/v2/gifts/{applicationNo}` 응답이 `status=CANCELED`인 경우에도 이 화면을 보여준다.
|
|
||||||
- 상단 타이틀: `취소 완료`
|
|
||||||
- hero chip: `취소 완료`
|
|
||||||
- hero title: `선물 신청이 취소되었어요`
|
|
||||||
- hero body: `{크리에이터}에게 보내는 선물 신청({applicationNo})이 취소되었습니다. 취소한 신청은 되돌릴 수 없으며, 다시 보내려면 새로 신청해 주세요.`
|
|
||||||
- 섹션 제목: `취소 상세`
|
|
||||||
- 취소 상세 카드 표시 필드:
|
|
||||||
- 크리에이터: `giftInfo.recipientCreatorNickname`
|
|
||||||
- 선물 사이즈: `giftInfo.sizeName`
|
|
||||||
- 카테고리: `giftInfo.categoryName`
|
|
||||||
- 신청번호: `applicationNo`
|
|
||||||
- 신청일: `statusTimeline`의 `RECEIVED.occurredAt`
|
|
||||||
- 취소일: 취소 API response의 `canceledAt` 또는 상세 API 조회 시 `delivery.canceledAt`
|
|
||||||
- 환불 캔: 취소 API response의 `priceCan` 또는 상세 API의 `giftInfo.paidCan`
|
|
||||||
- 하단 또는 카드 아래에 `문의하기` 버튼을 표시한다.
|
|
||||||
- 뒤로가기 동작은 취소 전 상세/운송장 등록 화면으로 돌아가지 않게 한다. 기존 앱 navigation 정책에 맞춰 선물함 리스트 또는 이전 안전 화면으로 이동한다.
|
|
||||||
|
|
||||||
운송장 등록 페이지 진입:
|
|
||||||
- 상세 페이지에서 `trackingRequired=true`일 때만 진입시킨다.
|
|
||||||
- 진입 시 `GET /api/v2/gifts/{applicationNo}`를 다시 조회한다.
|
|
||||||
- 조회 결과에서 `trackingRequired=false`이면 이미 등록 불가 상태이므로 기존 오류 처리 후 상세 페이지로 돌아간다.
|
|
||||||
|
|
||||||
운송장 등록 페이지 레이아웃:
|
|
||||||
1. 상단 고정 내비게이션
|
|
||||||
- 타이틀: `운송장 등록`
|
|
||||||
2. 신청 건 요약 카드
|
|
||||||
- `applicationNo`
|
|
||||||
- 크리에이터 닉네임: `giftInfo.recipientCreatorNickname`
|
|
||||||
- `giftInfo.sizeName · giftInfo.categoryName · 신청일`
|
|
||||||
- 신청일은 `statusTimeline`의 `RECEIVED.occurredAt`을 `YYYY.MM.DD`로 표시한다.
|
|
||||||
3. 보내실 주소
|
|
||||||
- `mailbox`를 사용한다.
|
|
||||||
- `mailbox=null`이면 운송장 등록을 막고 `받을 주소가 등록되어 있지 않습니다.` 오류를 표시한다.
|
|
||||||
4. 운송장 정보
|
|
||||||
- 택배사 select
|
|
||||||
- 운송장 번호 input
|
|
||||||
5. 등록 안내
|
|
||||||
- Figma의 안내 문구를 유지한다.
|
|
||||||
6. 하단 action bar
|
|
||||||
- 왼쪽: `신청 취소`
|
|
||||||
- 오른쪽: `등록 완료`
|
|
||||||
|
|
||||||
택배사 로컬 상수:
|
|
||||||
- 서버에서 택배사 옵션을 조회하지 않는다.
|
|
||||||
- 앱에 아래 로컬 옵션을 둔다. 이미 앱 공통 택배사 상수가 있으면 그것을 우선 사용한다.
|
|
||||||
```ts
|
|
||||||
const giftCourierCompanyNames = [
|
|
||||||
"CJ대한통운",
|
|
||||||
"우체국택배",
|
|
||||||
"GS25 편의점택배",
|
|
||||||
"CU 편의점택배",
|
|
||||||
"한진택배",
|
|
||||||
"롯데택배",
|
|
||||||
"로젠택배",
|
|
||||||
"경동택배",
|
|
||||||
"대신택배",
|
|
||||||
"일양로지스",
|
|
||||||
"천일택배",
|
|
||||||
"합동택배",
|
|
||||||
"건영택배",
|
|
||||||
"농협택배"
|
|
||||||
];
|
|
||||||
```
|
|
||||||
- 선택 UI는 기존 앱의 bottom sheet, picker, dialog 중 이미 쓰는 방식을 사용한다.
|
|
||||||
- API에는 선택된 한글 표시명을 그대로 `courierCompanyName`으로 보낸다.
|
|
||||||
|
|
||||||
운송장 등록 CTA 활성 조건:
|
|
||||||
- 택배사를 선택했다.
|
|
||||||
- 운송장 번호를 입력했다.
|
|
||||||
- 운송장 번호는 공백만 입력할 수 없다.
|
|
||||||
- submit 중에는 중복 탭을 막고 loading 상태를 표시한다.
|
|
||||||
- 비활성 상태는 Figma `2481:19066`, 활성 상태는 Figma `2481:19109`를 따른다.
|
|
||||||
|
|
||||||
운송장 등록 성공 처리:
|
|
||||||
- `POST /api/v2/gifts/{applicationNo}/tracking` 성공 후 성공 toast를 표시한다.
|
|
||||||
- 상세 페이지로 돌아가거나 replace navigation으로 상세 페이지를 다시 연다.
|
|
||||||
- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}`를 다시 호출해 `TRACKING_REGISTERED` 상태를 표시한다.
|
|
||||||
|
|
||||||
API 명세:
|
|
||||||
|
|
||||||
선물 상세 조회:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/gifts/{applicationNo}`
|
|
||||||
- 인증: 로그인 필요
|
|
||||||
- Response envelope 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"direction": "SENT",
|
|
||||||
"status": "RECEIVED",
|
|
||||||
"statusName": "접수 완료",
|
|
||||||
"giftInfo": {
|
|
||||||
"recipientCreatorNickname": "달빛수집가",
|
|
||||||
"senderNickname": null,
|
|
||||||
"sizeName": "소형",
|
|
||||||
"categoryName": "잡화",
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"paidCan": 100,
|
|
||||||
"tracking": null,
|
|
||||||
"shippingRequestedAt": null
|
|
||||||
},
|
|
||||||
"senderInfo": {
|
|
||||||
"name": "김소다",
|
|
||||||
"phoneNumber": "01012345678",
|
|
||||||
"address": "(04030) 서울특별시 마포구 양화로 000, 3층"
|
|
||||||
},
|
|
||||||
"recipientAddress": null,
|
|
||||||
"mailbox": {
|
|
||||||
"name": "소다라이브 사서함",
|
|
||||||
"address": "(06174) 서울특별시 강남구 테헤란로108길 8, 유민빌딩 4층",
|
|
||||||
"phoneNumber": "02-2055-1477"
|
|
||||||
},
|
|
||||||
"trackingRequired": true,
|
|
||||||
"recipientAddressRequired": false,
|
|
||||||
"recipientAddressDeadlineAt": null,
|
|
||||||
"delivery": {
|
|
||||||
"canceledAt": null,
|
|
||||||
"undeliverableAt": null,
|
|
||||||
"undeliverableReason": null
|
|
||||||
},
|
|
||||||
"statusTimeline": [
|
|
||||||
{
|
|
||||||
"status": "RECEIVED",
|
|
||||||
"statusName": "접수 완료",
|
|
||||||
"occurredAt": "2026-09-18T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "TRACKING_REGISTERED",
|
|
||||||
"statusName": "발송 확인",
|
|
||||||
"occurredAt": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "ARRIVED_AT_MAILBOX",
|
|
||||||
"statusName": "사서함 도착",
|
|
||||||
"occurredAt": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "INSPECTION_COMPLETED",
|
|
||||||
"statusName": "검수완료",
|
|
||||||
"occurredAt": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "DELIVERED",
|
|
||||||
"statusName": "전달완료",
|
|
||||||
"occurredAt": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response `data` 필드:
|
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| `applicationNo` | string | 선물 신청번호 |
|
|
||||||
| `direction` | string | `SENT` 또는 `RECEIVED` |
|
|
||||||
| `status` | string | 선물 상태 코드 |
|
|
||||||
| `statusName` | string | 서버 상태 표시명 |
|
|
||||||
| `giftInfo` | object | 선물 기본 정보 |
|
|
||||||
| `giftInfo.recipientCreatorNickname` | string 또는 null | 보낸 선물 관점에서 받는 크리에이터 닉네임 |
|
|
||||||
| `giftInfo.senderNickname` | string 또는 null | 받은 선물 관점에서 보낸 팬 닉네임 |
|
|
||||||
| `giftInfo.sizeName` | string | 선물 사이즈명 |
|
|
||||||
| `giftInfo.categoryName` | string | 카테고리명 |
|
|
||||||
| `giftInfo.applicationNo` | string 또는 null | 보낸 선물 관점 신청번호 |
|
|
||||||
| `giftInfo.paidCan` | number 또는 null | 보낸 선물 관점 사용 캔 |
|
|
||||||
| `giftInfo.tracking` | string 또는 null | 운송장 표시 문자열. 예: `한진택배_123456789012` |
|
|
||||||
| `giftInfo.shippingRequestedAt` | string 또는 null | 받은 선물 관점 배송신청일. UTC ISO 문자열 |
|
|
||||||
| `senderInfo` | object 또는 null | 보낸 팬 본인이 신청 시 입력한 보내는 사람 정보 |
|
|
||||||
| `senderInfo.name` | string | 이름 |
|
|
||||||
| `senderInfo.phoneNumber` | string | 휴대폰 번호 |
|
|
||||||
| `senderInfo.address` | string | `(우편번호) 주소, 상세주소` 형식 주소 |
|
|
||||||
| `recipientAddress` | object 또는 null | 받는 크리에이터 본인이 입력한 배송지 |
|
|
||||||
| `mailbox` | object 또는 null | 관리자가 등록한 전역 선물 받을 주소. 보낸 선물의 `DELIVERED` 전 정상 진행 상태에서만 값이 있다 |
|
|
||||||
| `mailbox.name` | string | 사서함/받는 분 이름 |
|
|
||||||
| `mailbox.address` | string | `(우편번호) 주소, 상세주소` 형식 주소 |
|
|
||||||
| `mailbox.phoneNumber` | string | 연락처 |
|
|
||||||
| `trackingRequired` | boolean | 보낸 팬이 운송장 등록을 해야 하는 상태인지 여부 |
|
|
||||||
| `recipientAddressRequired` | boolean | 받는 크리에이터가 배송지를 입력해야 하는 상태인지 여부 |
|
|
||||||
| `recipientAddressDeadlineAt` | string 또는 null | 배송지 입력 마감일. UTC ISO 문자열 |
|
|
||||||
| `delivery.canceledAt` | string 또는 null | 취소일. UTC ISO 문자열 |
|
|
||||||
| `delivery.undeliverableAt` | string 또는 null | 전달 불가 처리일. UTC ISO 문자열 |
|
|
||||||
| `delivery.undeliverableReason` | string 또는 null | 전달 불가 사유 |
|
|
||||||
| `statusTimeline` | array | 정상 진행 5단계 타임라인 |
|
|
||||||
| `statusTimeline[].status` | string | 단계 상태 코드 |
|
|
||||||
| `statusTimeline[].statusName` | string | 단계 표시명 |
|
|
||||||
| `statusTimeline[].occurredAt` | string 또는 null | 해당 단계 도달 시각. UTC ISO 문자열 |
|
|
||||||
|
|
||||||
운송장 등록:
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/gifts/{applicationNo}/tracking`
|
|
||||||
- 인증: 로그인 필요
|
|
||||||
- Request:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"courierCompanyName": "한진택배",
|
|
||||||
"trackingNumber": "123456789012"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Response envelope 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"status": "TRACKING_REGISTERED",
|
|
||||||
"statusName": "발송 확인",
|
|
||||||
"courierCompanyName": "한진택배",
|
|
||||||
"trackingNumber": "123456789012",
|
|
||||||
"trackingRegisteredAt": "2026-09-19T03:00:00Z",
|
|
||||||
"recipientAddressDeadlineAt": "2026-09-26T03:00:00Z"
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
운송장 등록 response `data` 필드:
|
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| `applicationNo` | string | 선물 신청번호 |
|
|
||||||
| `status` | string | 등록 후 상태. `TRACKING_REGISTERED` |
|
|
||||||
| `statusName` | string | 서버 상태 표시명 |
|
|
||||||
| `courierCompanyName` | string | 선택한 택배사 한글명 |
|
|
||||||
| `trackingNumber` | string | 운송장 번호 |
|
|
||||||
| `trackingRegisteredAt` | string | 운송장 등록일. UTC ISO 문자열 |
|
|
||||||
| `recipientAddressDeadlineAt` | string | 크리에이터 배송지 입력 마감일. UTC ISO 문자열 |
|
|
||||||
|
|
||||||
선물 보내기 취소:
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/gifts/{applicationNo}/cancel`
|
|
||||||
- Request: 없음
|
|
||||||
- 용도: `direction=SENT && status=RECEIVED`에서만 `신청 취소` action에 사용한다.
|
|
||||||
- Response envelope 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"status": "CANCELED",
|
|
||||||
"statusName": "신청 취소",
|
|
||||||
"priceCan": 100,
|
|
||||||
"canceledAt": "2026-09-18T04:00:00Z"
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
선물 보내기 취소 response `data` 필드:
|
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| `applicationNo` | string | 선물 신청번호 |
|
|
||||||
| `status` | string | 취소 후 상태. `CANCELED` |
|
|
||||||
| `statusName` | string | 서버 상태 표시명 |
|
|
||||||
| `priceCan` | number | 환불되는 캔 수량 |
|
|
||||||
| `canceledAt` | string | 취소일. UTC ISO 문자열 |
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- 리스트 item 탭 시 `applicationNo`로 상세 페이지가 열리는지 확인한다.
|
|
||||||
- 상세 진입 시 `GET /api/v2/gifts/{applicationNo}`가 호출되는지 확인한다.
|
|
||||||
- 상세 조회 결과 `status=CANCELED`이면 일반 상세가 아니라 취소 완료 페이지가 표시되는지 확인한다.
|
|
||||||
- `statusTimeline` 5개 단계가 모두 표시되는지 확인한다.
|
|
||||||
- Figma처럼 4단계로 하드코딩하지 않았는지 확인한다.
|
|
||||||
- `trackingRequired=true`이면 `운송장 작성하기`가 표시되는지 확인한다.
|
|
||||||
- 신청 취소 버튼 탭 시 `v2 dialog` 확인 팝업이 뜨고 즉시 API를 호출하지 않는지 확인한다.
|
|
||||||
- 취소 확인 dialog에서 `나가기`를 누르면 dialog만 닫히는지 확인한다.
|
|
||||||
- 취소 확인 dialog에서 `취소하기`를 누르면 `POST /api/v2/gifts/{applicationNo}/cancel`을 호출하는지 확인한다.
|
|
||||||
- 취소 성공 후 취소 완료 페이지가 표시되고 취소 전 상세/등록 화면으로 돌아가지 않는지 확인한다.
|
|
||||||
- `mailbox`가 있을 때만 사서함 주소 섹션이 표시되는지 확인한다.
|
|
||||||
- `direction=SENT`의 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 상태에서 `mailbox`가 표시되는지 확인한다.
|
|
||||||
- `DELIVERED`, `UNDELIVERABLE`, `CANCELED`, `direction=RECEIVED`에서는 `mailbox`가 표시되지 않는지 확인한다.
|
|
||||||
- `UNDELIVERABLE`이면 전달 불가 hero와 사유 섹션이 표시되는지 확인한다.
|
|
||||||
- 운송장 등록 페이지에서 택배사/운송장 번호 누락 시 API를 호출하지 않는지 확인한다.
|
|
||||||
- 운송장 등록 시 선택한 택배사 한글명과 운송장 번호가 request body로 전송되는지 확인한다.
|
|
||||||
- 운송장 등록 성공 후 상세 페이지가 재조회되어 `TRACKING_REGISTERED` 상태로 보이는지 확인한다.
|
|
||||||
- 모든 날짜는 UTC ISO 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다.
|
|
||||||
```
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# 선물함 리스트 페이지 생성 프롬프트
|
|
||||||
|
|
||||||
이 문서는 Android/iOS 앱의 `선물함` 리스트 페이지를 생성할 때 사용하는 프롬프트다.
|
|
||||||
|
|
||||||
참고 Figma:
|
|
||||||
|
|
||||||
- 팬 모드, 신청한 내역이 있을 때: `2481:19382`
|
|
||||||
- 팬 모드, 신청한 내역이 없을 때: `2520:35587`
|
|
||||||
- 크리에이터 모드, 받은 선물 내역이 없을 때: `2531:36283`
|
|
||||||
- 크리에이터 모드, 받은 선물 내역이 있을 때: `2531:36194`
|
|
||||||
- 크리에이터 모드, 받은 선물 상태별 카드 표시: `2531:36237`
|
|
||||||
|
|
||||||
전제:
|
|
||||||
|
|
||||||
- Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
|
|
||||||
- Figma의 visual structure를 기준으로 구현하되, 아래 사용자 요구사항을 Figma보다 우선한다.
|
|
||||||
- 상단 뒤로가기 내비게이션 영역은 고정한다.
|
|
||||||
- 상단 뒤로가기 내비게이션 영역을 제외한 전체 콘텐츠 영역은 스크롤되어야 한다.
|
|
||||||
- `신청 내역`, `선물 내역` 섹션 타이틀은 표시하지 않는다.
|
|
||||||
- 리스트는 신청일/취소일 최신순으로 보여야 한다.
|
|
||||||
- request/response 설명은 모바일 공통 JSON 기준이다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 모바일 앱 구현 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
Android/iOS 앱의 선물함 리스트 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘.
|
|
||||||
|
|
||||||
Figma는 두 관점의 상태를 보여준다.
|
|
||||||
- 보낸 선물 관점: 사용자가 본인이 신청한 선물 내역을 확인하는 상태다. Figma `2481:19382`, `2520:35587`를 기준으로 한다.
|
|
||||||
- 받은 선물 관점: 사용자가 본인이 받을 선물 내역을 확인하는 상태다. Figma `2531:36283`, `2531:36194`, `2531:36237`를 기준으로 한다.
|
|
||||||
- 실제 선물함 API는 두 관점을 나누어 호출하지 않고 `type=ALL`로 한 번 호출한다.
|
|
||||||
- 응답 item의 `direction` 값이 `SENT`이면 보낸 선물 카드로, `RECEIVED`이면 받은 선물 카드로 렌더링한다.
|
|
||||||
- 각 카드에는 상대방 프로필 이미지와 닉네임을 표시한다.
|
|
||||||
- `direction=SENT`: 상대방은 선물을 받는 크리에이터다.
|
|
||||||
- `direction=RECEIVED`: 상대방은 선물을 보낸 팬이다.
|
|
||||||
|
|
||||||
중요 요구사항:
|
|
||||||
- 상단 뒤로가기 내비게이션은 고정한다.
|
|
||||||
- 상단 내비게이션 아래의 전체 콘텐츠만 스크롤되게 한다.
|
|
||||||
- Figma에 보이는 `신청 내역`, `선물 내역` 섹션 타이틀은 실제 구현에서 표시하지 않는다.
|
|
||||||
- 선물함 리스트는 신청일/취소일 최신순으로 정렬한다.
|
|
||||||
- 현재 API 응답만으로 취소일 기준 정렬/표시는 완전하지 않으므로, API가 취소일을 내려주지 않으면 서버 응답 순서를 우선 사용하고 취소 건 날짜 라벨은 `createdAt` 기준으로 표시한다. 취소일 표시가 필수이면 API 보강이 필요하다.
|
|
||||||
|
|
||||||
API 호출:
|
|
||||||
- 선물함 리스트는 항상 `GET /api/v2/gifts?type=ALL&page=0&size=20`로 조회한다.
|
|
||||||
- `SENT`, `RECEIVED`로 따로 호출하지 않는다.
|
|
||||||
- `items[].direction`으로 보낸 선물/받은 선물 UI를 결정한다.
|
|
||||||
- 페이지 진입 시 첫 페이지를 조회한다.
|
|
||||||
- `hasNext=true`이면 스크롤 하단에서 다음 page를 추가 조회한다.
|
|
||||||
- pull-to-refresh가 기존 앱에 있으면 page를 0으로 초기화하고 다시 조회한다.
|
|
||||||
|
|
||||||
공통 레이아웃:
|
|
||||||
1. 상단 고정 내비게이션
|
|
||||||
- 타이틀: `선물함`
|
|
||||||
- 뒤로가기 버튼은 기존 앱 패턴을 사용한다.
|
|
||||||
- 이 영역은 스크롤되지 않는다.
|
|
||||||
|
|
||||||
2. 스크롤 콘텐츠
|
|
||||||
- nav 아래부터 시작한다.
|
|
||||||
- 안내 배너, 빈 상태, 리스트, 하단 여백 또는 하단 문의 버튼까지 스크롤 영역에 포함한다.
|
|
||||||
- iOS safe area와 Android navigation bar 여백은 기존 앱 패턴을 따른다.
|
|
||||||
|
|
||||||
보낸 선물 UI:
|
|
||||||
- `direction=SENT`인 item에 적용한다.
|
|
||||||
- 사용자가 신청한 선물 내역을 보여준다.
|
|
||||||
- 보낸 선물만 있는 상태는 Figma `2481:19382`를 따른다.
|
|
||||||
- 상단 안내 배너를 표시한다.
|
|
||||||
- 제목: `사서함을 통해 안전하게 전달됩니다`
|
|
||||||
- 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 선물은 소다라이브 사서함에 도착한 뒤 크리에이터에게 전달됩니다.`
|
|
||||||
- `신청 내역` 섹션 타이틀은 표시하지 않는다.
|
|
||||||
- 카드 구성:
|
|
||||||
- 상단 왼쪽: `statusName`
|
|
||||||
- 상단 오른쪽: 날짜 라벨
|
|
||||||
- 기본: `{createdAt} 신청`
|
|
||||||
- 상태가 `CANCELED`이고 취소일 필드가 있으면 `{canceledAt} 취소`
|
|
||||||
- 현재 리스트 API에는 `canceledAt`이 없으므로 기존 API만 사용할 때는 `{createdAt} 취소` 또는 `{createdAt} 신청` 중 제품 정책에 맞춰 하나로 통일한다.
|
|
||||||
- 프로필 row 내부: `counterpartProfileImageUrl`, `counterpartNickname`을 사용해 받는 크리에이터 프로필을 표시한다.
|
|
||||||
- 프로필 row 아래 보조 문구: `sizeName · categoryName`
|
|
||||||
- 프로필 이미지가 없으면 기존 앱의 기본 프로필 이미지를 표시한다.
|
|
||||||
- 우측 chevron
|
|
||||||
- 탭 시 선물 상세 페이지로 이동하며 `applicationNo`를 전달한다.
|
|
||||||
- 리스트가 비어 있고 보낸 선물 관점으로 진입한 화면이면 Figma `2520:35587`를 따른다.
|
|
||||||
- 상단 안내 배너만 표시한다.
|
|
||||||
- 별도 empty title/body를 추가하지 않는다.
|
|
||||||
|
|
||||||
받은 선물 UI:
|
|
||||||
- `direction=RECEIVED`인 item에 적용한다.
|
|
||||||
- 사용자가 받을 선물 내역을 보여준다.
|
|
||||||
- 리스트가 비어 있고 받은 선물 관점으로 진입한 화면이면 Figma `2531:36283`를 따른다.
|
|
||||||
- 가운데 empty title: `전달 예정인 선물이 없어요`
|
|
||||||
- empty body: `베타 기간에는 팬이 선물을 보내는 기능만 제공돼요. 더 다양해진 선물 기능으로 곧 다시 만나요!`
|
|
||||||
- 버튼: `의견 남기기`
|
|
||||||
- 의견 남기기 동작은 기존 앱의 문의/피드백 이동 패턴을 사용한다.
|
|
||||||
- 받은 선물이 있는 상태는 Figma `2531:36194`, `2531:36237`를 따른다.
|
|
||||||
- 상단 안내 배너를 표시한다.
|
|
||||||
- 제목: `배송지 입력 기간 안내`
|
|
||||||
- 본문: `알림을 받은 날부터 7일 이내에 배송지를 입력해 주세요. 기한 내 입력하지 않으면 선물이 반송됩니다.`
|
|
||||||
- `선물 내역` 섹션 타이틀은 표시하지 않는다.
|
|
||||||
- 배송지 입력이 필요한 선물이 있으면 파란 안내 배너를 리스트 상단에 표시한다.
|
|
||||||
- 제목: `팬이 보낸 선물이 있어요!`
|
|
||||||
- 본문: `선물이 늦지 않게 전달될 수 있도록 배송지를 입력해 주세요.`
|
|
||||||
- 보조 문구: `*n일 이내로 입력하지 않으면 선물이 사라져요.`
|
|
||||||
- 현재 리스트 API에는 배송지 입력 마감일이 없으므로 정확한 n일 계산이 필요하면 API 보강이 필요하다.
|
|
||||||
- 배너 탭 시 배송지 입력이 필요한 첫 번째 선물 상세로 이동한다.
|
|
||||||
- 카드 구성:
|
|
||||||
- 상단 왼쪽 프로필 row 내부: `counterpartProfileImageUrl`, `counterpartNickname`을 사용해 보낸 팬 프로필을 표시한다.
|
|
||||||
- 상단 오른쪽: `{createdAt} 신청`
|
|
||||||
- 본문 메인: 상태 표시 문구
|
|
||||||
- 본문 서브: 상태 보조 문구가 필요한 경우만 표시
|
|
||||||
- 우측 chevron
|
|
||||||
- 탭 시 선물 상세 페이지로 이동하며 `applicationNo`를 전달한다.
|
|
||||||
- 리스트 하단에는 Figma처럼 `문의하기` 하단 버튼을 둘 수 있다. 기존 앱 정책상 고정 버튼이면 하단 safe area를 포함하고, 스크롤 콘텐츠 내부 버튼이면 콘텐츠 하단에 둔다.
|
|
||||||
|
|
||||||
상태 표시 매핑:
|
|
||||||
|
|
||||||
보낸 선물 카드:
|
|
||||||
| API status | 표시 문구 |
|
|
||||||
|---|---|
|
|
||||||
| `RECEIVED` | `운송장 등록 필요` |
|
|
||||||
| `TRACKING_REGISTERED` | `발송 확인` |
|
|
||||||
| `ARRIVED_AT_MAILBOX` | `사서함 도착` |
|
|
||||||
| `INSPECTION_COMPLETED` | `선물 검수 완료` |
|
|
||||||
| `DELIVERED` | `전달 완료` |
|
|
||||||
| `UNDELIVERABLE` | `전달 불가` |
|
|
||||||
| `CANCELED` | `신청 취소` |
|
|
||||||
|
|
||||||
받은 선물 카드:
|
|
||||||
| API status | 메인 문구 | 서브 문구 |
|
|
||||||
|---|---|---|
|
|
||||||
| `TRACKING_REGISTERED` | `배송지 입력 필요` | `전달 예정` |
|
|
||||||
| `ARRIVED_AT_MAILBOX` | `선물 검수` | `선물 확인 중` |
|
|
||||||
| `INSPECTION_COMPLETED` | `선물 검수 완료` | `배송 중` |
|
|
||||||
| `DELIVERED` | `전달 완료` | 없음 |
|
|
||||||
| `UNDELIVERABLE` | `전달 불가` | 없음 |
|
|
||||||
|
|
||||||
API 명세:
|
|
||||||
|
|
||||||
선물함 리스트 조회 API:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/gifts`
|
|
||||||
- 인증: 로그인 필요
|
|
||||||
- Query:
|
|
||||||
- `type`: `ALL`, `SENT`, `RECEIVED`
|
|
||||||
- `page`: 0부터 시작
|
|
||||||
- `size`: 페이지 크기. 기본 20
|
|
||||||
- 이 페이지에서는 `type=ALL`만 사용한다.
|
|
||||||
- Response envelope 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"totalCount": 1,
|
|
||||||
"items": [],
|
|
||||||
"page": 0,
|
|
||||||
"size": 20,
|
|
||||||
"hasNext": false
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response `data` 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"totalCount": 2,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"direction": "SENT",
|
|
||||||
"status": "RECEIVED",
|
|
||||||
"statusName": "접수 완료",
|
|
||||||
"priceCan": 80,
|
|
||||||
"categoryName": "아크릴/스탠드",
|
|
||||||
"sizeName": "소형",
|
|
||||||
"counterpartMemberId": 200,
|
|
||||||
"counterpartNickname": "달빛수집가",
|
|
||||||
"counterpartProfileImageUrl": "https://example.com/profile.png",
|
|
||||||
"createdAt": "2026-09-18T03:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"page": 0,
|
|
||||||
"size": 20,
|
|
||||||
"hasNext": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response `data` 필드:
|
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| `totalCount` | number | 전체 개수 |
|
|
||||||
| `items` | array | 선물함 리스트 |
|
|
||||||
| `items[].applicationNo` | string | 선물 신청번호. 상세 이동 ID |
|
|
||||||
| `items[].direction` | string | `SENT` 또는 `RECEIVED` |
|
|
||||||
| `items[].status` | string | 선물 상태 코드 |
|
|
||||||
| `items[].statusName` | string | 서버 상태 표시명 |
|
|
||||||
| `items[].priceCan` | number | 사용된 캔 |
|
|
||||||
| `items[].categoryName` | string | 카테고리명 |
|
|
||||||
| `items[].sizeName` | string | 사이즈명 |
|
|
||||||
| `items[].counterpartMemberId` | number | 상대방 회원 ID. `SENT`에서는 받는 크리에이터, `RECEIVED`에서는 보낸 팬 |
|
|
||||||
| `items[].counterpartNickname` | string | 상대방 닉네임 |
|
|
||||||
| `items[].counterpartProfileImageUrl` | string 또는 null | 상대방 프로필 이미지 URL. 없으면 기본 프로필 이미지 표시 |
|
|
||||||
| `items[].createdAt` | string 또는 null | 신청일. UTC ISO 문자열 |
|
|
||||||
| `page` | number | 현재 페이지 |
|
|
||||||
| `size` | number | 페이지 크기 |
|
|
||||||
| `hasNext` | boolean | 다음 페이지 존재 여부 |
|
|
||||||
|
|
||||||
정렬:
|
|
||||||
- 현재 서버 API는 `createdAt desc, id desc`로 정렬한다.
|
|
||||||
- 요구사항은 신청일/취소일 최신순이다.
|
|
||||||
- 취소일 최신순까지 정확히 맞추려면 리스트 API가 `canceledAt` 또는 `displayedAt` 같은 정렬 기준 시각을 내려주고 그 기준으로 정렬해야 한다.
|
|
||||||
- API 보강 전에는 서버 응답 순서를 그대로 사용하고, 클라이언트에서 임의 재정렬하지 않는다.
|
|
||||||
|
|
||||||
상대방 프로필 표시 규칙:
|
|
||||||
- `counterpartNickname`은 카드의 프로필 row 이름으로 표시한다.
|
|
||||||
- `counterpartProfileImageUrl`이 있으면 해당 이미지를 원형 프로필 이미지로 표시한다.
|
|
||||||
- `counterpartProfileImageUrl`이 `null`이거나 빈 값이면 기존 앱의 기본 프로필 이미지를 표시한다.
|
|
||||||
- `direction=SENT`에서는 받는 크리에이터 정보로 해석한다.
|
|
||||||
- `direction=RECEIVED`에서는 보낸 팬 정보로 해석한다.
|
|
||||||
|
|
||||||
현재 API만으로 부족한 Figma 표시값:
|
|
||||||
- 취소일 `canceledAt`
|
|
||||||
- 배송지 입력 마감일 또는 남은 일수
|
|
||||||
|
|
||||||
추가 API 보강이 가능하면 리스트 item에 아래 필드를 추가해달라고 요청한다.
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"canceledAt": "2026-09-18T03:00:00Z",
|
|
||||||
"recipientAddressDeadlineAt": "2026-09-25T03:00:00Z",
|
|
||||||
"sortAt": "2026-09-18T03:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `canceledAt`: 취소 상태의 취소일 표시용
|
|
||||||
- `recipientAddressDeadlineAt`: 크리에이터 배송지 입력 안내 배너의 남은 일수 계산용
|
|
||||||
- `sortAt`: 신청/취소 최신순 정렬 기준. 취소 건은 취소일, 그 외에는 신청일
|
|
||||||
|
|
||||||
로딩/에러/빈 상태:
|
|
||||||
- 최초 로딩 중에는 기존 앱 리스트 스켈레톤 또는 loading 패턴을 사용한다.
|
|
||||||
- 실패 시 기존 앱 toast/dialog와 재시도 버튼을 사용한다.
|
|
||||||
- 빈 상태는 진입 관점에 따라 다르게 표시한다.
|
|
||||||
- 보낸 선물 관점으로 진입한 화면이면 안내 배너만 남긴다.
|
|
||||||
- 받은 선물 관점으로 진입한 화면이면 중앙 empty UI와 `의견 남기기` 버튼을 표시한다.
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- 선물함 진입 시 `type=ALL`로 API를 한 번 호출하는지 확인한다.
|
|
||||||
- `direction=SENT` item은 보낸 선물 카드 UI로 표시되는지 확인한다.
|
|
||||||
- `direction=RECEIVED` item은 받은 선물 카드 UI로 표시되는지 확인한다.
|
|
||||||
- 각 item의 `counterpartNickname`, `counterpartProfileImageUrl`이 프로필 row에 표시되는지 확인한다.
|
|
||||||
- `counterpartProfileImageUrl=null`이면 기본 프로필 이미지가 표시되는지 확인한다.
|
|
||||||
- 상단 내비게이션은 고정되고 아래 콘텐츠만 스크롤되는지 확인한다.
|
|
||||||
- `신청 내역`, `선물 내역` 텍스트가 실제 화면에 노출되지 않는지 확인한다.
|
|
||||||
- 리스트 item 탭 시 `applicationNo`로 상세 화면 이동이 되는지 확인한다.
|
|
||||||
- 빈 상태가 진입 관점에 맞는 Figma 기준으로 표시되는지 확인한다.
|
|
||||||
- `hasNext=true`일 때 다음 page를 추가 조회하는지 확인한다.
|
|
||||||
- API 실패 시 입력/스크롤 상태를 깨지 않고 오류와 재시도를 제공하는지 확인한다.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 요약
|
|
||||||
|
|
||||||
| 목적 | Method | URL | Query | Response data |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 선물함 리스트 조회 | GET | `/api/v2/gifts` | `type=ALL`, `page`, `size` | 선물 리스트, 페이지 정보 |
|
|
||||||
|
|
||||||
## Figma 반영 메모
|
|
||||||
|
|
||||||
- 보낸 선물 리스트 있음: 안내 배너 + 카드 리스트. 단, 실제 구현에서는 `신청 내역` 타이틀을 제거한다.
|
|
||||||
- 보낸 선물 관점 empty: 안내 배너만 표시한다.
|
|
||||||
- 받은 선물 관점 empty: 중앙 empty UI와 `의견 남기기` 버튼을 표시한다.
|
|
||||||
- 받은 선물 리스트 있음: 안내 배너 + 배송지 입력 CTA 배너 + 카드 리스트. 단, 실제 구현에서는 `선물 내역` 타이틀을 제거한다.
|
|
||||||
- 받은 선물 상태별 카드는 `배송지 입력 완료/전달 예정`, `전달 완료`, `전달 불가`, `선물 검수/선물 확인 중`, `선물 검수 완료/배송 중` 패턴을 사용한다.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# 선물 받을 주소 관리자 페이지 구현 프롬프트
|
|
||||||
|
|
||||||
이 문서는 관리자 페이지에서 전역 선물 받을 주소를 등록/수정하는 화면을 구현할 때 사용하는 프롬프트다.
|
|
||||||
|
|
||||||
## 전제
|
|
||||||
|
|
||||||
- 여기서 말하는 `선물 받을 주소`는 선물 상대방 주소가 아니다.
|
|
||||||
- 관리자가 등록하는 전역 단일 주소다.
|
|
||||||
- 팬이 보낸 선물 상세를 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)에 조회할 때, 이 주소가 `mailbox`로 노출된다.
|
|
||||||
- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 폼, 버튼, 토스트, 에러 표시 패턴을 재사용한다.
|
|
||||||
|
|
||||||
## 구현 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
관리자 선물 받을 주소 설정 페이지를 구현해줘.
|
|
||||||
|
|
||||||
목표:
|
|
||||||
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
|
|
||||||
- 이 주소는 상대방 주소가 아니라 관리자가 등록하는 운영 주소다.
|
|
||||||
- 팬의 선물 상세 API에서는 `direction=SENT`이고 `status`가 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`일 때 이 주소가 `mailbox`로 노출된다.
|
|
||||||
- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다.
|
|
||||||
|
|
||||||
라우트:
|
|
||||||
- `/gift/mailbox`
|
|
||||||
|
|
||||||
메뉴:
|
|
||||||
- parent: `선물함 관리`
|
|
||||||
- label: `받을 주소`
|
|
||||||
- path: `/gift/mailbox`
|
|
||||||
|
|
||||||
필수 화면 구성:
|
|
||||||
- 상단 제목: `받을 주소`
|
|
||||||
- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.`
|
|
||||||
- 입력 폼:
|
|
||||||
- 받을 사람 이름 `name`
|
|
||||||
- 연락처 `phoneNumber`
|
|
||||||
- 우편번호 `zipCode`
|
|
||||||
- 주소 `address`
|
|
||||||
- 상세주소 `addressDetail`
|
|
||||||
- 저장 버튼: `저장`
|
|
||||||
|
|
||||||
초기 조회 API:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/admin/gift-mailbox`
|
|
||||||
- 모든 API 응답은 기존 공통 envelope를 사용한다.
|
|
||||||
- 화면에서는 `response.data`를 실제 payload로 사용한다.
|
|
||||||
- response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftMailboxResponse = {
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
} | null;
|
|
||||||
```
|
|
||||||
- `data`가 null이면 아직 등록된 받을 주소가 없는 상태다.
|
|
||||||
- null이면 빈 폼을 표시한다.
|
|
||||||
|
|
||||||
저장 API:
|
|
||||||
- Method: `PUT`
|
|
||||||
- URL: `/api/v2/admin/gift-mailbox`
|
|
||||||
- Request:
|
|
||||||
```ts
|
|
||||||
type AdminGiftMailboxRequest = {
|
|
||||||
name: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
zipCode: string;
|
|
||||||
address: string;
|
|
||||||
addressDetail: string | null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
- Response `data`:
|
|
||||||
```ts
|
|
||||||
type AdminGiftMailboxResponse = {
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
동작 규칙:
|
|
||||||
- 받을 주소는 전역 단일 설정이다.
|
|
||||||
- 등록과 수정은 같은 `PUT /api/v2/admin/gift-mailbox` API를 사용한다.
|
|
||||||
- 최초 저장이면 생성처럼 동작한다.
|
|
||||||
- 이미 저장된 값이 있으면 기존 주소를 수정한다.
|
|
||||||
- 별도 ID, 목록, 삭제 기능은 만들지 않는다.
|
|
||||||
|
|
||||||
폼 validation:
|
|
||||||
- `name`, `phoneNumber`, `zipCode`, `address`는 필수다.
|
|
||||||
- 필수값은 공백만 입력할 수 없다.
|
|
||||||
- `addressDetail`은 선택이다.
|
|
||||||
- validation 실패 시 API를 호출하지 않고 기존 관리자 페이지의 필드 에러 표시 방식을 따른다.
|
|
||||||
|
|
||||||
저장 성공 UX:
|
|
||||||
- 성공 토스트를 표시한다.
|
|
||||||
- 저장 API response 기준으로 화면 값을 갱신한다.
|
|
||||||
- response의 `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식이다.
|
|
||||||
- 입력 필드는 사용자가 입력한 `zipCode`, `address`, `addressDetail` 값을 유지해도 된다.
|
|
||||||
|
|
||||||
빈 상태 UX:
|
|
||||||
- 조회 결과 `data=null`이면 빈 폼을 보여준다.
|
|
||||||
- 필요하면 안내 문구 `등록된 받을 주소가 없습니다. 주소를 입력하고 저장해주세요.`를 표시한다.
|
|
||||||
|
|
||||||
에러 처리:
|
|
||||||
- API 실패 시 기존 관리자 페이지의 공통 에러 토스트/알림 패턴을 따른다.
|
|
||||||
- 인증/권한 처리는 기존 관리자 API client와 토큰 처리 방식을 따른다.
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- `/gift/mailbox` route가 선물함 관리 메뉴에서 접근되는지 확인한다.
|
|
||||||
- 초기 조회 시 `GET /api/v2/admin/gift-mailbox`를 호출하는지 확인한다.
|
|
||||||
- 조회 결과가 null이면 빈 폼을 표시하는지 확인한다.
|
|
||||||
- 저장 시 `PUT /api/v2/admin/gift-mailbox`와 request body가 정확한지 확인한다.
|
|
||||||
- 필수값 공백 validation 시 API를 호출하지 않는지 확인한다.
|
|
||||||
- 저장 성공 후 성공 토스트와 화면 갱신이 일어나는지 확인한다.
|
|
||||||
```
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
# 받은 선물 상세 및 배송지 입력 페이지 생성 프롬프트
|
|
||||||
|
|
||||||
이 문서는 Android/iOS 앱의 `선물함` 리스트에서 받은 선물 상세 페이지와 배송지 입력 UI를 생성할 때 사용하는 프롬프트다.
|
|
||||||
|
|
||||||
참고 Figma:
|
|
||||||
|
|
||||||
- 배송지 입력 전 상세 UI: `2531:36290`
|
|
||||||
- 배송지 입력 완료 후 상세 UI: `2531:36399`
|
|
||||||
- 선물 전달 완료시 상세 UI: `2531:36330`
|
|
||||||
- 선물 전달 불가시 상세 UI: `2531:36422`
|
|
||||||
|
|
||||||
전제:
|
|
||||||
|
|
||||||
- 선물함 리스트 item 탭 시 `applicationNo`를 전달받아 상세 페이지로 이동한다.
|
|
||||||
- 새 디자인 시스템을 만들지 말고 Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
|
|
||||||
- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `response.data`다.
|
|
||||||
- 이 프롬프트는 받는 크리에이터 관점(`direction=RECEIVED`)만 다룬다.
|
|
||||||
- API 예시는 TypeScript 타입이 아니라 JSON으로 작성한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 모바일 앱 구현 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
Android/iOS 앱의 받은 선물 상세 페이지와 배송지 입력 UI를 Figma 기준으로 생성하고 API 연동을 구현해줘.
|
|
||||||
|
|
||||||
범위:
|
|
||||||
- 선물함 리스트에서 `applicationNo`를 받아 받은 선물 상세 페이지로 이동한다.
|
|
||||||
- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}` 응답으로 렌더링한다.
|
|
||||||
- `direction=RECEIVED && recipientAddressRequired=true`이면 배송지 입력 UI를 표시한다.
|
|
||||||
- 배송지 입력은 `POST /api/v2/gifts/{applicationNo}/recipient-address`로 연결한다.
|
|
||||||
- 배송지 입력 완료 후에는 상세를 다시 조회해 배송지 입력 완료 상태 UI를 표시한다.
|
|
||||||
|
|
||||||
중요 전제:
|
|
||||||
- Figma의 visual structure와 문구를 우선 따르되, 데이터 노출과 CTA 조건은 API 계약을 우선한다.
|
|
||||||
- `direction=RECEIVED` 화면에서 `senderInfo`, `mailbox`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`은 표시하지 않는다.
|
|
||||||
- `recipientAddress`가 null이면 받는 주소 섹션 자체를 표시하지 않는다. 전달 완료 후 배송지 정보가 삭제될 예정이므로 빈 섹션, skeleton, `-` 값도 표시하지 않는다.
|
|
||||||
- 전달 불가 화면의 `문의하기`는 기존 앱의 카카오 채널 문의 연결과 동일하게 연결한다.
|
|
||||||
- 전달 불가 화면의 `선물 정책 확인`은 버튼/action만 만들어두고 실제 외부 페이지 연결은 하지 않는다. 임의 URL을 연결하지 않는다.
|
|
||||||
|
|
||||||
상세 페이지 진입:
|
|
||||||
- 화면 진입 시 `GET /api/v2/gifts/{applicationNo}`를 호출한다.
|
|
||||||
- 조회 결과 `direction`이 `RECEIVED`가 아니면 이 화면을 렌더링하지 말고 기존 앱의 잘못된 접근/상세 재라우팅 정책을 따른다.
|
|
||||||
- 로딩 중에는 기존 앱 상세 화면 로딩/스켈레톤 패턴을 사용한다.
|
|
||||||
- 실패 시 기존 앱 toast/dialog와 재시도 패턴을 사용한다.
|
|
||||||
|
|
||||||
배송지 입력 전 UI:
|
|
||||||
- 적용 조건: `direction=RECEIVED && recipientAddressRequired=true`.
|
|
||||||
- Figma `2531:36290`을 따른다.
|
|
||||||
- 상단 타이틀: `선물 받기`.
|
|
||||||
- 개인정보 이용 안내 배너를 표시한다.
|
|
||||||
- 제목: `개인정보 이용 안내`
|
|
||||||
- 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 입력한 배송지는 선물 전달 및 필요한 사고 처리에서 사용되며, 전달 완료 후 파기됩니다.`
|
|
||||||
- `전달 예정 선물` 섹션을 표시한다.
|
|
||||||
- 발송인: `giftInfo.senderNickname`
|
|
||||||
- 사이즈: `giftInfo.sizeName`
|
|
||||||
- 카테고리: `giftInfo.categoryName`
|
|
||||||
- `배송지 입력` 섹션을 표시한다.
|
|
||||||
- 이름 input: `recipientName`
|
|
||||||
- 휴대폰 번호 input: `recipientPhoneNumber`
|
|
||||||
- 우편번호 input: `recipientZipCode`
|
|
||||||
- 우편번호 검색 버튼: 기존 페이지와 동일하게 Kakao 우편번호 서비스를 사용한다.
|
|
||||||
- 주소 input: `recipientAddress`
|
|
||||||
- 상세 주소 input: `recipientAddressDetail`
|
|
||||||
- `이용 약관 동의` 섹션을 표시한다.
|
|
||||||
- `소다라이브 크리에이터 상품 전달 이용약관 및 전달 가능 제한 품목을 확인하고 동의합니다.(필수)`
|
|
||||||
- `상품 전달을 위해 수령인 성명·연락처·주소 등 필요한 개인정보를 일시적으로 수집·이용하는 것에 동의합니다.(필수)`
|
|
||||||
- 두 항목 모두 체크해야 제출 가능하다.
|
|
||||||
- 약관 상세 링크는 앱에 기존 약관/webview 경로가 있으면 연결하고, 없으면 새 페이지를 만들지 않는다.
|
|
||||||
- 하단 주요 CTA: `선물 받기`.
|
|
||||||
- CTA 활성 조건:
|
|
||||||
- 이름, 휴대폰 번호, 우편번호, 주소가 공백이 아니다.
|
|
||||||
- 상세 주소는 선택 입력이다.
|
|
||||||
- 약관 2개가 모두 체크됐다.
|
|
||||||
- submit 중에는 중복 탭을 막고 loading 상태를 표시한다.
|
|
||||||
|
|
||||||
배송지 입력 API 연결:
|
|
||||||
- CTA 탭 시 `POST /api/v2/gifts/{applicationNo}/recipient-address`를 호출한다.
|
|
||||||
- request body에는 화면 입력값과 약관 동의 boolean을 그대로 전송한다.
|
|
||||||
- 성공 시 성공 toast를 표시하고 상세 페이지를 다시 조회하거나 replace navigation으로 상세를 다시 연다.
|
|
||||||
- 실패 시 기존 앱 form error/toast/dialog 패턴을 따른다. 실패 상태에서 입력값은 유지한다.
|
|
||||||
|
|
||||||
주소 검색 구현:
|
|
||||||
- 기존 앱/페이지에 Kakao 우편번호 서비스 래퍼가 있으면 반드시 재사용한다.
|
|
||||||
- 신규 연결이 필요하면 Kakao 우편번호 서비스 공식 가이드 `https://postcode.map.kakao.com/guide`를 따른다.
|
|
||||||
- SDK script는 공식 가이드의 `//t1.kakaocdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js`를 사용한다. 임의 수정본이나 별도 주소 검색 API를 만들지 않는다.
|
|
||||||
- Android/iOS WebView 또는 모바일웹 환경에서는 `window.open` 팝업이 막힐 수 있으므로, 기존 페이지와 같은 방식이 없다면 Kakao 가이드의 `embed()` 기반 레이어/페이지 삽입 방식을 우선 검토한다.
|
|
||||||
- 검색 결과 선택 시:
|
|
||||||
- `data.zonecode`를 `recipientZipCode`에 넣는다.
|
|
||||||
- `data.userSelectedType === "R"`이면 `data.roadAddress`를, 그 외에는 `data.jibunAddress`를 `recipientAddress`에 넣는다.
|
|
||||||
- 주소 선택 후 `recipientAddressDetail` 입력으로 포커스를 이동한다.
|
|
||||||
- 상세 주소는 Kakao 결과에서 채우지 않고 사용자가 직접 입력하게 한다.
|
|
||||||
- 주소 검색 실패/취소 시 입력 중이던 이름, 휴대폰 번호, 약관 체크 상태를 초기화하지 않는다.
|
|
||||||
|
|
||||||
배송지 입력 완료 후 상세 UI:
|
|
||||||
- 적용 조건: `direction=RECEIVED && recipientAddressRequired=false && status`가 `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 중 하나이며 `recipientAddress`가 있다.
|
|
||||||
- Figma `2531:36399`를 따른다.
|
|
||||||
- 상단 타이틀: `선물 상세`.
|
|
||||||
- hero title: `배송지 입력 완료!`
|
|
||||||
- hero body: `빠른 시일 내로 선물을 전달 드릴게요.`
|
|
||||||
- `문의하기` 버튼을 표시하고 기존 카카오 채널 문의로 연결한다.
|
|
||||||
- `선물 정보` 섹션을 표시한다.
|
|
||||||
- 발송인: `giftInfo.senderNickname`
|
|
||||||
- 사이즈: `giftInfo.sizeName`
|
|
||||||
- 카테고리: `giftInfo.categoryName`
|
|
||||||
- 배송 신청일: `giftInfo.shippingRequestedAt`
|
|
||||||
- `받는 주소` 섹션은 `recipientAddress`가 있을 때만 표시한다.
|
|
||||||
- 이름: `recipientAddress.name`
|
|
||||||
- 휴대폰 번호: `recipientAddress.phoneNumber`
|
|
||||||
- 주소: `recipientAddress.address`
|
|
||||||
|
|
||||||
선물 전달 완료 상세 UI:
|
|
||||||
- 적용 조건: `direction=RECEIVED && status=DELIVERED`.
|
|
||||||
- Figma `2531:36330`을 따른다.
|
|
||||||
- 상단 타이틀: `선물 상세`.
|
|
||||||
- hero title: `선물 전달 완료!`
|
|
||||||
- hero body: `팬이 보낸 선물이 도착했어요. 선물이 도착하지 않은 경우, 문의해 주세요.`
|
|
||||||
- `문의하기` 버튼을 표시하고 기존 카카오 채널 문의로 연결한다.
|
|
||||||
- `선물 정보` 섹션을 표시한다.
|
|
||||||
- 발송인: `giftInfo.senderNickname`
|
|
||||||
- 사이즈: `giftInfo.sizeName`
|
|
||||||
- 카테고리: `giftInfo.categoryName`
|
|
||||||
- 배송 신청일: `giftInfo.shippingRequestedAt`
|
|
||||||
- 배송 완료일: `statusTimeline`에서 `DELIVERED.occurredAt`
|
|
||||||
- `받는 주소` 섹션은 `recipientAddress`가 있을 때만 표시한다.
|
|
||||||
- 섹션 보조 문구: `배송 완료 3일 후 해당 정보는 사라져요`
|
|
||||||
- 이름: `recipientAddress.name`
|
|
||||||
- 휴대폰 번호: `recipientAddress.phoneNumber`
|
|
||||||
- 주소: `recipientAddress.address`
|
|
||||||
- `recipientAddress=null`이면 `받는 주소` 섹션 전체를 숨긴다.
|
|
||||||
|
|
||||||
선물 전달 불가 상세 UI:
|
|
||||||
- 적용 조건: `direction=RECEIVED && status=UNDELIVERABLE`.
|
|
||||||
- Figma `2531:36422`를 따른다.
|
|
||||||
- 상단 타이틀: `선물 상세`.
|
|
||||||
- chip: `전달 불가`
|
|
||||||
- hero title: `전달할 수 없는 품목입니다`
|
|
||||||
- hero body: `해당 선물은 SODALIVE 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다.`
|
|
||||||
- action button:
|
|
||||||
- `문의하기`: 기존 카카오 채널 문의로 연결한다.
|
|
||||||
- `선물 정책 확인`: action handler만 연결하고 실제 외부 페이지/URL은 연결하지 않는다. 기존 앱 정책상 no-op이 금지되어 있으면 `준비 중입니다.` toast만 표시한다.
|
|
||||||
- `사유` 섹션을 표시한다.
|
|
||||||
- 사유 제목: `delivery.undeliverableReason`이 있으면 그 값을 사용한다.
|
|
||||||
- `delivery.undeliverableReason`이 null이면 `전달 불가`로 표시한다.
|
|
||||||
- 안내 문구: `해당 선물은 정책에 따라 폐기되며 반송되지 않습니다.`
|
|
||||||
- 전달 불가 상태에서는 배송지 입력 UI를 표시하지 않는다.
|
|
||||||
- `recipientAddress`가 응답에 있더라도 전달 불가 Figma에는 받는 주소 섹션이 없으므로 표시하지 않는다.
|
|
||||||
|
|
||||||
날짜 표시:
|
|
||||||
- API의 모든 날짜는 UTC ISO 문자열이다.
|
|
||||||
- 앱 공통 날짜 formatter가 있으면 재사용한다.
|
|
||||||
- Figma처럼 상세 필드 날짜는 `YYYY.MM.DD`로 표시한다.
|
|
||||||
- 값이 null이면 해당 row를 숨긴다.
|
|
||||||
|
|
||||||
API 명세:
|
|
||||||
|
|
||||||
선물 상세 조회:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/gifts/{applicationNo}`
|
|
||||||
- 인증: 로그인 필요
|
|
||||||
- Response envelope 예시 - 배송지 입력 전:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"direction": "RECEIVED",
|
|
||||||
"status": "TRACKING_REGISTERED",
|
|
||||||
"statusName": "발송 확인",
|
|
||||||
"giftInfo": {
|
|
||||||
"recipientCreatorNickname": null,
|
|
||||||
"senderNickname": "팬닉네임이들어가는부분",
|
|
||||||
"sizeName": "소형",
|
|
||||||
"categoryName": "신규 전자굿즈",
|
|
||||||
"applicationNo": null,
|
|
||||||
"paidCan": null,
|
|
||||||
"tracking": null,
|
|
||||||
"shippingRequestedAt": "2026-10-12T03:00:00Z"
|
|
||||||
},
|
|
||||||
"senderInfo": null,
|
|
||||||
"recipientAddress": null,
|
|
||||||
"mailbox": null,
|
|
||||||
"trackingRequired": false,
|
|
||||||
"recipientAddressRequired": true,
|
|
||||||
"recipientAddressDeadlineAt": "2026-10-19T03:00:00Z",
|
|
||||||
"delivery": {
|
|
||||||
"canceledAt": null,
|
|
||||||
"undeliverableAt": null,
|
|
||||||
"undeliverableReason": null
|
|
||||||
},
|
|
||||||
"statusTimeline": [
|
|
||||||
{
|
|
||||||
"status": "RECEIVED",
|
|
||||||
"statusName": "접수 완료",
|
|
||||||
"occurredAt": "2026-10-11T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "TRACKING_REGISTERED",
|
|
||||||
"statusName": "발송 확인",
|
|
||||||
"occurredAt": "2026-10-12T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "ARRIVED_AT_MAILBOX",
|
|
||||||
"statusName": "사서함 도착",
|
|
||||||
"occurredAt": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "INSPECTION_COMPLETED",
|
|
||||||
"statusName": "검수완료",
|
|
||||||
"occurredAt": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "DELIVERED",
|
|
||||||
"statusName": "전달완료",
|
|
||||||
"occurredAt": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response envelope 예시 - 배송지 입력 완료/전달 완료:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"direction": "RECEIVED",
|
|
||||||
"status": "DELIVERED",
|
|
||||||
"statusName": "전달완료",
|
|
||||||
"giftInfo": {
|
|
||||||
"recipientCreatorNickname": null,
|
|
||||||
"senderNickname": "팬닉네임이들어가는부분",
|
|
||||||
"sizeName": "소형",
|
|
||||||
"categoryName": "신규 전자굿즈",
|
|
||||||
"applicationNo": null,
|
|
||||||
"paidCan": null,
|
|
||||||
"tracking": null,
|
|
||||||
"shippingRequestedAt": "2026-10-12T03:00:00Z"
|
|
||||||
},
|
|
||||||
"senderInfo": null,
|
|
||||||
"recipientAddress": {
|
|
||||||
"name": "크리에이터이름",
|
|
||||||
"phoneNumber": "01012345678",
|
|
||||||
"address": "(04030) 서울특별시 마포구 양화로 000, 3층"
|
|
||||||
},
|
|
||||||
"mailbox": null,
|
|
||||||
"trackingRequired": false,
|
|
||||||
"recipientAddressRequired": false,
|
|
||||||
"recipientAddressDeadlineAt": "2026-10-19T03:00:00Z",
|
|
||||||
"delivery": {
|
|
||||||
"canceledAt": null,
|
|
||||||
"undeliverableAt": null,
|
|
||||||
"undeliverableReason": null
|
|
||||||
},
|
|
||||||
"statusTimeline": [
|
|
||||||
{
|
|
||||||
"status": "RECEIVED",
|
|
||||||
"statusName": "접수 완료",
|
|
||||||
"occurredAt": "2026-10-11T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "TRACKING_REGISTERED",
|
|
||||||
"statusName": "발송 확인",
|
|
||||||
"occurredAt": "2026-10-12T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "ARRIVED_AT_MAILBOX",
|
|
||||||
"statusName": "사서함 도착",
|
|
||||||
"occurredAt": "2026-10-14T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "INSPECTION_COMPLETED",
|
|
||||||
"statusName": "검수완료",
|
|
||||||
"occurredAt": "2026-10-16T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "DELIVERED",
|
|
||||||
"statusName": "전달완료",
|
|
||||||
"occurredAt": "2026-10-22T03:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response envelope 예시 - 전달 불가:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"direction": "RECEIVED",
|
|
||||||
"status": "UNDELIVERABLE",
|
|
||||||
"statusName": "전달불가",
|
|
||||||
"giftInfo": {
|
|
||||||
"recipientCreatorNickname": null,
|
|
||||||
"senderNickname": "팬닉네임이들어가는부분",
|
|
||||||
"sizeName": "소형",
|
|
||||||
"categoryName": "신규 전자굿즈",
|
|
||||||
"applicationNo": null,
|
|
||||||
"paidCan": null,
|
|
||||||
"tracking": null,
|
|
||||||
"shippingRequestedAt": "2026-10-12T03:00:00Z"
|
|
||||||
},
|
|
||||||
"senderInfo": null,
|
|
||||||
"recipientAddress": null,
|
|
||||||
"mailbox": null,
|
|
||||||
"trackingRequired": false,
|
|
||||||
"recipientAddressRequired": false,
|
|
||||||
"recipientAddressDeadlineAt": "2026-10-19T03:00:00Z",
|
|
||||||
"delivery": {
|
|
||||||
"canceledAt": null,
|
|
||||||
"undeliverableAt": "2026-10-16T03:00:00Z",
|
|
||||||
"undeliverableReason": "음식물 또는 보관이 어려운 품목"
|
|
||||||
},
|
|
||||||
"statusTimeline": [
|
|
||||||
{
|
|
||||||
"status": "RECEIVED",
|
|
||||||
"statusName": "접수 완료",
|
|
||||||
"occurredAt": "2026-10-11T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "TRACKING_REGISTERED",
|
|
||||||
"statusName": "발송 확인",
|
|
||||||
"occurredAt": "2026-10-12T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "ARRIVED_AT_MAILBOX",
|
|
||||||
"statusName": "사서함 도착",
|
|
||||||
"occurredAt": "2026-10-14T03:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "INSPECTION_COMPLETED",
|
|
||||||
"statusName": "검수완료",
|
|
||||||
"occurredAt": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "DELIVERED",
|
|
||||||
"statusName": "전달완료",
|
|
||||||
"occurredAt": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
선물 상세 response `data` 필드:
|
|
||||||
|
|
||||||
| 필드 | 설명 |
|
|
||||||
|---|---|
|
|
||||||
| `applicationNo` | 선물 신청번호. 화면 진입 key로 사용한다. |
|
|
||||||
| `direction` | 이 프롬프트에서는 `RECEIVED`만 렌더링한다. |
|
|
||||||
| `status` | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`, `DELIVERED`, `UNDELIVERABLE` 등을 상태별 UI 분기에 사용한다. |
|
|
||||||
| `giftInfo.senderNickname` | 받은 선물 관점의 발송인 팬 닉네임. |
|
|
||||||
| `giftInfo.sizeName` | 선물 사이즈명. |
|
|
||||||
| `giftInfo.categoryName` | 카테고리명. |
|
|
||||||
| `giftInfo.shippingRequestedAt` | 배송 신청일. UTC ISO 문자열. |
|
|
||||||
| `recipientAddress` | 크리에이터 본인이 입력한 배송지. null이면 받는 주소 섹션을 숨긴다. |
|
|
||||||
| `recipientAddressRequired` | 배송지 입력 UI 표시 여부. |
|
|
||||||
| `recipientAddressDeadlineAt` | 배송지 입력 마감일. 필요하면 안내 문구에 사용한다. |
|
|
||||||
| `delivery.undeliverableReason` | 전달 불가 사유. |
|
|
||||||
| `statusTimeline` | 배송 완료일 등 상태별 날짜를 찾는 데 사용한다. |
|
|
||||||
|
|
||||||
배송지 입력:
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/gifts/{applicationNo}/recipient-address`
|
|
||||||
- 인증: 로그인 필요
|
|
||||||
- Request:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"recipientName": "크리에이터이름",
|
|
||||||
"recipientPhoneNumber": "01012345678",
|
|
||||||
"recipientZipCode": "04030",
|
|
||||||
"recipientAddress": "서울특별시 마포구 양화로 000",
|
|
||||||
"recipientAddressDetail": "3층",
|
|
||||||
"recipientTermsAgreed": true,
|
|
||||||
"recipientPrivacyAgreed": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Response envelope 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"status": "TRACKING_REGISTERED",
|
|
||||||
"statusName": "발송 확인",
|
|
||||||
"recipientAddressRegisteredAt": "2026-10-13T03:00:00Z"
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
배송지 입력 response `data` 필드:
|
|
||||||
|
|
||||||
| 필드 | 설명 |
|
|
||||||
|---|---|
|
|
||||||
| `applicationNo` | 선물 신청번호 |
|
|
||||||
| `status` | 등록 후 상태. 일반적으로 기존 진행 상태 유지 |
|
|
||||||
| `statusName` | 서버 상태 표시명 |
|
|
||||||
| `recipientAddressRegisteredAt` | 배송지 입력 완료 시각. UTC ISO 문자열 |
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- 리스트 item 탭 시 `applicationNo`로 받은 선물 상세 페이지가 열리는지 확인한다.
|
|
||||||
- 상세 진입 시 `GET /api/v2/gifts/{applicationNo}`가 호출되는지 확인한다.
|
|
||||||
- `direction=RECEIVED && recipientAddressRequired=true`이면 배송지 입력 UI가 표시되는지 확인한다.
|
|
||||||
- 배송지 입력 필수값 또는 약관 동의가 누락되면 `POST /recipient-address`를 호출하지 않는지 확인한다.
|
|
||||||
- 배송지 입력 submit 시 request body가 API 명세와 일치하는지 확인한다.
|
|
||||||
- 배송지 입력 성공 후 상세가 재조회되고 배송지 입력 완료 UI가 표시되는지 확인한다.
|
|
||||||
- 배송지 입력 완료/전달 완료 상태에서 `recipientAddress`가 있으면 받는 주소 섹션이 표시되는지 확인한다.
|
|
||||||
- `recipientAddress=null`이면 받는 주소 섹션 자체가 표시되지 않는지 확인한다.
|
|
||||||
- `status=DELIVERED`이면 전달 완료 UI와 배송 완료일이 표시되는지 확인한다.
|
|
||||||
- `status=UNDELIVERABLE`이면 전달 불가 UI, 사유, 폐기/반송 불가 안내가 표시되는지 확인한다.
|
|
||||||
- 전달 불가 화면의 `문의하기`는 기존 카카오 채널 문의로 연결되는지 확인한다.
|
|
||||||
- 전달 불가 화면의 `선물 정책 확인`은 버튼 action만 있고 실제 외부 URL을 열지 않는지 확인한다.
|
|
||||||
- 모든 날짜는 UTC ISO 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다.
|
|
||||||
```
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
CREATE TABLE gift_category (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
|
||||||
classification_number VARCHAR(3) NOT NULL COMMENT '신청번호에 사용하는 3자리 분류번호',
|
|
||||||
category_code VARCHAR(50) NOT NULL COMMENT '내부 범용코드',
|
|
||||||
name VARCHAR(50) NOT NULL COMMENT '분류명',
|
|
||||||
receipt_code VARCHAR(9) NOT NULL COMMENT '신청번호에 사용하는 접수코드',
|
|
||||||
representative_item VARCHAR(100) NOT NULL COMMENT '대표품목',
|
|
||||||
requires_damage_waiver TINYINT(1) NOT NULL COMMENT '파손면책 동의 필요 여부',
|
|
||||||
is_active TINYINT(1) NOT NULL COMMENT '활성 여부',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uk_gift_category_classification_number (classification_number),
|
|
||||||
UNIQUE KEY uk_gift_category_code (category_code),
|
|
||||||
UNIQUE KEY uk_gift_category_receipt_code (receipt_code)
|
|
||||||
) COMMENT='선물 카테고리';
|
|
||||||
|
|
||||||
CREATE TABLE gift_mailbox (
|
|
||||||
id BIGINT NOT NULL COMMENT '전역 단일 설정 ID. 항상 1',
|
|
||||||
name VARCHAR(50) NOT NULL COMMENT '받을 사람 이름',
|
|
||||||
phone_number VARCHAR(30) NOT NULL COMMENT '받을 연락처',
|
|
||||||
zip_code VARCHAR(20) NOT NULL COMMENT '받을 우편번호',
|
|
||||||
address VARCHAR(255) NOT NULL COMMENT '받을 주소',
|
|
||||||
address_detail VARCHAR(255) NULL COMMENT '받을 상세주소',
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
) COMMENT='선물 받을 주소 전역 설정';
|
|
||||||
|
|
||||||
CREATE TABLE gift (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
|
||||||
application_no VARCHAR(40) NOT NULL COMMENT '신청번호',
|
|
||||||
sender_member_id BIGINT NOT NULL COMMENT '보내는 팬 회원번호',
|
|
||||||
recipient_member_id BIGINT NOT NULL COMMENT '받는 크리에이터 회원번호',
|
|
||||||
status VARCHAR(30) NOT NULL COMMENT '선물 상태',
|
|
||||||
size_code VARCHAR(20) NOT NULL COMMENT '선물 사이즈 코드',
|
|
||||||
category_id BIGINT NOT NULL COMMENT '신청 시 카테고리 ID',
|
|
||||||
category_name_snapshot VARCHAR(50) NOT NULL COMMENT '신청 시 카테고리명 스냅샷',
|
|
||||||
sale_price_can INT NOT NULL COMMENT '신청 시 결제 금액 스냅샷',
|
|
||||||
can_usage_id BIGINT NOT NULL COMMENT '캔 사용내역 ID',
|
|
||||||
sender_terms_agreed TINYINT(1) NOT NULL COMMENT '팬 이용약관 동의 여부',
|
|
||||||
sender_privacy_agreed TINYINT(1) NOT NULL COMMENT '팬 개인정보 동의 여부',
|
|
||||||
sender_terms_agreed_at TIMESTAMP NOT NULL COMMENT '팬 이용약관 동의 시각',
|
|
||||||
sender_privacy_agreed_at TIMESTAMP NOT NULL COMMENT '팬 개인정보 동의 시각',
|
|
||||||
recipient_terms_agreed TINYINT(1) NOT NULL COMMENT '크리에이터 이용약관 동의 여부',
|
|
||||||
recipient_privacy_agreed TINYINT(1) NOT NULL COMMENT '크리에이터 개인정보 동의 여부',
|
|
||||||
recipient_terms_agreed_at TIMESTAMP NULL COMMENT '크리에이터 이용약관 동의 시각',
|
|
||||||
recipient_privacy_agreed_at TIMESTAMP NULL COMMENT '크리에이터 개인정보 동의 시각',
|
|
||||||
damage_waiver_agreed TINYINT(1) NOT NULL COMMENT '파손면책 동의 여부',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uk_gift_application_no (application_no),
|
|
||||||
CONSTRAINT fk_gift_sender_member FOREIGN KEY (sender_member_id) REFERENCES member (id),
|
|
||||||
CONSTRAINT fk_gift_recipient_member FOREIGN KEY (recipient_member_id) REFERENCES member (id),
|
|
||||||
CONSTRAINT fk_gift_category FOREIGN KEY (category_id) REFERENCES gift_category (id),
|
|
||||||
CONSTRAINT fk_gift_can_usage FOREIGN KEY (can_usage_id) REFERENCES use_can (id)
|
|
||||||
) COMMENT='선물 신청';
|
|
||||||
|
|
||||||
CREATE TABLE gift_delivery (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
|
||||||
gift_id BIGINT NOT NULL COMMENT '선물 ID',
|
|
||||||
sender_name VARCHAR(50) NOT NULL COMMENT '발신자 이름',
|
|
||||||
sender_phone_number VARCHAR(30) NOT NULL COMMENT '발신자 휴대폰 번호',
|
|
||||||
sender_zip_code VARCHAR(20) NOT NULL COMMENT '발신자 우편번호',
|
|
||||||
sender_address VARCHAR(255) NOT NULL COMMENT '발신자 주소',
|
|
||||||
sender_address_detail VARCHAR(255) NULL COMMENT '발신자 상세주소',
|
|
||||||
recipient_name VARCHAR(50) NULL COMMENT '수신자 이름',
|
|
||||||
recipient_phone_number VARCHAR(30) NULL COMMENT '수신자 휴대폰 번호',
|
|
||||||
recipient_zip_code VARCHAR(20) NULL COMMENT '수신자 우편번호',
|
|
||||||
recipient_address VARCHAR(255) NULL COMMENT '수신자 주소',
|
|
||||||
recipient_address_detail VARCHAR(255) NULL COMMENT '수신자 상세주소',
|
|
||||||
courier_company_name VARCHAR(50) NULL COMMENT '택배사 한글 표시명',
|
|
||||||
tracking_number VARCHAR(100) NULL COMMENT '운송장 번호',
|
|
||||||
tracking_deadline_at TIMESTAMP NOT NULL COMMENT '운송장 등록 기한',
|
|
||||||
recipient_address_deadline_at TIMESTAMP NULL COMMENT '수신자 배송지 입력 기한',
|
|
||||||
tracking_deadline_reminder_sent_at TIMESTAMP NULL COMMENT '운송장 등록 기한 안내 발송 시각',
|
|
||||||
recipient_address_deadline_reminder_sent_at TIMESTAMP NULL COMMENT '수신자 배송지 입력 기한 안내 발송 시각',
|
|
||||||
tracking_registered_at TIMESTAMP NULL COMMENT '운송장 등록 시각',
|
|
||||||
arrived_at_mailbox_at TIMESTAMP NULL COMMENT '사서함 도착 시각',
|
|
||||||
inspection_completed_at TIMESTAMP NULL COMMENT '검수완료 시각',
|
|
||||||
delivered_at TIMESTAMP NULL COMMENT '전달완료 시각',
|
|
||||||
canceled_at TIMESTAMP NULL COMMENT '취소 시각',
|
|
||||||
undeliverable_at TIMESTAMP NULL COMMENT '전달불가 시각',
|
|
||||||
undeliverable_reason VARCHAR(255) NULL COMMENT '전달불가 사유',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uk_gift_delivery_gift_id (gift_id),
|
|
||||||
CONSTRAINT fk_gift_delivery_gift FOREIGN KEY (gift_id) REFERENCES gift (id)
|
|
||||||
) COMMENT='선물 배송 정보';
|
|
||||||
|
|
||||||
CREATE TABLE gift_size_price (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
|
||||||
size_code VARCHAR(20) NOT NULL COMMENT '선물 사이즈 코드',
|
|
||||||
base_price_can INT NOT NULL COMMENT '기본 금액',
|
|
||||||
sale_price_can INT NOT NULL COMMENT '결제 금액',
|
|
||||||
is_active TINYINT(1) NOT NULL COMMENT '활성 여부',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uk_gift_size_price_size_code (size_code)
|
|
||||||
) COMMENT='선물 사이즈별 가격';
|
|
||||||
|
|
||||||
CREATE TABLE gift_review (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
|
||||||
gift_id BIGINT NOT NULL COMMENT '선물 ID',
|
|
||||||
sender_member_id BIGINT NOT NULL COMMENT '리뷰 작성 팬 회원번호',
|
|
||||||
rating INT NOT NULL COMMENT '별점',
|
|
||||||
keywords TEXT NULL COMMENT '리뷰 키워드 목록',
|
|
||||||
comment VARCHAR(255) NULL COMMENT '추가의견',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uk_gift_review_gift_id (gift_id),
|
|
||||||
CONSTRAINT fk_gift_review_gift FOREIGN KEY (gift_id) REFERENCES gift (id),
|
|
||||||
CONSTRAINT fk_gift_review_sender_member FOREIGN KEY (sender_member_id) REFERENCES member (id)
|
|
||||||
) COMMENT='선물 리뷰';
|
|
||||||
|
|
||||||
CREATE TABLE gift_application_no_sequence (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
|
||||||
category_id BIGINT NOT NULL COMMENT '채번 기준 카테고리 ID',
|
|
||||||
sequence_date VARCHAR(6) NOT NULL COMMENT '채번 기준일 yyMMdd',
|
|
||||||
last_sequence INT NOT NULL COMMENT '마지막 일련번호',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uk_gift_application_no_sequence_category_date (category_id, sequence_date),
|
|
||||||
CONSTRAINT fk_gift_application_no_sequence_category FOREIGN KEY (category_id) REFERENCES gift_category (id)
|
|
||||||
) COMMENT='선물 신청번호 날짜별 채번';
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
# 선물 보내기 페이지 생성 프롬프트
|
|
||||||
|
|
||||||
이 문서는 Android/iOS 앱의 `선물 보내기` 페이지를 생성하거나 기존 생성 UI에 API 연동을 붙일 때 사용하는 프롬프트다.
|
|
||||||
|
|
||||||
참고 Figma:
|
|
||||||
|
|
||||||
- 빈 입력 상태: `2481:18904`
|
|
||||||
- 입력 완료 및 CTA 활성화 상태: `2481:18957`
|
|
||||||
|
|
||||||
전제:
|
|
||||||
|
|
||||||
- 크리에이터 닉네임과 `memberId`를 받아 `선물 보내기` 화면으로 이동하는 흐름은 이미 생성되어 있다.
|
|
||||||
- 새 진입 흐름을 만들지 않는다.
|
|
||||||
- Figma에 있는 UI 구조와 문구를 유지한다.
|
|
||||||
- Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
|
|
||||||
- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `data`에 있다.
|
|
||||||
- request/response 설명은 모바일 공통 JSON 기준이다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 모바일 앱 구현 프롬프트
|
|
||||||
|
|
||||||
```text
|
|
||||||
Android/iOS 앱의 선물 보내기 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘.
|
|
||||||
|
|
||||||
중요 전제:
|
|
||||||
- 크리에이터 닉네임과 memberId를 받아 이 화면으로 이동하는 기능은 이미 구현되어 있다.
|
|
||||||
- 이 작업에서는 이동 경로를 새로 만들지 말고, 전달받은 값만 화면과 API request에 연결한다.
|
|
||||||
- 전달받은 크리에이터 닉네임은 `받는 크리에이터` 영역에 표시한다.
|
|
||||||
- 전달받은 memberId는 선물 신청 API의 `recipientMemberId`로 보낸다.
|
|
||||||
|
|
||||||
Figma 참고 상태:
|
|
||||||
- 빈 입력 상태: node `2481:18904`
|
|
||||||
- 입력 완료 및 최하단 CTA 활성화 상태: node `2481:18957`
|
|
||||||
|
|
||||||
화면 구조:
|
|
||||||
|
|
||||||
1. 상단
|
|
||||||
- 타이틀: `선물 보내기`
|
|
||||||
- 뒤로가기 버튼은 기존 앱 패턴을 사용한다.
|
|
||||||
|
|
||||||
2. 안내 배너
|
|
||||||
- 제목: `선물 보내기 베타 서비스 안내`
|
|
||||||
- 본문: `베타 기간 동안은 일부 기능만 제공됩니다. 크리에이터에게 마음을 잘 전할 수 있도록 더 넓어진 선물 보내기로 곧 다시 만나요!`
|
|
||||||
|
|
||||||
3. 받는 크리에이터
|
|
||||||
- 이전 화면에서 전달받은 크리에이터 닉네임을 표시한다.
|
|
||||||
- Figma의 프로필 row 형태를 유지한다.
|
|
||||||
- memberId는 화면 표시용이 아니라 API request용으로만 사용한다.
|
|
||||||
|
|
||||||
4. 선물 사이즈
|
|
||||||
- 화면 진입 시 `GET /api/v2/gifts/form-options`를 호출해 `sizes`를 가져온다.
|
|
||||||
- 조회된 `sizes`를 사이즈 카드 목록에 바인딩한다.
|
|
||||||
- 카드에는 `name`, 사이즈 설명, 캔 가격을 표시한다.
|
|
||||||
- 서버 응답에는 사이즈 설명 필드가 없으므로 설명 문구는 앱에서 코드별로 매핑한다.
|
|
||||||
- `SMALL`: `세 변의 합 100cm 이하 · 5kg 이하`
|
|
||||||
- `MEDIUM`, `LARGE`: 정책 문구가 앱에 이미 있으면 기존 문구를 사용하고, 없으면 이름과 가격만 표시한다.
|
|
||||||
- 가격은 `salePriceCan`을 표시하고 submit 금액도 `salePriceCan`을 사용한다.
|
|
||||||
- `basePriceCan`과 `salePriceCan`이 다르면 기존 앱 할인/정가 표시 패턴이 있을 때만 정가를 함께 보여준다.
|
|
||||||
- 기본 선택은 서버가 내려준 첫 번째 사이즈로 둔다.
|
|
||||||
|
|
||||||
5. 카테고리
|
|
||||||
- 화면 진입 시 같은 `GET /api/v2/gifts/form-options` 응답의 `categories`를 사용한다.
|
|
||||||
- Figma의 select field를 유지한다.
|
|
||||||
- 빈 상태 문구: `카테고리 선택`
|
|
||||||
- 선택 후에는 선택한 카테고리 `name`을 표시한다.
|
|
||||||
- 카테고리 선택 UI는 기존 Android/iOS 앱 패턴에 맞는 bottom sheet, picker, dialog 중 이미 쓰는 방식을 사용한다.
|
|
||||||
- `requiresDamageWaiver=true`인 카테고리를 선택한 경우에만 카테고리 아래에 파손 면책 동의 row를 표시한다.
|
|
||||||
- 파손 면책 동의 문구: `파손 및 분실 면책 사항에 동의합니다. (필수)`
|
|
||||||
- `requiresDamageWaiver=false` 카테고리를 선택하면 파손 면책 동의 row를 숨기고 `damageWaiverAgreed=false`로 초기화한다.
|
|
||||||
|
|
||||||
6. 보내는 사람
|
|
||||||
- 이름 input → `senderName`
|
|
||||||
- 휴대폰 번호 input → `senderPhoneNumber`
|
|
||||||
- 우편번호 input → `senderZipCode`
|
|
||||||
- 주소 input → `senderAddress`
|
|
||||||
- 상세 주소 input → `senderAddressDetail`
|
|
||||||
- `우편번호 검색` 버튼은 기존 주소 검색 기능과 연결한다.
|
|
||||||
- 주소 검색 결과로 우편번호와 기본 주소를 채운다.
|
|
||||||
- 기본 주소는 사용자가 직접 수정하지 못하게 하는 기존 패턴이 있으면 그 패턴을 따른다.
|
|
||||||
|
|
||||||
7. 이용 약관 동의
|
|
||||||
- Figma의 발송 규정 요약 박스와 체크박스 2개를 유지한다.
|
|
||||||
- `소다라이브 크리에이터 상품 전달 이용약관에 동의합니다. (필수)` → `senderTermsAgreed`
|
|
||||||
- `상품 전달을 위한 개인정보 수집·이용에 동의합니다. (필수)` → `senderPrivacyAgreed`
|
|
||||||
|
|
||||||
8. 하단 CTA
|
|
||||||
- 빈 상태 또는 필수값 누락 상태는 Figma 빈 상태처럼 비활성 버튼을 표시한다.
|
|
||||||
- 활성 조건을 모두 만족하면 Figma 활성 상태처럼 soda 색상 CTA로 바꾼다.
|
|
||||||
- 활성 CTA 문구는 선택된 사이즈의 `salePriceCan`을 사용해 `{salePriceCan}캔으로 선물 보내기`로 표시한다.
|
|
||||||
- submit 중에는 중복 탭을 막고 loading 상태를 표시한다.
|
|
||||||
|
|
||||||
화면 진입 시 처리:
|
|
||||||
- 이미 전달받은 `creatorNickname`과 `memberId`를 읽는다.
|
|
||||||
- 둘 중 하나라도 없으면 기존 앱의 오류 처리 또는 뒤로가기 패턴을 따른다.
|
|
||||||
- 즉시 `GET /api/v2/gifts/form-options`를 호출한다.
|
|
||||||
- 로딩 중에는 기존 화면 스켈레톤/로딩 패턴을 사용한다.
|
|
||||||
- 조회 실패 시 toast/dialog와 재시도 동작을 기존 패턴으로 제공한다.
|
|
||||||
- `sizes` 또는 `categories`가 비어 있으면 선물 신청을 막고 CTA를 비활성화한다.
|
|
||||||
|
|
||||||
CTA 활성 조건:
|
|
||||||
- `memberId`가 있다.
|
|
||||||
- 사이즈가 선택되어 있다.
|
|
||||||
- 카테고리가 선택되어 있다.
|
|
||||||
- 이름이 입력되어 있다.
|
|
||||||
- 휴대폰 번호가 입력되어 있다.
|
|
||||||
- 우편번호가 입력되어 있다.
|
|
||||||
- 주소가 입력되어 있다.
|
|
||||||
- 이용약관 동의가 true다.
|
|
||||||
- 개인정보 수집·이용 동의가 true다.
|
|
||||||
- 선택한 카테고리의 `requiresDamageWaiver=true`이면 파손 면책 동의가 true다.
|
|
||||||
|
|
||||||
검증 실패 처리:
|
|
||||||
- CTA는 기본적으로 위 조건을 만족할 때만 활성화한다.
|
|
||||||
- 그래도 submit 시점에 한 번 더 validation 한다.
|
|
||||||
- 누락된 필드는 기존 앱의 input error, toast, dialog 중 현재 화면 패턴에 맞춰 안내한다.
|
|
||||||
- validation 실패 시 `POST /api/v2/gifts`를 호출하지 않는다.
|
|
||||||
|
|
||||||
폼 옵션 조회 API:
|
|
||||||
- Method: `GET`
|
|
||||||
- URL: `/api/v2/gifts/form-options`
|
|
||||||
- Request: 없음
|
|
||||||
- Response envelope 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"sizes": [],
|
|
||||||
"categories": []
|
|
||||||
},
|
|
||||||
"message": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Response `data` 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sizes": [
|
|
||||||
{
|
|
||||||
"sizeCode": "SMALL",
|
|
||||||
"name": "소형",
|
|
||||||
"basePriceCan": 100,
|
|
||||||
"salePriceCan": 80
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"categories": [
|
|
||||||
{
|
|
||||||
"categoryId": 1,
|
|
||||||
"name": "아크릴",
|
|
||||||
"requiresDamageWaiver": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
폼 옵션 필드:
|
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| `sizes` | array | 선택 가능한 선물 사이즈 목록 |
|
|
||||||
| `sizes[].sizeCode` | string | 사이즈 코드. `SMALL`, `MEDIUM`, `LARGE` |
|
|
||||||
| `sizes[].name` | string | 사이즈 표시명 |
|
|
||||||
| `sizes[].basePriceCan` | number | 기본 가격, 단위는 캔 |
|
|
||||||
| `sizes[].salePriceCan` | number | 실제 결제 가격, 단위는 캔 |
|
|
||||||
| `categories` | array | 선택 가능한 활성 카테고리 목록 |
|
|
||||||
| `categories[].categoryId` | number | 카테고리 ID |
|
|
||||||
| `categories[].name` | string | 카테고리 표시명 |
|
|
||||||
| `categories[].requiresDamageWaiver` | boolean | 파손 면책 동의 필요 여부 |
|
|
||||||
|
|
||||||
선물 신청 접수 API:
|
|
||||||
- Method: `POST`
|
|
||||||
- URL: `/api/v2/gifts`
|
|
||||||
- 인증: 로그인 회원 필요. 기존 앱 인증 토큰/세션 처리 방식을 사용한다.
|
|
||||||
- Request body 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"recipientMemberId": 100,
|
|
||||||
"senderName": "김소다",
|
|
||||||
"senderPhoneNumber": "01000000000",
|
|
||||||
"senderZipCode": "12345",
|
|
||||||
"senderAddress": "서울시 강남구 ...",
|
|
||||||
"senderAddressDetail": "123동 456호",
|
|
||||||
"sizeCode": "SMALL",
|
|
||||||
"categoryId": 1,
|
|
||||||
"senderTermsAgreed": true,
|
|
||||||
"senderPrivacyAgreed": true,
|
|
||||||
"damageWaiverAgreed": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Request body 필드:
|
|
||||||
|
|
||||||
| 필드 | 타입 | 필수 | 설명 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `recipientMemberId` | number | Y | 화면 진입 시 전달받은 memberId |
|
|
||||||
| `senderName` | string | Y | 보내는 사람 이름 |
|
|
||||||
| `senderPhoneNumber` | string | Y | 보내는 사람 휴대폰 번호 |
|
|
||||||
| `senderZipCode` | string | Y | 보내는 사람 우편번호 |
|
|
||||||
| `senderAddress` | string | Y | 보내는 사람 기본 주소 |
|
|
||||||
| `senderAddressDetail` | string 또는 null | N | 보내는 사람 상세 주소 |
|
|
||||||
| `sizeCode` | string | Y | 선택한 사이즈 코드 |
|
|
||||||
| `categoryId` | number | Y | 선택한 카테고리 ID |
|
|
||||||
| `senderTermsAgreed` | boolean | Y | 이용약관 동의 여부. 반드시 `true` |
|
|
||||||
| `senderPrivacyAgreed` | boolean | Y | 개인정보 수집·이용 동의 여부. 반드시 `true` |
|
|
||||||
| `damageWaiverAgreed` | boolean | Y | 파손 면책 동의 여부. 필요한 카테고리에서는 `true`, 필요 없는 카테고리에서는 `false` |
|
|
||||||
|
|
||||||
- Response `data` 예시:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"applicationNo": "A-1002609300001",
|
|
||||||
"status": "RECEIVED",
|
|
||||||
"statusName": "접수 완료",
|
|
||||||
"priceCan": 80,
|
|
||||||
"trackingDeadlineAt": "2026-10-02T03:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response `data` 필드:
|
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| `applicationNo` | string | 선물 신청번호 |
|
|
||||||
| `status` | string | 신청 상태 코드 |
|
|
||||||
| `statusName` | string | 신청 상태 표시명 |
|
|
||||||
| `priceCan` | number | 실제 차감된 캔 금액 |
|
|
||||||
| `trackingDeadlineAt` | string | 운송장 등록 기한. UTC ISO 문자열 |
|
|
||||||
|
|
||||||
성공 처리:
|
|
||||||
- 신청 성공 시 기존 앱의 완료 dialog 또는 완료 화면 패턴을 사용한다.
|
|
||||||
- 최소 표시값은 `applicationNo`, `statusName`, `priceCan`, `trackingDeadlineAt`이다.
|
|
||||||
- 성공 이후 이동 CTA가 필요하면 기존 선물함 또는 선물 상세 이동 패턴을 사용한다.
|
|
||||||
|
|
||||||
실패 처리:
|
|
||||||
- 서버 오류 메시지가 있으면 기존 앱 에러 노출 방식으로 표시한다.
|
|
||||||
- 잔액 부족, 유효하지 않은 카테고리, 파손 면책 미동의, 약관 미동의 등의 오류는 서버 메시지를 우선 사용한다.
|
|
||||||
- 실패 시 입력값은 유지한다.
|
|
||||||
|
|
||||||
테스트/검증:
|
|
||||||
- 화면 진입 시 `GET /api/v2/gifts/form-options`가 호출되는지 확인한다.
|
|
||||||
- 전달받은 크리에이터 닉네임이 받는 크리에이터 영역에 표시되는지 확인한다.
|
|
||||||
- 전달받은 memberId가 `POST /api/v2/gifts`의 `recipientMemberId`로 들어가는지 확인한다.
|
|
||||||
- 빈 상태에서는 CTA가 비활성화되는지 확인한다.
|
|
||||||
- 필수 입력과 필수 동의를 모두 완료하면 CTA가 활성화되고 `{salePriceCan}캔으로 선물 보내기`가 표시되는지 확인한다.
|
|
||||||
- `requiresDamageWaiver=true` 카테고리 선택 시 파손 면책 동의 row가 표시되고 동의 전에는 CTA가 비활성인지 확인한다.
|
|
||||||
- `requiresDamageWaiver=false` 카테고리 선택 시 파손 면책 동의 row가 숨겨지고 request의 `damageWaiverAgreed`가 false인지 확인한다.
|
|
||||||
- submit 성공 시 요청 body와 성공 처리 값을 확인한다.
|
|
||||||
- submit 실패 시 입력값이 유지되고 기존 앱 방식으로 오류가 표시되는지 확인한다.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 요약
|
|
||||||
|
|
||||||
| 목적 | Method | URL | Request | Response data |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 폼 옵션 조회 | GET | `/api/v2/gifts/form-options` | 없음 | 사이즈 목록, 카테고리 목록 |
|
|
||||||
| 선물 신청 접수 | POST | `/api/v2/gifts` | 선물 신청 JSON body | 신청번호, 상태, 차감 금액, 운송장 등록 기한 |
|
|
||||||
|
|
||||||
## Figma 반영 메모
|
|
||||||
|
|
||||||
- 빈 상태 Figma는 카테고리 미선택, 보내는 사람 정보 미입력, 약관 미동의, 하단 CTA 비활성 상태다.
|
|
||||||
- 활성 상태 Figma는 크리에이터 닉네임 표시, 카테고리 선택, 파손 면책 동의 노출, 보내는 사람 정보 입력, 약관 동의, 하단 CTA 활성 상태다.
|
|
||||||
- Figma의 하단 버튼 텍스트는 캔 금액이 포함된 형태이므로 API의 `salePriceCan`으로 동적으로 표시한다.
|
|
||||||
- Figma의 select, checkbox, radio, bottom action bar, safe area는 각 플랫폼의 기존 컴포넌트로 대응한다.
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ class CanController(private val service: CanService) {
|
|||||||
fun getCans(
|
fun getCans(
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
): ApiResponse<List<CanResponse>> {
|
): ApiResponse<List<CanResponse>> {
|
||||||
val forcedCurrency = if (member != null && (member.id == 4L || member.id == 44144L)) {
|
val forcedCurrency = if (member != null && (member.id == 2L || member.id == 4L || member.id == 44144L)) {
|
||||||
"JPY"
|
"JPY"
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ class CanService(
|
|||||||
|
|
||||||
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
|
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
|
||||||
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
|
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
|
||||||
CanUsage.GIFT -> "선물하기"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val createdAt = it.createdAt
|
val createdAt = it.createdAt
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class CanPaymentService(
|
|||||||
communityPost: CreatorCommunity? = null,
|
communityPost: CreatorCommunity? = null,
|
||||||
auditionApplicant: AuditionApplicant? = null,
|
auditionApplicant: AuditionApplicant? = null,
|
||||||
container: String
|
container: String
|
||||||
): UseCan {
|
) {
|
||||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
?: throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
||||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||||
@@ -131,8 +131,6 @@ class CanPaymentService(
|
|||||||
useCan.member = member
|
useCan.member = member
|
||||||
useCan.chatRoomId = chatRoomId
|
useCan.chatRoomId = chatRoomId
|
||||||
useCan.characterId = characterId
|
useCan.characterId = characterId
|
||||||
} else if (canUsage == CanUsage.GIFT) {
|
|
||||||
useCan.member = member
|
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "common.error.invalid_request")
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
}
|
}
|
||||||
@@ -162,18 +160,6 @@ class CanPaymentService(
|
|||||||
useCan,
|
useCan,
|
||||||
paymentGateway = PaymentGateway.APPLE_IAP
|
paymentGateway = PaymentGateway.APPLE_IAP
|
||||||
)
|
)
|
||||||
|
|
||||||
return useCan
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun spendGiftCan(memberId: Long, needCan: Int, container: String): Long {
|
|
||||||
return spendCan(
|
|
||||||
memberId = memberId,
|
|
||||||
needCan = needCan,
|
|
||||||
canUsage = CanUsage.GIFT,
|
|
||||||
container = container
|
|
||||||
).id!!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setUseCanCalculate(
|
private fun setUseCanCalculate(
|
||||||
@@ -338,24 +324,6 @@ class CanPaymentService(
|
|||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
canUsage = CanUsage.LIVE
|
canUsage = CanUsage.LIVE
|
||||||
) ?: throw SodaException(messageKey = "can.payment.invalid_reservation")
|
) ?: throw SodaException(messageKey = "can.payment.invalid_reservation")
|
||||||
refundUseCan(member, useCan)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun refundGiftCan(memberId: Long, useCanId: Long) {
|
|
||||||
val member = memberRepository.findByIdOrNull(memberId)
|
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
|
||||||
val useCan = useCanRepository.findByIdOrNull(useCanId)
|
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
|
||||||
|
|
||||||
if (useCan.member?.id != member.id || useCan.canUsage != CanUsage.GIFT || useCan.isRefund) {
|
|
||||||
throw SodaException(messageKey = "common.error.invalid_request")
|
|
||||||
}
|
|
||||||
|
|
||||||
refundUseCan(member, useCan)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refundUseCan(member: Member, useCan: UseCan) {
|
|
||||||
useCan.isRefund = true
|
useCan.isRefund = true
|
||||||
|
|
||||||
val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
|
val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
|
||||||
|
|||||||
@@ -14,6 +14,5 @@ enum class CanUsage {
|
|||||||
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||||
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||||
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
||||||
CHAT_ROOM_RESET, // 채팅방 초기화 결제(별도 구분)
|
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
|
||||||
GIFT
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import kr.co.vividnext.sodalive.jwt.TokenProvider
|
|||||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler
|
||||||
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.core.Ordered
|
import org.springframework.core.Ordered
|
||||||
@@ -157,8 +156,6 @@ class SecurityConfig(
|
|||||||
JwtFilter::class.java
|
JwtFilter::class.java
|
||||||
)
|
)
|
||||||
.authorizeRequests()
|
.authorizeRequests()
|
||||||
.antMatchers("/readyz").permitAll()
|
|
||||||
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
|
|
||||||
.antMatchers("/member/check/email").permitAll()
|
.antMatchers("/member/check/email").permitAll()
|
||||||
.antMatchers("/member/check/nickname").permitAll()
|
.antMatchers("/member/check/nickname").permitAll()
|
||||||
.antMatchers("/member/signup").permitAll()
|
.antMatchers("/member/signup").permitAll()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
|||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
|
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
|
||||||
@@ -90,7 +90,6 @@ class AudioContentService(
|
|||||||
|
|
||||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.content-bucket}")
|
@Value("\${cloud.aws.s3.content-bucket}")
|
||||||
private val audioContentBucket: String,
|
private val audioContentBucket: String,
|
||||||
@@ -617,7 +616,7 @@ class AudioContentService(
|
|||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
timezone: String
|
timezone: String
|
||||||
): GetAudioContentDetailResponse {
|
): GetAudioContentDetailResponse {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
|
|
||||||
// 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH)
|
// 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH)
|
||||||
val audioContent = repository.findByIdOrNull(id)
|
val audioContent = repository.findByIdOrNull(id)
|
||||||
@@ -978,7 +977,7 @@ class AudioContentService(
|
|||||||
member: Member,
|
member: Member,
|
||||||
isAdultContentVisible: Boolean
|
isAdultContentVisible: Boolean
|
||||||
): GetAudioContentListItem? {
|
): GetAudioContentListItem? {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
|
|
||||||
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
|
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
|
||||||
return null
|
return null
|
||||||
@@ -1052,7 +1051,7 @@ class AudioContentService(
|
|||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Long
|
limit: Long
|
||||||
): GetAudioContentListResponse {
|
): GetAudioContentListResponse {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val isCreator = member.id == creatorId
|
val isCreator = member.id == creatorId
|
||||||
|
|
||||||
if (!isCreator && isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
|
if (!isCreator && isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
|||||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -25,12 +22,8 @@ import org.springframework.transaction.annotation.Propagation
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import org.springframework.transaction.event.TransactionPhase
|
import org.springframework.transaction.event.TransactionPhase
|
||||||
import org.springframework.transaction.event.TransactionalEventListener
|
import org.springframework.transaction.event.TransactionalEventListener
|
||||||
import org.springframework.transaction.support.TransactionSynchronization
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
|
||||||
import org.springframework.util.LinkedMultiValueMap
|
import org.springframework.util.LinkedMultiValueMap
|
||||||
import org.springframework.web.client.RestTemplate
|
import org.springframework.web.client.RestTemplate
|
||||||
import javax.persistence.EntityManager
|
|
||||||
import javax.persistence.LockModeType
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
||||||
@@ -44,16 +37,13 @@ enum class LanguageDetectTargetType {
|
|||||||
SERIES,
|
SERIES,
|
||||||
ORIGINAL_WORK,
|
ORIGINAL_WORK,
|
||||||
|
|
||||||
CREATOR_CONTENT_CATEGORY,
|
CREATOR_CONTENT_CATEGORY
|
||||||
CREATOR_COMMUNITY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class LanguageDetectEvent(
|
class LanguageDetectEvent(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val query: String,
|
val query: String,
|
||||||
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT,
|
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT
|
||||||
val sourceRevision: Long? = null,
|
|
||||||
val targetLanguage: String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PapagoLanguageDetectResponse(
|
data class PapagoLanguageDetectResponse(
|
||||||
@@ -70,10 +60,7 @@ class LanguageDetectListener(
|
|||||||
private val seriesRepository: ContentSeriesRepository,
|
private val seriesRepository: ContentSeriesRepository,
|
||||||
private val originalWorkRepository: OriginalWorkRepository,
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
private val categoryRepository: CategoryRepository,
|
private val categoryRepository: CategoryRepository,
|
||||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
|
||||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
|
||||||
private val entityManager: EntityManager,
|
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@@ -108,7 +95,6 @@ class LanguageDetectListener(
|
|||||||
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
|
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
|
||||||
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
|
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
|
||||||
LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event)
|
LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event)
|
||||||
LanguageDetectTargetType.CREATOR_COMMUNITY -> handleCreatorCommunityLanguageDetect(event)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,53 +366,6 @@ class LanguageDetectListener(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleCreatorCommunityLanguageDetect(event: LanguageDetectEvent) {
|
|
||||||
val sourceRevision = event.sourceRevision ?: return
|
|
||||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(event.id) ?: return
|
|
||||||
if (
|
|
||||||
creatorCommunity.contentRevision != sourceRevision ||
|
|
||||||
creatorCommunity.content != event.query
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!creatorCommunity.languageCode.isNullOrBlank()) {
|
|
||||||
requestTranslationsAfterCommit(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val languageCode = detectLanguageCode(event, event.id) ?: return
|
|
||||||
if (languageCode !in getTranslatableLanguageCodes(null)) return
|
|
||||||
|
|
||||||
val currentCreatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(event.id) ?: return
|
|
||||||
entityManager.refresh(currentCreatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
if (
|
|
||||||
currentCreatorCommunity.contentRevision != sourceRevision ||
|
|
||||||
currentCreatorCommunity.content != event.query
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentCreatorCommunity.languageCode.isNullOrBlank()) {
|
|
||||||
requestTranslationsAfterCommit(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCreatorCommunity.languageCode = languageCode
|
|
||||||
creatorCommunityRepository.save(currentCreatorCommunity)
|
|
||||||
requestTranslationsAfterCommit(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestTranslationsAfterCommit(event: LanguageDetectEvent) {
|
|
||||||
TransactionSynchronizationManager.registerSynchronization(
|
|
||||||
object : TransactionSynchronization {
|
|
||||||
override fun afterCommit() {
|
|
||||||
creatorCommunityTranslationService.requestTranslations(event.id, event.targetLanguage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
|
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
|
||||||
return languageDetectionCacheService.detectWithCache(event.query) {
|
return languageDetectionCacheService.detectWithCache(event.query) {
|
||||||
requestPapagoLanguageCode(event.query, targetIdForLog)
|
requestPapagoLanguageCode(event.query, targetIdForLog)
|
||||||
|
|||||||
@@ -25,22 +25,18 @@ class LanguageDetectionCacheService(
|
|||||||
)
|
)
|
||||||
if (cached != null) return cached.detectedLanguage
|
if (cached != null) return cached.detectedLanguage
|
||||||
|
|
||||||
val detectedLanguage = detector()?.takeIf { it.isNotBlank() }?.lowercase() ?: return null
|
val detectedLanguage = detector()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
languageDetectionResultRepository.insertIfAbsent(
|
languageDetectionResultRepository.save(
|
||||||
sourceHash = sourceHash,
|
LanguageDetectionResult(
|
||||||
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
|
|
||||||
detectedLanguage = detectedLanguage,
|
|
||||||
provider = provider,
|
|
||||||
confidence = null,
|
|
||||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
|
||||||
)
|
|
||||||
return requireNotNull(
|
|
||||||
languageDetectionResultRepository.findDetectedLanguageForUpdate(
|
|
||||||
sourceHash = sourceHash,
|
sourceHash = sourceHash,
|
||||||
|
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
|
||||||
|
detectedLanguage = detectedLanguage.lowercase(),
|
||||||
provider = provider,
|
provider = provider,
|
||||||
|
confidence = null,
|
||||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
return detectedLanguage.lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.content
|
package kr.co.vividnext.sodalive.content
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Modifying
|
|
||||||
import org.springframework.data.jpa.repository.Query
|
|
||||||
import org.springframework.data.repository.query.Param
|
|
||||||
|
|
||||||
interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionResult, Long> {
|
interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionResult, Long> {
|
||||||
fun findBySourceHashAndProviderAndNormalizationVersion(
|
fun findBySourceHashAndProviderAndNormalizationVersion(
|
||||||
@@ -11,55 +8,4 @@ interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionRes
|
|||||||
provider: String,
|
provider: String,
|
||||||
normalizationVersion: String
|
normalizationVersion: String
|
||||||
): LanguageDetectionResult?
|
): LanguageDetectionResult?
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query(
|
|
||||||
value = """
|
|
||||||
INSERT INTO language_detection_result (
|
|
||||||
source_hash,
|
|
||||||
source_text_sample,
|
|
||||||
detected_language,
|
|
||||||
provider,
|
|
||||||
confidence,
|
|
||||||
normalization_version,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
) VALUES (
|
|
||||||
:sourceHash,
|
|
||||||
:sourceTextSample,
|
|
||||||
:detectedLanguage,
|
|
||||||
:provider,
|
|
||||||
:confidence,
|
|
||||||
:normalizationVersion,
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON DUPLICATE KEY UPDATE id = id
|
|
||||||
""",
|
|
||||||
nativeQuery = true
|
|
||||||
)
|
|
||||||
fun insertIfAbsent(
|
|
||||||
@Param("sourceHash") sourceHash: String,
|
|
||||||
@Param("sourceTextSample") sourceTextSample: String,
|
|
||||||
@Param("detectedLanguage") detectedLanguage: String,
|
|
||||||
@Param("provider") provider: String,
|
|
||||||
@Param("confidence") confidence: Double?,
|
|
||||||
@Param("normalizationVersion") normalizationVersion: String
|
|
||||||
): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
value = """
|
|
||||||
SELECT detected_language
|
|
||||||
FROM language_detection_result
|
|
||||||
WHERE source_hash = :sourceHash
|
|
||||||
AND provider = :provider
|
|
||||||
AND normalization_version = :normalizationVersion
|
|
||||||
FOR UPDATE
|
|
||||||
""",
|
|
||||||
nativeQuery = true
|
|
||||||
)
|
|
||||||
fun findDetectedLanguageForUpdate(
|
|
||||||
@Param("sourceHash") sourceHash: String,
|
|
||||||
@Param("provider") provider: String,
|
|
||||||
@Param("normalizationVersion") normalizationVersion: String
|
|
||||||
): String?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import kr.co.vividnext.sodalive.event.EventItem
|
|||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.cache.annotation.Cacheable
|
import org.springframework.cache.annotation.Cacheable
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
@@ -33,8 +33,6 @@ class AudioContentMainService(
|
|||||||
|
|
||||||
private val langContext: LangContext,
|
private val langContext: LangContext,
|
||||||
|
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
@@ -71,7 +69,7 @@ class AudioContentMainService(
|
|||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
},
|
},
|
||||||
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
|
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
offset = pageable.offset,
|
offset = pageable.offset,
|
||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
@@ -90,7 +88,7 @@ class AudioContentMainService(
|
|||||||
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
||||||
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
|
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
|
||||||
*/
|
*/
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val themeListRaw = if (theme.isBlank()) {
|
val themeListRaw = if (theme.isBlank()) {
|
||||||
audioContentThemeRepository.getActiveThemeOfContent(
|
audioContentThemeRepository.getActiveThemeOfContent(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
|
|||||||
+3
-4
@@ -6,7 +6,7 @@ import kr.co.vividnext.sodalive.content.SortType
|
|||||||
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -17,7 +17,6 @@ class AudioContentCurationService(
|
|||||||
private val repository: AudioContentCurationQueryRepository,
|
private val repository: AudioContentCurationQueryRepository,
|
||||||
private val contentRepository: AudioContentRepository,
|
private val contentRepository: AudioContentRepository,
|
||||||
private val blockMemberRepository: BlockMemberRepository,
|
private val blockMemberRepository: BlockMemberRepository,
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
@@ -32,14 +31,14 @@ class AudioContentCurationService(
|
|||||||
): GetCurationContentResponse {
|
): GetCurationContentResponse {
|
||||||
val totalCount = repository.findTotalCountByCurationId(
|
val totalCount = repository.findTotalCountByCurationId(
|
||||||
curationId = curationId,
|
curationId = curationId,
|
||||||
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
|
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
|
|
||||||
val audioContentList = repository.findByCurationId(
|
val audioContentList = repository.findByCurationId(
|
||||||
curationId = curationId,
|
curationId = curationId,
|
||||||
cloudfrontHost = cloudFrontHost,
|
cloudfrontHost = cloudFrontHost,
|
||||||
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
|
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
sortType = sortType,
|
sortType = sortType,
|
||||||
offset = pageable.offset,
|
offset = pageable.offset,
|
||||||
|
|||||||
+4
-5
@@ -8,7 +8,7 @@ import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationQueryR
|
|||||||
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
||||||
import kr.co.vividnext.sodalive.event.EventService
|
import kr.co.vividnext.sodalive.event.EventService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
@@ -21,15 +21,14 @@ class AudioContentMainTabAlarmService(
|
|||||||
private val contentRepository: AudioContentRepository,
|
private val contentRepository: AudioContentRepository,
|
||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val eventService: EventService,
|
private val eventService: EventService,
|
||||||
private val curationRepository: AudioContentCurationQueryRepository,
|
private val curationRepository: AudioContentCurationQueryRepository
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService
|
|
||||||
) {
|
) {
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member
|
member: Member
|
||||||
): GetContentMainTabAlarmResponse {
|
): GetContentMainTabAlarmResponse {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val memberId = member.id!!
|
val memberId = member.id!!
|
||||||
|
|
||||||
val contentBannerList = bannerService.getBannerList(
|
val contentBannerList = bannerService.getBannerList(
|
||||||
@@ -107,7 +106,7 @@ class AudioContentMainTabAlarmService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val memberId = member.id!!
|
val memberId = member.id!!
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
|
|
||||||
val totalCount = contentRepository.totalAlarmCountByTheme(
|
val totalCount = contentRepository.totalAlarmCountByTheme(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
|
|||||||
+3
-4
@@ -9,7 +9,7 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTabRepository
|
|||||||
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
||||||
import kr.co.vividnext.sodalive.event.EventService
|
import kr.co.vividnext.sodalive.event.EventService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@@ -20,15 +20,14 @@ class AudioContentMainTabAsmrService(
|
|||||||
private val contentRepository: AudioContentRepository,
|
private val contentRepository: AudioContentRepository,
|
||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val eventService: EventService,
|
private val eventService: EventService,
|
||||||
private val curationRepository: AudioContentCurationQueryRepository,
|
private val curationRepository: AudioContentCurationQueryRepository
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService
|
|
||||||
) {
|
) {
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member
|
member: Member
|
||||||
): GetContentMainTabAsmrResponse {
|
): GetContentMainTabAsmrResponse {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val memberId = member.id!!
|
val memberId = member.id!!
|
||||||
val theme = "ASMR"
|
val theme = "ASMR"
|
||||||
val tabId = 5L
|
val tabId = 5L
|
||||||
|
|||||||
+4
-5
@@ -10,7 +10,7 @@ import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
|||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
import kr.co.vividnext.sodalive.event.EventService
|
import kr.co.vividnext.sodalive.event.EventService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -23,8 +23,7 @@ class AudioContentMainTabContentService(
|
|||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val eventService: EventService,
|
private val eventService: EventService,
|
||||||
private val tagCurationService: ContentMainTabTagCurationService,
|
private val tagCurationService: ContentMainTabTagCurationService,
|
||||||
private val curationRepository: AudioContentCurationQueryRepository,
|
private val curationRepository: AudioContentCurationQueryRepository
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService
|
|
||||||
) {
|
) {
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
@@ -32,7 +31,7 @@ class AudioContentMainTabContentService(
|
|||||||
member: Member
|
member: Member
|
||||||
): GetContentMainTabContentResponse {
|
): GetContentMainTabContentResponse {
|
||||||
val memberId = member.id!!
|
val memberId = member.id!!
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val tabId = 3L
|
val tabId = 3L
|
||||||
|
|
||||||
// 단편 배너
|
// 단편 배너
|
||||||
@@ -192,7 +191,7 @@ class AudioContentMainTabContentService(
|
|||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member
|
member: Member
|
||||||
): List<GetAudioContentMainItem> {
|
): List<GetAudioContentMainItem> {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
|
|
||||||
val themeList = if (theme.isBlank()) {
|
val themeList = if (theme.isBlank()) {
|
||||||
audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
|
audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
|
||||||
|
|||||||
+6
-7
@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
|||||||
import kr.co.vividnext.sodalive.content.main.tab.RecommendSeriesRepository
|
import kr.co.vividnext.sodalive.content.main.tab.RecommendSeriesRepository
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@@ -24,15 +24,14 @@ class AudioContentMainTabFreeService(
|
|||||||
private val contentRepository: AudioContentRepository,
|
private val contentRepository: AudioContentRepository,
|
||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val audioContentRepository: AudioContentRepository,
|
private val audioContentRepository: AudioContentRepository,
|
||||||
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
private val audioContentThemeRepository: AudioContentThemeQueryRepository
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService
|
|
||||||
) {
|
) {
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member
|
member: Member
|
||||||
): GetContentMainTabFreeResponse {
|
): GetContentMainTabFreeResponse {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val memberId = member.id!!
|
val memberId = member.id!!
|
||||||
val tabId = 7L
|
val tabId = 7L
|
||||||
|
|
||||||
@@ -136,7 +135,7 @@ class AudioContentMainTabFreeService(
|
|||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Long
|
limit: Long
|
||||||
): List<GetAudioContentMainItem> {
|
): List<GetAudioContentMainItem> {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val memberId = member.id!!
|
val memberId = member.id!!
|
||||||
|
|
||||||
val introduceCreatorCuration = curationRepository.findByContentMainTabIdAndTitle(
|
val introduceCreatorCuration = curationRepository.findByContentMainTabIdAndTitle(
|
||||||
@@ -173,7 +172,7 @@ class AudioContentMainTabFreeService(
|
|||||||
listOf(theme)
|
listOf(theme)
|
||||||
} else {
|
} else {
|
||||||
audioContentThemeRepository.getActiveThemeOfContent(
|
audioContentThemeRepository.getActiveThemeOfContent(
|
||||||
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
|
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
|
||||||
isFree = true,
|
isFree = true,
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
).filter {
|
).filter {
|
||||||
@@ -187,7 +186,7 @@ class AudioContentMainTabFreeService(
|
|||||||
it != "자기소개"
|
it != "자기소개"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
|
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
|
|||||||
+4
-9
@@ -5,7 +5,7 @@ import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
|||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
||||||
import kr.co.vividnext.sodalive.event.EventService
|
import kr.co.vividnext.sodalive.event.EventService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.notice.ServiceNoticeService
|
import kr.co.vividnext.sodalive.notice.ServiceNoticeService
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -19,8 +19,7 @@ class AudioContentMainTabHomeService(
|
|||||||
private val noticeService: ServiceNoticeService,
|
private val noticeService: ServiceNoticeService,
|
||||||
private val bannerService: AudioContentBannerService,
|
private val bannerService: AudioContentBannerService,
|
||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val eventService: EventService,
|
private val eventService: EventService
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService
|
|
||||||
) {
|
) {
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
@@ -44,9 +43,7 @@ class AudioContentMainTabHomeService(
|
|||||||
val formattedLastMonday = startDate.format(startDateFormatter)
|
val formattedLastMonday = startDate.format(startDateFormatter)
|
||||||
val formattedLastSunday = endDate.format(endDateFormatter)
|
val formattedLastSunday = endDate.format(endDateFormatter)
|
||||||
|
|
||||||
val isAdult = member
|
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
|
||||||
?.let { memberContentPreferenceService.isAdultVisibleForQuery(it, isAdultContentVisible) }
|
|
||||||
?: false
|
|
||||||
|
|
||||||
// 최근 공지사항
|
// 최근 공지사항
|
||||||
val latestNotice = noticeService.getLatestNotice()
|
val latestNotice = noticeService.getLatestNotice()
|
||||||
@@ -134,9 +131,7 @@ class AudioContentMainTabHomeService(
|
|||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member?
|
member: Member?
|
||||||
): List<GetAudioContentRankingItem> {
|
): List<GetAudioContentRankingItem> {
|
||||||
val isAdult = member
|
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
|
||||||
?.let { memberContentPreferenceService.isAdultVisibleForQuery(it, isAdultContentVisible) }
|
|
||||||
?: false
|
|
||||||
|
|
||||||
val currentDateTime = LocalDateTime.now()
|
val currentDateTime = LocalDateTime.now()
|
||||||
val startDate = currentDateTime
|
val startDate = currentDateTime
|
||||||
|
|||||||
+3
-4
@@ -9,7 +9,7 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTabRepository
|
|||||||
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
||||||
import kr.co.vividnext.sodalive.event.EventService
|
import kr.co.vividnext.sodalive.event.EventService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@@ -20,15 +20,14 @@ class AudioContentMainTabLiveReplayService(
|
|||||||
private val contentRepository: AudioContentRepository,
|
private val contentRepository: AudioContentRepository,
|
||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val eventService: EventService,
|
private val eventService: EventService,
|
||||||
private val curationRepository: AudioContentCurationQueryRepository,
|
private val curationRepository: AudioContentCurationQueryRepository
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService
|
|
||||||
) {
|
) {
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member
|
member: Member
|
||||||
): GetContentMainTabLiveReplayResponse {
|
): GetContentMainTabLiveReplayResponse {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val memberId = member.id!!
|
val memberId = member.id!!
|
||||||
val theme = "다시듣기"
|
val theme = "다시듣기"
|
||||||
val tabId = 6L
|
val tabId = 6L
|
||||||
|
|||||||
+3
-4
@@ -8,7 +8,7 @@ import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
|||||||
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||||
import kr.co.vividnext.sodalive.event.EventService
|
import kr.co.vividnext.sodalive.event.EventService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
@@ -24,15 +24,14 @@ class AudioContentMainTabSeriesService(
|
|||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val recommendSeriesRepository: RecommendSeriesRepository,
|
private val recommendSeriesRepository: RecommendSeriesRepository,
|
||||||
private val eventService: EventService,
|
private val eventService: EventService,
|
||||||
private val curationRepository: AudioContentCurationQueryRepository,
|
private val curationRepository: AudioContentCurationQueryRepository
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService
|
|
||||||
) {
|
) {
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member
|
member: Member
|
||||||
): GetContentMainTabSeriesResponse {
|
): GetContentMainTabSeriesResponse {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
val memberId = member.id!!
|
val memberId = member.id!!
|
||||||
|
|
||||||
// 메인 배너 (시리즈)
|
// 메인 배너 (시리즈)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
|||||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -40,7 +40,6 @@ class ContentSeriesService(
|
|||||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||||
private val contentTranslationRepository: ContentTranslationRepository,
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val coverImageHost: String
|
private val coverImageHost: String
|
||||||
@@ -148,7 +147,7 @@ class ContentSeriesService(
|
|||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Long = 20
|
limit: Long = 20
|
||||||
): GetSeriesListResponse {
|
): GetSeriesListResponse {
|
||||||
val isAuth = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
|
|
||||||
val totalCount = repository.getSeriesTotalCount(
|
val totalCount = repository.getSeriesTotalCount(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
@@ -186,7 +185,7 @@ class ContentSeriesService(
|
|||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Long = 20
|
limit: Long = 20
|
||||||
): GetSeriesListResponse {
|
): GetSeriesListResponse {
|
||||||
val isAuth = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
|
|
||||||
val totalCount = repository.getSeriesByGenreTotalCount(
|
val totalCount = repository.getSeriesByGenreTotalCount(
|
||||||
genreId = genreId,
|
genreId = genreId,
|
||||||
@@ -220,7 +219,7 @@ class ContentSeriesService(
|
|||||||
): GetSeriesDetailResponse {
|
): GetSeriesDetailResponse {
|
||||||
val series = repository.getSeriesDetail(
|
val series = repository.getSeriesDetail(
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
isAuth = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
|
isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible),
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
) ?: throw SodaException(messageKey = "series.error.invalid_series_retry")
|
) ?: throw SodaException(messageKey = "series.error.invalid_series_retry")
|
||||||
|
|
||||||
@@ -365,7 +364,7 @@ class ContentSeriesService(
|
|||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Long
|
limit: Long
|
||||||
): GetSeriesContentListResponse {
|
): GetSeriesContentListResponse {
|
||||||
val isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
|
|
||||||
val totalCount = seriesContentRepository.getContentCount(seriesId, isAdult = isAdult, contentType = contentType)
|
val totalCount = seriesContentRepository.getContentCount(seriesId, isAdult = isAdult, contentType = contentType)
|
||||||
val contentList = seriesContentRepository.getContentList(
|
val contentList = seriesContentRepository.getContentList(
|
||||||
@@ -428,7 +427,7 @@ class ContentSeriesService(
|
|||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member
|
member: Member
|
||||||
): List<GetSeriesListResponse.SeriesListItem> {
|
): List<GetSeriesListResponse.SeriesListItem> {
|
||||||
val isAuth = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)
|
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
|
||||||
return repository.getRecommendSeriesListV2(
|
return repository.getRecommendSeriesListV2(
|
||||||
imageHost = coverImageHost,
|
imageHost = coverImageHost,
|
||||||
isAuth = isAuth,
|
isAuth = isAuth,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
|
|||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
@@ -22,8 +22,7 @@ class AudioContentThemeService(
|
|||||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
|
|
||||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||||
private val langContext: LangContext,
|
private val langContext: LangContext
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService
|
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getThemes(): List<GetAudioContentThemeResponse> {
|
fun getThemes(): List<GetAudioContentThemeResponse> {
|
||||||
@@ -101,7 +100,7 @@ class AudioContentThemeService(
|
|||||||
val totalCount = contentRepository.totalCountByTheme(
|
val totalCount = contentRepository.totalCountByTheme(
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
theme = listOf(theme.theme),
|
theme = listOf(theme.theme),
|
||||||
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
|
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ class AudioContentThemeService(
|
|||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
theme = listOf(theme.theme),
|
theme = listOf(theme.theme),
|
||||||
sortType = sortType,
|
sortType = sortType,
|
||||||
isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible),
|
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
limit = limit
|
limit = limit
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.deployment
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint
|
|
||||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@Endpoint(id = "deployment")
|
|
||||||
class DeploymentEndpoint(
|
|
||||||
@Value("\${deployment.artifact-version}")
|
|
||||||
private val artifactVersion: String,
|
|
||||||
@Value("\${deployment.config-commit}")
|
|
||||||
private val configCommit: String
|
|
||||||
) {
|
|
||||||
@ReadOperation
|
|
||||||
fun deployment(): Map<String, String> = mapOf(
|
|
||||||
"application_artifact_version" to artifactVersion,
|
|
||||||
"config_commit" to configCommit
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+1
-5
@@ -24,11 +24,7 @@ class CreatorCommunity(
|
|||||||
var isActive: Boolean = true,
|
var isActive: Boolean = true,
|
||||||
var isFixed: Boolean = false,
|
var isFixed: Boolean = false,
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var fixedAt: LocalDateTime? = null,
|
var fixedAt: LocalDateTime? = null
|
||||||
@Column(name = "language_code", nullable = true, length = 10)
|
|
||||||
var languageCode: String? = null,
|
|
||||||
@Column(name = "content_revision", nullable = false)
|
|
||||||
var contentRevision: Long = 0
|
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
|
|||||||
-17
@@ -7,29 +7,12 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun
|
|||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
|
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Lock
|
|
||||||
import org.springframework.data.jpa.repository.Query
|
|
||||||
import org.springframework.data.repository.query.Param
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import javax.persistence.LockModeType
|
|
||||||
|
|
||||||
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository {
|
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository {
|
||||||
fun countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(memberId: Long): Long
|
fun countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(memberId: Long): Long
|
||||||
|
|
||||||
fun findByIdAndIsActiveTrue(postId: Long): CreatorCommunity?
|
|
||||||
|
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
@Query("select c from CreatorCommunity c where c.id = :postId and c.isActive = true")
|
|
||||||
fun findByIdAndIsActiveTrueForUpdate(@Param("postId") postId: Long): CreatorCommunity?
|
|
||||||
|
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
@Query("select c from CreatorCommunity c where c.id = :postId and c.member.id = :memberId")
|
|
||||||
fun findByIdAndMemberIdForUpdate(
|
|
||||||
@Param("postId") postId: Long,
|
|
||||||
@Param("memberId") memberId: Long
|
|
||||||
): CreatorCommunity?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreatorCommunityQueryRepository {
|
interface CreatorCommunityQueryRepository {
|
||||||
|
|||||||
+10
-37
@@ -27,7 +27,6 @@ import kr.co.vividnext.sodalive.member.Member
|
|||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import kr.co.vividnext.sodalive.utils.validateImage
|
import kr.co.vividnext.sodalive.utils.validateImage
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
@@ -40,8 +39,6 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
|||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import javax.persistence.EntityManager
|
|
||||||
import javax.persistence.LockModeType
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -61,8 +58,6 @@ class CreatorCommunityService(
|
|||||||
private val messageSource: SodaMessageSource,
|
private val messageSource: SodaMessageSource,
|
||||||
private val langContext: LangContext,
|
private val langContext: LangContext,
|
||||||
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
||||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
|
||||||
private val entityManager: EntityManager,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val imageBucket: String,
|
private val imageBucket: String,
|
||||||
@@ -149,7 +144,6 @@ class CreatorCommunityService(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
publishCommunityPostCreatedAfterCommit(post, member)
|
publishCommunityPostCreatedAfterCommit(post, member)
|
||||||
requestTranslationsAfterCommit(post.id!!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) {
|
private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) {
|
||||||
@@ -172,12 +166,6 @@ class CreatorCommunityService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestTranslationsAfterCommit(postId: Long) {
|
|
||||||
afterCommit {
|
|
||||||
creatorCommunityTranslationService.requestTranslations(postId, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun CreatorCommunity.newsContentPreview(): String {
|
private fun CreatorCommunity.newsContentPreview(): String {
|
||||||
if (price <= 0) {
|
if (price <= 0) {
|
||||||
return content
|
return content
|
||||||
@@ -211,17 +199,13 @@ class CreatorCommunityService(
|
|||||||
fun modifyCommunityPost(postImage: MultipartFile?, requestString: String, member: Member) {
|
fun modifyCommunityPost(postImage: MultipartFile?, requestString: String, member: Member) {
|
||||||
val request = objectMapper.readValue(requestString, ModifyCommunityPostRequest::class.java)
|
val request = objectMapper.readValue(requestString, ModifyCommunityPostRequest::class.java)
|
||||||
|
|
||||||
val post = repository.findByIdAndMemberIdForUpdate(postId = request.creatorCommunityId, memberId = member.id!!)
|
val post = repository.findByIdAndMemberId(id = request.creatorCommunityId, memberId = member.id!!)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
entityManager.refresh(post, LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
|
|
||||||
postImage?.let { validateImage(it, post.price > 0) }
|
postImage?.let { validateImage(it, post.price > 0) }
|
||||||
|
|
||||||
if (request.content != null && request.content != post.content) {
|
if (request.content != null) {
|
||||||
post.content = request.content
|
post.content = request.content
|
||||||
post.languageCode = null
|
|
||||||
post.contentRevision += 1
|
|
||||||
requestTranslationsAfterCommit(post.id!!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.isCommentAvailable != null) {
|
if (request.isCommentAvailable != null) {
|
||||||
@@ -262,9 +246,8 @@ class CreatorCommunityService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest, member: Member) {
|
fun updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest, member: Member) {
|
||||||
val post = repository.findByIdAndMemberIdForUpdate(postId = request.postId, memberId = member.id!!)
|
val post = repository.findByIdAndMemberId(id = request.postId, memberId = member.id!!)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
entityManager.refresh(post, LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
|
|
||||||
if (request.isFixed) {
|
if (request.isFixed) {
|
||||||
if (!post.isFixed) {
|
if (!post.isFixed) {
|
||||||
@@ -302,8 +285,6 @@ class CreatorCommunityService(
|
|||||||
limit = limit,
|
limit = limit,
|
||||||
isAdult = isAdult
|
isAdult = isAdult
|
||||||
)
|
)
|
||||||
val displayContents = creatorCommunityTranslationService
|
|
||||||
.findDisplayContents(postList.map { it.id }, langContext.lang.code)
|
|
||||||
|
|
||||||
return postList
|
return postList
|
||||||
.map {
|
.map {
|
||||||
@@ -356,7 +337,7 @@ class CreatorCommunityService(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse(
|
it.toCommunityPostListResponse(
|
||||||
imageHost = imageHost,
|
imageHost = imageHost,
|
||||||
audioUrl = audioUrl,
|
audioUrl = audioUrl,
|
||||||
date = it.date.getTimeAgoString(),
|
date = it.date.getTimeAgoString(),
|
||||||
@@ -395,12 +376,6 @@ class CreatorCommunityService(
|
|||||||
throw SodaException(message = String.format(messageTemplate, post.creatorNickname))
|
throw SodaException(message = String.format(messageTemplate, post.creatorNickname))
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayContent = creatorCommunityTranslationService
|
|
||||||
.findDisplayContents(listOf(post.id), langContext.lang.code)[post.id]
|
|
||||||
if (displayContent?.hasValidTranslation != true) {
|
|
||||||
creatorCommunityTranslationService.requestTranslations(post.id, langContext.lang.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isLike = likeRepository.findByPostIdAndMemberId(postId = post.id, memberId = memberId)?.isActive ?: false
|
val isLike = likeRepository.findByPostIdAndMemberId(postId = post.id, memberId = memberId)?.isActive ?: false
|
||||||
val likeCount = likeRepository.totalCountCommunityPostLikeByPostId(post.id)
|
val likeCount = likeRepository.totalCountCommunityPostLikeByPostId(post.id)
|
||||||
val commentCount = if (post.isCommentAvailable) {
|
val commentCount = if (post.isCommentAvailable) {
|
||||||
@@ -442,7 +417,7 @@ class CreatorCommunityService(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
return post.copy(content = displayContent?.content ?: post.content).toCommunityPostListResponse(
|
return post.toCommunityPostListResponse(
|
||||||
imageHost = imageHost,
|
imageHost = imageHost,
|
||||||
audioUrl = audioUrl,
|
audioUrl = audioUrl,
|
||||||
date = post.date.getTimeAgoString(),
|
date = post.date.getTimeAgoString(),
|
||||||
@@ -635,13 +610,11 @@ class CreatorCommunityService(
|
|||||||
isAdult: Boolean
|
isAdult: Boolean
|
||||||
): List<GetCommunityPostListResponse> {
|
): List<GetCommunityPostListResponse> {
|
||||||
val postList = repository.getLatestPostListFromCreatorsYouFollow(memberId = memberId, isAdult = isAdult)
|
val postList = repository.getLatestPostListFromCreatorsYouFollow(memberId = memberId, isAdult = isAdult)
|
||||||
val visiblePostList = postList.filter {
|
|
||||||
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
|
|
||||||
}
|
|
||||||
val displayContents = creatorCommunityTranslationService
|
|
||||||
.findDisplayContents(visiblePostList.map { it.id }, langContext.lang.code)
|
|
||||||
|
|
||||||
return visiblePostList
|
return postList
|
||||||
|
.filter {
|
||||||
|
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
|
||||||
|
}
|
||||||
.map {
|
.map {
|
||||||
val isLike =
|
val isLike =
|
||||||
likeRepository.findByPostIdAndMemberId(postId = it.id, memberId = memberId)?.isActive ?: false
|
likeRepository.findByPostIdAndMemberId(postId = it.id, memberId = memberId)?.isActive ?: false
|
||||||
@@ -676,7 +649,7 @@ class CreatorCommunityService(
|
|||||||
|
|
||||||
val existOrdered = useCanRepository.isExistCommunityPostOrdered(postId = it.id, memberId = memberId)
|
val existOrdered = useCanRepository.isExistCommunityPostOrdered(postId = it.id, memberId = memberId)
|
||||||
|
|
||||||
it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse(
|
it.toCommunityPostListResponse(
|
||||||
imageHost = imageHost,
|
imageHost = imageHost,
|
||||||
audioUrl = null,
|
audioUrl = null,
|
||||||
date = it.date.getTimeAgoString(),
|
date = it.date.getTimeAgoString(),
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ enum class FcmDeepLinkValue(val value: String) {
|
|||||||
SERIES("series"),
|
SERIES("series"),
|
||||||
AUDITION("audition"),
|
AUDITION("audition"),
|
||||||
COMMUNITY("community"),
|
COMMUNITY("community"),
|
||||||
CHAT("chat"),
|
CHAT("chat")
|
||||||
GIFT_DETAIL("gift-detail")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FcmEvent(
|
class FcmEvent(
|
||||||
@@ -51,7 +50,7 @@ class FcmEvent(
|
|||||||
val creatorId: Long? = null,
|
val creatorId: Long? = null,
|
||||||
val auditionId: Long? = null,
|
val auditionId: Long? = null,
|
||||||
val deepLinkValue: FcmDeepLinkValue? = null,
|
val deepLinkValue: FcmDeepLinkValue? = null,
|
||||||
val deepLinkId: Any? = null,
|
val deepLinkId: Long? = null,
|
||||||
val deepLinkCommentPostId: Long? = null,
|
val deepLinkCommentPostId: Long? = null,
|
||||||
val commentParentId: Long? = null,
|
val commentParentId: Long? = null,
|
||||||
val myMemberId: Long? = null,
|
val myMemberId: Long? = null,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class FcmService(
|
|||||||
creatorId: Long? = null,
|
creatorId: Long? = null,
|
||||||
auditionId: Long? = null,
|
auditionId: Long? = null,
|
||||||
deepLinkValue: FcmDeepLinkValue? = null,
|
deepLinkValue: FcmDeepLinkValue? = null,
|
||||||
deepLinkId: Any? = null,
|
deepLinkId: Long? = null,
|
||||||
deepLinkCommentPostId: Long? = null,
|
deepLinkCommentPostId: Long? = null,
|
||||||
chatType: String? = null
|
chatType: String? = null
|
||||||
) {
|
) {
|
||||||
@@ -118,7 +118,7 @@ class FcmService(
|
|||||||
|
|
||||||
private fun createDeepLink(
|
private fun createDeepLink(
|
||||||
deepLinkValue: FcmDeepLinkValue?,
|
deepLinkValue: FcmDeepLinkValue?,
|
||||||
deepLinkId: Any?,
|
deepLinkId: Long?,
|
||||||
deepLinkCommentPostId: Long?
|
deepLinkCommentPostId: Long?
|
||||||
): String? {
|
): String? {
|
||||||
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)
|
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)
|
||||||
@@ -194,7 +194,7 @@ class FcmService(
|
|||||||
fun buildDeepLink(
|
fun buildDeepLink(
|
||||||
serverEnv: String,
|
serverEnv: String,
|
||||||
deepLinkValue: FcmDeepLinkValue?,
|
deepLinkValue: FcmDeepLinkValue?,
|
||||||
deepLinkId: Any?,
|
deepLinkId: Long?,
|
||||||
deepLinkCommentPostId: Long? = null
|
deepLinkCommentPostId: Long? = null
|
||||||
): String? {
|
): String? {
|
||||||
if (deepLinkValue == null || deepLinkId == null) {
|
if (deepLinkValue == null || deepLinkId == null) {
|
||||||
@@ -222,7 +222,7 @@ class FcmService(
|
|||||||
creatorId: Long? = null,
|
creatorId: Long? = null,
|
||||||
auditionId: Long? = null,
|
auditionId: Long? = null,
|
||||||
deepLinkValue: FcmDeepLinkValue? = null,
|
deepLinkValue: FcmDeepLinkValue? = null,
|
||||||
deepLinkId: Any? = null,
|
deepLinkId: Long? = null,
|
||||||
deepLinkCommentPostId: Long? = null,
|
deepLinkCommentPostId: Long? = null,
|
||||||
deepLink: String? = null,
|
deepLink: String? = null,
|
||||||
chatType: String? = null
|
chatType: String? = null
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ enum class PushNotificationCategory(val code: String) {
|
|||||||
COMMUNITY("community"),
|
COMMUNITY("community"),
|
||||||
MESSAGE("message"),
|
MESSAGE("message"),
|
||||||
AUDITION("audition"),
|
AUDITION("audition"),
|
||||||
GIFT("gift"),
|
|
||||||
SYSTEM("system");
|
SYSTEM("system");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+1
-2
@@ -18,8 +18,7 @@ enum class LanguageTranslationTargetType {
|
|||||||
|
|
||||||
ORIGINAL_WORK,
|
ORIGINAL_WORK,
|
||||||
|
|
||||||
CREATOR_CONTENT_CATEGORY,
|
CREATOR_CONTENT_CATEGORY
|
||||||
CREATOR_COMMUNITY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class LanguageTranslationEvent(
|
class LanguageTranslationEvent(
|
||||||
|
|||||||
+1
-53
@@ -18,13 +18,8 @@ import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslatio
|
|||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import javax.persistence.EntityManager
|
|
||||||
import javax.persistence.LockModeType
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class TranslationReadModelMaterializer(
|
class TranslationReadModelMaterializer(
|
||||||
@@ -36,10 +31,7 @@ class TranslationReadModelMaterializer(
|
|||||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||||
private val categoryTranslationRepository: CategoryTranslationRepository,
|
private val categoryTranslationRepository: CategoryTranslationRepository
|
||||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
|
||||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
|
||||||
private val entityManager: EntityManager
|
|
||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
|
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
|
||||||
@@ -54,9 +46,6 @@ class TranslationReadModelMaterializer(
|
|||||||
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
|
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
|
||||||
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
|
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
|
||||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
|
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
|
||||||
LanguageTranslationTargetType.CREATOR_COMMUNITY -> {
|
|
||||||
return upsertCreatorCommunity(source, targetLanguage.lowercase(), translations)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -191,47 +180,6 @@ class TranslationReadModelMaterializer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun upsertCreatorCommunity(
|
|
||||||
source: TranslationSource,
|
|
||||||
targetLanguage: String,
|
|
||||||
translations: Map<String, String>
|
|
||||||
): Boolean {
|
|
||||||
val sourceRevision = source.sourceRevision ?: return false
|
|
||||||
val sourceHash = SourceTextNormalizer.hash(source.fields.single().sourceText)
|
|
||||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(source.resourceId) ?: return false
|
|
||||||
entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
if (
|
|
||||||
!creatorCommunity.isActive ||
|
|
||||||
creatorCommunity.contentRevision != sourceRevision ||
|
|
||||||
creatorCommunity.languageCode?.lowercase() != source.sourceLanguage.lowercase() ||
|
|
||||||
SourceTextNormalizer.hash(creatorCommunity.content) != sourceHash
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val existing = creatorCommunityTranslationRepository
|
|
||||||
.findByCreatorCommunityIdAndLocaleForUpdate(source.resourceId, targetLanguage)
|
|
||||||
if (existing == null) {
|
|
||||||
creatorCommunityTranslationRepository.save(
|
|
||||||
CreatorCommunityTranslation(
|
|
||||||
creatorCommunityId = source.resourceId,
|
|
||||||
locale = targetLanguage,
|
|
||||||
content = translations["content"].orEmpty(),
|
|
||||||
sourceRevision = sourceRevision,
|
|
||||||
sourceHash = sourceHash,
|
|
||||||
sourceLanguage = source.sourceLanguage.lowercase()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
existing.content = translations["content"].orEmpty()
|
|
||||||
existing.sourceRevision = sourceRevision
|
|
||||||
existing.sourceHash = sourceHash
|
|
||||||
existing.sourceLanguage = source.sourceLanguage.lowercase()
|
|
||||||
creatorCommunityTranslationRepository.save(existing)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_PROVIDER = "papago"
|
const val DEFAULT_PROVIDER = "papago"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-18
@@ -7,7 +7,6 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
|||||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
@@ -20,8 +19,7 @@ data class TranslationSource(
|
|||||||
val resourceType: LanguageTranslationTargetType,
|
val resourceType: LanguageTranslationTargetType,
|
||||||
val resourceId: Long,
|
val resourceId: Long,
|
||||||
val sourceLanguage: String,
|
val sourceLanguage: String,
|
||||||
val fields: List<TranslationSourceField>,
|
val fields: List<TranslationSourceField>
|
||||||
val sourceRevision: Long? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -32,8 +30,7 @@ class TranslationSourceExtractor(
|
|||||||
private val seriesRepository: AdminContentSeriesRepository,
|
private val seriesRepository: AdminContentSeriesRepository,
|
||||||
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
|
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
|
||||||
private val originalWorkRepository: OriginalWorkRepository,
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
private val categoryRepository: CategoryRepository,
|
private val categoryRepository: CategoryRepository
|
||||||
private val creatorCommunityRepository: CreatorCommunityRepository
|
|
||||||
) {
|
) {
|
||||||
fun extract(resourceType: LanguageTranslationTargetType, resourceId: Long): TranslationSource? {
|
fun extract(resourceType: LanguageTranslationTargetType, resourceId: Long): TranslationSource? {
|
||||||
return when (resourceType) {
|
return when (resourceType) {
|
||||||
@@ -44,7 +41,6 @@ class TranslationSourceExtractor(
|
|||||||
LanguageTranslationTargetType.SERIES_GENRE -> extractSeriesGenre(resourceId)
|
LanguageTranslationTargetType.SERIES_GENRE -> extractSeriesGenre(resourceId)
|
||||||
LanguageTranslationTargetType.ORIGINAL_WORK -> extractOriginalWork(resourceId)
|
LanguageTranslationTargetType.ORIGINAL_WORK -> extractOriginalWork(resourceId)
|
||||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> extractCategory(resourceId)
|
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> extractCategory(resourceId)
|
||||||
LanguageTranslationTargetType.CREATOR_COMMUNITY -> extractCreatorCommunity(resourceId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,16 +152,4 @@ class TranslationSourceExtractor(
|
|||||||
fields = listOf(TranslationSourceField("category", category.title))
|
fields = listOf(TranslationSourceField("category", category.title))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractCreatorCommunity(resourceId: Long): TranslationSource? {
|
|
||||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(resourceId) ?: return null
|
|
||||||
val sourceLanguage = creatorCommunity.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
|
||||||
return TranslationSource(
|
|
||||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
|
||||||
resourceId = resourceId,
|
|
||||||
sourceLanguage = sourceLanguage,
|
|
||||||
fields = listOf(TranslationSourceField("content", creatorCommunity.content)),
|
|
||||||
sourceRevision = creatorCommunity.contentRevision
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -812,14 +812,6 @@ class LiveRoomService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
member.role == MemberRole.CREATOR &&
|
|
||||||
room.member!!.id!! != member.id!! &&
|
|
||||||
!room.isAvailableJoinCreator
|
|
||||||
) {
|
|
||||||
throw SodaException(messageKey = "live.room.not_found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
|
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
|
||||||
throw SodaException(messageKey = "live.room.gender_restricted")
|
throw SodaException(messageKey = "live.room.gender_restricted")
|
||||||
}
|
}
|
||||||
@@ -983,18 +975,6 @@ class LiveRoomService(
|
|||||||
throw SodaException(messageKey = "live.room.info_not_found")
|
throw SodaException(messageKey = "live.room.info_not_found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
member.role == MemberRole.CREATOR &&
|
|
||||||
room.member!!.id!! != member.id!! &&
|
|
||||||
!room.isAvailableJoinCreator
|
|
||||||
) {
|
|
||||||
throw SodaException(messageKey = "live.room.not_found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
|
|
||||||
throw SodaException(messageKey = "live.room.gender_restricted")
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentTimeStamp = Date().time
|
val currentTimeStamp = Date().time
|
||||||
val expireTimestamp = (currentTimeStamp + (60 * 60 * 24 * 1000)) / 1000
|
val expireTimestamp = (currentTimeStamp + (60 * 60 * 24 * 1000)) / 1000
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.member.contentpreference
|
|||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
|
||||||
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
|
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
|
||||||
private val FORCED_JP_MEMBER_IDS = setOf(29721L, 32050L, 37543L, 40850L)
|
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L)
|
||||||
|
|
||||||
fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String {
|
fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String {
|
||||||
val memberId = member?.id
|
val memberId = member?.id
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package kr.co.vividnext.sodalive.member.contentpreference
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes
|
||||||
|
|
||||||
|
@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")
|
||||||
|
fun resolveCountryCodeByPolicy(member: Member): String {
|
||||||
|
val requestAttributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes
|
||||||
|
val requestCountryCode = requestAttributes?.request?.getHeader("CloudFront-Viewer-Country")
|
||||||
|
return resolveCountryCodeWithForcedMapping(member, requestCountryCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")
|
||||||
|
fun isAdultVisibleByPolicy(member: Member, isAdultContentVisible: Boolean): Boolean {
|
||||||
|
return if (resolveCountryCodeByPolicy(member) == "KR") {
|
||||||
|
member.auth != null && isAdultContentVisible
|
||||||
|
} else {
|
||||||
|
isAdultContentVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
-8
@@ -157,14 +157,6 @@ class MemberContentPreferenceService(
|
|||||||
return getStoredPreference(member).isAdult
|
return getStoredPreference(member).isAdult
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAdultVisibleForQuery(member: Member, isAdultContentVisible: Boolean): Boolean {
|
|
||||||
return calculateIsAdultForQuery(
|
|
||||||
member = member,
|
|
||||||
countryCode = resolveCountryCode(member),
|
|
||||||
isAdultContentVisible = isAdultContentVisible
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resolveCountryCode(member: Member?): String {
|
fun resolveCountryCode(member: Member?): String {
|
||||||
if (member != null) requireMemberId(member)
|
if (member != null) requireMemberId(member)
|
||||||
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
|
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.menu
|
package kr.co.vividnext.sodalive.menu
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -9,20 +8,6 @@ class MenuService(
|
|||||||
private val repository: MenuRepository
|
private val repository: MenuRepository
|
||||||
) {
|
) {
|
||||||
fun getMenus(member: Member): List<GetMenuResponse> {
|
fun getMenus(member: Member): List<GetMenuResponse> {
|
||||||
val menus = repository.getMenu(member.role)
|
return repository.getMenu(member.role)
|
||||||
if (member.role != MemberRole.ADMIN) return menus
|
|
||||||
|
|
||||||
return menus + giftManagementMenu
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val giftManagementMenu = GetMenuResponse(
|
|
||||||
title = "선물함 관리",
|
|
||||||
items = listOf(
|
|
||||||
GetMenuResponse(title = "선물함 리스트", route = "/gift/list"),
|
|
||||||
GetMenuResponse(title = "선물 카테고리", route = "/gift/category"),
|
|
||||||
GetMenuResponse(title = "선물 사이즈", route = "/gift/size")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-106
@@ -1,106 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftListResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxRequest
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryService
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/v2/admin")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
class AdminGiftController(
|
|
||||||
private val service: GiftAdminService,
|
|
||||||
private val queryService: GiftAdminQueryService
|
|
||||||
) {
|
|
||||||
@GetMapping("/gifts")
|
|
||||||
fun getGifts(
|
|
||||||
@RequestParam(required = false) status: GiftStatus?,
|
|
||||||
@RequestParam(required = false) applicationNo: String?,
|
|
||||||
@RequestParam(required = false) nickname: String?,
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = ApiResponse.ok(AdminGiftListResponse.from(queryService.getGifts(status, applicationNo, nickname, page, size)))
|
|
||||||
|
|
||||||
@GetMapping("/gifts/{applicationNo}")
|
|
||||||
fun getGiftDetail(@PathVariable applicationNo: String) = ApiResponse.ok(
|
|
||||||
AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo))
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/gift-mailbox")
|
|
||||||
fun getMailbox() = ApiResponse.ok(service.getMailbox()?.let { AdminGiftMailboxResponse.from(it) })
|
|
||||||
|
|
||||||
@PutMapping("/gift-mailbox")
|
|
||||||
fun updateMailbox(@RequestBody request: AdminGiftMailboxRequest) = ApiResponse.ok(
|
|
||||||
AdminGiftMailboxResponse.from(service.updateMailbox(request.toCommand()))
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/gift-categories")
|
|
||||||
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
|
|
||||||
|
|
||||||
@PostMapping("/gift-categories")
|
|
||||||
fun createCategory(@RequestBody request: AdminGiftCategoryRequest) = ApiResponse.ok(
|
|
||||||
AdminGiftCategoryResponse.from(service.createCategory(request.toCommand()))
|
|
||||||
)
|
|
||||||
|
|
||||||
@PutMapping("/gift-categories/{categoryId}")
|
|
||||||
fun updateCategory(
|
|
||||||
@PathVariable categoryId: Long,
|
|
||||||
@RequestBody request: AdminGiftCategoryRequest
|
|
||||||
) = ApiResponse.ok(AdminGiftCategoryResponse.from(service.updateCategory(categoryId, request.toCommand())))
|
|
||||||
|
|
||||||
@DeleteMapping("/gift-categories/{categoryId}")
|
|
||||||
fun deleteCategory(@PathVariable categoryId: Long) = ApiResponse.ok(
|
|
||||||
AdminGiftCategoryResponse.from(service.deleteCategory(categoryId))
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/gift-size-prices")
|
|
||||||
fun getSizePrices() = ApiResponse.ok(service.getSizePrices().map { AdminGiftSizePriceResponse.from(it) })
|
|
||||||
|
|
||||||
@PutMapping("/gift-size-prices/{sizeCode}")
|
|
||||||
fun updateSizePrice(
|
|
||||||
@PathVariable sizeCode: GiftSize,
|
|
||||||
@RequestBody request: AdminGiftSizePriceRequest
|
|
||||||
) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand())))
|
|
||||||
|
|
||||||
@PostMapping("/gifts/{applicationNo}/arrive-mailbox")
|
|
||||||
fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok(
|
|
||||||
AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo))
|
|
||||||
)
|
|
||||||
|
|
||||||
@PostMapping("/gifts/{applicationNo}/complete-inspection")
|
|
||||||
fun completeInspection(@PathVariable applicationNo: String) = ApiResponse.ok(
|
|
||||||
AdminGiftOperationStatusResponse.from(service.completeInspection(applicationNo))
|
|
||||||
)
|
|
||||||
|
|
||||||
@PostMapping("/gifts/{applicationNo}/mark-undeliverable")
|
|
||||||
fun markUndeliverable(
|
|
||||||
@PathVariable applicationNo: String,
|
|
||||||
@RequestBody request: AdminGiftMarkUndeliverableRequest
|
|
||||||
) = ApiResponse.ok(AdminGiftOperationStatusResponse.from(service.markUndeliverable(applicationNo, request.reason)))
|
|
||||||
|
|
||||||
@PostMapping("/gifts/{applicationNo}/complete-delivery")
|
|
||||||
fun completeDelivery(@PathVariable applicationNo: String) = ApiResponse.ok(
|
|
||||||
AdminGiftOperationStatusResponse.from(service.completeDelivery(applicationNo))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxCommand
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand
|
|
||||||
|
|
||||||
data class AdminGiftCategoryRequest(
|
|
||||||
val classificationNumber: String,
|
|
||||||
val categoryCode: String,
|
|
||||||
val name: String,
|
|
||||||
val receiptCode: String,
|
|
||||||
val representativeItem: String,
|
|
||||||
val requiresDamageWaiver: Boolean,
|
|
||||||
val isActive: Boolean
|
|
||||||
) {
|
|
||||||
fun toCommand() = GiftCategoryCommand(
|
|
||||||
classificationNumber = classificationNumber,
|
|
||||||
categoryCode = categoryCode,
|
|
||||||
name = name,
|
|
||||||
receiptCode = receiptCode,
|
|
||||||
representativeItem = representativeItem,
|
|
||||||
requiresDamageWaiver = requiresDamageWaiver,
|
|
||||||
isActive = isActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftSizePriceRequest(
|
|
||||||
val basePriceCan: Int,
|
|
||||||
val salePriceCan: Int,
|
|
||||||
val isActive: Boolean
|
|
||||||
) {
|
|
||||||
fun toCommand() = GiftSizePriceCommand(
|
|
||||||
basePriceCan = basePriceCan,
|
|
||||||
salePriceCan = salePriceCan,
|
|
||||||
isActive = isActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftMailboxRequest(
|
|
||||||
val name: String,
|
|
||||||
val phoneNumber: String,
|
|
||||||
val zipCode: String,
|
|
||||||
val address: String,
|
|
||||||
val addressDetail: String?
|
|
||||||
) {
|
|
||||||
fun toCommand() = GiftMailboxCommand(
|
|
||||||
name = name,
|
|
||||||
phoneNumber = phoneNumber,
|
|
||||||
zipCode = zipCode,
|
|
||||||
address = address,
|
|
||||||
addressDetail = addressDetail
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftMarkUndeliverableRequest(
|
|
||||||
val reason: String
|
|
||||||
)
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftDetailResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftInboundDeliveryInfoResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListItemResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftMemberInfoResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
|
||||||
|
|
||||||
data class AdminGiftCategoryResponse(
|
|
||||||
val categoryId: Long,
|
|
||||||
val classificationNumber: String,
|
|
||||||
val categoryCode: String,
|
|
||||||
val name: String,
|
|
||||||
val receiptCode: String,
|
|
||||||
val representativeItem: String,
|
|
||||||
val requiresDamageWaiver: Boolean,
|
|
||||||
val isActive: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftCategoryResult) = AdminGiftCategoryResponse(
|
|
||||||
categoryId = result.categoryId,
|
|
||||||
classificationNumber = result.classificationNumber,
|
|
||||||
categoryCode = result.categoryCode,
|
|
||||||
name = result.name,
|
|
||||||
receiptCode = result.receiptCode,
|
|
||||||
representativeItem = result.representativeItem,
|
|
||||||
requiresDamageWaiver = result.requiresDamageWaiver,
|
|
||||||
isActive = result.isActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftSizePriceResponse(
|
|
||||||
val sizeCode: String,
|
|
||||||
val name: String,
|
|
||||||
val basePriceCan: Int,
|
|
||||||
val salePriceCan: Int,
|
|
||||||
val isActive: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftSizePriceResult) = AdminGiftSizePriceResponse(
|
|
||||||
sizeCode = result.sizeCode.name,
|
|
||||||
name = result.sizeName,
|
|
||||||
basePriceCan = result.basePriceCan,
|
|
||||||
salePriceCan = result.salePriceCan,
|
|
||||||
isActive = result.isActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftOperationStatusResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: GiftStatus,
|
|
||||||
val statusName: String,
|
|
||||||
val occurredAt: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftOperationStatusResult) = AdminGiftOperationStatusResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
status = result.status,
|
|
||||||
statusName = result.statusName,
|
|
||||||
occurredAt = result.occurredAt.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftMailboxResponse(
|
|
||||||
val name: String,
|
|
||||||
val address: String,
|
|
||||||
val phoneNumber: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftMailboxResult) = AdminGiftMailboxResponse(
|
|
||||||
name = result.name,
|
|
||||||
address = result.address,
|
|
||||||
phoneNumber = result.phoneNumber
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftListResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val items: List<AdminGiftListItemResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: AdminGiftListResult) = AdminGiftListResponse(
|
|
||||||
totalCount = result.totalCount,
|
|
||||||
items = result.items.map { AdminGiftListItemResponse.from(it) },
|
|
||||||
page = result.page,
|
|
||||||
size = result.size,
|
|
||||||
hasNext = result.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftListItemResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val senderNickname: String,
|
|
||||||
val recipientNickname: String,
|
|
||||||
val sizeCode: String,
|
|
||||||
val sizeName: String,
|
|
||||||
val categoryName: String,
|
|
||||||
val classificationNumber: String,
|
|
||||||
val courierCompanyName: String?,
|
|
||||||
val trackingNumber: String?,
|
|
||||||
val status: GiftStatus,
|
|
||||||
val statusName: String,
|
|
||||||
val availableActions: List<String>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: AdminGiftListItemResult) = AdminGiftListItemResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
senderNickname = result.senderNickname,
|
|
||||||
recipientNickname = result.recipientNickname,
|
|
||||||
sizeCode = result.sizeCode,
|
|
||||||
sizeName = result.sizeName,
|
|
||||||
categoryName = result.categoryName,
|
|
||||||
classificationNumber = result.classificationNumber,
|
|
||||||
courierCompanyName = result.courierCompanyName,
|
|
||||||
trackingNumber = result.trackingNumber,
|
|
||||||
status = result.status,
|
|
||||||
statusName = result.statusName,
|
|
||||||
availableActions = result.availableActions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftDetailResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val senderInfo: AdminGiftMemberInfoResponse,
|
|
||||||
val recipientInfo: AdminGiftMemberInfoResponse,
|
|
||||||
val productInfo: AdminGiftProductInfoResponse,
|
|
||||||
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse,
|
|
||||||
val status: GiftStatus,
|
|
||||||
val statusName: String,
|
|
||||||
val availableActions: List<String>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: AdminGiftDetailResult) = AdminGiftDetailResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
senderInfo = AdminGiftMemberInfoResponse.from(result.senderInfo),
|
|
||||||
recipientInfo = AdminGiftMemberInfoResponse.from(result.recipientInfo),
|
|
||||||
productInfo = AdminGiftProductInfoResponse.from(result.productInfo),
|
|
||||||
inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResponse.from(result.inboundDeliveryInfo),
|
|
||||||
status = result.status,
|
|
||||||
statusName = result.statusName,
|
|
||||||
availableActions = result.availableActions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftMemberInfoResponse(
|
|
||||||
val nickname: String,
|
|
||||||
val name: String,
|
|
||||||
val phoneNumber: String,
|
|
||||||
val address: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: AdminGiftMemberInfoResult) = AdminGiftMemberInfoResponse(
|
|
||||||
nickname = result.nickname,
|
|
||||||
name = result.name,
|
|
||||||
phoneNumber = result.phoneNumber,
|
|
||||||
address = result.address
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftProductInfoResponse(
|
|
||||||
val sizeCode: String,
|
|
||||||
val sizeName: String,
|
|
||||||
val categoryName: String,
|
|
||||||
val classificationNumber: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: AdminGiftProductInfoResult) = AdminGiftProductInfoResponse(
|
|
||||||
sizeCode = result.sizeCode,
|
|
||||||
sizeName = result.sizeName,
|
|
||||||
categoryName = result.categoryName,
|
|
||||||
classificationNumber = result.classificationNumber
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AdminGiftInboundDeliveryInfoResponse(
|
|
||||||
val courierCompanyName: String?,
|
|
||||||
val trackingNumber: String?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: AdminGiftInboundDeliveryInfoResult) = AdminGiftInboundDeliveryInfoResponse(
|
|
||||||
courierCompanyName = result.courierCompanyName,
|
|
||||||
trackingNumber = result.trackingNumber
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-153
@@ -1,153 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftCancellationResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDeliveryConfirmationResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDetailResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftFormOptionsResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftListResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftQueryService
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/v2/gifts")
|
|
||||||
class GiftController(
|
|
||||||
private val queryService: GiftQueryService,
|
|
||||||
private val commandService: GiftCommandService
|
|
||||||
) {
|
|
||||||
@GetMapping("/form-options")
|
|
||||||
fun getFormOptions() = ApiResponse.ok(GiftFormOptionsResponse.from(queryService.getFormOptions()))
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
fun getGifts(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
|
||||||
@RequestParam(defaultValue = "ALL") type: GiftListType,
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
GiftListResponse.from(
|
|
||||||
queryService.getGifts(
|
|
||||||
memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
|
||||||
type = type,
|
|
||||||
page = page,
|
|
||||||
size = size
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/{applicationNo}")
|
|
||||||
fun getGiftDetail(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
|
||||||
@PathVariable applicationNo: String
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
GiftDetailResponse.from(
|
|
||||||
queryService.getGiftDetail(
|
|
||||||
memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
|
||||||
applicationNo = applicationNo
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
fun registerGift(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
|
||||||
@RequestBody request: GiftApplicationRequest
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
GiftApplicationResponse.from(
|
|
||||||
commandService.registerGift(
|
|
||||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
|
||||||
command = request.toCommand()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@PostMapping("/{applicationNo}/cancel")
|
|
||||||
fun cancelGift(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
|
||||||
@PathVariable applicationNo: String
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
GiftCancellationResponse.from(
|
|
||||||
commandService.cancelGift(
|
|
||||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
|
||||||
applicationNo = applicationNo
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@PostMapping("/{applicationNo}/tracking")
|
|
||||||
fun registerTracking(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
|
||||||
@PathVariable applicationNo: String,
|
|
||||||
@RequestBody request: GiftTrackingRegistrationRequest
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
GiftTrackingRegistrationResponse.from(
|
|
||||||
commandService.registerTracking(
|
|
||||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
|
||||||
applicationNo = applicationNo,
|
|
||||||
command = request.toCommand()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@PostMapping("/{applicationNo}/recipient-address")
|
|
||||||
fun registerRecipientAddress(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
|
||||||
@PathVariable applicationNo: String,
|
|
||||||
@RequestBody request: GiftRecipientAddressRegistrationRequest
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
GiftRecipientAddressRegistrationResponse.from(
|
|
||||||
commandService.registerRecipientAddress(
|
|
||||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
|
||||||
applicationNo = applicationNo,
|
|
||||||
command = request.toCommand()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@PostMapping("/{applicationNo}/delivery-complete")
|
|
||||||
fun confirmDelivery(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
|
||||||
@PathVariable applicationNo: String
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
GiftDeliveryConfirmationResponse.from(
|
|
||||||
commandService.confirmDelivery(
|
|
||||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
|
||||||
applicationNo = applicationNo
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@PostMapping("/{applicationNo}/review")
|
|
||||||
fun writeReview(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
|
||||||
@PathVariable applicationNo: String,
|
|
||||||
@RequestBody request: GiftReviewRequest
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
GiftReviewResponse.from(
|
|
||||||
commandService.writeReview(
|
|
||||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
|
||||||
applicationNo = applicationNo,
|
|
||||||
command = request.toCommand()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
|
||||||
|
|
||||||
data class GiftApplicationRequest(
|
|
||||||
val recipientMemberId: Long,
|
|
||||||
val senderName: String,
|
|
||||||
val senderPhoneNumber: String,
|
|
||||||
val senderZipCode: String,
|
|
||||||
val senderAddress: String,
|
|
||||||
val senderAddressDetail: String?,
|
|
||||||
val sizeCode: GiftSize,
|
|
||||||
val categoryId: Long,
|
|
||||||
val senderTermsAgreed: Boolean,
|
|
||||||
val senderPrivacyAgreed: Boolean,
|
|
||||||
val damageWaiverAgreed: Boolean
|
|
||||||
) {
|
|
||||||
fun toCommand() = GiftApplicationCommand(
|
|
||||||
recipientMemberId = recipientMemberId,
|
|
||||||
senderName = senderName,
|
|
||||||
senderPhoneNumber = senderPhoneNumber,
|
|
||||||
senderZipCode = senderZipCode,
|
|
||||||
senderAddress = senderAddress,
|
|
||||||
senderAddressDetail = senderAddressDetail,
|
|
||||||
sizeCode = sizeCode,
|
|
||||||
categoryId = categoryId,
|
|
||||||
senderTermsAgreed = senderTermsAgreed,
|
|
||||||
senderPrivacyAgreed = senderPrivacyAgreed,
|
|
||||||
damageWaiverAgreed = damageWaiverAgreed
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand
|
|
||||||
|
|
||||||
data class GiftRecipientAddressRegistrationRequest(
|
|
||||||
val recipientName: String,
|
|
||||||
val recipientPhoneNumber: String,
|
|
||||||
val recipientZipCode: String,
|
|
||||||
val recipientAddress: String,
|
|
||||||
val recipientAddressDetail: String?,
|
|
||||||
val recipientTermsAgreed: Boolean,
|
|
||||||
val recipientPrivacyAgreed: Boolean
|
|
||||||
) {
|
|
||||||
fun toCommand() = GiftRecipientAddressRegistrationCommand(
|
|
||||||
name = recipientName,
|
|
||||||
phoneNumber = recipientPhoneNumber,
|
|
||||||
zipCode = recipientZipCode,
|
|
||||||
address = recipientAddress,
|
|
||||||
addressDetail = recipientAddressDetail,
|
|
||||||
recipientTermsAgreed = recipientTermsAgreed,
|
|
||||||
recipientPrivacyAgreed = recipientPrivacyAgreed
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftDetailResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftFormOptionsResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftListResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult
|
|
||||||
|
|
||||||
data class GiftFormOptionsResponse(
|
|
||||||
val sizes: List<GiftSizeOptionResponse>,
|
|
||||||
val categories: List<GiftCategoryOptionResponse>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftFormOptionsResult) = GiftFormOptionsResponse(
|
|
||||||
sizes = result.sizes.map {
|
|
||||||
GiftSizeOptionResponse(it.sizeCode.name, it.sizeName, it.basePriceCan, it.salePriceCan)
|
|
||||||
},
|
|
||||||
categories = result.categories.map {
|
|
||||||
GiftCategoryOptionResponse(it.categoryId, it.name, it.requiresDamageWaiver)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GiftSizeOptionResponse(
|
|
||||||
val sizeCode: String,
|
|
||||||
val name: String,
|
|
||||||
val basePriceCan: Int,
|
|
||||||
val salePriceCan: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftCategoryOptionResponse(
|
|
||||||
val categoryId: Long,
|
|
||||||
val name: String,
|
|
||||||
val requiresDamageWaiver: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftListResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val items: List<GiftListItemResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftListResult) = GiftListResponse(
|
|
||||||
totalCount = result.totalCount,
|
|
||||||
items = result.items.map {
|
|
||||||
GiftListItemResponse(
|
|
||||||
applicationNo = it.applicationNo,
|
|
||||||
direction = it.direction.name,
|
|
||||||
status = it.status.name,
|
|
||||||
statusName = it.statusName,
|
|
||||||
priceCan = it.priceCan,
|
|
||||||
categoryName = it.categoryName,
|
|
||||||
sizeName = it.sizeName,
|
|
||||||
counterpartMemberId = it.counterpartMemberId,
|
|
||||||
counterpartNickname = it.counterpartNickname,
|
|
||||||
counterpartProfileImageUrl = it.counterpartProfileImageUrl,
|
|
||||||
createdAt = it.createdAt?.toUtcIso()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
page = result.page,
|
|
||||||
size = result.size,
|
|
||||||
hasNext = result.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GiftListItemResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val direction: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val priceCan: Int,
|
|
||||||
val categoryName: String,
|
|
||||||
val sizeName: String,
|
|
||||||
val counterpartMemberId: Long,
|
|
||||||
val counterpartNickname: String,
|
|
||||||
val counterpartProfileImageUrl: String,
|
|
||||||
val createdAt: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftDetailResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val direction: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val giftInfo: GiftDetailInfoResponse,
|
|
||||||
val senderInfo: GiftAddressResponse?,
|
|
||||||
val recipientAddress: GiftAddressResponse?,
|
|
||||||
val mailbox: GiftMailboxResponse?,
|
|
||||||
val trackingRequired: Boolean,
|
|
||||||
val recipientAddressRequired: Boolean,
|
|
||||||
val recipientAddressDeadlineAt: String?,
|
|
||||||
val delivery: GiftDeliveryInfoResponse,
|
|
||||||
val statusTimeline: List<GiftStatusTimelineResponse>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftDetailResult) = GiftDetailResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
direction = result.direction.name,
|
|
||||||
status = result.status.name,
|
|
||||||
statusName = result.statusName,
|
|
||||||
giftInfo = GiftDetailInfoResponse(
|
|
||||||
recipientCreatorNickname = result.giftInfo.recipientCreatorNickname,
|
|
||||||
senderNickname = result.giftInfo.senderNickname,
|
|
||||||
sizeName = result.giftInfo.sizeName,
|
|
||||||
categoryName = result.giftInfo.categoryName,
|
|
||||||
applicationNo = result.giftInfo.applicationNo,
|
|
||||||
paidCan = result.giftInfo.paidCan,
|
|
||||||
tracking = result.giftInfo.tracking,
|
|
||||||
shippingRequestedAt = result.giftInfo.shippingRequestedAt?.toUtcIso()
|
|
||||||
),
|
|
||||||
senderInfo = result.senderInfo?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) },
|
|
||||||
recipientAddress = result.recipientAddress?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) },
|
|
||||||
mailbox = result.mailbox?.let { GiftMailboxResponse(it.name, it.address, it.phoneNumber) },
|
|
||||||
trackingRequired = result.trackingRequired,
|
|
||||||
recipientAddressRequired = result.recipientAddressRequired,
|
|
||||||
recipientAddressDeadlineAt = result.recipientAddressDeadlineAt?.toUtcIso(),
|
|
||||||
delivery = GiftDeliveryInfoResponse(
|
|
||||||
canceledAt = result.delivery.canceledAt?.toUtcIso(),
|
|
||||||
undeliverableAt = result.delivery.undeliverableAt?.toUtcIso(),
|
|
||||||
undeliverableReason = result.delivery.undeliverableReason
|
|
||||||
),
|
|
||||||
statusTimeline = result.statusTimeline.map {
|
|
||||||
GiftStatusTimelineResponse(it.status.name, it.statusName, it.occurredAt?.toUtcIso())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GiftDetailInfoResponse(
|
|
||||||
val recipientCreatorNickname: String?,
|
|
||||||
val senderNickname: String?,
|
|
||||||
val sizeName: String,
|
|
||||||
val categoryName: String,
|
|
||||||
val applicationNo: String?,
|
|
||||||
val paidCan: Int?,
|
|
||||||
val tracking: String?,
|
|
||||||
val shippingRequestedAt: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftAddressResponse(
|
|
||||||
val name: String,
|
|
||||||
val phoneNumber: String,
|
|
||||||
val address: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftMailboxResponse(
|
|
||||||
val name: String,
|
|
||||||
val address: String,
|
|
||||||
val phoneNumber: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftDeliveryInfoResponse(
|
|
||||||
val canceledAt: String?,
|
|
||||||
val undeliverableAt: String?,
|
|
||||||
val undeliverableReason: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftStatusTimelineResponse(
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val occurredAt: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GiftApplicationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val priceCan: Int,
|
|
||||||
val trackingDeadlineAt: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftApplicationResult) = GiftApplicationResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
status = result.status.name,
|
|
||||||
statusName = result.statusName,
|
|
||||||
priceCan = result.priceCan,
|
|
||||||
trackingDeadlineAt = result.trackingDeadlineAt.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GiftCancellationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val priceCan: Int,
|
|
||||||
val canceledAt: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftCancellationResult) = GiftCancellationResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
status = result.status.name,
|
|
||||||
statusName = result.statusName,
|
|
||||||
priceCan = result.priceCan,
|
|
||||||
canceledAt = result.canceledAt.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GiftTrackingRegistrationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val courierCompanyName: String,
|
|
||||||
val trackingNumber: String,
|
|
||||||
val trackingRegisteredAt: String,
|
|
||||||
val recipientAddressDeadlineAt: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftTrackingRegistrationResult) = GiftTrackingRegistrationResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
status = result.status.name,
|
|
||||||
statusName = result.statusName,
|
|
||||||
courierCompanyName = result.courierCompanyName,
|
|
||||||
trackingNumber = result.trackingNumber,
|
|
||||||
trackingRegisteredAt = result.trackingRegisteredAt.toUtcIso(),
|
|
||||||
recipientAddressDeadlineAt = result.recipientAddressDeadlineAt.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GiftRecipientAddressRegistrationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val recipientAddressRegisteredAt: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftRecipientAddressRegistrationResult) = GiftRecipientAddressRegistrationResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
status = result.status.name,
|
|
||||||
statusName = result.statusName,
|
|
||||||
recipientAddressRegisteredAt = result.recipientAddressRegisteredAt.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GiftDeliveryConfirmationResponse(
|
|
||||||
val applicationNo: String,
|
|
||||||
val status: String,
|
|
||||||
val statusName: String,
|
|
||||||
val deliveredAt: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftDeliveryConfirmationResult) = GiftDeliveryConfirmationResponse(
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
status = result.status.name,
|
|
||||||
statusName = result.statusName,
|
|
||||||
deliveredAt = result.deliveredAt.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GiftReviewResponse(
|
|
||||||
val reviewId: Long,
|
|
||||||
val applicationNo: String,
|
|
||||||
val rating: Int,
|
|
||||||
val keywords: List<String>,
|
|
||||||
val comment: String?,
|
|
||||||
val createdAt: String?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(result: GiftReviewResult) = GiftReviewResponse(
|
|
||||||
reviewId = result.reviewId,
|
|
||||||
applicationNo = result.applicationNo,
|
|
||||||
rating = result.rating,
|
|
||||||
keywords = result.keywords,
|
|
||||||
comment = result.comment,
|
|
||||||
createdAt = result.createdAt?.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand
|
|
||||||
|
|
||||||
data class GiftReviewRequest(
|
|
||||||
val rating: Int,
|
|
||||||
val keywords: List<String>,
|
|
||||||
val comment: String?
|
|
||||||
) {
|
|
||||||
fun toCommand() = GiftReviewCommand(
|
|
||||||
rating = rating,
|
|
||||||
keywords = keywords,
|
|
||||||
comment = comment
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
|
|
||||||
|
|
||||||
data class GiftTrackingRegistrationRequest(
|
|
||||||
val courierCompanyName: String,
|
|
||||||
val trackingNumber: String
|
|
||||||
) {
|
|
||||||
fun toCommand() = GiftTrackingRegistrationCommand(
|
|
||||||
courierCompanyName = courierCompanyName,
|
|
||||||
trackingNumber = trackingNumber
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+2
-12
@@ -2,9 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home.application
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.event.EventItem
|
import kr.co.vividnext.sodalive.event.EventItem
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.Gender
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
||||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeActiveCreatorItem
|
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeActiveCreatorItem
|
||||||
@@ -55,9 +53,7 @@ class HomeRecommendationFacade(
|
|||||||
lives = queryService.findLiveRecommendations(
|
lives = queryService.findLiveRecommendations(
|
||||||
limit = HOME_LIVE_LIMIT,
|
limit = HOME_LIVE_LIMIT,
|
||||||
memberId = member?.id,
|
memberId = member?.id,
|
||||||
includeAdultLives = includeAdult,
|
includeAdultLives = includeAdult
|
||||||
isViewerCreator = member?.role == MemberRole.CREATOR,
|
|
||||||
effectiveViewerGender = member?.effectiveGender()
|
|
||||||
).map { it.toItem() },
|
).map { it.toItem() },
|
||||||
banners = queryService.findHomeBanners(
|
banners = queryService.findHomeBanners(
|
||||||
limit = HOME_BANNER_LIMIT,
|
limit = HOME_BANNER_LIMIT,
|
||||||
@@ -129,9 +125,7 @@ class HomeRecommendationFacade(
|
|||||||
offset = page.toOffset(size),
|
offset = page.toOffset(size),
|
||||||
limit = size + 1,
|
limit = size + 1,
|
||||||
memberId = member.id,
|
memberId = member.id,
|
||||||
includeAdultLives = resolveAdultVisibility(member),
|
includeAdultLives = resolveAdultVisibility(member)
|
||||||
isViewerCreator = member.role == MemberRole.CREATOR,
|
|
||||||
effectiveViewerGender = member.effectiveGender()
|
|
||||||
)
|
)
|
||||||
fetched.toPage(page, size) { it.toItem() }
|
fetched.toPage(page, size) { it.toItem() }
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
@@ -215,10 +209,6 @@ class HomeRecommendationFacade(
|
|||||||
return memberContentPreferenceService.canViewAdultContent(member)
|
return memberContentPreferenceService.canViewAdultContent(member)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Member.effectiveGender(): Gender {
|
|
||||||
return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Int.toOffset(size: Int): Long = this.toLong() * size
|
private fun Int.toOffset(size: Int): Long = this.toLong() * size
|
||||||
|
|
||||||
private fun <S, T> List<S>.toPage(
|
private fun <S, T> List<S>.toPage(
|
||||||
|
|||||||
+1
-9
@@ -1,8 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.home.live.application
|
package kr.co.vividnext.sodalive.v2.api.home.live.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.member.Gender
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse
|
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse
|
||||||
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse
|
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse
|
||||||
@@ -26,9 +24,7 @@ class HomeOnAirLiveFacade(
|
|||||||
offset = normalizedPage.toLong() * PAGE_SIZE,
|
offset = normalizedPage.toLong() * PAGE_SIZE,
|
||||||
limit = PAGE_SIZE + 1,
|
limit = PAGE_SIZE + 1,
|
||||||
memberId = member.id,
|
memberId = member.id,
|
||||||
includeAdultLives = memberContentPreferenceService.canViewAdultContent(member),
|
includeAdultLives = memberContentPreferenceService.canViewAdultContent(member)
|
||||||
isViewerCreator = member.role == MemberRole.CREATOR,
|
|
||||||
effectiveViewerGender = member.effectiveGender()
|
|
||||||
)
|
)
|
||||||
val items = fetched.take(PAGE_SIZE).map { it.toResponse() }
|
val items = fetched.take(PAGE_SIZE).map { it.toResponse() }
|
||||||
|
|
||||||
@@ -61,10 +57,6 @@ class HomeOnAirLiveFacade(
|
|||||||
return atOffset(ZoneOffset.UTC).toInstant().toString()
|
return atOffset(ZoneOffset.UTC).toInstant().toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Member.effectiveGender(): Gender {
|
|
||||||
return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PAGE_SIZE = 20
|
private const val PAGE_SIZE = 20
|
||||||
private const val MAX_PAGE = 10_000
|
private const val MAX_PAGE = 10_000
|
||||||
|
|||||||
+1
-22
@@ -12,7 +12,6 @@ import kr.co.vividnext.sodalive.content.order.QOrder
|
|||||||
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
||||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.translation.QContentTranslation
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
@@ -59,7 +58,6 @@ class DefaultMainContentAllQueryRepository(
|
|||||||
sort: ContentSort,
|
sort: ContentSort,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
locale: String,
|
|
||||||
onlyPaid: Boolean,
|
onlyPaid: Boolean,
|
||||||
onlyFree: Boolean,
|
onlyFree: Boolean,
|
||||||
onlyPointAvailable: Boolean
|
onlyPointAvailable: Boolean
|
||||||
@@ -71,14 +69,13 @@ class DefaultMainContentAllQueryRepository(
|
|||||||
val creatorIds = rows.map { it.get(audioContent.member.id)!! }.distinct()
|
val creatorIds = rows.map { it.get(audioContent.member.id)!! }.distinct()
|
||||||
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
||||||
val originalSeriesByContentId = originalSeriesFlags(contentIds)
|
val originalSeriesByContentId = originalSeriesFlags(contentIds)
|
||||||
val titlesByContentId = audioTitlesByContentIds(contentIds, locale)
|
|
||||||
|
|
||||||
return rows.map { row ->
|
return rows.map { row ->
|
||||||
val contentId = row.get(audioContent.id)!!
|
val contentId = row.get(audioContent.id)!!
|
||||||
val creatorId = row.get(audioContent.member.id)!!
|
val creatorId = row.get(audioContent.member.id)!!
|
||||||
MainContentAllAudio(
|
MainContentAllAudio(
|
||||||
audioContentId = contentId,
|
audioContentId = contentId,
|
||||||
title = titlesByContentId[contentId] ?: row.get(audioContent.title)!!,
|
title = row.get(audioContent.title)!!,
|
||||||
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||||
price = row.get(audioContent.price)!!,
|
price = row.get(audioContent.price)!!,
|
||||||
isAdult = row.get(audioContent.isAdult)!!,
|
isAdult = row.get(audioContent.isAdult)!!,
|
||||||
@@ -346,24 +343,6 @@ class DefaultMainContentAllQueryRepository(
|
|||||||
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
|
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun audioTitlesByContentIds(contentIds: List<Long>, locale: String): Map<Long, String> {
|
|
||||||
if (contentIds.isEmpty()) return emptyMap()
|
|
||||||
val contentTranslation = QContentTranslation("mainContentAllContentTranslation")
|
|
||||||
return queryFactory
|
|
||||||
.select(contentTranslation.contentId, contentTranslation.renderedPayload)
|
|
||||||
.from(contentTranslation)
|
|
||||||
.where(
|
|
||||||
contentTranslation.contentId.`in`(contentIds),
|
|
||||||
contentTranslation.locale.eq(locale)
|
|
||||||
)
|
|
||||||
.fetch()
|
|
||||||
.mapNotNull {
|
|
||||||
val title = it.get(contentTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank)
|
|
||||||
title?.let { translatedTitle -> it.get(contentTranslation.contentId)!! to translatedTitle }
|
|
||||||
}
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun audioCondition(
|
private fun audioCondition(
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
|
|||||||
-1
@@ -122,7 +122,6 @@ class MainContentAllQueryService(
|
|||||||
sort = sort,
|
sort = sort,
|
||||||
offset = page.offset,
|
offset = page.offset,
|
||||||
limit = page.size + 1,
|
limit = page.size + 1,
|
||||||
locale = langContext.lang.code,
|
|
||||||
onlyPaid = onlyPaid,
|
onlyPaid = onlyPaid,
|
||||||
onlyFree = onlyFree,
|
onlyFree = onlyFree,
|
||||||
onlyPointAvailable = onlyPointAvailable
|
onlyPointAvailable = onlyPointAvailable
|
||||||
|
|||||||
-1
@@ -23,7 +23,6 @@ interface MainContentAllQueryPort {
|
|||||||
sort: ContentSort,
|
sort: ContentSort,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
locale: String,
|
|
||||||
onlyPaid: Boolean = false,
|
onlyPaid: Boolean = false,
|
||||||
onlyFree: Boolean = false,
|
onlyFree: Boolean = false,
|
||||||
onlyPointAvailable: Boolean = false
|
onlyPointAvailable: Boolean = false
|
||||||
|
|||||||
+7
-22
@@ -1,6 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||||
@@ -10,28 +9,25 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class DefaultAudioRankingSnapshotPersistenceAdapter(
|
class DefaultAudioRankingSnapshotPersistenceAdapter(
|
||||||
private val repository: AudioRankingSnapshotRepository,
|
private val repository: AudioRankingSnapshotRepository
|
||||||
private val contentTranslationRepository: ContentTranslationRepository
|
|
||||||
) : AudioRankingSnapshotPort {
|
) : AudioRankingSnapshotPort {
|
||||||
override fun findLatestVisibleSnapshots(
|
override fun findLatestVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
nowUtc: LocalDateTime,
|
nowUtc: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioRankingSnapshotRecord> {
|
): List<AudioRankingSnapshotRecord> {
|
||||||
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).toRecords(locale)
|
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findPreviousVisibleSnapshots(
|
override fun findPreviousVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
currentAggregationStartAtUtc: LocalDateTime,
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
nowUtc: LocalDateTime,
|
nowUtc: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioRankingSnapshotRecord> {
|
): List<AudioRankingSnapshotRecord> {
|
||||||
return repository.findPreviousVisibleSnapshots(
|
return repository.findPreviousVisibleSnapshots(
|
||||||
rankingType = rankingType.name,
|
rankingType = rankingType.name,
|
||||||
currentAggregationStartAtUtc = currentAggregationStartAtUtc,
|
currentAggregationStartAtUtc = currentAggregationStartAtUtc,
|
||||||
nowUtc = nowUtc
|
nowUtc = nowUtc
|
||||||
).toRecords(locale)
|
).map { it.toRecord() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -50,25 +46,14 @@ class DefaultAudioRankingSnapshotPersistenceAdapter(
|
|||||||
repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) })
|
repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<AudioRankingSnapshot>.toRecords(locale: String): List<AudioRankingSnapshotRecord> {
|
private fun AudioRankingSnapshot.toRecord(): AudioRankingSnapshotRecord {
|
||||||
if (isEmpty()) return emptyList()
|
|
||||||
val translatedTitleByContentId = contentTranslationRepository.findByContentIdInAndLocale(
|
|
||||||
map { it.contentId },
|
|
||||||
locale
|
|
||||||
).mapNotNull { translation ->
|
|
||||||
translation.renderedPayload.title.takeIf { it.isNotBlank() }?.let { translation.contentId to it }
|
|
||||||
}.toMap()
|
|
||||||
return map { snapshot -> snapshot.toRecord(translatedTitleByContentId[snapshot.contentId] ?: snapshot.title) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun AudioRankingSnapshot.toRecord(displayTitle: String = title): AudioRankingSnapshotRecord {
|
|
||||||
return AudioRankingSnapshotRecord(
|
return AudioRankingSnapshotRecord(
|
||||||
rankingType = rankingType,
|
rankingType = rankingType,
|
||||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||||
aggregationEndAtUtc = aggregationEndAtUtc,
|
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||||
visibleFromAtUtc = visibleFromAtUtc,
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
title = displayTitle,
|
title = title,
|
||||||
creatorMemberId = creatorMemberId,
|
creatorMemberId = creatorMemberId,
|
||||||
creatorNickname = creatorNickname,
|
creatorNickname = creatorNickname,
|
||||||
coverImageUrl = coverImageUrl,
|
coverImageUrl = coverImageUrl,
|
||||||
|
|||||||
+5
-10
@@ -1,6 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||||
@@ -19,7 +18,6 @@ import java.time.ZonedDateTime
|
|||||||
@Service
|
@Service
|
||||||
class AudioRankingQueryService(
|
class AudioRankingQueryService(
|
||||||
private val snapshotPort: AudioRankingSnapshotPort,
|
private val snapshotPort: AudioRankingSnapshotPort,
|
||||||
private val langContext: LangContext,
|
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
private val blockPort: AudioRankingBlockPort,
|
private val blockPort: AudioRankingBlockPort,
|
||||||
private val jobService: AudioRankingSnapshotJobService,
|
private val jobService: AudioRankingSnapshotJobService,
|
||||||
@@ -31,8 +29,7 @@ class AudioRankingQueryService(
|
|||||||
|
|
||||||
fun getRankings(type: AudioRankingType, member: Member?): AudioRanking {
|
fun getRankings(type: AudioRankingType, member: Member?): AudioRanking {
|
||||||
val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
|
val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
|
||||||
val locale = langContext.lang.code
|
val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc)
|
||||||
val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc, locale)
|
|
||||||
if (latestSnapshots.isEmpty()) {
|
if (latestSnapshots.isEmpty()) {
|
||||||
return AudioRanking(showRankChange = false, type = type, items = emptyList())
|
return AudioRanking(showRankChange = false, type = type, items = emptyList())
|
||||||
}
|
}
|
||||||
@@ -40,8 +37,7 @@ class AudioRankingQueryService(
|
|||||||
val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots(
|
val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots(
|
||||||
rankingType = type,
|
rankingType = type,
|
||||||
currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc,
|
currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc,
|
||||||
nowUtc = nowUtc,
|
nowUtc = nowUtc
|
||||||
locale = locale
|
|
||||||
)
|
)
|
||||||
val blockedCreatorMemberIds = blockedCreatorMemberIds(member, latestSnapshots + previousSnapshots)
|
val blockedCreatorMemberIds = blockedCreatorMemberIds(member, latestSnapshots + previousSnapshots)
|
||||||
val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds).take(ITEM_LIMIT)
|
val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds).take(ITEM_LIMIT)
|
||||||
@@ -62,10 +58,9 @@ class AudioRankingQueryService(
|
|||||||
|
|
||||||
private fun findLatestVisibleSnapshots(
|
private fun findLatestVisibleSnapshots(
|
||||||
type: AudioRankingType,
|
type: AudioRankingType,
|
||||||
nowUtc: java.time.LocalDateTime,
|
nowUtc: java.time.LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioRankingSnapshotRecord> {
|
): List<AudioRankingSnapshotRecord> {
|
||||||
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc, locale)
|
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
|
||||||
if (latestSnapshots.isNotEmpty()) return latestSnapshots
|
if (latestSnapshots.isNotEmpty()) return latestSnapshots
|
||||||
|
|
||||||
runCatching { jobService.refreshLastCompletedWeekByFallback(type) }
|
runCatching { jobService.refreshLastCompletedWeekByFallback(type) }
|
||||||
@@ -77,7 +72,7 @@ class AudioRankingQueryService(
|
|||||||
ex
|
ex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return snapshotPort.findLatestVisibleSnapshots(type, nowUtc, locale)
|
return snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canViewAdultContent(member: Member?): Boolean {
|
private fun canViewAdultContent(member: Member?): Boolean {
|
||||||
|
|||||||
+2
-4
@@ -6,15 +6,13 @@ import java.time.LocalDateTime
|
|||||||
interface AudioRankingSnapshotPort {
|
interface AudioRankingSnapshotPort {
|
||||||
fun findLatestVisibleSnapshots(
|
fun findLatestVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
nowUtc: LocalDateTime,
|
nowUtc: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioRankingSnapshotRecord>
|
): List<AudioRankingSnapshotRecord>
|
||||||
|
|
||||||
fun findPreviousVisibleSnapshots(
|
fun findPreviousVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
currentAggregationStartAtUtc: LocalDateTime,
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
nowUtc: LocalDateTime,
|
nowUtc: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioRankingSnapshotRecord>
|
): List<AudioRankingSnapshotRecord>
|
||||||
|
|
||||||
fun replaceSnapshots(
|
fun replaceSnapshots(
|
||||||
|
|||||||
+12
-40
@@ -11,7 +11,6 @@ import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
|||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.event.EventItem
|
import kr.co.vividnext.sodalive.event.EventItem
|
||||||
@@ -39,7 +38,6 @@ import javax.persistence.EntityManager
|
|||||||
class DefaultAudioRecommendationQueryRepository(
|
class DefaultAudioRecommendationQueryRepository(
|
||||||
private val queryFactory: JPAQueryFactory,
|
private val queryFactory: JPAQueryFactory,
|
||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
private val contentTranslationRepository: ContentTranslationRepository,
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
) : AudioRecommendationQueryRepository {
|
) : AudioRecommendationQueryRepository {
|
||||||
@@ -117,35 +115,32 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioCard> {
|
): List<AudioCard> {
|
||||||
val rows = audioRows(memberId, canViewAdultContent, now) {
|
val rows = audioRows(memberId, canViewAdultContent, now) {
|
||||||
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
|
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
|
||||||
}
|
}
|
||||||
return rows.toAudioCards(now, canViewAdultContent, locale)
|
return rows.toAudioCards(now, canViewAdultContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findFreeAudios(
|
override fun findFreeAudios(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioCard> {
|
): List<AudioCard> {
|
||||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
|
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
|
||||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||||
}
|
}
|
||||||
return rows.toAudioCards(now, canViewAdultContent, locale)
|
return rows.toAudioCards(now, canViewAdultContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findPointAudios(
|
override fun findPointAudios(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioCard> {
|
): List<AudioCard> {
|
||||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
val rows = audioRows(
|
val rows = audioRows(
|
||||||
@@ -156,27 +151,25 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
) {
|
) {
|
||||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||||
}
|
}
|
||||||
return rows.toAudioCards(now, canViewAdultContent, locale)
|
return rows.toAudioCards(now, canViewAdultContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findAudioCardsByIds(
|
override fun findAudioCardsByIds(
|
||||||
contentIds: List<Long>,
|
contentIds: List<Long>,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioCard> {
|
): List<AudioCard> {
|
||||||
if (contentIds.isEmpty()) return emptyList()
|
if (contentIds.isEmpty()) return emptyList()
|
||||||
val orderById = contentIds.withIndex().associate { it.value to it.index }
|
val orderById = contentIds.withIndex().associate { it.value to it.index }
|
||||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
|
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
|
||||||
return rows.toAudioCards(now, canViewAdultContent, locale).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
|
return rows.toAudioCards(now, canViewAdultContent).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findCommentedAudiosByIds(
|
override fun findCommentedAudiosByIds(
|
||||||
contentIds: List<Long>,
|
contentIds: List<Long>,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean
|
||||||
locale: String
|
|
||||||
): List<CommentedAudio> {
|
): List<CommentedAudio> {
|
||||||
if (contentIds.isEmpty()) return emptyList()
|
if (contentIds.isEmpty()) return emptyList()
|
||||||
val contentOrder = contentIds.withIndex().associate { it.value to it.index }
|
val contentOrder = contentIds.withIndex().associate { it.value to it.index }
|
||||||
@@ -242,7 +235,7 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val commentedAudios = entityManager.createNativeQuery(sql)
|
return entityManager.createNativeQuery(sql)
|
||||||
.setParameter("contentIds", contentIds)
|
.setParameter("contentIds", contentIds)
|
||||||
.setParameter("memberId", memberId)
|
.setParameter("memberId", memberId)
|
||||||
.setParameter("canViewAdultContent", canViewAdultContent)
|
.setParameter("canViewAdultContent", canViewAdultContent)
|
||||||
@@ -258,11 +251,6 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
?: "$cloudFrontHost/profile/default-profile.png"
|
?: "$cloudFrontHost/profile/default-profile.png"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val translatedTitleByContentId = translatedTitles(commentedAudios.map { it.audioContentId }, locale)
|
|
||||||
return commentedAudios
|
|
||||||
.map { audio ->
|
|
||||||
translatedTitleByContentId[audio.audioContentId]?.let { audio.copy(title = it) } ?: audio
|
|
||||||
}
|
|
||||||
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
|
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,23 +455,18 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<Tuple>.toAudioCards(
|
private fun List<Tuple>.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List<AudioCard> {
|
||||||
now: LocalDateTime,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
locale: String
|
|
||||||
): List<AudioCard> {
|
|
||||||
if (isEmpty()) return emptyList()
|
if (isEmpty()) return emptyList()
|
||||||
val contentIds = map { it.get(audioContent.id)!! }
|
val contentIds = map { it.get(audioContent.id)!! }
|
||||||
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
|
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
|
||||||
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
||||||
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
|
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
|
||||||
val translatedTitleByContentId = translatedTitles(contentIds, locale)
|
|
||||||
return map { row ->
|
return map { row ->
|
||||||
val contentId = row.get(audioContent.id)!!
|
val contentId = row.get(audioContent.id)!!
|
||||||
val creatorId = row.get(audioContent.member.id)!!
|
val creatorId = row.get(audioContent.member.id)!!
|
||||||
AudioCard(
|
AudioCard(
|
||||||
audioContentId = contentId,
|
audioContentId = contentId,
|
||||||
title = translatedTitleByContentId[contentId] ?: row.get(audioContent.title)!!,
|
title = row.get(audioContent.title)!!,
|
||||||
duration = row.get(audioContent.duration),
|
duration = row.get(audioContent.duration),
|
||||||
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||||
price = row.get(audioContent.price)!!,
|
price = row.get(audioContent.price)!!,
|
||||||
@@ -496,17 +479,6 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun translatedTitles(contentIds: List<Long>, locale: String): Map<Long, String> {
|
|
||||||
if (contentIds.isEmpty()) return emptyMap()
|
|
||||||
return contentTranslationRepository.findByContentIdInAndLocale(contentIds, locale)
|
|
||||||
.mapNotNull { translation ->
|
|
||||||
translation.renderedPayload.title
|
|
||||||
.takeIf { it.isNotBlank() }
|
|
||||||
?.let { translation.contentId to it }
|
|
||||||
}
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun firstAudioContentIds(
|
private fun firstAudioContentIds(
|
||||||
creatorIds: List<Long>,
|
creatorIds: List<Long>,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
|
|||||||
+8
-15
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||||
@@ -18,8 +17,7 @@ import java.time.ZoneId
|
|||||||
class AudioRecommendationQueryService(
|
class AudioRecommendationQueryService(
|
||||||
private val queryPort: AudioRecommendationQueryPort,
|
private val queryPort: AudioRecommendationQueryPort,
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService,
|
private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService
|
||||||
private val langContext: LangContext
|
|
||||||
) {
|
) {
|
||||||
fun getRecommendations(member: Member?): AudioRecommendations {
|
fun getRecommendations(member: Member?): AudioRecommendations {
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
@@ -27,7 +25,6 @@ class AudioRecommendationQueryService(
|
|||||||
val canViewAdultContent = canViewAdultContent(member)
|
val canViewAdultContent = canViewAdultContent(member)
|
||||||
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
|
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
|
||||||
val memberId = member?.id
|
val memberId = member?.id
|
||||||
val locale = langContext.lang.code
|
|
||||||
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||||
val newAndHotSectionType = newAndHotSectionType(visibility)
|
val newAndHotSectionType = newAndHotSectionType(visibility)
|
||||||
val mostCommentedSectionType = mostCommentedSectionType(visibility)
|
val mostCommentedSectionType = mostCommentedSectionType(visibility)
|
||||||
@@ -51,28 +48,25 @@ class AudioRecommendationQueryService(
|
|||||||
return AudioRecommendations(
|
return AudioRecommendations(
|
||||||
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent, bannerLang),
|
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent, bannerLang),
|
||||||
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
|
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
|
||||||
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now, locale),
|
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
||||||
newAndHotAudios = queryPort.findAudioCardsByIds(
|
newAndHotAudios = queryPort.findAudioCardsByIds(
|
||||||
newAndHotSnapshots.map { it.targetId },
|
newAndHotSnapshots.map { it.targetId },
|
||||||
memberId,
|
memberId,
|
||||||
canViewAdultContent,
|
canViewAdultContent,
|
||||||
now,
|
now
|
||||||
locale
|
|
||||||
),
|
),
|
||||||
freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, memberId, canViewAdultContent, now, locale),
|
freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
||||||
pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, memberId, canViewAdultContent, now, locale),
|
pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
||||||
mostCommentedAudios = queryPort.findCommentedAudiosByIds(
|
mostCommentedAudios = queryPort.findCommentedAudiosByIds(
|
||||||
mostCommentedSnapshots.map { it.targetId },
|
mostCommentedSnapshots.map { it.targetId },
|
||||||
memberId,
|
memberId,
|
||||||
canViewAdultContent,
|
canViewAdultContent
|
||||||
locale
|
|
||||||
),
|
),
|
||||||
recommendedAudios = queryPort.findAudioCardsByIds(
|
recommendedAudios = queryPort.findAudioCardsByIds(
|
||||||
recommendedSnapshots.map { it.targetId },
|
recommendedSnapshots.map { it.targetId },
|
||||||
memberId,
|
memberId,
|
||||||
canViewAdultContent,
|
canViewAdultContent,
|
||||||
now,
|
now
|
||||||
locale
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -89,8 +83,7 @@ class AudioRecommendationQueryService(
|
|||||||
snapshots.map { it.targetId },
|
snapshots.map { it.targetId },
|
||||||
member.id,
|
member.id,
|
||||||
canViewAdultContent,
|
canViewAdultContent,
|
||||||
now,
|
now
|
||||||
langContext.lang.code
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-29
@@ -17,40 +17,16 @@ interface AudioRecommendationQueryPort {
|
|||||||
lang: Lang = Lang.KO
|
lang: Lang = Lang.KO
|
||||||
): List<RecommendationBanner>
|
): List<RecommendationBanner>
|
||||||
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
||||||
fun findLatestAudios(
|
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||||
limit: Int,
|
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||||
memberId: Long?,
|
fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
now: LocalDateTime,
|
|
||||||
locale: String
|
|
||||||
): List<AudioCard>
|
|
||||||
fun findFreeAudios(
|
|
||||||
limit: Int,
|
|
||||||
memberId: Long?,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
now: LocalDateTime,
|
|
||||||
locale: String
|
|
||||||
): List<AudioCard>
|
|
||||||
fun findPointAudios(
|
|
||||||
limit: Int,
|
|
||||||
memberId: Long?,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
now: LocalDateTime,
|
|
||||||
locale: String
|
|
||||||
): List<AudioCard>
|
|
||||||
fun findAudioCardsByIds(
|
fun findAudioCardsByIds(
|
||||||
contentIds: List<Long>,
|
contentIds: List<Long>,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime
|
||||||
locale: String
|
|
||||||
): List<AudioCard>
|
): List<AudioCard>
|
||||||
fun findCommentedAudiosByIds(
|
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
|
||||||
contentIds: List<Long>,
|
|
||||||
memberId: Long?,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
locale: String
|
|
||||||
): List<CommentedAudio>
|
|
||||||
fun findNewAndHotSnapshots(
|
fun findNewAndHotSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
|
|||||||
+2
-23
@@ -12,7 +12,6 @@ import kr.co.vividnext.sodalive.content.order.QOrder.order
|
|||||||
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
||||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslation
|
import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslation
|
||||||
import kr.co.vividnext.sodalive.content.translation.QContentTranslation
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
@@ -174,11 +173,10 @@ class DefaultCreatorChannelAudioQueryRepository(
|
|||||||
val rows = findAudioContentRows(creatorId, viewerId, themeId, now, canViewAdultContent, sort, offset, limit)
|
val rows = findAudioContentRows(creatorId, viewerId, themeId, now, canViewAdultContent, sort, offset, limit)
|
||||||
val contentIds = rows.map { itAudioId(it) }
|
val contentIds = rows.map { itAudioId(it) }
|
||||||
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
||||||
val titlesByContentId = audioTitlesByContentIds(contentIds, locale)
|
|
||||||
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
|
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
|
||||||
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
||||||
|
|
||||||
return rows.map { it.toAudioRecord(firstContentId, titlesByContentId, seriesByContentId, orderStatesByContentId) }
|
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findAudioContentRows(
|
private fun findAudioContentRows(
|
||||||
@@ -309,7 +307,6 @@ class DefaultCreatorChannelAudioQueryRepository(
|
|||||||
|
|
||||||
private fun Tuple.toAudioRecord(
|
private fun Tuple.toAudioRecord(
|
||||||
firstContentId: Long?,
|
firstContentId: Long?,
|
||||||
titlesByContentId: Map<Long, String>,
|
|
||||||
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
||||||
orderStatesByContentId: Map<Long, AudioOrderState>
|
orderStatesByContentId: Map<Long, AudioOrderState>
|
||||||
): CreatorChannelAudioContentRecord {
|
): CreatorChannelAudioContentRecord {
|
||||||
@@ -318,7 +315,7 @@ class DefaultCreatorChannelAudioQueryRepository(
|
|||||||
val orderState = orderStatesByContentId[audioContentId]
|
val orderState = orderStatesByContentId[audioContentId]
|
||||||
return CreatorChannelAudioContentRecord(
|
return CreatorChannelAudioContentRecord(
|
||||||
audioContentId = audioContentId,
|
audioContentId = audioContentId,
|
||||||
title = titlesByContentId[audioContentId] ?: get(audioContent.title)!!,
|
title = get(audioContent.title)!!,
|
||||||
duration = get(audioContent.duration),
|
duration = get(audioContent.duration),
|
||||||
imagePath = get(audioContent.coverImage),
|
imagePath = get(audioContent.coverImage),
|
||||||
price = get(audioContent.price)!!,
|
price = get(audioContent.price)!!,
|
||||||
@@ -332,24 +329,6 @@ class DefaultCreatorChannelAudioQueryRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun audioTitlesByContentIds(contentIds: List<Long>, locale: String): Map<Long, String> {
|
|
||||||
if (contentIds.isEmpty()) return emptyMap()
|
|
||||||
val contentTranslation = QContentTranslation("audioContentTranslation")
|
|
||||||
return queryFactory
|
|
||||||
.select(contentTranslation.contentId, contentTranslation.renderedPayload)
|
|
||||||
.from(contentTranslation)
|
|
||||||
.where(
|
|
||||||
contentTranslation.contentId.`in`(contentIds),
|
|
||||||
contentTranslation.locale.eq(locale)
|
|
||||||
)
|
|
||||||
.fetch()
|
|
||||||
.mapNotNull {
|
|
||||||
val title = it.get(contentTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank)
|
|
||||||
title?.let { translatedTitle -> it.get(contentTranslation.contentId)!! to translatedTitle }
|
|
||||||
}
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun firstAudioContentId(
|
private fun firstAudioContentId(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
|
|||||||
+4
-23
@@ -21,7 +21,6 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha
|
|||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
|
||||||
import org.springframework.beans.factory.ObjectProvider
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -37,7 +36,6 @@ class CreatorChannelCommunityQueryService(
|
|||||||
private val audioContentCloudFront: AudioContentCloudFront,
|
private val audioContentCloudFront: AudioContentCloudFront,
|
||||||
private val messageSource: SodaMessageSource,
|
private val messageSource: SodaMessageSource,
|
||||||
private val langContext: LangContext,
|
private val langContext: LangContext,
|
||||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
) {
|
) {
|
||||||
@@ -71,9 +69,6 @@ class CreatorChannelCommunityQueryService(
|
|||||||
offset = communityPage.offset,
|
offset = communityPage.offset,
|
||||||
limit = communityPage.fetchLimit
|
limit = communityPage.fetchLimit
|
||||||
)
|
)
|
||||||
val communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage)
|
|
||||||
val displayContents = creatorCommunityTranslationService
|
|
||||||
.findDisplayContents(communityPosts.map { it.postId }, langContext.lang.code)
|
|
||||||
|
|
||||||
return CreatorChannelCommunityTab(
|
return CreatorChannelCommunityTab(
|
||||||
communityPostCount = queryPort.countCommunityPosts(
|
communityPostCount = queryPort.countCommunityPosts(
|
||||||
@@ -81,9 +76,7 @@ class CreatorChannelCommunityQueryService(
|
|||||||
viewerId = viewerId,
|
viewerId = viewerId,
|
||||||
canViewAdultContent = canViewAdultContent
|
canViewAdultContent = canViewAdultContent
|
||||||
),
|
),
|
||||||
communityPosts = communityPosts.map {
|
communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage).map { it.toDomain(viewerId) },
|
||||||
it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId)
|
|
||||||
},
|
|
||||||
page = communityPage,
|
page = communityPage,
|
||||||
hasNext = queryPolicy.hasNext(fetchedPosts, communityPage)
|
hasNext = queryPolicy.hasNext(fetchedPosts, communityPage)
|
||||||
)
|
)
|
||||||
@@ -96,7 +89,7 @@ class CreatorChannelCommunityQueryService(
|
|||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelCommunityPost> {
|
): List<CreatorChannelCommunityPost> {
|
||||||
val posts = queryPortProvider.getObject()
|
return queryPortProvider.getObject()
|
||||||
.findHomeCommunityPosts(
|
.findHomeCommunityPosts(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
viewerId = viewerId,
|
viewerId = viewerId,
|
||||||
@@ -104,12 +97,7 @@ class CreatorChannelCommunityQueryService(
|
|||||||
canViewAdultContent = canViewAdultContent,
|
canViewAdultContent = canViewAdultContent,
|
||||||
limit = limit
|
limit = limit
|
||||||
)
|
)
|
||||||
val displayContents = creatorCommunityTranslationService
|
.map { it.toDomain(viewerId) }
|
||||||
.findDisplayContents(posts.map { it.postId }, langContext.lang.code)
|
|
||||||
|
|
||||||
return posts.map {
|
|
||||||
it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCommunityPostDetail(
|
fun getCommunityPostDetail(
|
||||||
@@ -125,15 +113,8 @@ class CreatorChannelCommunityQueryService(
|
|||||||
|
|
||||||
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
|
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
|
||||||
|
|
||||||
val locale = langContext.lang.code
|
|
||||||
val displayContent = creatorCommunityTranslationService
|
|
||||||
.findDisplayContents(listOf(postRecord.postId), locale)[postRecord.postId]
|
|
||||||
if (displayContent?.hasValidTranslation != true) {
|
|
||||||
creatorCommunityTranslationService.requestTranslations(postRecord.postId, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreatorChannelCommunityPostDetail(
|
return CreatorChannelCommunityPostDetail(
|
||||||
post = postRecord.copy(content = displayContent?.content ?: postRecord.content).toDomain(viewerId),
|
post = postRecord.toDomain(viewerId),
|
||||||
comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now)
|
comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Table
|
|
||||||
import javax.persistence.UniqueConstraint
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(
|
|
||||||
name = "creator_community_translation",
|
|
||||||
uniqueConstraints = [
|
|
||||||
UniqueConstraint(
|
|
||||||
name = "uk_creator_community_translation_post_locale",
|
|
||||||
columnNames = ["creator_community_id", "locale"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class CreatorCommunityTranslation(
|
|
||||||
@Column(name = "creator_community_id", nullable = false)
|
|
||||||
val creatorCommunityId: Long,
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 10)
|
|
||||||
val locale: String,
|
|
||||||
|
|
||||||
@Column(nullable = false, columnDefinition = "text")
|
|
||||||
var content: String,
|
|
||||||
|
|
||||||
@Column(name = "source_revision", nullable = false)
|
|
||||||
var sourceRevision: Long,
|
|
||||||
|
|
||||||
@Column(name = "source_hash", nullable = false, length = 64)
|
|
||||||
var sourceHash: String,
|
|
||||||
|
|
||||||
@Column(name = "source_language", nullable = false, length = 10)
|
|
||||||
var sourceLanguage: String
|
|
||||||
) : BaseEntity()
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
import org.springframework.data.jpa.repository.Lock
|
|
||||||
import org.springframework.data.jpa.repository.Query
|
|
||||||
import org.springframework.data.repository.query.Param
|
|
||||||
import javax.persistence.LockModeType
|
|
||||||
|
|
||||||
interface CreatorCommunityTranslationRepository : JpaRepository<CreatorCommunityTranslation, Long> {
|
|
||||||
fun findByCreatorCommunityIdAndLocale(creatorCommunityId: Long, locale: String): CreatorCommunityTranslation?
|
|
||||||
|
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
@Query(
|
|
||||||
"select t from CreatorCommunityTranslation t " +
|
|
||||||
"where t.creatorCommunityId = :creatorCommunityId and t.locale = :locale"
|
|
||||||
)
|
|
||||||
fun findByCreatorCommunityIdAndLocaleForUpdate(
|
|
||||||
@Param("creatorCommunityId") creatorCommunityId: Long,
|
|
||||||
@Param("locale") locale: String
|
|
||||||
): CreatorCommunityTranslation?
|
|
||||||
|
|
||||||
fun findByCreatorCommunityIdInAndLocale(
|
|
||||||
creatorCommunityIds: Collection<Long>,
|
|
||||||
locale: String
|
|
||||||
): List<CreatorCommunityTranslation>
|
|
||||||
}
|
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Propagation
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import javax.persistence.EntityManager
|
|
||||||
import javax.persistence.LockModeType
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class CreatorCommunityTranslationService(
|
|
||||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
|
||||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
|
||||||
private val sourceExtractor: TranslationSourceExtractor,
|
|
||||||
private val materializer: TranslationReadModelMaterializer,
|
|
||||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
|
||||||
private val entityManager: EntityManager
|
|
||||||
) {
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findDisplayContents(postIds: List<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent> {
|
|
||||||
if (postIds.isEmpty()) return emptyMap()
|
|
||||||
|
|
||||||
val normalizedLocale = locale.lowercase()
|
|
||||||
val posts = creatorCommunityRepository.findAllById(postIds).filter { it.isActive }
|
|
||||||
val translations = creatorCommunityTranslationRepository
|
|
||||||
.findByCreatorCommunityIdInAndLocale(posts.mapNotNull { it.id }, normalizedLocale)
|
|
||||||
.associateBy { it.creatorCommunityId }
|
|
||||||
|
|
||||||
return posts.associate { post ->
|
|
||||||
post.id!! to translations[post.id].displayContentOr(post, normalizedLocale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
fun requestTranslations(postId: Long, targetLanguage: String? = null) {
|
|
||||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(postId) ?: return
|
|
||||||
entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
|
|
||||||
val source = sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)
|
|
||||||
if (source == null) {
|
|
||||||
requestLanguageDetection(postId, targetLanguage)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val sourceLanguage = source.sourceLanguage.lowercase()
|
|
||||||
val targetLanguages = targetLanguage?.let { setOf(it.lowercase()) }
|
|
||||||
?: getTranslatableLanguageCodes(sourceLanguage)
|
|
||||||
|
|
||||||
targetLanguages
|
|
||||||
.filter { it != sourceLanguage }
|
|
||||||
.forEach { language ->
|
|
||||||
if (!materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, language)) {
|
|
||||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
|
||||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
|
||||||
resourceId = postId,
|
|
||||||
targetLanguage = language
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestLanguageDetection(postId: Long, targetLanguage: String?) {
|
|
||||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(postId) ?: return
|
|
||||||
if (!creatorCommunity.languageCode.isNullOrBlank() || creatorCommunity.content.isBlank()) return
|
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageDetectEvent(
|
|
||||||
id = postId,
|
|
||||||
query = creatorCommunity.content,
|
|
||||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
|
||||||
sourceRevision = creatorCommunity.contentRevision,
|
|
||||||
targetLanguage = targetLanguage
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun CreatorCommunityTranslation?.displayContentOr(
|
|
||||||
creatorCommunity: CreatorCommunity,
|
|
||||||
locale: String
|
|
||||||
): CreatorCommunityDisplayContent {
|
|
||||||
val sourceLanguage = creatorCommunity.languageCode?.lowercase()
|
|
||||||
return if (
|
|
||||||
this != null &&
|
|
||||||
sourceLanguage != null &&
|
|
||||||
sourceLanguage != locale &&
|
|
||||||
sourceRevision == creatorCommunity.contentRevision &&
|
|
||||||
sourceLanguage == this.sourceLanguage.lowercase() &&
|
|
||||||
sourceHash == SourceTextNormalizer.hash(creatorCommunity.content)
|
|
||||||
) {
|
|
||||||
CreatorCommunityDisplayContent(content = content, hasValidTranslation = true)
|
|
||||||
} else {
|
|
||||||
CreatorCommunityDisplayContent(content = creatorCommunity.content, hasValidTranslation = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorCommunityDisplayContent(
|
|
||||||
val content: String,
|
|
||||||
val hasValidTranslation: Boolean
|
|
||||||
)
|
|
||||||
+17
-74
@@ -10,8 +10,6 @@ import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
|||||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||||
import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
|
import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
|
||||||
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
|
||||||
import kr.co.vividnext.sodalive.content.translation.QContentTranslation
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
|
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
|
||||||
@@ -155,15 +153,13 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long?,
|
viewerId: Long?
|
||||||
locale: String
|
|
||||||
): CreatorChannelAudioContentRecord? {
|
): CreatorChannelAudioContentRecord? {
|
||||||
val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null
|
val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null
|
||||||
val contentId = itAudioId(row)
|
val contentId = itAudioId(row)
|
||||||
return row.toAudioRecord(
|
return row.toAudioRecord(
|
||||||
firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent),
|
firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent),
|
||||||
titlesByContentId = audioTitlesByContentIds(listOf(contentId), locale),
|
seriesByContentId = audioSeriesByContentIds(listOf(contentId)),
|
||||||
seriesByContentId = audioSeriesByContentIds(listOf(contentId), locale),
|
|
||||||
orderStatesByContentId = orderStatesByContentIds(viewerId, listOf(contentId), now)
|
orderStatesByContentId = orderStatesByContentIds(viewerId, listOf(contentId), now)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -213,7 +209,6 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
isViewerCreator: Boolean,
|
isViewerCreator: Boolean,
|
||||||
effectiveViewerGender: Gender?,
|
effectiveViewerGender: Gender?,
|
||||||
locale: String,
|
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelScheduleRecord> {
|
): List<CreatorChannelScheduleRecord> {
|
||||||
val liveSchedules = queryFactory
|
val liveSchedules = queryFactory
|
||||||
@@ -240,21 +235,18 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
|
|
||||||
val contentTranslation = QContentTranslation("homeScheduleContentTranslation")
|
|
||||||
val audioSchedules = queryFactory
|
val audioSchedules = queryFactory
|
||||||
.select(
|
.select(
|
||||||
audioContent.releaseDate,
|
Projections.constructor(
|
||||||
audioContent.title,
|
CreatorChannelScheduleRecord::class.java,
|
||||||
contentTranslation.renderedPayload,
|
audioContent.releaseDate,
|
||||||
audioContent.id,
|
audioContent.title,
|
||||||
audioContent.isAdult
|
Expressions.constant(CreatorActivityType.AUDIO),
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.isAdult
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.from(audioContent)
|
.from(audioContent)
|
||||||
.leftJoin(contentTranslation)
|
|
||||||
.on(
|
|
||||||
contentTranslation.contentId.eq(audioContent.id),
|
|
||||||
contentTranslation.locale.eq(locale)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
audioContent.member.id.eq(creatorId),
|
audioContent.member.id.eq(creatorId),
|
||||||
audioContent.member.isActive.isTrue,
|
audioContent.member.isActive.isTrue,
|
||||||
@@ -264,17 +256,6 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
adultAudioCondition(canViewAdultContent)
|
adultAudioCondition(canViewAdultContent)
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
.map {
|
|
||||||
val originalTitle = it.get(audioContent.title)!!
|
|
||||||
val translatedTitle = it.get(contentTranslation.renderedPayload)?.title
|
|
||||||
CreatorChannelScheduleRecord(
|
|
||||||
scheduledAt = it.get(audioContent.releaseDate)!!,
|
|
||||||
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle,
|
|
||||||
type = CreatorActivityType.AUDIO,
|
|
||||||
targetId = it.get(audioContent.id)!!,
|
|
||||||
isAdult = it.get(audioContent.isAdult)!!
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (liveSchedules + audioSchedules)
|
return (liveSchedules + audioSchedules)
|
||||||
.sortedWith(compareBy<CreatorChannelScheduleRecord> { it.scheduledAt }.thenBy { it.type.sortOrder })
|
.sortedWith(compareBy<CreatorChannelScheduleRecord> { it.scheduledAt }.thenBy { it.type.sortOrder })
|
||||||
@@ -287,16 +268,14 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
latestAudioContentId: Long?,
|
latestAudioContentId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
locale: String,
|
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelAudioContentRecord> {
|
): List<CreatorChannelAudioContentRecord> {
|
||||||
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true)
|
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true)
|
||||||
val contentIds = rows.map { itAudioId(it) }
|
val contentIds = rows.map { itAudioId(it) }
|
||||||
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
||||||
val titlesByContentId = audioTitlesByContentIds(contentIds, locale)
|
val seriesByContentId = audioSeriesByContentIds(contentIds)
|
||||||
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
|
|
||||||
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
||||||
return rows.map { it.toAudioRecord(firstContentId, titlesByContentId, seriesByContentId, orderStatesByContentId) }
|
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findSeries(
|
override fun findSeries(
|
||||||
@@ -305,24 +284,16 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
locale: String,
|
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelSeriesRecord> {
|
): List<CreatorChannelSeriesRecord> {
|
||||||
val seriesTranslation = QSeriesTranslation("homeSeriesTranslation")
|
|
||||||
val seriesRows = queryFactory
|
val seriesRows = queryFactory
|
||||||
.select(
|
.select(
|
||||||
series.id,
|
series.id,
|
||||||
series.title,
|
series.title,
|
||||||
seriesTranslation.renderedPayload,
|
|
||||||
series.coverImage,
|
series.coverImage,
|
||||||
series.isOriginal
|
series.isOriginal
|
||||||
)
|
)
|
||||||
.from(series)
|
.from(series)
|
||||||
.leftJoin(seriesTranslation)
|
|
||||||
.on(
|
|
||||||
seriesTranslation.seriesId.eq(series.id),
|
|
||||||
seriesTranslation.locale.eq(locale)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
series.member.id.eq(creatorId),
|
series.member.id.eq(creatorId),
|
||||||
series.member.isActive.isTrue,
|
series.member.isActive.isTrue,
|
||||||
@@ -346,8 +317,7 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
val seriesId = seriesRow.get(series.id)!!
|
val seriesId = seriesRow.get(series.id)!!
|
||||||
CreatorChannelSeriesRecord(
|
CreatorChannelSeriesRecord(
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
title = seriesRow.get(seriesTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank)
|
title = seriesRow.get(series.title)!!,
|
||||||
?: seriesRow.get(series.title)!!,
|
|
||||||
coverImagePath = seriesRow.get(series.coverImage),
|
coverImagePath = seriesRow.get(series.coverImage),
|
||||||
numberOfContent = stats.contentCount,
|
numberOfContent = stats.contentCount,
|
||||||
isNew = seriesId in newSeriesIds,
|
isNew = seriesId in newSeriesIds,
|
||||||
@@ -523,7 +493,6 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
|
|
||||||
private fun com.querydsl.core.Tuple.toAudioRecord(
|
private fun com.querydsl.core.Tuple.toAudioRecord(
|
||||||
firstContentId: Long?,
|
firstContentId: Long?,
|
||||||
titlesByContentId: Map<Long, String>,
|
|
||||||
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
||||||
orderStatesByContentId: Map<Long, AudioOrderState>
|
orderStatesByContentId: Map<Long, AudioOrderState>
|
||||||
): CreatorChannelAudioContentRecord {
|
): CreatorChannelAudioContentRecord {
|
||||||
@@ -532,7 +501,7 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
val orderState = orderStatesByContentId[audioContentId]
|
val orderState = orderStatesByContentId[audioContentId]
|
||||||
return CreatorChannelAudioContentRecord(
|
return CreatorChannelAudioContentRecord(
|
||||||
audioContentId = audioContentId,
|
audioContentId = audioContentId,
|
||||||
title = titlesByContentId[audioContentId] ?: get(audioContent.title)!!,
|
title = get(audioContent.title)!!,
|
||||||
duration = get(audioContent.duration),
|
duration = get(audioContent.duration),
|
||||||
imagePath = get(audioContent.coverImage),
|
imagePath = get(audioContent.coverImage),
|
||||||
price = get(audioContent.price)!!,
|
price = get(audioContent.price)!!,
|
||||||
@@ -546,24 +515,6 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun audioTitlesByContentIds(contentIds: List<Long>, locale: String): Map<Long, String> {
|
|
||||||
if (contentIds.isEmpty()) return emptyMap()
|
|
||||||
val contentTranslation = QContentTranslation("homeAudioContentTranslation")
|
|
||||||
return queryFactory
|
|
||||||
.select(contentTranslation.contentId, contentTranslation.renderedPayload)
|
|
||||||
.from(contentTranslation)
|
|
||||||
.where(
|
|
||||||
contentTranslation.contentId.`in`(contentIds),
|
|
||||||
contentTranslation.locale.eq(locale)
|
|
||||||
)
|
|
||||||
.fetch()
|
|
||||||
.mapNotNull {
|
|
||||||
val title = it.get(contentTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank)
|
|
||||||
title?.let { translatedTitle -> it.get(contentTranslation.contentId)!! to translatedTitle }
|
|
||||||
}
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun orderStatesByContentIds(
|
private fun orderStatesByContentIds(
|
||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
contentIds: List<Long>,
|
contentIds: List<Long>,
|
||||||
@@ -591,25 +542,17 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun audioSeriesByContentIds(contentIds: List<Long>, locale: String): Map<Long, AudioSeriesSummary> {
|
private fun audioSeriesByContentIds(contentIds: List<Long>): Map<Long, AudioSeriesSummary> {
|
||||||
if (contentIds.isEmpty()) return emptyMap()
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
val seriesTranslation = QSeriesTranslation("homeAudioSeriesTranslation")
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(seriesContent.content.id, series.title, seriesTranslation.renderedPayload, series.isOriginal)
|
.select(seriesContent.content.id, series.title, series.isOriginal)
|
||||||
.from(seriesContent)
|
.from(seriesContent)
|
||||||
.innerJoin(seriesContent.series, series)
|
.innerJoin(seriesContent.series, series)
|
||||||
.leftJoin(seriesTranslation)
|
|
||||||
.on(
|
|
||||||
seriesTranslation.seriesId.eq(series.id),
|
|
||||||
seriesTranslation.locale.eq(locale)
|
|
||||||
)
|
|
||||||
.where(seriesContent.content.id.`in`(contentIds))
|
.where(seriesContent.content.id.`in`(contentIds))
|
||||||
.fetch()
|
.fetch()
|
||||||
.associate {
|
.associate {
|
||||||
val originalTitle = it.get(series.title)!!
|
|
||||||
val translatedTitle = it.get(seriesTranslation.renderedPayload)?.title
|
|
||||||
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
|
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
|
||||||
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle,
|
title = it.get(series.title)!!,
|
||||||
isOriginal = it.get(series.isOriginal)!!
|
isOriginal = it.get(series.isOriginal)!!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-8
@@ -73,9 +73,8 @@ class CreatorChannelHomeQueryService(
|
|||||||
val canViewAdultContent = preference.isAdult
|
val canViewAdultContent = preference.isAdult
|
||||||
val isViewerCreator = viewer.role == MemberRole.CREATOR
|
val isViewerCreator = viewer.role == MemberRole.CREATOR
|
||||||
val effectiveViewerGender = viewer.effectiveGender()
|
val effectiveViewerGender = viewer.effectiveGender()
|
||||||
val locale = langContext.lang.code
|
|
||||||
val latestAudioContent = queryPort
|
val latestAudioContent = queryPort
|
||||||
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId, locale)
|
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId)
|
||||||
?.toDomain()
|
?.toDomain()
|
||||||
val audioContents = queryPolicy.excludeLatestAudioContent(
|
val audioContents = queryPolicy.excludeLatestAudioContent(
|
||||||
queryPort.findAudioContents(
|
queryPort.findAudioContents(
|
||||||
@@ -83,8 +82,7 @@ class CreatorChannelHomeQueryService(
|
|||||||
now = now,
|
now = now,
|
||||||
latestAudioContentId = latestAudioContent?.audioContentId,
|
latestAudioContentId = latestAudioContent?.audioContentId,
|
||||||
canViewAdultContent = canViewAdultContent,
|
canViewAdultContent = canViewAdultContent,
|
||||||
viewerId = viewerId,
|
viewerId = viewerId
|
||||||
locale = locale
|
|
||||||
).map { it.toDomain() },
|
).map { it.toDomain() },
|
||||||
latestAudioContent?.audioContentId
|
latestAudioContent?.audioContentId
|
||||||
)
|
)
|
||||||
@@ -115,8 +113,7 @@ class CreatorChannelHomeQueryService(
|
|||||||
canViewAdultContent = canViewAdultContent,
|
canViewAdultContent = canViewAdultContent,
|
||||||
viewerId = viewerId,
|
viewerId = viewerId,
|
||||||
isViewerCreator = isViewerCreator,
|
isViewerCreator = isViewerCreator,
|
||||||
effectiveViewerGender = effectiveViewerGender,
|
effectiveViewerGender = effectiveViewerGender
|
||||||
locale = locale
|
|
||||||
).map { it.toDomain() },
|
).map { it.toDomain() },
|
||||||
now,
|
now,
|
||||||
canViewAdultContent
|
canViewAdultContent
|
||||||
@@ -127,8 +124,7 @@ class CreatorChannelHomeQueryService(
|
|||||||
viewerId = viewerId,
|
viewerId = viewerId,
|
||||||
now = now,
|
now = now,
|
||||||
canViewAdultContent = canViewAdultContent,
|
canViewAdultContent = canViewAdultContent,
|
||||||
contentType = preference.contentType,
|
contentType = preference.contentType
|
||||||
locale = locale
|
|
||||||
).map { it.toDomain() },
|
).map { it.toDomain() },
|
||||||
communities = communityQueryService.findHomeCommunityPosts(
|
communities = communityQueryService.findHomeCommunityPosts(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
|
|||||||
+1
-5
@@ -24,8 +24,7 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long? = null,
|
viewerId: Long? = null
|
||||||
locale: String = "ko"
|
|
||||||
): CreatorChannelAudioContentRecord?
|
): CreatorChannelAudioContentRecord?
|
||||||
|
|
||||||
fun findChannelDonations(
|
fun findChannelDonations(
|
||||||
@@ -42,7 +41,6 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
isViewerCreator: Boolean,
|
isViewerCreator: Boolean,
|
||||||
effectiveViewerGender: Gender?,
|
effectiveViewerGender: Gender?,
|
||||||
locale: String = "ko",
|
|
||||||
limit: Int = 3
|
limit: Int = 3
|
||||||
): List<CreatorChannelScheduleRecord>
|
): List<CreatorChannelScheduleRecord>
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
latestAudioContentId: Long?,
|
latestAudioContentId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long? = null,
|
viewerId: Long? = null,
|
||||||
locale: String = "ko",
|
|
||||||
limit: Int = 9
|
limit: Int = 9
|
||||||
): List<CreatorChannelAudioContentRecord>
|
): List<CreatorChannelAudioContentRecord>
|
||||||
|
|
||||||
@@ -62,7 +59,6 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
locale: String = "ko",
|
|
||||||
limit: Int = 8
|
limit: Int = 8
|
||||||
): List<CreatorChannelSeriesRecord>
|
): List<CreatorChannelSeriesRecord>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ class CreatorChannelLiveQueryService(
|
|||||||
validateCreatorRole(creator)
|
validateCreatorRole(creator)
|
||||||
|
|
||||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
|
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
|
||||||
val isViewerCreator = viewer.role == MemberRole.CREATOR
|
val isViewerCreator = viewerId == creatorId
|
||||||
val effectiveViewerGender = viewer.effectiveGender()
|
val effectiveViewerGender = viewer.effectiveGender()
|
||||||
val fetchedContents = queryPort.findLiveReplayAudioContents(
|
val fetchedContents = queryPort.findLiveReplayAudioContents(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
|
||||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.EnumType
|
|
||||||
import javax.persistence.Enumerated
|
|
||||||
import javax.persistence.Table
|
|
||||||
import javax.persistence.UniqueConstraint
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(
|
|
||||||
name = "gift",
|
|
||||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_application_no", columnNames = ["application_no"])]
|
|
||||||
)
|
|
||||||
class Gift(
|
|
||||||
@Column(name = "application_no", nullable = false, length = 20)
|
|
||||||
val applicationNo: String,
|
|
||||||
|
|
||||||
@Column(name = "sender_member_id", nullable = false)
|
|
||||||
val senderMemberId: Long,
|
|
||||||
|
|
||||||
@Column(name = "recipient_member_id", nullable = false)
|
|
||||||
val recipientMemberId: Long,
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "status", nullable = false, length = 30)
|
|
||||||
var status: GiftStatus,
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "size_code", nullable = false, length = 20)
|
|
||||||
val sizeCode: GiftSize,
|
|
||||||
|
|
||||||
@Column(name = "category_id", nullable = false)
|
|
||||||
val categoryId: Long,
|
|
||||||
|
|
||||||
@Column(name = "category_name_snapshot", nullable = false, length = 50)
|
|
||||||
val categoryNameSnapshot: String,
|
|
||||||
|
|
||||||
@Column(name = "sale_price_can", nullable = false)
|
|
||||||
val salePriceCan: Int,
|
|
||||||
|
|
||||||
@Column(name = "can_usage_id", nullable = false)
|
|
||||||
val canUsageId: Long,
|
|
||||||
|
|
||||||
@Column(name = "sender_terms_agreed", nullable = false)
|
|
||||||
val senderTermsAgreed: Boolean,
|
|
||||||
|
|
||||||
@Column(name = "sender_privacy_agreed", nullable = false)
|
|
||||||
val senderPrivacyAgreed: Boolean,
|
|
||||||
|
|
||||||
@Column(name = "sender_terms_agreed_at", nullable = false)
|
|
||||||
val senderTermsAgreedAt: LocalDateTime,
|
|
||||||
|
|
||||||
@Column(name = "sender_privacy_agreed_at", nullable = false)
|
|
||||||
val senderPrivacyAgreedAt: LocalDateTime,
|
|
||||||
|
|
||||||
@Column(name = "recipient_terms_agreed", nullable = false)
|
|
||||||
var recipientTermsAgreed: Boolean = false,
|
|
||||||
|
|
||||||
@Column(name = "recipient_privacy_agreed", nullable = false)
|
|
||||||
var recipientPrivacyAgreed: Boolean = false,
|
|
||||||
|
|
||||||
@Column(name = "recipient_terms_agreed_at")
|
|
||||||
var recipientTermsAgreedAt: LocalDateTime? = null,
|
|
||||||
|
|
||||||
@Column(name = "recipient_privacy_agreed_at")
|
|
||||||
var recipientPrivacyAgreedAt: LocalDateTime? = null,
|
|
||||||
|
|
||||||
@Column(name = "damage_waiver_agreed", nullable = false)
|
|
||||||
val damageWaiverAgreed: Boolean = false
|
|
||||||
) : BaseEntity()
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Table
|
|
||||||
import javax.persistence.UniqueConstraint
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(
|
|
||||||
name = "gift_application_no_sequence",
|
|
||||||
uniqueConstraints = [
|
|
||||||
UniqueConstraint(
|
|
||||||
name = "uk_gift_application_no_sequence_category_date",
|
|
||||||
columnNames = ["category_id", "sequence_date"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class GiftApplicationNoSequence(
|
|
||||||
@Column(name = "category_id", nullable = false)
|
|
||||||
val categoryId: Long,
|
|
||||||
|
|
||||||
@Column(name = "sequence_date", nullable = false, length = 6)
|
|
||||||
val sequenceDate: String,
|
|
||||||
|
|
||||||
@Column(name = "last_sequence", nullable = false)
|
|
||||||
var lastSequence: Int
|
|
||||||
) : BaseEntity()
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
import org.springframework.data.jpa.repository.Lock
|
|
||||||
import org.springframework.data.jpa.repository.Query
|
|
||||||
import org.springframework.data.repository.query.Param
|
|
||||||
import javax.persistence.LockModeType
|
|
||||||
|
|
||||||
interface GiftApplicationNoSequenceRepository : JpaRepository<GiftApplicationNoSequence, Long> {
|
|
||||||
fun findByCategoryIdAndSequenceDate(categoryId: Long, sequenceDate: String): GiftApplicationNoSequence?
|
|
||||||
|
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
select s
|
|
||||||
from GiftApplicationNoSequence s
|
|
||||||
where s.categoryId = :categoryId
|
|
||||||
and s.sequenceDate = :sequenceDate
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun findByCategoryIdAndSequenceDateForUpdate(
|
|
||||||
@Param("categoryId") categoryId: Long,
|
|
||||||
@Param("sequenceDate") sequenceDate: String
|
|
||||||
): GiftApplicationNoSequence?
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user