From b67dce4afd3fdfc25ac4f3c2750d8689b8782f8c Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 13 May 2026 11:47:59 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs:=20prd=20=ED=8F=B4=EB=8D=94=EC=99=80?= =?UTF-8?q?=20sample-prd=20=EC=B6=94=EA=B0=80,=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20plan-task=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/{ => plan-task}/20260220_lsp설정추가.md | 0 .../20260220_member_fancimm_x_url_ddl.sql | 0 .../20260220_삭제닉네임접두사표시정리.md | 0 .../20260220_커밋규칙스킬분리.md | 0 .../20260220_커밋메시지검증규칙추가.md | 0 .../20260220_커스텀커맨드커밋추가.md | 0 .../20260220_팬심M와XURL추가.md | 0 .../20260223_channel_donation_message_ddl.sql | 0 ...260223_차단유저댓글및크리에이터노출차단.md | 0 .../20260223_채널후원기능추가.md | 0 .../20260223_크리에이터상세정보조회api추가.md | 0 .../20260223_회원차단동일본인인증확장.md | 0 .../20260224_SNS카카오오픈채팅전환.md | 0 .../20260225_인기크리에이터차단필터링.md | 0 ...260225_채널후원메시지_캔_천단위콤마추가.md | 0 .../20260225_회원차단동일인판별조건강화.md | 0 ..._channel_donation_settlement_index_ddl.sql | 0 ...크리에이터관리자채널후원정산페이지api생성.md | 0 ...20260226_라이브추천차단조인및캐시무효화.md | 0 ..._라이브추천차단조인캐시무효화검증테스트.md | 0 .../20260226_오리지널시리즈차단필터적용.md | 0 .../20260226_채널후원정산합계추가.md | 0 ...0226_콘텐츠시리즈상세차단오류메시지수정.md | 0 ...260226_홈콘텐츠랭킹차단크리에이터필터링.md | 0 .../20260227_채널후원후원랭킹반영.md | 0 .../20260227_최근종료라이브최적화.md | 0 ..._크리에이터프로필채널후원조회월범위수정.md | 0 ...0303_관리자채널후원정산리뷰지적사항반영.md | 0 ...자채널후원크리에이터별정산조회및엑셀API.md | 0 ...20260303_기부목록조회월범위한국시간수정.md | 0 .../20260305_관리자사용자차단기능추가.md | 0 .../20260305_관리자정산엑셀다운로드추가.md | 0 ...자정산콘텐츠크리에이터별조회SQL오류수정.md | 0 .../20260305_관리자정산페이징추가.md | 0 .../20260305_관리자충전상세응답필드수정.md | 0 .../20260305_관리자충전상세캔개수추가.md | 0 ...0305_관리자충전상세쿼리프로젝션리팩토링.md | 0 .../20260305_정산엑셀스트리밍전환.md | 0 .../{ => plan-task}/20260305_캔환불API생성.md | 0 ...0305_콘텐츠후원정산70퍼센트검증및최적화.md | 0 .../20260309_푸시딥링크검증.md | 0 .../20260309_푸시딥링크파라미터추가.md | 0 .../20260311_푸시알림리스트구현.md | 0 .../20260312_푸시알림조회쿼리오류수정.md | 0 ...브추천팔로잉전체채널조회그룹바이오류수정.md | 0 ...13_크리에이터커뮤니티댓글알림딥링크적용.md | 0 ...20260313_푸시시스템카테고리저장정책보완.md | 0 ...260313_푸시알림조회기간타임존정합성수정.md | 0 .../20260316_라이브환불기능추가.md | 0 .../20260316_작업문서한글명변경.md | 0 .../20260316_캐릭터등록JP성별일본어변환.md | 0 ...20260316_캔사용내역조회DISTINCT오류수정.md | 0 .../20260316_캔사용내역조회리팩토링.md | 0 .../20260316_캔사용내역타임존및널처리개선.md | 0 ...16_크리에이터커뮤니티게시물고정기능추가.md | 0 .../20260317_라이브방후원랭킹기간반영.md | 0 ...20260319_라이브룸채팅얼림상태저장및조회.md | 0 .../20260324_라이브생성_19금방전환로직추가.md | 0 ...0324_차단유저구매콘텐츠상세조회예외처리.md | 0 .../20260325_콘텐츠조회설정서버저장전환.md | 0 .../20260325_회원차단요청id만적용.md | 0 ...20260326_member_content_preference_ddl.sql | 0 .../20260327_멤버콘텐츠선호기본값조정.md | 0 ...20260327_멤버콘텐츠선호신규생성정책수정.md | 0 ...260328_라이브진행중목록19금노출정책수정.md | 0 .../20260328_채널후원탈퇴닉네임접두사제거.md | 0 ...텐츠조회파라미터제거및비로그인기본값고정.md | 0 ...260330_live_room_capture_recording_ddl.sql | 0 .../20260330_라이브캡쳐녹화설정추가.md | 0 .../20260330_애플로그인aud검증실패원인분석.md | 0 ...20260402_AI캐릭터본인인증국가별분기적용.md | 0 ...20260402_audio_content_banner_lang_ddl.sql | 0 ...0260402_chat_character_banner_lang_ddl.sql | 0 ...live_recommend_creator_banner_lang_ddl.sql | 0 ...20260402_관리자채팅배너목록언어표기추가.md | 0 .../20260402_라이브추천크리에이터언어적용.md | 0 .../20260402_시리즈배너언어별조회적용.md | 0 .../20260402_오디오콘텐츠배너언어적용.md | 0 .../20260402_일본어채팅캐릭터배너추가.md | 0 .../20260402_쿠폰사용본인인증예외추가.md | 0 .../20260403_메시지전송username추가.md | 0 docs/{ => plan-task}/20260406_omxgitignore.md | 0 ...407_audio_content_settlement_ratio_ddl.sql | 0 .../20260407_커밋footer자동추가차단.md | 0 .../20260407_콘텐츠별정산요율추가.md | 0 .../20260408_에이전트권한및정산기능추가.md | 0 ...409_partner_agent_assignment_ratio_ddl.sql | 0 ...0410_관리자에이전트정산상세조회구현계획.md | 0 ...20260410_관리자에이전트정산상세조회설계.md | 0 ...0410_에이전트권한및정산기능컨텍스트리뷰.md | 0 .../20260410_에이전트정산기능QA.md | 0 .../20260411_에이전트검색기능추가.md | 0 .../20260411_에이전트정산비율수정오류수정.md | 0 ..._에이전트소속크리에이터프로필이미지추가.md | 0 ...60413_에이전트크리에이터소속시간UTC변환.md | 0 ...0260415_에이전트정산기본비율7퍼센트변경.md | 0 .../20260421_라이브방무료여부응답추가.md | 0 .../20260421_오리지널시리즈정산내역.md | 0 .../20260429_에이전트가이드통합정리.md | 0 .../20260429_연속작업계획문서재사용규칙.md | 0 .../20260429_채팅방쿼터충전방식확장.md | 0 .../20260501_payverse-jpy-지원.md | 0 .../20260506_번역언어감지효율화구상.md | 0 .../20260507_번역작업원문언어제한.md | 0 ...7_콘텐츠관리자권한및관리자로그인API추가.md | 0 ...8_크리에이터관리자로그아웃AGENT권한허용.md | 0 docs/prd/sample-prd.md | 106 ++++++++++++++++++ 107 files changed, 106 insertions(+) rename docs/{ => plan-task}/20260220_lsp설정추가.md (100%) rename docs/{ => plan-task}/20260220_member_fancimm_x_url_ddl.sql (100%) rename docs/{ => plan-task}/20260220_삭제닉네임접두사표시정리.md (100%) rename docs/{ => plan-task}/20260220_커밋규칙스킬분리.md (100%) rename docs/{ => plan-task}/20260220_커밋메시지검증규칙추가.md (100%) rename docs/{ => plan-task}/20260220_커스텀커맨드커밋추가.md (100%) rename docs/{ => plan-task}/20260220_팬심M와XURL추가.md (100%) rename docs/{ => plan-task}/20260223_channel_donation_message_ddl.sql (100%) rename docs/{ => plan-task}/20260223_차단유저댓글및크리에이터노출차단.md (100%) rename docs/{ => plan-task}/20260223_채널후원기능추가.md (100%) rename docs/{ => plan-task}/20260223_크리에이터상세정보조회api추가.md (100%) rename docs/{ => plan-task}/20260223_회원차단동일본인인증확장.md (100%) rename docs/{ => plan-task}/20260224_SNS카카오오픈채팅전환.md (100%) rename docs/{ => plan-task}/20260225_인기크리에이터차단필터링.md (100%) rename docs/{ => plan-task}/20260225_채널후원메시지_캔_천단위콤마추가.md (100%) rename docs/{ => plan-task}/20260225_회원차단동일인판별조건강화.md (100%) rename docs/{ => plan-task}/20260226_channel_donation_settlement_index_ddl.sql (100%) rename docs/{ => plan-task}/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md (100%) rename docs/{ => plan-task}/20260226_라이브추천차단조인및캐시무효화.md (100%) rename docs/{ => plan-task}/20260226_라이브추천차단조인캐시무효화검증테스트.md (100%) rename docs/{ => plan-task}/20260226_오리지널시리즈차단필터적용.md (100%) rename docs/{ => plan-task}/20260226_채널후원정산합계추가.md (100%) rename docs/{ => plan-task}/20260226_콘텐츠시리즈상세차단오류메시지수정.md (100%) rename docs/{ => plan-task}/20260226_홈콘텐츠랭킹차단크리에이터필터링.md (100%) rename docs/{ => plan-task}/20260227_채널후원후원랭킹반영.md (100%) rename docs/{ => plan-task}/20260227_최근종료라이브최적화.md (100%) rename docs/{ => plan-task}/20260227_크리에이터프로필채널후원조회월범위수정.md (100%) rename docs/{ => plan-task}/20260303_관리자채널후원정산리뷰지적사항반영.md (100%) rename docs/{ => plan-task}/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md (100%) rename docs/{ => plan-task}/20260303_기부목록조회월범위한국시간수정.md (100%) rename docs/{ => plan-task}/20260305_관리자사용자차단기능추가.md (100%) rename docs/{ => plan-task}/20260305_관리자정산엑셀다운로드추가.md (100%) rename docs/{ => plan-task}/20260305_관리자정산콘텐츠크리에이터별조회SQL오류수정.md (100%) rename docs/{ => plan-task}/20260305_관리자정산페이징추가.md (100%) rename docs/{ => plan-task}/20260305_관리자충전상세응답필드수정.md (100%) rename docs/{ => plan-task}/20260305_관리자충전상세캔개수추가.md (100%) rename docs/{ => plan-task}/20260305_관리자충전상세쿼리프로젝션리팩토링.md (100%) rename docs/{ => plan-task}/20260305_정산엑셀스트리밍전환.md (100%) rename docs/{ => plan-task}/20260305_캔환불API생성.md (100%) rename docs/{ => plan-task}/20260305_콘텐츠후원정산70퍼센트검증및최적화.md (100%) rename docs/{ => plan-task}/20260309_푸시딥링크검증.md (100%) rename docs/{ => plan-task}/20260309_푸시딥링크파라미터추가.md (100%) rename docs/{ => plan-task}/20260311_푸시알림리스트구현.md (100%) rename docs/{ => plan-task}/20260312_푸시알림조회쿼리오류수정.md (100%) rename docs/{ => plan-task}/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md (100%) rename docs/{ => plan-task}/20260313_크리에이터커뮤니티댓글알림딥링크적용.md (100%) rename docs/{ => plan-task}/20260313_푸시시스템카테고리저장정책보완.md (100%) rename docs/{ => plan-task}/20260313_푸시알림조회기간타임존정합성수정.md (100%) rename docs/{ => plan-task}/20260316_라이브환불기능추가.md (100%) rename docs/{ => plan-task}/20260316_작업문서한글명변경.md (100%) rename docs/{ => plan-task}/20260316_캐릭터등록JP성별일본어변환.md (100%) rename docs/{ => plan-task}/20260316_캔사용내역조회DISTINCT오류수정.md (100%) rename docs/{ => plan-task}/20260316_캔사용내역조회리팩토링.md (100%) rename docs/{ => plan-task}/20260316_캔사용내역타임존및널처리개선.md (100%) rename docs/{ => plan-task}/20260316_크리에이터커뮤니티게시물고정기능추가.md (100%) rename docs/{ => plan-task}/20260317_라이브방후원랭킹기간반영.md (100%) rename docs/{ => plan-task}/20260319_라이브룸채팅얼림상태저장및조회.md (100%) rename docs/{ => plan-task}/20260324_라이브생성_19금방전환로직추가.md (100%) rename docs/{ => plan-task}/20260324_차단유저구매콘텐츠상세조회예외처리.md (100%) rename docs/{ => plan-task}/20260325_콘텐츠조회설정서버저장전환.md (100%) rename docs/{ => plan-task}/20260325_회원차단요청id만적용.md (100%) rename docs/{ => plan-task}/20260326_member_content_preference_ddl.sql (100%) rename docs/{ => plan-task}/20260327_멤버콘텐츠선호기본값조정.md (100%) rename docs/{ => plan-task}/20260327_멤버콘텐츠선호신규생성정책수정.md (100%) rename docs/{ => plan-task}/20260328_라이브진행중목록19금노출정책수정.md (100%) rename docs/{ => plan-task}/20260328_채널후원탈퇴닉네임접두사제거.md (100%) rename docs/{ => plan-task}/20260328_콘텐츠조회파라미터제거및비로그인기본값고정.md (100%) rename docs/{ => plan-task}/20260330_live_room_capture_recording_ddl.sql (100%) rename docs/{ => plan-task}/20260330_라이브캡쳐녹화설정추가.md (100%) rename docs/{ => plan-task}/20260330_애플로그인aud검증실패원인분석.md (100%) rename docs/{ => plan-task}/20260402_AI캐릭터본인인증국가별분기적용.md (100%) rename docs/{ => plan-task}/20260402_audio_content_banner_lang_ddl.sql (100%) rename docs/{ => plan-task}/20260402_chat_character_banner_lang_ddl.sql (100%) rename docs/{ => plan-task}/20260402_live_recommend_creator_banner_lang_ddl.sql (100%) rename docs/{ => plan-task}/20260402_관리자채팅배너목록언어표기추가.md (100%) rename docs/{ => plan-task}/20260402_라이브추천크리에이터언어적용.md (100%) rename docs/{ => plan-task}/20260402_시리즈배너언어별조회적용.md (100%) rename docs/{ => plan-task}/20260402_오디오콘텐츠배너언어적용.md (100%) rename docs/{ => plan-task}/20260402_일본어채팅캐릭터배너추가.md (100%) rename docs/{ => plan-task}/20260402_쿠폰사용본인인증예외추가.md (100%) rename docs/{ => plan-task}/20260403_메시지전송username추가.md (100%) rename docs/{ => plan-task}/20260406_omxgitignore.md (100%) rename docs/{ => plan-task}/20260407_audio_content_settlement_ratio_ddl.sql (100%) rename docs/{ => plan-task}/20260407_커밋footer자동추가차단.md (100%) rename docs/{ => plan-task}/20260407_콘텐츠별정산요율추가.md (100%) rename docs/{ => plan-task}/20260408_에이전트권한및정산기능추가.md (100%) rename docs/{ => plan-task}/20260409_partner_agent_assignment_ratio_ddl.sql (100%) rename docs/{ => plan-task}/20260410_관리자에이전트정산상세조회구현계획.md (100%) rename docs/{ => plan-task}/20260410_관리자에이전트정산상세조회설계.md (100%) rename docs/{ => plan-task}/20260410_에이전트권한및정산기능컨텍스트리뷰.md (100%) rename docs/{ => plan-task}/20260410_에이전트정산기능QA.md (100%) rename docs/{ => plan-task}/20260411_에이전트검색기능추가.md (100%) rename docs/{ => plan-task}/20260411_에이전트정산비율수정오류수정.md (100%) rename docs/{ => plan-task}/20260413_에이전트소속크리에이터프로필이미지추가.md (100%) rename docs/{ => plan-task}/20260413_에이전트크리에이터소속시간UTC변환.md (100%) rename docs/{ => plan-task}/20260415_에이전트정산기본비율7퍼센트변경.md (100%) rename docs/{ => plan-task}/20260421_라이브방무료여부응답추가.md (100%) rename docs/{ => plan-task}/20260421_오리지널시리즈정산내역.md (100%) rename docs/{ => plan-task}/20260429_에이전트가이드통합정리.md (100%) rename docs/{ => plan-task}/20260429_연속작업계획문서재사용규칙.md (100%) rename docs/{ => plan-task}/20260429_채팅방쿼터충전방식확장.md (100%) rename docs/{ => plan-task}/20260501_payverse-jpy-지원.md (100%) rename docs/{ => plan-task}/20260506_번역언어감지효율화구상.md (100%) rename docs/{ => plan-task}/20260507_번역작업원문언어제한.md (100%) rename docs/{ => plan-task}/20260507_콘텐츠관리자권한및관리자로그인API추가.md (100%) rename docs/{ => plan-task}/20260508_크리에이터관리자로그아웃AGENT권한허용.md (100%) create mode 100644 docs/prd/sample-prd.md diff --git a/docs/20260220_lsp설정추가.md b/docs/plan-task/20260220_lsp설정추가.md similarity index 100% rename from docs/20260220_lsp설정추가.md rename to docs/plan-task/20260220_lsp설정추가.md diff --git a/docs/20260220_member_fancimm_x_url_ddl.sql b/docs/plan-task/20260220_member_fancimm_x_url_ddl.sql similarity index 100% rename from docs/20260220_member_fancimm_x_url_ddl.sql rename to docs/plan-task/20260220_member_fancimm_x_url_ddl.sql diff --git a/docs/20260220_삭제닉네임접두사표시정리.md b/docs/plan-task/20260220_삭제닉네임접두사표시정리.md similarity index 100% rename from docs/20260220_삭제닉네임접두사표시정리.md rename to docs/plan-task/20260220_삭제닉네임접두사표시정리.md diff --git a/docs/20260220_커밋규칙스킬분리.md b/docs/plan-task/20260220_커밋규칙스킬분리.md similarity index 100% rename from docs/20260220_커밋규칙스킬분리.md rename to docs/plan-task/20260220_커밋규칙스킬분리.md diff --git a/docs/20260220_커밋메시지검증규칙추가.md b/docs/plan-task/20260220_커밋메시지검증규칙추가.md similarity index 100% rename from docs/20260220_커밋메시지검증규칙추가.md rename to docs/plan-task/20260220_커밋메시지검증규칙추가.md diff --git a/docs/20260220_커스텀커맨드커밋추가.md b/docs/plan-task/20260220_커스텀커맨드커밋추가.md similarity index 100% rename from docs/20260220_커스텀커맨드커밋추가.md rename to docs/plan-task/20260220_커스텀커맨드커밋추가.md diff --git a/docs/20260220_팬심M와XURL추가.md b/docs/plan-task/20260220_팬심M와XURL추가.md similarity index 100% rename from docs/20260220_팬심M와XURL추가.md rename to docs/plan-task/20260220_팬심M와XURL추가.md diff --git a/docs/20260223_channel_donation_message_ddl.sql b/docs/plan-task/20260223_channel_donation_message_ddl.sql similarity index 100% rename from docs/20260223_channel_donation_message_ddl.sql rename to docs/plan-task/20260223_channel_donation_message_ddl.sql diff --git a/docs/20260223_차단유저댓글및크리에이터노출차단.md b/docs/plan-task/20260223_차단유저댓글및크리에이터노출차단.md similarity index 100% rename from docs/20260223_차단유저댓글및크리에이터노출차단.md rename to docs/plan-task/20260223_차단유저댓글및크리에이터노출차단.md diff --git a/docs/20260223_채널후원기능추가.md b/docs/plan-task/20260223_채널후원기능추가.md similarity index 100% rename from docs/20260223_채널후원기능추가.md rename to docs/plan-task/20260223_채널후원기능추가.md diff --git a/docs/20260223_크리에이터상세정보조회api추가.md b/docs/plan-task/20260223_크리에이터상세정보조회api추가.md similarity index 100% rename from docs/20260223_크리에이터상세정보조회api추가.md rename to docs/plan-task/20260223_크리에이터상세정보조회api추가.md diff --git a/docs/20260223_회원차단동일본인인증확장.md b/docs/plan-task/20260223_회원차단동일본인인증확장.md similarity index 100% rename from docs/20260223_회원차단동일본인인증확장.md rename to docs/plan-task/20260223_회원차단동일본인인증확장.md diff --git a/docs/20260224_SNS카카오오픈채팅전환.md b/docs/plan-task/20260224_SNS카카오오픈채팅전환.md similarity index 100% rename from docs/20260224_SNS카카오오픈채팅전환.md rename to docs/plan-task/20260224_SNS카카오오픈채팅전환.md diff --git a/docs/20260225_인기크리에이터차단필터링.md b/docs/plan-task/20260225_인기크리에이터차단필터링.md similarity index 100% rename from docs/20260225_인기크리에이터차단필터링.md rename to docs/plan-task/20260225_인기크리에이터차단필터링.md diff --git a/docs/20260225_채널후원메시지_캔_천단위콤마추가.md b/docs/plan-task/20260225_채널후원메시지_캔_천단위콤마추가.md similarity index 100% rename from docs/20260225_채널후원메시지_캔_천단위콤마추가.md rename to docs/plan-task/20260225_채널후원메시지_캔_천단위콤마추가.md diff --git a/docs/20260225_회원차단동일인판별조건강화.md b/docs/plan-task/20260225_회원차단동일인판별조건강화.md similarity index 100% rename from docs/20260225_회원차단동일인판별조건강화.md rename to docs/plan-task/20260225_회원차단동일인판별조건강화.md diff --git a/docs/20260226_channel_donation_settlement_index_ddl.sql b/docs/plan-task/20260226_channel_donation_settlement_index_ddl.sql similarity index 100% rename from docs/20260226_channel_donation_settlement_index_ddl.sql rename to docs/plan-task/20260226_channel_donation_settlement_index_ddl.sql diff --git a/docs/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md b/docs/plan-task/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md similarity index 100% rename from docs/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md rename to docs/plan-task/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md diff --git a/docs/20260226_라이브추천차단조인및캐시무효화.md b/docs/plan-task/20260226_라이브추천차단조인및캐시무효화.md similarity index 100% rename from docs/20260226_라이브추천차단조인및캐시무효화.md rename to docs/plan-task/20260226_라이브추천차단조인및캐시무효화.md diff --git a/docs/20260226_라이브추천차단조인캐시무효화검증테스트.md b/docs/plan-task/20260226_라이브추천차단조인캐시무효화검증테스트.md similarity index 100% rename from docs/20260226_라이브추천차단조인캐시무효화검증테스트.md rename to docs/plan-task/20260226_라이브추천차단조인캐시무효화검증테스트.md diff --git a/docs/20260226_오리지널시리즈차단필터적용.md b/docs/plan-task/20260226_오리지널시리즈차단필터적용.md similarity index 100% rename from docs/20260226_오리지널시리즈차단필터적용.md rename to docs/plan-task/20260226_오리지널시리즈차단필터적용.md diff --git a/docs/20260226_채널후원정산합계추가.md b/docs/plan-task/20260226_채널후원정산합계추가.md similarity index 100% rename from docs/20260226_채널후원정산합계추가.md rename to docs/plan-task/20260226_채널후원정산합계추가.md diff --git a/docs/20260226_콘텐츠시리즈상세차단오류메시지수정.md b/docs/plan-task/20260226_콘텐츠시리즈상세차단오류메시지수정.md similarity index 100% rename from docs/20260226_콘텐츠시리즈상세차단오류메시지수정.md rename to docs/plan-task/20260226_콘텐츠시리즈상세차단오류메시지수정.md diff --git a/docs/20260226_홈콘텐츠랭킹차단크리에이터필터링.md b/docs/plan-task/20260226_홈콘텐츠랭킹차단크리에이터필터링.md similarity index 100% rename from docs/20260226_홈콘텐츠랭킹차단크리에이터필터링.md rename to docs/plan-task/20260226_홈콘텐츠랭킹차단크리에이터필터링.md diff --git a/docs/20260227_채널후원후원랭킹반영.md b/docs/plan-task/20260227_채널후원후원랭킹반영.md similarity index 100% rename from docs/20260227_채널후원후원랭킹반영.md rename to docs/plan-task/20260227_채널후원후원랭킹반영.md diff --git a/docs/20260227_최근종료라이브최적화.md b/docs/plan-task/20260227_최근종료라이브최적화.md similarity index 100% rename from docs/20260227_최근종료라이브최적화.md rename to docs/plan-task/20260227_최근종료라이브최적화.md diff --git a/docs/20260227_크리에이터프로필채널후원조회월범위수정.md b/docs/plan-task/20260227_크리에이터프로필채널후원조회월범위수정.md similarity index 100% rename from docs/20260227_크리에이터프로필채널후원조회월범위수정.md rename to docs/plan-task/20260227_크리에이터프로필채널후원조회월범위수정.md diff --git a/docs/20260303_관리자채널후원정산리뷰지적사항반영.md b/docs/plan-task/20260303_관리자채널후원정산리뷰지적사항반영.md similarity index 100% rename from docs/20260303_관리자채널후원정산리뷰지적사항반영.md rename to docs/plan-task/20260303_관리자채널후원정산리뷰지적사항반영.md diff --git a/docs/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md b/docs/plan-task/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md similarity index 100% rename from docs/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md rename to docs/plan-task/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md diff --git a/docs/20260303_기부목록조회월범위한국시간수정.md b/docs/plan-task/20260303_기부목록조회월범위한국시간수정.md similarity index 100% rename from docs/20260303_기부목록조회월범위한국시간수정.md rename to docs/plan-task/20260303_기부목록조회월범위한국시간수정.md diff --git a/docs/20260305_관리자사용자차단기능추가.md b/docs/plan-task/20260305_관리자사용자차단기능추가.md similarity index 100% rename from docs/20260305_관리자사용자차단기능추가.md rename to docs/plan-task/20260305_관리자사용자차단기능추가.md diff --git a/docs/20260305_관리자정산엑셀다운로드추가.md b/docs/plan-task/20260305_관리자정산엑셀다운로드추가.md similarity index 100% rename from docs/20260305_관리자정산엑셀다운로드추가.md rename to docs/plan-task/20260305_관리자정산엑셀다운로드추가.md diff --git a/docs/20260305_관리자정산콘텐츠크리에이터별조회SQL오류수정.md b/docs/plan-task/20260305_관리자정산콘텐츠크리에이터별조회SQL오류수정.md similarity index 100% rename from docs/20260305_관리자정산콘텐츠크리에이터별조회SQL오류수정.md rename to docs/plan-task/20260305_관리자정산콘텐츠크리에이터별조회SQL오류수정.md diff --git a/docs/20260305_관리자정산페이징추가.md b/docs/plan-task/20260305_관리자정산페이징추가.md similarity index 100% rename from docs/20260305_관리자정산페이징추가.md rename to docs/plan-task/20260305_관리자정산페이징추가.md diff --git a/docs/20260305_관리자충전상세응답필드수정.md b/docs/plan-task/20260305_관리자충전상세응답필드수정.md similarity index 100% rename from docs/20260305_관리자충전상세응답필드수정.md rename to docs/plan-task/20260305_관리자충전상세응답필드수정.md diff --git a/docs/20260305_관리자충전상세캔개수추가.md b/docs/plan-task/20260305_관리자충전상세캔개수추가.md similarity index 100% rename from docs/20260305_관리자충전상세캔개수추가.md rename to docs/plan-task/20260305_관리자충전상세캔개수추가.md diff --git a/docs/20260305_관리자충전상세쿼리프로젝션리팩토링.md b/docs/plan-task/20260305_관리자충전상세쿼리프로젝션리팩토링.md similarity index 100% rename from docs/20260305_관리자충전상세쿼리프로젝션리팩토링.md rename to docs/plan-task/20260305_관리자충전상세쿼리프로젝션리팩토링.md diff --git a/docs/20260305_정산엑셀스트리밍전환.md b/docs/plan-task/20260305_정산엑셀스트리밍전환.md similarity index 100% rename from docs/20260305_정산엑셀스트리밍전환.md rename to docs/plan-task/20260305_정산엑셀스트리밍전환.md diff --git a/docs/20260305_캔환불API생성.md b/docs/plan-task/20260305_캔환불API생성.md similarity index 100% rename from docs/20260305_캔환불API생성.md rename to docs/plan-task/20260305_캔환불API생성.md diff --git a/docs/20260305_콘텐츠후원정산70퍼센트검증및최적화.md b/docs/plan-task/20260305_콘텐츠후원정산70퍼센트검증및최적화.md similarity index 100% rename from docs/20260305_콘텐츠후원정산70퍼센트검증및최적화.md rename to docs/plan-task/20260305_콘텐츠후원정산70퍼센트검증및최적화.md diff --git a/docs/20260309_푸시딥링크검증.md b/docs/plan-task/20260309_푸시딥링크검증.md similarity index 100% rename from docs/20260309_푸시딥링크검증.md rename to docs/plan-task/20260309_푸시딥링크검증.md diff --git a/docs/20260309_푸시딥링크파라미터추가.md b/docs/plan-task/20260309_푸시딥링크파라미터추가.md similarity index 100% rename from docs/20260309_푸시딥링크파라미터추가.md rename to docs/plan-task/20260309_푸시딥링크파라미터추가.md diff --git a/docs/20260311_푸시알림리스트구현.md b/docs/plan-task/20260311_푸시알림리스트구현.md similarity index 100% rename from docs/20260311_푸시알림리스트구현.md rename to docs/plan-task/20260311_푸시알림리스트구현.md diff --git a/docs/20260312_푸시알림조회쿼리오류수정.md b/docs/plan-task/20260312_푸시알림조회쿼리오류수정.md similarity index 100% rename from docs/20260312_푸시알림조회쿼리오류수정.md rename to docs/plan-task/20260312_푸시알림조회쿼리오류수정.md diff --git a/docs/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md b/docs/plan-task/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md similarity index 100% rename from docs/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md rename to docs/plan-task/20260313_라이브추천팔로잉전체채널조회그룹바이오류수정.md diff --git a/docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md b/docs/plan-task/20260313_크리에이터커뮤니티댓글알림딥링크적용.md similarity index 100% rename from docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md rename to docs/plan-task/20260313_크리에이터커뮤니티댓글알림딥링크적용.md diff --git a/docs/20260313_푸시시스템카테고리저장정책보완.md b/docs/plan-task/20260313_푸시시스템카테고리저장정책보완.md similarity index 100% rename from docs/20260313_푸시시스템카테고리저장정책보완.md rename to docs/plan-task/20260313_푸시시스템카테고리저장정책보완.md diff --git a/docs/20260313_푸시알림조회기간타임존정합성수정.md b/docs/plan-task/20260313_푸시알림조회기간타임존정합성수정.md similarity index 100% rename from docs/20260313_푸시알림조회기간타임존정합성수정.md rename to docs/plan-task/20260313_푸시알림조회기간타임존정합성수정.md diff --git a/docs/20260316_라이브환불기능추가.md b/docs/plan-task/20260316_라이브환불기능추가.md similarity index 100% rename from docs/20260316_라이브환불기능추가.md rename to docs/plan-task/20260316_라이브환불기능추가.md diff --git a/docs/20260316_작업문서한글명변경.md b/docs/plan-task/20260316_작업문서한글명변경.md similarity index 100% rename from docs/20260316_작업문서한글명변경.md rename to docs/plan-task/20260316_작업문서한글명변경.md diff --git a/docs/20260316_캐릭터등록JP성별일본어변환.md b/docs/plan-task/20260316_캐릭터등록JP성별일본어변환.md similarity index 100% rename from docs/20260316_캐릭터등록JP성별일본어변환.md rename to docs/plan-task/20260316_캐릭터등록JP성별일본어변환.md diff --git a/docs/20260316_캔사용내역조회DISTINCT오류수정.md b/docs/plan-task/20260316_캔사용내역조회DISTINCT오류수정.md similarity index 100% rename from docs/20260316_캔사용내역조회DISTINCT오류수정.md rename to docs/plan-task/20260316_캔사용내역조회DISTINCT오류수정.md diff --git a/docs/20260316_캔사용내역조회리팩토링.md b/docs/plan-task/20260316_캔사용내역조회리팩토링.md similarity index 100% rename from docs/20260316_캔사용내역조회리팩토링.md rename to docs/plan-task/20260316_캔사용내역조회리팩토링.md diff --git a/docs/20260316_캔사용내역타임존및널처리개선.md b/docs/plan-task/20260316_캔사용내역타임존및널처리개선.md similarity index 100% rename from docs/20260316_캔사용내역타임존및널처리개선.md rename to docs/plan-task/20260316_캔사용내역타임존및널처리개선.md diff --git a/docs/20260316_크리에이터커뮤니티게시물고정기능추가.md b/docs/plan-task/20260316_크리에이터커뮤니티게시물고정기능추가.md similarity index 100% rename from docs/20260316_크리에이터커뮤니티게시물고정기능추가.md rename to docs/plan-task/20260316_크리에이터커뮤니티게시물고정기능추가.md diff --git a/docs/20260317_라이브방후원랭킹기간반영.md b/docs/plan-task/20260317_라이브방후원랭킹기간반영.md similarity index 100% rename from docs/20260317_라이브방후원랭킹기간반영.md rename to docs/plan-task/20260317_라이브방후원랭킹기간반영.md diff --git a/docs/20260319_라이브룸채팅얼림상태저장및조회.md b/docs/plan-task/20260319_라이브룸채팅얼림상태저장및조회.md similarity index 100% rename from docs/20260319_라이브룸채팅얼림상태저장및조회.md rename to docs/plan-task/20260319_라이브룸채팅얼림상태저장및조회.md diff --git a/docs/20260324_라이브생성_19금방전환로직추가.md b/docs/plan-task/20260324_라이브생성_19금방전환로직추가.md similarity index 100% rename from docs/20260324_라이브생성_19금방전환로직추가.md rename to docs/plan-task/20260324_라이브생성_19금방전환로직추가.md diff --git a/docs/20260324_차단유저구매콘텐츠상세조회예외처리.md b/docs/plan-task/20260324_차단유저구매콘텐츠상세조회예외처리.md similarity index 100% rename from docs/20260324_차단유저구매콘텐츠상세조회예외처리.md rename to docs/plan-task/20260324_차단유저구매콘텐츠상세조회예외처리.md diff --git a/docs/20260325_콘텐츠조회설정서버저장전환.md b/docs/plan-task/20260325_콘텐츠조회설정서버저장전환.md similarity index 100% rename from docs/20260325_콘텐츠조회설정서버저장전환.md rename to docs/plan-task/20260325_콘텐츠조회설정서버저장전환.md diff --git a/docs/20260325_회원차단요청id만적용.md b/docs/plan-task/20260325_회원차단요청id만적용.md similarity index 100% rename from docs/20260325_회원차단요청id만적용.md rename to docs/plan-task/20260325_회원차단요청id만적용.md diff --git a/docs/20260326_member_content_preference_ddl.sql b/docs/plan-task/20260326_member_content_preference_ddl.sql similarity index 100% rename from docs/20260326_member_content_preference_ddl.sql rename to docs/plan-task/20260326_member_content_preference_ddl.sql diff --git a/docs/20260327_멤버콘텐츠선호기본값조정.md b/docs/plan-task/20260327_멤버콘텐츠선호기본값조정.md similarity index 100% rename from docs/20260327_멤버콘텐츠선호기본값조정.md rename to docs/plan-task/20260327_멤버콘텐츠선호기본값조정.md diff --git a/docs/20260327_멤버콘텐츠선호신규생성정책수정.md b/docs/plan-task/20260327_멤버콘텐츠선호신규생성정책수정.md similarity index 100% rename from docs/20260327_멤버콘텐츠선호신규생성정책수정.md rename to docs/plan-task/20260327_멤버콘텐츠선호신규생성정책수정.md diff --git a/docs/20260328_라이브진행중목록19금노출정책수정.md b/docs/plan-task/20260328_라이브진행중목록19금노출정책수정.md similarity index 100% rename from docs/20260328_라이브진행중목록19금노출정책수정.md rename to docs/plan-task/20260328_라이브진행중목록19금노출정책수정.md diff --git a/docs/20260328_채널후원탈퇴닉네임접두사제거.md b/docs/plan-task/20260328_채널후원탈퇴닉네임접두사제거.md similarity index 100% rename from docs/20260328_채널후원탈퇴닉네임접두사제거.md rename to docs/plan-task/20260328_채널후원탈퇴닉네임접두사제거.md diff --git a/docs/20260328_콘텐츠조회파라미터제거및비로그인기본값고정.md b/docs/plan-task/20260328_콘텐츠조회파라미터제거및비로그인기본값고정.md similarity index 100% rename from docs/20260328_콘텐츠조회파라미터제거및비로그인기본값고정.md rename to docs/plan-task/20260328_콘텐츠조회파라미터제거및비로그인기본값고정.md diff --git a/docs/20260330_live_room_capture_recording_ddl.sql b/docs/plan-task/20260330_live_room_capture_recording_ddl.sql similarity index 100% rename from docs/20260330_live_room_capture_recording_ddl.sql rename to docs/plan-task/20260330_live_room_capture_recording_ddl.sql diff --git a/docs/20260330_라이브캡쳐녹화설정추가.md b/docs/plan-task/20260330_라이브캡쳐녹화설정추가.md similarity index 100% rename from docs/20260330_라이브캡쳐녹화설정추가.md rename to docs/plan-task/20260330_라이브캡쳐녹화설정추가.md diff --git a/docs/20260330_애플로그인aud검증실패원인분석.md b/docs/plan-task/20260330_애플로그인aud검증실패원인분석.md similarity index 100% rename from docs/20260330_애플로그인aud검증실패원인분석.md rename to docs/plan-task/20260330_애플로그인aud검증실패원인분석.md diff --git a/docs/20260402_AI캐릭터본인인증국가별분기적용.md b/docs/plan-task/20260402_AI캐릭터본인인증국가별분기적용.md similarity index 100% rename from docs/20260402_AI캐릭터본인인증국가별분기적용.md rename to docs/plan-task/20260402_AI캐릭터본인인증국가별분기적용.md diff --git a/docs/20260402_audio_content_banner_lang_ddl.sql b/docs/plan-task/20260402_audio_content_banner_lang_ddl.sql similarity index 100% rename from docs/20260402_audio_content_banner_lang_ddl.sql rename to docs/plan-task/20260402_audio_content_banner_lang_ddl.sql diff --git a/docs/20260402_chat_character_banner_lang_ddl.sql b/docs/plan-task/20260402_chat_character_banner_lang_ddl.sql similarity index 100% rename from docs/20260402_chat_character_banner_lang_ddl.sql rename to docs/plan-task/20260402_chat_character_banner_lang_ddl.sql diff --git a/docs/20260402_live_recommend_creator_banner_lang_ddl.sql b/docs/plan-task/20260402_live_recommend_creator_banner_lang_ddl.sql similarity index 100% rename from docs/20260402_live_recommend_creator_banner_lang_ddl.sql rename to docs/plan-task/20260402_live_recommend_creator_banner_lang_ddl.sql diff --git a/docs/20260402_관리자채팅배너목록언어표기추가.md b/docs/plan-task/20260402_관리자채팅배너목록언어표기추가.md similarity index 100% rename from docs/20260402_관리자채팅배너목록언어표기추가.md rename to docs/plan-task/20260402_관리자채팅배너목록언어표기추가.md diff --git a/docs/20260402_라이브추천크리에이터언어적용.md b/docs/plan-task/20260402_라이브추천크리에이터언어적용.md similarity index 100% rename from docs/20260402_라이브추천크리에이터언어적용.md rename to docs/plan-task/20260402_라이브추천크리에이터언어적용.md diff --git a/docs/20260402_시리즈배너언어별조회적용.md b/docs/plan-task/20260402_시리즈배너언어별조회적용.md similarity index 100% rename from docs/20260402_시리즈배너언어별조회적용.md rename to docs/plan-task/20260402_시리즈배너언어별조회적용.md diff --git a/docs/20260402_오디오콘텐츠배너언어적용.md b/docs/plan-task/20260402_오디오콘텐츠배너언어적용.md similarity index 100% rename from docs/20260402_오디오콘텐츠배너언어적용.md rename to docs/plan-task/20260402_오디오콘텐츠배너언어적용.md diff --git a/docs/20260402_일본어채팅캐릭터배너추가.md b/docs/plan-task/20260402_일본어채팅캐릭터배너추가.md similarity index 100% rename from docs/20260402_일본어채팅캐릭터배너추가.md rename to docs/plan-task/20260402_일본어채팅캐릭터배너추가.md diff --git a/docs/20260402_쿠폰사용본인인증예외추가.md b/docs/plan-task/20260402_쿠폰사용본인인증예외추가.md similarity index 100% rename from docs/20260402_쿠폰사용본인인증예외추가.md rename to docs/plan-task/20260402_쿠폰사용본인인증예외추가.md diff --git a/docs/20260403_메시지전송username추가.md b/docs/plan-task/20260403_메시지전송username추가.md similarity index 100% rename from docs/20260403_메시지전송username추가.md rename to docs/plan-task/20260403_메시지전송username추가.md diff --git a/docs/20260406_omxgitignore.md b/docs/plan-task/20260406_omxgitignore.md similarity index 100% rename from docs/20260406_omxgitignore.md rename to docs/plan-task/20260406_omxgitignore.md diff --git a/docs/20260407_audio_content_settlement_ratio_ddl.sql b/docs/plan-task/20260407_audio_content_settlement_ratio_ddl.sql similarity index 100% rename from docs/20260407_audio_content_settlement_ratio_ddl.sql rename to docs/plan-task/20260407_audio_content_settlement_ratio_ddl.sql diff --git a/docs/20260407_커밋footer자동추가차단.md b/docs/plan-task/20260407_커밋footer자동추가차단.md similarity index 100% rename from docs/20260407_커밋footer자동추가차단.md rename to docs/plan-task/20260407_커밋footer자동추가차단.md diff --git a/docs/20260407_콘텐츠별정산요율추가.md b/docs/plan-task/20260407_콘텐츠별정산요율추가.md similarity index 100% rename from docs/20260407_콘텐츠별정산요율추가.md rename to docs/plan-task/20260407_콘텐츠별정산요율추가.md diff --git a/docs/20260408_에이전트권한및정산기능추가.md b/docs/plan-task/20260408_에이전트권한및정산기능추가.md similarity index 100% rename from docs/20260408_에이전트권한및정산기능추가.md rename to docs/plan-task/20260408_에이전트권한및정산기능추가.md diff --git a/docs/20260409_partner_agent_assignment_ratio_ddl.sql b/docs/plan-task/20260409_partner_agent_assignment_ratio_ddl.sql similarity index 100% rename from docs/20260409_partner_agent_assignment_ratio_ddl.sql rename to docs/plan-task/20260409_partner_agent_assignment_ratio_ddl.sql diff --git a/docs/20260410_관리자에이전트정산상세조회구현계획.md b/docs/plan-task/20260410_관리자에이전트정산상세조회구현계획.md similarity index 100% rename from docs/20260410_관리자에이전트정산상세조회구현계획.md rename to docs/plan-task/20260410_관리자에이전트정산상세조회구현계획.md diff --git a/docs/20260410_관리자에이전트정산상세조회설계.md b/docs/plan-task/20260410_관리자에이전트정산상세조회설계.md similarity index 100% rename from docs/20260410_관리자에이전트정산상세조회설계.md rename to docs/plan-task/20260410_관리자에이전트정산상세조회설계.md diff --git a/docs/20260410_에이전트권한및정산기능컨텍스트리뷰.md b/docs/plan-task/20260410_에이전트권한및정산기능컨텍스트리뷰.md similarity index 100% rename from docs/20260410_에이전트권한및정산기능컨텍스트리뷰.md rename to docs/plan-task/20260410_에이전트권한및정산기능컨텍스트리뷰.md diff --git a/docs/20260410_에이전트정산기능QA.md b/docs/plan-task/20260410_에이전트정산기능QA.md similarity index 100% rename from docs/20260410_에이전트정산기능QA.md rename to docs/plan-task/20260410_에이전트정산기능QA.md diff --git a/docs/20260411_에이전트검색기능추가.md b/docs/plan-task/20260411_에이전트검색기능추가.md similarity index 100% rename from docs/20260411_에이전트검색기능추가.md rename to docs/plan-task/20260411_에이전트검색기능추가.md diff --git a/docs/20260411_에이전트정산비율수정오류수정.md b/docs/plan-task/20260411_에이전트정산비율수정오류수정.md similarity index 100% rename from docs/20260411_에이전트정산비율수정오류수정.md rename to docs/plan-task/20260411_에이전트정산비율수정오류수정.md diff --git a/docs/20260413_에이전트소속크리에이터프로필이미지추가.md b/docs/plan-task/20260413_에이전트소속크리에이터프로필이미지추가.md similarity index 100% rename from docs/20260413_에이전트소속크리에이터프로필이미지추가.md rename to docs/plan-task/20260413_에이전트소속크리에이터프로필이미지추가.md diff --git a/docs/20260413_에이전트크리에이터소속시간UTC변환.md b/docs/plan-task/20260413_에이전트크리에이터소속시간UTC변환.md similarity index 100% rename from docs/20260413_에이전트크리에이터소속시간UTC변환.md rename to docs/plan-task/20260413_에이전트크리에이터소속시간UTC변환.md diff --git a/docs/20260415_에이전트정산기본비율7퍼센트변경.md b/docs/plan-task/20260415_에이전트정산기본비율7퍼센트변경.md similarity index 100% rename from docs/20260415_에이전트정산기본비율7퍼센트변경.md rename to docs/plan-task/20260415_에이전트정산기본비율7퍼센트변경.md diff --git a/docs/20260421_라이브방무료여부응답추가.md b/docs/plan-task/20260421_라이브방무료여부응답추가.md similarity index 100% rename from docs/20260421_라이브방무료여부응답추가.md rename to docs/plan-task/20260421_라이브방무료여부응답추가.md diff --git a/docs/20260421_오리지널시리즈정산내역.md b/docs/plan-task/20260421_오리지널시리즈정산내역.md similarity index 100% rename from docs/20260421_오리지널시리즈정산내역.md rename to docs/plan-task/20260421_오리지널시리즈정산내역.md diff --git a/docs/20260429_에이전트가이드통합정리.md b/docs/plan-task/20260429_에이전트가이드통합정리.md similarity index 100% rename from docs/20260429_에이전트가이드통합정리.md rename to docs/plan-task/20260429_에이전트가이드통합정리.md diff --git a/docs/20260429_연속작업계획문서재사용규칙.md b/docs/plan-task/20260429_연속작업계획문서재사용규칙.md similarity index 100% rename from docs/20260429_연속작업계획문서재사용규칙.md rename to docs/plan-task/20260429_연속작업계획문서재사용규칙.md diff --git a/docs/20260429_채팅방쿼터충전방식확장.md b/docs/plan-task/20260429_채팅방쿼터충전방식확장.md similarity index 100% rename from docs/20260429_채팅방쿼터충전방식확장.md rename to docs/plan-task/20260429_채팅방쿼터충전방식확장.md diff --git a/docs/20260501_payverse-jpy-지원.md b/docs/plan-task/20260501_payverse-jpy-지원.md similarity index 100% rename from docs/20260501_payverse-jpy-지원.md rename to docs/plan-task/20260501_payverse-jpy-지원.md diff --git a/docs/20260506_번역언어감지효율화구상.md b/docs/plan-task/20260506_번역언어감지효율화구상.md similarity index 100% rename from docs/20260506_번역언어감지효율화구상.md rename to docs/plan-task/20260506_번역언어감지효율화구상.md diff --git a/docs/20260507_번역작업원문언어제한.md b/docs/plan-task/20260507_번역작업원문언어제한.md similarity index 100% rename from docs/20260507_번역작업원문언어제한.md rename to docs/plan-task/20260507_번역작업원문언어제한.md diff --git a/docs/20260507_콘텐츠관리자권한및관리자로그인API추가.md b/docs/plan-task/20260507_콘텐츠관리자권한및관리자로그인API추가.md similarity index 100% rename from docs/20260507_콘텐츠관리자권한및관리자로그인API추가.md rename to docs/plan-task/20260507_콘텐츠관리자권한및관리자로그인API추가.md diff --git a/docs/20260508_크리에이터관리자로그아웃AGENT권한허용.md b/docs/plan-task/20260508_크리에이터관리자로그아웃AGENT권한허용.md similarity index 100% rename from docs/20260508_크리에이터관리자로그아웃AGENT권한허용.md rename to docs/plan-task/20260508_크리에이터관리자로그아웃AGENT권한허용.md diff --git a/docs/prd/sample-prd.md b/docs/prd/sample-prd.md new file mode 100644 index 00000000..3136d742 --- /dev/null +++ b/docs/prd/sample-prd.md @@ -0,0 +1,106 @@ +# PRD: [제품명] + +## 1. Overview +이 제품이 무엇인지 한 줄 설명 + +--- + +## 2. Problem +어떤 문제를 해결하는가? + +- 현재 사용자의 불편 +- 기존 방식의 한계 +- 왜 지금 필요한가 + +--- + +## 3. Goals +성공 기준 + +예: +- 가입 전환율 20% +- 작업 시간 50% 감소 + +--- + +## 4. Non-Goals +이번에 하지 않을 것 + +매우 중요함. + +예: +- 모바일 앱 지원 안 함 +- 실시간 협업 제외 +- 다국어 제외 + +--- + +## 5. Target Users +누가 사용하는가? + +- 초보 개발자 +- PM +- 디자이너 + +--- + +## 6. User Stories +사용자 행동 시나리오 + +예: +- 사용자는 버튼 하나로 요약하고 싶다 +- 사용자는 로그인 없이 체험하고 싶다 + +--- + +## 7. Core Features + +### Feature A +설명 + +#### Requirements +- must +- should +- constraints + +#### Edge Cases +- 빈 입력 +- timeout +- 중복 요청 + +--- + +## 8. UX / UI Expectations + +- 반응속도 +- 클릭 수 +- 모바일 대응 +- 접근성 + +--- + +## 9. Technical Constraints + +- Next.js 사용 +- PostgreSQL 사용 +- API latency 2초 이하 + +--- + +## 10. Metrics + +무엇을 측정할 것인가? + +- retention +- DAU +- conversion + +--- + +## 11. Open Questions + +아직 결정 안 된 것 + +- OAuth 제공? +- pricing? +- offline mode? From 6e22198b6f787e238072dffeb9213445a7f6ad78 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 13 May 2026 12:04:03 +0900 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=EB=AC=B8=EC=84=9C=20=EC=A0=88=EC=B0=A8=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 17 ++++-- docs/agent-guides/문서유지보수.md | 5 +- docs/agent-guides/작업절차.md | 6 +- .../20260513_에이전트문서작업절차개선.md | 23 ++++++++ .../20260513_에이전트문서작업절차개선_prd.md | 58 +++++++++++++++++++ 5 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 docs/plan-task/20260513_에이전트문서작업절차개선.md create mode 100644 docs/prd/20260513_에이전트문서작업절차개선_prd.md diff --git a/AGENTS.md b/AGENTS.md index d91426da..390f9fef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,14 +158,19 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - 커밋 본문에는 `Ultraworked with [Sisyphus]...` 및 `Co-authored-by: Sisyphus ` 자동 footer를 포함하지 않는다. - `git commit` 실행 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 검증한다. -## 작업 계획 문서 규칙 (docs) -- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다. -- 연속된 하나의 작업에 대한 후속 수정/보완이라면 새 계획 문서를 만들지 말고 기존 계획 문서에 작업 항목과 검증 기록을 이어서 추가한다. -- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다. +## PRD 및 계획 TASK 문서 규칙 (docs) +- PRD와 계획 TASK 문서 없이 구현하지 않는다. +- 작업 문서 작성과 구현은 반드시 `사용자 프롬프트 입력 -> PRD 문서 작성 -> 모호한 사항 사용자 인터뷰 -> 인터뷰 내용으로 PRD 보강 -> PRD 기반 계획 TASK 문서 작성 -> 계획 TASK 기반 최소 구현` 순서로 진행한다. +- PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰한다. +- PRD 문서는 `docs/prd/` 아래에 작성하고, `docs/prd/sample-prd.md`에서 필요한 섹션만 발췌해 작성한다. +- 계획 TASK 문서는 `docs/plan-task/` 아래에 작성하고, 해당 문서를 기준으로 구현을 진행한다. +- 연속된 하나의 작업에 대한 후속 수정/보완이라면 새 PRD 또는 계획 TASK 문서를 만들지 말고 기존 문서에 요구사항, 작업 항목, 검증 기록을 이어서 추가한다. +- PRD 문서 파일명은 `[날짜]_구현할내용한글_prd.md` 형식을 사용해 계획 TASK 문서와 구분한다. +- 계획 TASK 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다. - 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. -- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다. +- 계획 TASK 문서의 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다. - 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 범위가 바뀌면 문서를 먼저 갱신한다. -- 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 누적 기록한다. +- 결과 보고 시 계획 TASK 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 누적 기록한다. ## 에이전트 동작 원칙 - 추측하지 말고, 근거 파일을 읽고 결정한다. diff --git a/docs/agent-guides/문서유지보수.md b/docs/agent-guides/문서유지보수.md index d2addaa2..3763b015 100644 --- a/docs/agent-guides/문서유지보수.md +++ b/docs/agent-guides/문서유지보수.md @@ -1,11 +1,14 @@ # 문서 유지보수 ## 문서 유지보수 규칙 +- PRD 문서는 `docs/prd/`에 두고, 계획 TASK 문서는 `docs/plan-task/`에 둔다. +- PRD 문서 파일명은 `[날짜]_구현할내용한글_prd.md`, 계획 TASK 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다. +- PRD 문서는 `docs/prd/sample-prd.md`에서 필요한 섹션만 발췌해 작성하고, 불필요한 빈 섹션을 기계적으로 복사하지 않는다. - `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다. - 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다. - `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다. - Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다. -- 연속된 하나의 작업에 대해 계획 문서가 여러 개 생기지 않도록 기존 계획 문서 재사용 여부를 먼저 확인한다. +- 연속된 하나의 작업에 대해 PRD 또는 계획 TASK 문서가 여러 개 생기지 않도록 기존 문서 재사용 여부를 먼저 확인한다. - 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다. - 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다. - 에이전트 안내 문구는 한국어 중심으로 유지한다. diff --git a/docs/agent-guides/작업절차.md b/docs/agent-guides/작업절차.md index 8484f703..63bf1c6e 100644 --- a/docs/agent-guides/작업절차.md +++ b/docs/agent-guides/작업절차.md @@ -1,8 +1,12 @@ # 작업 절차 ## 작업 절차 체크리스트 +- 변경 전: PRD와 계획 TASK 문서 없이 구현하지 않는다. +- 변경 전: 사용자 프롬프트를 받으면 먼저 `docs/prd/` 아래에 PRD 문서를 작성하고, `docs/prd/sample-prd.md`에서 필요한 섹션만 발췌한다. +- 변경 전: PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰하고 PRD를 보강한다. +- 변경 전: 보강된 PRD를 바탕으로 `docs/plan-task/` 아래에 계획 TASK 문서를 작성한 뒤, 해당 문서를 기준으로 필요한 내용만 최소 구현한다. - 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다. -- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 계획 문서를 만들지 말고 기존 계획 문서를 갱신한다. +- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 PRD 또는 계획 TASK 문서를 만들지 말고 기존 문서를 갱신한다. - 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. - 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다. - 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다. diff --git a/docs/plan-task/20260513_에이전트문서작업절차개선.md b/docs/plan-task/20260513_에이전트문서작업절차개선.md new file mode 100644 index 00000000..d6c58ebf --- /dev/null +++ b/docs/plan-task/20260513_에이전트문서작업절차개선.md @@ -0,0 +1,23 @@ +# 에이전트 문서 작업 절차 개선 계획 + +## 구현 계획 +- [x] `AGENTS.md`, 연결 문서, `docs/prd/sample-prd.md`, 기존 `docs/plan-task/` 구조를 확인한다. +- [x] 이번 변경을 위한 PRD 문서를 `docs/prd/` 아래에 작성한다. +- [x] PRD/계획/TASK 필수 작성 순서와 저장 위치 규칙을 `AGENTS.md`에 반영한다. +- [x] 같은 취지의 실행 흐름을 `docs/agent-guides/작업절차.md`에 반영한다. +- [x] 문서 유지보수 규칙을 `docs/agent-guides/문서유지보수.md`에 반영한다. +- [x] 문서 진단과 검증 결과를 기록한다. + +## 검증 계획 +- [x] 변경한 Markdown 문서에 대해 `lsp_diagnostics`를 실행한다. +- [x] 문서 변경 후 `./gradlew tasks --all`로 Gradle 명령 유효성을 확인한다. + +## 검증 기록 +- 1차 PRD/계획 작성 + - 무엇을: 사용자 요청에 따라 구현 전 PRD와 계획 TASK 문서를 먼저 작성했다. + - 왜: 새 규칙 자체가 PRD와 계획 TASK 문서 없이 구현하지 않는 흐름을 요구하므로, 이번 문서 변경에도 동일한 절차를 적용하기 위해서다. + - 어떻게: `docs/prd/sample-prd.md`에서 필요한 섹션을 발췌해 `docs/prd/20260513_에이전트문서작업절차개선_prd.md`를 작성하고, 구현 체크리스트를 `docs/plan-task/20260513_에이전트문서작업절차개선.md`에 정리했다. +- 2차 문서 반영 및 검증 + - 무엇을: `AGENTS.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`에 PRD와 계획 TASK 문서 작성 순서, 저장 위치, 파일명 규칙, 사용자 인터뷰 규칙을 반영했다. + - 왜: 에이전트가 구현 전에 요구사항을 PRD로 고정하고, 모호한 사항을 사용자 인터뷰로 해소한 뒤 계획 TASK 문서를 기준으로 최소 구현하도록 문서 간 규칙을 일치시키기 위해서다. + - 어떻게: 변경한 Markdown 문서 5개에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 13s`를 확인했다. diff --git a/docs/prd/20260513_에이전트문서작업절차개선_prd.md b/docs/prd/20260513_에이전트문서작업절차개선_prd.md new file mode 100644 index 00000000..7bf506d0 --- /dev/null +++ b/docs/prd/20260513_에이전트문서작업절차개선_prd.md @@ -0,0 +1,58 @@ +# PRD: 에이전트 문서 작업 절차 개선 + +## 1. Overview +에이전트가 구현 전에 PRD와 계획 TASK 문서를 순서대로 작성하고, 필요한 경우 사용자 인터뷰로 요구사항을 확정하도록 작업 절차 문서를 정리한다. + +--- + +## 2. Problem +- 기존 `AGENTS.md`는 작업 계획 문서 작성만 요구하고 있어 PRD 작성과 사용자 인터뷰 흐름이 명확하지 않다. +- 계획 문서 저장 위치가 `docs`로 넓게 표현되어 있어 현재 저장 구조인 `docs/plan-task/`와 완전히 일치하지 않는다. +- PRD 문서 작성 시 `docs/prd/sample-prd.md`에서 필요한 섹션을 발췌한다는 기준이 연결 문서에 명시되어 있지 않다. + +--- + +## 3. Goals +- 구현 전 필수 문서 순서를 `PRD -> 계획 TASK -> 최소 구현`으로 고정한다. +- PRD 문서 저장 위치를 `docs/prd/`, 계획 TASK 문서 저장 위치를 `docs/plan-task/`로 명확히 한다. +- PRD 파일명은 기존 계획 문서 파일명 규칙을 따르되 계획 문서와 구분되도록 한다. +- 애매한 요구사항은 PRD 단계에서 사용자 인터뷰를 반복해 해소하도록 명시한다. + +--- + +## 4. Non-Goals +- 기존 작업 계획 문서들의 파일명을 일괄 변경하지 않는다. +- `sample-prd.md` 템플릿 자체를 변경하지 않는다. +- 코드 구현 방식이나 테스트 스타일 규칙은 이번 변경 범위에 포함하지 않는다. + +--- + +## 7. Core Features + +### 문서 작성 순서 고정 + +#### Requirements +- 사용자가 작업 프롬프트를 입력하면 먼저 PRD 문서를 작성한다. +- 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 해소될 때까지 사용자와 인터뷰한다. +- 인터뷰 내용을 바탕으로 PRD 문서를 보강한다. +- PRD를 바탕으로 계획 TASK 문서를 작성한다. +- 계획 TASK 문서를 바탕으로 필요한 내용만 최소 구현한다. + +### 문서 저장 위치와 파일명 규칙 + +#### Requirements +- PRD 문서는 `docs/prd/`에 작성한다. +- 계획 TASK 문서는 `docs/plan-task/`에 작성한다. +- PRD 문서 파일명은 `[날짜]_구현할내용한글_prd.md`처럼 기존 계획 문서 규칙과 구분되는 접미사를 사용한다. +- 계획 TASK 문서 파일명은 기존 `[날짜]_구현할내용한글.md` 규칙을 유지한다. + +--- + +## 9. Technical Constraints +- `AGENTS.md`와 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`의 표현을 일치시킨다. +- 기존 문서 구조와 한국어 안내 원칙을 유지한다. + +--- + +## 11. Open Questions +- 없음. 사용자 요청에서 필수 흐름과 저장 위치가 구체적으로 지정되었다. From 1daf67fa49284cadd8de0083e56b7c05cea409e3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 13 May 2026 18:02:11 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(user-creator-chat):=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유저와 크리에이터 간 텍스트/음성 메시지, SSE presence, 조건부 푸시 흐름을 신규 도메인으로 분리한다. --- .../20260513_유저크리에이터채팅방개편.md | 248 ++++++++++++++++ .../20260513_유저크리에이터채팅방개편_prd.md | 162 +++++++++++ .../usercreatorchat/UserCreatorChatMessage.kt | 37 +++ .../UserCreatorChatParticipant.kt | 21 ++ .../v2/usercreatorchat/UserCreatorChatRoom.kt | 18 ++ .../controller/UserCreatorChatController.kt | 95 +++++++ .../dto/UserCreatorChatDtos.kt | 48 ++++ .../UserCreatorChatMessageRepository.kt | 23 ++ .../UserCreatorChatParticipantRepository.kt | 36 +++ .../UserCreatorChatRoomRepository.kt | 35 +++ .../service/UserCreatorChatRealtimeService.kt | 87 ++++++ .../service/UserCreatorChatService.kt | 244 ++++++++++++++++ .../UserCreatorChatServiceTest.kt | 264 ++++++++++++++++++ 13 files changed, 1318 insertions(+) create mode 100644 docs/plan-task/20260513_유저크리에이터채팅방개편.md create mode 100644 docs/prd/20260513_유저크리에이터채팅방개편_prd.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatMessage.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatParticipant.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatRoom.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt diff --git a/docs/plan-task/20260513_유저크리에이터채팅방개편.md b/docs/plan-task/20260513_유저크리에이터채팅방개편.md new file mode 100644 index 00000000..74006ec4 --- /dev/null +++ b/docs/plan-task/20260513_유저크리에이터채팅방개편.md @@ -0,0 +1,248 @@ +# 유저-크리에이터 채팅방 개편 계획 + +> 이 문서는 현재 요청 범위인 문서 작성만 다룬다. 실제 구현 시에는 이 계획의 구현 항목을 기준으로 세부 API, 엔티티, 테스트를 별도 작업 단위로 쪼갠다. + +## 결정 요약 + +- 새 기능부터 유저-크리에이터 채팅방을 제공한다. +- 기존 `message` 도메인의 과거 메시지는 신규 채팅방으로 마이그레이션하지 않고 별도 유지한다. +- 기존 AI `chat/room` 엔티티는 외부 AI 세션, 캐릭터 참여자, 이미지 메시지, 쿼터 관련 의미에 결합되어 있으므로 직접 재사용하지 않는다. +- 신규 유저-크리에이터 채팅방 도메인을 작성하되, 기존 `message`의 텍스트/음성 및 FCM 패턴과 AI `chat/room`의 방/참여자/메시지 구조는 참고한다. +- 실시간 전송 방식은 SSE(`SseEmitter`)로 결정한다. +- 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료, 타임아웃 시 실시간 수신 상태를 해제한다. +- typing indicator는 요구사항에서 제거한다. +- 상대방이 방에 입장 중이라고 판단된 상태에서 실시간 전송이 실패해도 보완 푸시는 발송하지 않는다. + +## 접근안 비교 + +- [x] Option A: 기존 `message` 도메인 확장 검토 + - 장점: 텍스트/음성 메시지와 FCM 발행 흐름 재사용이 쉽다. + - 단점: 방, 참여자, presence, typing을 송수신함 모델에 섞어야 한다. + - 결론: 기존 메시지를 별도 유지하기로 했으므로 채택하지 않는다. + +- [x] Option B: 기존 AI `chat/room` 엔티티 재활용 검토 + - 장점: 방/참여자/메시지 구조와 cursor 조회 패턴을 참고할 수 있다. + - 단점: `ChatRoom.sessionId`는 AI 외부 세션 의미이고, `ChatParticipant`는 `USER/CHARACTER`와 `ChatCharacter` 참조를 전제로 하며, `ChatMessage`는 음성 메시지 필드가 없다. + - 결론: 엔티티만 재활용하더라도 사람 간 채팅 의미와 맞지 않아 채택하지 않고 구조만 참고한다. + +- [x] Option C: 신규 유저-크리에이터 채팅방 도메인 작성 검토 + - 장점: 신규 요구사항인 실시간 수신, 조건부 푸시, presence를 독립적으로 설계할 수 있다. + - 단점: 신규 엔티티, API, 실시간 연결 관리 구현이 필요하다. + - 결론: 이번 개편의 권장안으로 채택한다. + +## 구현 계획 항목 + +- [x] 신규 도메인 패키지와 엔티티 설계 + - 신규 패키지: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/` + - 신규 엔티티: `UserCreatorChatRoom`, `UserCreatorChatParticipant`, `UserCreatorChatMessage` + - 메시지 타입: `TEXT`, `VOICE` + - 채팅방 참여자는 `UserCreatorChatParticipant`를 기준으로 관리하고, `UserCreatorChatRoom`에는 특정 참여자 컬럼을 두지 않는다. + - AI `chat/room` 엔티티는 직접 재활용하지 않고, 방/참여자/메시지 분리 구조만 참고한다. + +- [x] 채팅방 생성/열기 API 설계 + - 구현 전 신규 채팅방 API의 URL prefix와 응답 DTO 필드명을 추천안으로 제시하고 확정한다. + - URL prefix는 기존 `/api/chat/room`과 충돌하지 않도록 `/api/v2/user-creator-chat/rooms`를 사용한다. + - 응답 DTO 필드명은 기존 `chat/room`의 응답 관례를 참고해 `roomId`, `messages`, `hasMore`, `nextCursor`, `messageId`, `messageType`, `mine`, `createdAt`, `textMessage`, `voiceMessageUrl`, `senderId`, `senderNickname`, `senderProfileImageUrl`을 우선 사용한다. + - 활성 방 조회 또는 생성 API를 정의한다. + - 채팅방 화면을 열 때 최신 메시지를 cursor 기반으로 조회한다. + - 기존 `chat/room`의 `getChatMessages` cursor 조회 패턴을 참고한다. + +- [x] 텍스트 메시지 발송 API 설계 + - 기존 `MessageService.sendTextMessage`의 수신자 활성 여부 검증, 차단 검증, FCM 이벤트 발행 흐름을 참고한다. + - 메시지 저장 후 상대방 presence를 확인해 실시간 전송 또는 푸시 발송 중 하나만 수행한다. + +- [x] 음성 메시지 발송 API 설계 + - 기존 `MessageService.sendVoiceMessage`의 S3 업로드 경로와 CloudFront 응답 방식을 참고한다. + - 파일 업로드와 메시지 저장 정합성 정책을 정한다. + +- [x] 실시간 연결 방식 확정 + - SSE(`SseEmitter`)를 사용한다. + - 현재 `build.gradle.kts`에는 `spring-boot-starter-web`이 있고 `spring-boot-starter-websocket`은 없으므로 신규 WebSocket 의존성을 추가하지 않는다. + - 클라이언트 메시지 발송과 방 화면 열기는 HTTP API로 처리하고 서버의 새 메시지 전달만 SSE로 처리한다. + - SSE 연결 인증은 기존 API 인증과 동일하게 `Authorization: Bearer ` 헤더를 사용하는 방식을 우선 사용한다. + - 모바일 클라이언트에서 헤더 설정이 제한되는 라이브러리를 사용할 경우에만 단기 수명 SSE 전용 토큰을 발급해 query parameter로 전달하는 대안을 검토한다. + - 클라이언트 재연결 간격은 기본 3초로 시작하고, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초 지수 백오프로 확정한다. + - 서버는 SSE `retry` 값을 3초로 내려 클라이언트 기본 재연결 기준을 맞춘다. + +- [x] Presence 정책 설계 + - SSE 연결 시 해당 회원의 활성 연결을 등록한다. + - 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료, 만료 시간 초과 시 활성 연결을 제거한다. + - 여러 기기에서 같은 방에 입장한 경우 하나 이상의 활성 연결이 있으면 입장 중으로 판단한다. + - 이미 Redis/Redisson 의존성이 있으므로 Redis TTL 기반 presence 저장을 우선 검토한다. + +- [x] 조건부 푸시 발송 정책 설계 + - 상대방이 같은 방에 입장 중이면 푸시를 발송하지 않는다. + - 상대방이 같은 방에 입장 중이 아니면 기존 `FcmEventType.SEND_MESSAGE` 패턴을 재사용하거나 신규 타입을 추가한다. + - 상대방이 같은 방에 입장 중이라고 판단된 상태에서 SSE 전송이 실패해도 보완 푸시는 발송하지 않는다. + - 신규 채팅방 딥링크가 필요하면 `FcmDeepLinkValue` 확장 여부를 검토한다. + +- [x] 테스트 계획 수립 + - 방 생성 중복 방지 테스트를 작성한다. + - 텍스트/음성 메시지 발송 성공 테스트를 작성한다. + - 상대방 presence 상태에 따른 실시간 전송/푸시 발송 분기 테스트를 작성한다. + - 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료 시 presence 해제 테스트를 작성한다. + - SSE 전송 실패 시 보완 푸시를 발송하지 않는 테스트를 작성한다. + +- [x] 기존 도메인 영향도 확인 + - 기존 `message` API 동작을 변경하지 않는다. + - 기존 AI `chat/room` API 동작을 변경하지 않는다. + - 신규 도메인에서 필요한 공통 로직만 재사용하고, 외부 AI 세션 또는 쿼터 정책을 끌어오지 않는다. + +## 참고 파일 + +- `src/main/kotlin/kr/co/vividnext/sodalive/message/Message.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt` + +## MySQL 테이블 생성 SQL + +```sql +CREATE TABLE user_creator_chat_room ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '유저-크리에이터 채팅방 ID', + is_active TINYINT(1) NOT NULL DEFAULT TRUE COMMENT '활성 여부', + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE user_creator_chat_participant ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '유저-크리에이터 채팅방 참여자 ID', + chat_room_id BIGINT NOT NULL COMMENT '유저-크리에이터 채팅방 ID', + member_id BIGINT NOT NULL COMMENT '참여 회원 ID', + is_active TINYINT(1) NOT NULL DEFAULT TRUE COMMENT '활성 여부', + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + PRIMARY KEY (id), + UNIQUE KEY uk_user_creator_chat_participant_room_member (chat_room_id, member_id), + KEY idx_user_creator_chat_participant_member (member_id), + CONSTRAINT fk_user_creator_chat_participant_room FOREIGN KEY (chat_room_id) REFERENCES user_creator_chat_room (id), + CONSTRAINT fk_user_creator_chat_participant_member FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE user_creator_chat_message ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '유저-크리에이터 채팅 메시지 ID', + chat_room_id BIGINT NOT NULL COMMENT '유저-크리에이터 채팅방 ID', + participant_id BIGINT NOT NULL COMMENT '메시지 발신 참여자 ID', + message_type VARCHAR(20) NOT NULL COMMENT '메시지 타입(TEXT, VOICE)', + text_message TEXT NULL COMMENT '텍스트 메시지 본문', + voice_message VARCHAR(1024) NULL COMMENT '음성 메시지 파일 경로', + is_active TINYINT(1) NOT NULL DEFAULT TRUE COMMENT '활성 여부', + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + PRIMARY KEY (id), + KEY idx_user_creator_chat_message_room_id_id (chat_room_id, id), + KEY idx_user_creator_chat_message_participant (participant_id), + CONSTRAINT fk_user_creator_chat_message_room FOREIGN KEY (chat_room_id) REFERENCES user_creator_chat_room (id), + CONSTRAINT fk_user_creator_chat_message_participant FOREIGN KEY (participant_id) REFERENCES user_creator_chat_participant (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +## 클라이언트 연동 프롬프트 + +```text +신규 유저-크리에이터 채팅방 API를 연동한다. + +공통 조건: +- 모든 요청은 `Authorization: Bearer ` 헤더를 포함한다. +- API prefix는 `/api/v2/user-creator-chat/rooms`를 사용한다. +- 메시지 타입은 `TEXT`, `VOICE`만 처리한다. +- 앱이 백그라운드로 전환되거나 채팅방 화면에서 나가면 `POST /{roomId}/events/disconnect`를 호출하고 SSE 연결을 종료한다. +- SSE 연결이 끊기면 3초부터 재연결하고, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초까지 지수 백오프한다. + +연동할 API: +1. 방 생성/조회 + - `POST /api/v2/user-creator-chat/rooms/create` + - body: `{ "creatorId": number }` + - response data: `{ "roomId": number }` + +2. 채팅방 화면 열기 및 최신 메시지 조회 + - `GET /api/v2/user-creator-chat/rooms/{roomId}/open?limit=20` + - response data: `{ "roomId", "messages", "hasMore", "nextCursor" }` + +3. 과거 메시지 조회 + - `GET /api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={nextCursor}&limit=20` + - response data: `{ "messages", "hasMore", "nextCursor" }` + +4. SSE 연결 + - `GET /api/v2/user-creator-chat/rooms/{roomId}/events` + - Accept: `text/event-stream` + - 이벤트 이름 `message`를 수신하면 payload를 현재 채팅방 메시지 목록에 append한다. + - 이벤트 이름 `connected`는 연결 확인용으로만 사용한다. + +5. 텍스트 메시지 전송 + - `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` + - body: `{ "textMessage": string }` + - response data: `{ "message", "deliveredRealtime", "pushSent" }` + +6. 음성 메시지 전송 + - `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` + - multipart/form-data + - part `voiceMessageFile`: 음성 파일 + - part `request`: `{}` JSON 문자열 + - response data: `{ "message", "deliveredRealtime", "pushSent" }` + +7. 실시간 연결 해제 + - `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` + - DB 참여자를 삭제하거나 비활성화하지 않고 SSE/presence 상태만 해제한다. + +메시지 DTO 필드: +- `messageId`: number +- `messageType`: `TEXT` 또는 `VOICE` +- `mine`: boolean +- `createdAt`: epoch milliseconds +- `textMessage`: string 또는 null +- `voiceMessageUrl`: string 또는 null +- `senderId`: number +- `senderNickname`: string +- `senderProfileImageUrl`: string +``` + +## 검증 기록 + +### 1차 문서 작성 +- 무엇을: 유저-크리에이터 메시지 채팅방 개편 PRD와 계획 TASK 문서를 작성했다. +- 왜: 구현 전에 PRD와 계획 TASK 문서를 작성하고, 기존 AI 채팅 도메인 재사용 여부를 코드 근거로 결정해야 하기 때문이다. +- 어떻게: 기존 `message`, AI `chat/room`, `fcm` 도메인 파일과 문서 작성 규칙을 확인했다. 문서에는 기존 메시지를 별도 유지하고 신규 기능부터 채팅방으로 제공한다는 사용자 결정을 반영했다. 문서 내 미완성 표식 검색에서 추가 수정 필요 항목이 없음을 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. + +### 2차 피드백 반영 +- 무엇을: 실시간 전송 방식을 SSE로 확정하고, 앱 백그라운드/채팅방 화면 이탈 시 presence 해제, typing indicator 제거, 실시간 전송 실패 시 보완 푸시 미발송, AI `chat/room` 엔티티 직접 재활용 불가 판단을 문서에 반영했다. +- 왜: 사용자 피드백에서 신규 요구사항과 도메인 재활용 질문의 의도가 명확해졌고, 현재 코드베이스의 의존성 기준으로 WebSocket보다 SSE가 더 작은 변경이기 때문이다. +- 어떻게: `build.gradle.kts`의 의존성과 실시간 관련 구현 검색 결과를 확인했고, `ChatRoom`, `ChatParticipant`, `ChatMessage` 엔티티 필드가 유저-크리에이터 채팅 의미와 맞지 않는 점을 기준으로 문서를 수정했다. 문서 내 미완성 표식과 남은 미결정 표현 검색에서 추가 수정 필요 항목이 없음을 확인했고, Markdown diagnostics는 두 문서 모두 `No diagnostics found`, `./gradlew tasks --all`은 `BUILD SUCCESSFUL`이었다. + +### 3차 API/SSE 정책 보강 +- 무엇을: 신규 채팅방 API URL prefix와 응답 DTO 필드명은 구현 직전 추천 후 확정하는 절차를 추가하고, SSE 인증 방식과 클라이언트 재연결 간격 추천안을 확정해 문서에 반영했다. +- 왜: 구현 전에 API 계약을 한 번 더 확정하되, SSE 운영 정책은 계획 단계에서 명확히 고정해야 하기 때문이다. +- 어떻게: URL prefix 추천안은 `/api/v2/user-creator-chat/rooms`, SSE 인증은 `Authorization: Bearer ` 헤더 우선, 헤더 제한 시 단기 수명 SSE 전용 토큰 대안, 재연결은 기본 3초 및 최대 30초 지수 백오프로 정리했다. + +### 4차 구현 +- 무엇을: `kr.co.vividnext.sodalive.v2.usercreatorchat` 신규 패키지 아래에 신규 엔티티, 리포지토리, 서비스, 컨트롤러, DTO, SSE presence 서비스를 추가했다. +- 왜: 기존 기능과 분리된 신규 버전 API로 개발하고, 기존 메시지/AI 채팅 도메인은 참고만 하기로 했기 때문이다. +- 어떻게: 실패 테스트를 먼저 작성해 신규 클래스 부재로 실패하는 것을 확인한 뒤 최소 구현을 추가했다. 이후 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build`, 단독 `./gradlew test` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다. Kotlin LSP는 이 환경에 서버가 없어 diagnostics를 수행할 수 없었고, Markdown diagnostics는 `No diagnostics found`였다. 신규 엔티티 기준 MySQL 테이블 생성 SQL과 클라이언트 연동 프롬프트를 이 문서에 추가했다. + +### 5차 SQL 보완 +- 무엇을: MySQL 테이블 생성 SQL의 `created_at`, `updated_at` 타입을 `TIMESTAMP` 기본값/자동 갱신 형식으로 변경하고 모든 컬럼에 `COMMENT`를 추가했다. +- 왜: 실제 MySQL DDL에서 생성/수정 시간 기본 동작과 컬럼 설명을 명확히 남기기 위해서다. +- 어떻게: SQL 블록의 세 테이블 컬럼 정의를 직접 수정했다. + +### 6차 SQL 컬럼 순서 및 Boolean 보완 +- 무엇을: MySQL 테이블 생성 SQL에서 `created_at`, `updated_at`을 각 테이블 컬럼 목록의 마지막으로 이동하고, `is_active`를 `TINYINT(1) NOT NULL DEFAULT TRUE`로 변경했다. +- 왜: 공통 시간 컬럼 위치와 boolean 기본값 표기를 일관되게 맞추기 위해서다. +- 어떻게: SQL 블록의 세 테이블 컬럼 순서와 `is_active` 타입/기본값, 활성 방 생성 컬럼 조건식을 수정했다. + +### 7차 이전 메시지 조회 테스트 보강 +- 무엇을: 유저-크리에이터 채팅방의 cursor 기반 이전 메시지 조회 테스트를 추가했다. +- 왜: 방 내부에서 이전 채팅을 조회할 수 있는지 검증하고, 기본 조회 개수 20개가 repository `PageRequest`에 적용되는지 확인하기 위해서다. +- 어떻게: `UserCreatorChatServiceTest`에 `cursor`가 있을 때 `findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc`가 `PageRequest.of(0, 20)`으로 호출되고, 응답의 `messages`, `hasMore`, `nextCursor`가 기대대로 반환되는지 검증하는 테스트를 추가했다. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. + +### 8차 참여자 모델 단순화 +- 무엇을: `UserCreatorChatRoom`의 `user`, `creator` 고정 참여자 컬럼과 `UserCreatorChatParticipantRole`을 제거하고, 참여자는 `UserCreatorChatParticipant`만 기준으로 관리하도록 수정했다. +- 왜: 현재 1:1 방에서는 별도 참여자 권한이 필요하지 않고, 향후 user-to-user 채팅 확장을 막지 않도록 방 엔티티에서 특정 참여자 역할을 고정하지 않기 위해서다. +- 어떻게: 테스트를 먼저 새 구조 기준으로 바꿔 컴파일 실패를 확인한 뒤, 방 중복 조회를 participants 기준 쿼리로 변경하고 엔티티/서비스/테스트를 수정했다. 아직 테이블을 생성하지 않았으므로 MySQL `CREATE TABLE` 문 자체에서 `user_id`, `creator_id`, `active_room_key`, participant `role` 컬럼을 제거했다. + +### 9차 API 의미 명확화 +- 무엇을: 실제 채팅방 탈퇴로 오해될 수 있는 `enter`, `leave` 표현을 제거하고, 방 화면 열기는 `open`, 실시간 수신 해제는 `events/disconnect`로 변경했다. +- 왜: 현재 기능은 DB 참여자 삭제/비활성화가 아니라 최신 메시지 조회와 SSE/presence 해제이므로, 일반적인 채팅방 입장/탈퇴 의미와 혼동되지 않게 하기 위해서다. +- 어떻게: 테스트에서 `disconnectRealtime` 메서드가 필요하도록 먼저 변경해 컴파일 실패를 확인한 뒤, 컨트롤러 URL과 서비스/DTO 함수명을 수정했다. 클라이언트 연동 문서도 새 API 의미와 URL로 갱신했다. diff --git a/docs/prd/20260513_유저크리에이터채팅방개편_prd.md b/docs/prd/20260513_유저크리에이터채팅방개편_prd.md new file mode 100644 index 00000000..1ad0d37e --- /dev/null +++ b/docs/prd/20260513_유저크리에이터채팅방개편_prd.md @@ -0,0 +1,162 @@ +# PRD: 유저-크리에이터 채팅방 개편 + +## 1. Overview +유저와 크리에이터가 주고받는 신규 메시지를 채팅방 형태로 제공하고, 텍스트/음성 메시지, 실시간 수신, 조건부 푸시 알림을 지원한다. + +--- + +## 2. Problem +- 기존 `message` 도메인은 유저 간 1:1 텍스트/음성 메시지를 송수신함으로 구분해 조회하는 구조이며, 채팅방 입장 상태나 실시간 수신 상태를 표현하지 않는다. +- 기존 `chat/room` 도메인은 AI 캐릭터 채팅방으로, 외부 AI 세션 생성, 캐릭터 응답 저장, 이미지 메시지, 쿼터 차감이 핵심 책임이다. +- 유저-크리에이터 채팅방은 사람 간 대화이며, 상대방이 방에 들어와 있으면 푸시가 아닌 실시간 메시지 표시가 필요하다. +- 상대방이 현재 채팅방 화면에 있는지 판단하려면 방 단위 presence가 필요하지만, 현재 코드에서 WebSocket/SSE/STOMP 또는 presence 구현은 확인되지 않았다. + +--- + +## 3. Goals +- 새 기능부터 유저-크리에이터 메시지를 채팅방 형태로 제공한다. +- 기존 `message` 도메인의 과거 메시지는 별도 유지하고, 신규 채팅방 메시지로 자동 마이그레이션하지 않는다. +- 신규 채팅방 메시지는 기존 메시지와 동일하게 텍스트 또는 음성 메시지만 허용한다. +- 메시지 발송 시 상대방이 해당 방에 입장해 있으면 푸시를 보내지 않고 실시간으로 메시지를 표시한다. +- 메시지 발송 시 상대방이 해당 방에 입장해 있지 않으면 푸시 알림을 발송한다. +- 기존 AI 채팅 도메인의 엔티티라도 재활용할 수 있는지 코드 근거에 따라 결정한다. + +--- + +## 4. Non-Goals +- 기존 `message` 데이터의 신규 채팅방 메시지 마이그레이션은 이번 범위에 포함하지 않는다. +- 기존 `message` API와 조회 화면을 제거하지 않는다. +- AI 캐릭터 채팅의 외부 세션, 캐릭터 응답, 이미지 메시지, 쿼터 정책을 유저-크리에이터 채팅에 적용하지 않는다. +- 텍스트/음성 외 이미지, 파일, 이모티콘, 읽음 확인, 메시지 수정 기능은 이번 범위에 포함하지 않는다. +- typing indicator는 이번 범위에 포함하지 않는다. +- 관리자 화면 개편은 이번 범위에 포함하지 않는다. + +--- + +## 5. Target Users +- 유저: 크리에이터에게 텍스트 또는 음성 메시지를 보내고 채팅방에서 대화를 이어가려는 회원 +- 크리에이터: 유저와의 대화를 채팅방 단위로 확인하고 실시간으로 응답하려는 회원 + +--- + +## 6. User Stories +- 유저는 크리에이터와의 대화방에 들어가 이전 신규 채팅 메시지를 시간순으로 보고 싶다. +- 유저는 채팅방에서 텍스트 메시지를 보내고 상대방이 방에 있으면 바로 표시되기를 원한다. +- 유저는 채팅방에서 음성 메시지를 보내고 기존 메시지와 동일한 방식으로 재생 가능한 URL을 받기를 원한다. +- 크리에이터는 방에 들어와 있지 않을 때 새 메시지가 오면 푸시로 알림을 받고 싶다. +- 크리에이터는 방에 들어와 있을 때 새 메시지가 오면 푸시 없이 화면에 바로 표시되기를 원한다. +- 양쪽 사용자는 상대방이 방에 들어와 있을 때 새 메시지를 즉시 확인하고 싶다. + +--- + +## 7. Core Features + +### 도메인 결정 + +#### Requirements +- 신규 유저-크리에이터 채팅방 도메인과 엔티티를 작성한다. +- 기존 `message` 도메인은 과거 1:1 메시지와 기존 API 유지 용도로 둔다. +- 기존 `chat/room` 도메인은 AI 캐릭터 채팅 용도로 유지하고, 유저-크리에이터 채팅방의 엔티티로 직접 재활용하지 않는다. +- `message` 도메인의 텍스트/음성 저장 방식, 차단 검증, 음성 파일 업로드 방식, `FcmEventType.SEND_MESSAGE` 푸시 발행 패턴은 참고한다. +- `chat/room` 도메인의 방/참여자/메시지 분리 구조와 cursor 기반 메시지 조회 방식은 참고할 수 있다. + +#### Rationale +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt`는 외부 `weraser` AI 세션 생성과 `/api/chat` 호출, 캐릭터 응답 저장, 이미지 메시지, 쿼터 차감에 강하게 결합되어 있다. +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt`는 `sessionId`와 `title`이 필수이고, AI 외부 세션 식별자 의미가 들어간다. +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatParticipant.kt`는 `USER/CHARACTER` 참여자와 `ChatCharacter` 참조를 전제로 하므로 유저-크리에이터의 회원 간 참여자 모델로 그대로 쓰기 어렵다. +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt`는 텍스트/이미지 중심이고 `CharacterImage`, `imagePath`, `price`를 포함하지만 음성 메시지 경로는 없다. +- `src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt`는 텍스트/음성 메시지와 푸시 발행 흐름이 유사하지만 방, 참여자, presence 개념이 없다. +- 따라서 AI 채팅 엔티티를 직접 재활용하기보다 신규 엔티티를 만들고, 엔티티 분리 구조와 조회 패턴만 참고하는 것이 책임 경계가 명확하다. + +### 채팅방 생성 및 입장 + +#### Requirements +- 유저와 크리에이터 조합당 활성 채팅방은 하나만 사용한다. +- 신규 메시지를 처음 보내거나 채팅방 진입을 요청할 때 활성 방이 없으면 생성한다. +- 채팅방 입장 시 최신 메시지 목록을 cursor 기반으로 조회한다. +- 입장 상태는 실시간 연결 상태와 연동해 관리한다. + +#### Edge Cases +- 상대방이 비활성 회원이면 메시지 발송을 거부한다. +- 기존 차단 관계가 있으면 메시지 발송을 거부한다. +- 동시에 같은 유저-크리에이터 조합으로 방 생성 요청이 들어오면 하나의 활성 방만 남도록 유니크 제약 또는 트랜잭션 정책을 둔다. + +### 메시지 발송 + +#### Requirements +- 메시지 타입은 `TEXT`, `VOICE`만 허용한다. +- 텍스트 메시지는 본문을 저장한다. +- 음성 메시지는 기존 `message` 도메인처럼 S3 업로드 후 저장 경로를 메시지에 연결한다. +- 메시지 저장 후 상대방의 현재 방 입장 여부를 확인한다. +- 상대방이 같은 방에 입장 중이면 실시간 채널로 메시지를 전송하고 푸시는 발송하지 않는다. +- 상대방이 같은 방에 입장 중이 아니면 기존 FCM 발송 패턴을 사용해 푸시 알림을 발송한다. + +#### Edge Cases +- 음성 파일 업로드 실패 시 메시지 저장과 파일 저장의 정합성을 보장한다. +- 실시간 전송 실패 시 메시지는 저장되어야 하지만, 상대방이 방에 있는 것으로 판단된 경우 보완 푸시는 발송하지 않는다. +- 같은 사용자의 중복 전송 요청은 클라이언트 재시도 정책과 서버 idempotency 필요 여부를 별도 결정한다. + +### 실시간 수신 및 presence + +#### Requirements +- 실시간 전송 방식은 SSE(`SseEmitter`)를 우선 사용한다. +- 메시지 발송은 기존 HTTP API로 처리하고, 서버에서 수신자에게 전달해야 하는 새 메시지만 SSE로 전송한다. +- 방 입장 시 사용자를 해당 방의 온라인 참여자로 등록하고 SSE 연결을 생성한다. +- 앱이 백그라운드로 전환되거나, 사용자가 채팅방 화면에서 나가거나, 연결이 종료되거나, 타임아웃되면 방 입장 상태를 해제한다. +- 메시지 발송 시 presence 상태를 기준으로 푸시 발송 여부를 결정한다. +- 서버 재시작 또는 비정상 연결 종료에도 오래된 presence가 남지 않도록 만료 시간을 둔다. +- Redis/Redisson 의존성이 이미 있으므로, 다중 서버 가능성을 고려해 presence는 Redis TTL 기반 저장을 우선 검토한다. + +#### Edge Cases +- 한 사용자가 여러 기기에서 같은 방에 입장할 수 있다면, 하나 이상의 활성 연결이 있을 때 입장 중으로 판단한다. +- 앱 백그라운드 전환 또는 화면 이탈 이벤트가 서버에 도달하지 못해도 SSE 연결 종료와 Redis TTL 만료로 방 입장 상태가 해제되어야 한다. + +--- + +## 8. Technical Constraints +- 기존 Spring Boot 2.7.14, Kotlin, Java 17, Gradle Wrapper 구조를 유지한다. +- 문서 기준 신규 도메인 후보 패키지는 `user/creator/chat` 또는 기존 패키지 관례에 맞춘 별도 패키지로 둔다. +- 실시간 전송 방식은 SSE(`SseEmitter`)를 사용한다. 현재 코드베이스에 `spring-boot-starter-web`은 있으나 `spring-boot-starter-websocket`은 없고, typing indicator 요구사항이 제거되어 양방향 실시간 프로토콜 필요성이 낮기 때문이다. +- 서버는 새 메시지 이벤트만 SSE로 전송하고, 클라이언트의 메시지 발송/입장/퇴장 이벤트는 HTTP API로 처리한다. +- 신규 채팅방 API의 URL prefix와 응답 DTO 필드명은 구현 직전 추천안을 제시한 뒤 확정한다. +- 계획 문서 기준 URL prefix 추천안은 기존 `/api/chat/room`과 충돌하지 않는 `/api/user-creator-chat/rooms`이다. +- SSE 연결 인증은 기존 API 인증과 동일하게 `Authorization: Bearer ` 헤더 방식을 우선 사용한다. +- 모바일 클라이언트에서 `Authorization` 헤더 기반 SSE 연결을 사용할 수 없는 라이브러리를 선택하는 경우에만 단기 수명 SSE 전용 토큰을 발급해 query parameter로 전달한다. +- 클라이언트 재연결 간격은 기본 3초, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초 지수 백오프로 확정한다. +- 푸시 발송은 기존 `FcmEvent`, `FcmSendListener`, `PushNotificationService` 패턴을 우선 재사용한다. +- 음성 파일 업로드는 기존 `S3Uploader`와 CloudFront URL 조합 방식을 우선 재사용한다. +- 공개 API 스키마는 신규 API로 분리하고 기존 `message` 및 AI `chat/room` API를 깨지 않는다. + +--- + +## 9. Domain Approach Options + +### Option A: 기존 `message` 도메인 확장 +- 장점: 텍스트/음성 메시지, 차단 검증, FCM 발행 흐름을 가장 많이 재사용할 수 있다. +- 단점: 방, 참여자, presence를 기존 송수신함 모델에 추가해야 하므로 기존 API와 데이터 의미가 혼재된다. +- 판단: 기존 메시지를 별도 유지하기로 했으므로 권장하지 않는다. + +### Option B: 기존 AI `chat/room` 엔티티 재활용 +- 장점: `ChatRoom`, `ChatParticipant`, `ChatMessage`의 방/참여자/메시지 분리 구조와 cursor 조회 패턴이 이미 있다. +- 단점: `ChatRoom.sessionId`는 AI 외부 세션 의미이고, `ChatParticipant`는 `USER/CHARACTER` 및 `ChatCharacter` 참조를 전제로 하며, `ChatMessage`는 이미지/가격 필드는 있지만 음성 메시지 필드가 없다. +- 판단: 엔티티만 재활용하더라도 유저-크리에이터 채팅 의미와 맞지 않는 필드와 제약이 많아 권장하지 않는다. 구조만 참고한다. + +### Option C: 신규 유저-크리에이터 채팅방 도메인 작성 +- 장점: 사람 간 채팅, presence, 조건부 푸시의 책임을 명확히 분리할 수 있다. +- 장점: 기존 `message`와 AI `chat/room`을 깨지 않고 필요한 패턴만 참고할 수 있다. +- 단점: 신규 엔티티, 저장소, 실시간 연결 관리, API가 필요해 초기 구현량이 늘어난다. +- 판단: 이번 요구사항의 권장안이다. + +--- + +## 10. Metrics +- 신규 채팅방 메시지 발송 성공률 +- 실시간 수신 성공률 +- 방 입장 중 메시지에 대한 푸시 미발송 비율 +- 방 미입장 상태 메시지에 대한 푸시 발송 성공률 +- 방 입장 상태 해제 지연 시간 + +--- + +## 11. Open Questions +- 없음. URL prefix와 DTO 필드명은 구현 직전 추천안을 제시해 확정하고, SSE 인증과 재연결 간격은 본 문서의 Technical Constraints 기준을 따른다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatMessage.kt new file mode 100644 index 00000000..b1e5fdfc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatMessage.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class UserCreatorChatMessage( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + var chatRoom: UserCreatorChatRoom, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "participant_id", nullable = false) + var participant: UserCreatorChatParticipant, + + @Enumerated(EnumType.STRING) + @Column(name = "message_type", nullable = false) + var messageType: UserCreatorChatMessageType, + + @Column(columnDefinition = "TEXT") + var textMessage: String? = null, + + @Column(length = 1024) + var voiceMessage: String? = null, + + var isActive: Boolean = true +) : BaseEntity() + +enum class UserCreatorChatMessageType { + TEXT, VOICE +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatParticipant.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatParticipant.kt new file mode 100644 index 00000000..0e900f06 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatParticipant.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class UserCreatorChatParticipant( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + var chatRoom: UserCreatorChatRoom, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member, + + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatRoom.kt new file mode 100644 index 00000000..dea253d9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatRoom.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.OneToMany + +@Entity +class UserCreatorChatRoom( + var isActive: Boolean = true +) : BaseEntity() { + @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var participants: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var messages: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt new file mode 100644 index 00000000..57b66eeb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt @@ -0,0 +1,95 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.controller + +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.usercreatorchat.dto.CreateUserCreatorChatRoomRequest +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import org.springframework.http.MediaType +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.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/api/v2/user-creator-chat/rooms") +class UserCreatorChatController( + private val service: UserCreatorChatService +) { + @PostMapping("/create") + fun createOrGetRoom( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestBody request: CreateUserCreatorChatRoomRequest + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(service.createOrGetRoom(member, request.creatorId)) + } + + @GetMapping("/{roomId}/open") + fun openRoom( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable roomId: Long, + @RequestParam(defaultValue = "20") limit: Int + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(service.openRoom(member, roomId, limit)) + } + + @PostMapping("/{roomId}/events/disconnect") + fun disconnectRealtime( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable roomId: Long + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + service.disconnectRealtime(member, roomId) + ApiResponse.ok(true) + } + + @GetMapping("/{roomId}/messages") + fun getMessages( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable roomId: Long, + @RequestParam(required = false) cursor: Long?, + @RequestParam(defaultValue = "20") limit: Int + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(service.getMessages(member, roomId, cursor, limit)) + } + + @GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) + fun connectEvents( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable roomId: Long + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + service.connect(member, roomId) + } + + @PostMapping("/{roomId}/messages/text") + fun sendTextMessage( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable roomId: Long, + @RequestBody request: SendUserCreatorTextMessageRequest + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(service.sendTextMessage(member, roomId, request)) + } + + @PostMapping("/{roomId}/messages/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun sendVoiceMessage( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable roomId: Long, + @RequestPart("voiceMessageFile") voiceMessageFile: MultipartFile, + @RequestPart("request") requestString: String + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(service.sendVoiceMessage(member, roomId, voiceMessageFile, requestString)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt new file mode 100644 index 00000000..4baae69c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.dto + +data class CreateUserCreatorChatRoomRequest( + val creatorId: Long +) + +data class CreateUserCreatorChatRoomResponse( + val roomId: Long +) + +data class SendUserCreatorTextMessageRequest( + val textMessage: String +) + +data class SendUserCreatorVoiceMessageRequest( + val recipientId: Long? = null +) + +data class SendUserCreatorChatMessageResponse( + val message: UserCreatorChatMessageItemDto, + val deliveredRealtime: Boolean, + val pushSent: Boolean +) + +data class UserCreatorChatRoomOpenResponse( + val roomId: Long, + val messages: List, + val hasMore: Boolean, + val nextCursor: Long? +) + +data class UserCreatorChatMessagesPageResponse( + val messages: List, + val hasMore: Boolean, + val nextCursor: Long? +) + +data class UserCreatorChatMessageItemDto( + val messageId: Long, + val messageType: String, + val mine: Boolean, + val createdAt: Long, + val textMessage: String?, + val voiceMessageUrl: String?, + val senderId: Long, + val senderNickname: String, + val senderProfileImageUrl: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt new file mode 100644 index 00000000..bb6ad062 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatMessageRepository.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.repository + +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface UserCreatorChatMessageRepository : JpaRepository { + fun findByChatRoomAndIsActiveTrueOrderByIdDesc( + chatRoom: UserCreatorChatRoom, + pageable: Pageable + ): List + + fun findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( + chatRoom: UserCreatorChatRoom, + id: Long, + pageable: Pageable + ): List + + fun existsByChatRoomAndIsActiveTrueAndIdLessThan(chatRoom: UserCreatorChatRoom, id: Long): Boolean +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt new file mode 100644 index 00000000..329faeec --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatParticipantRepository.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.repository + +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface UserCreatorChatParticipantRepository : JpaRepository { + @Query( + """ + select p from UserCreatorChatParticipant p + where p.isActive = true + and p.chatRoom.id = :roomId + and p.member.id = :memberId + """ + ) + fun findActiveByRoomIdAndMemberId( + @Param("roomId") roomId: Long, + @Param("memberId") memberId: Long + ): UserCreatorChatParticipant? + + @Query( + """ + select p from UserCreatorChatParticipant p + where p.isActive = true + and p.chatRoom.id = :roomId + and p.member.id <> :memberId + """ + ) + fun findActiveOpponent( + @Param("roomId") roomId: Long, + @Param("memberId") memberId: Long + ): UserCreatorChatParticipant? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt new file mode 100644 index 00000000..5870bc72 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.repository + +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface UserCreatorChatRoomRepository : JpaRepository { + fun findByIdAndIsActiveTrue(id: Long): UserCreatorChatRoom? + + @Query( + """ + select r from UserCreatorChatRoom r + where r.isActive = true + and exists ( + select 1 from UserCreatorChatParticipant p1 + where p1.chatRoom = r + and p1.isActive = true + and p1.member.id = :firstMemberId + ) + and exists ( + select 1 from UserCreatorChatParticipant p2 + where p2.chatRoom = r + and p2.isActive = true + and p2.member.id = :secondMemberId + ) + """ + ) + fun findActiveRoomByParticipantMemberIds( + @Param("firstMemberId") firstMemberId: Long, + @Param("secondMemberId") secondMemberId: Long + ): UserCreatorChatRoom? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt new file mode 100644 index 00000000..b401128d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt @@ -0,0 +1,87 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.service + +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Service +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.io.IOException +import java.time.Duration +import java.util.concurrent.ConcurrentHashMap + +@Service +class UserCreatorChatRealtimeService( + private val stringRedisTemplate: StringRedisTemplate +) { + private val emitters = ConcurrentHashMap() + + fun connect(roomId: Long, memberId: Long): SseEmitter { + val emitter = SseEmitter(SSE_TIMEOUT_MILLIS) + val key = emitterKey(roomId, memberId) + emitters[key] = emitter + markPresent(roomId, memberId) + + emitter.onCompletion { disconnect(roomId, memberId) } + emitter.onTimeout { disconnect(roomId, memberId) } + emitter.onError { disconnect(roomId, memberId) } + + sendConnectEvent(emitter) + return emitter + } + + fun disconnect(roomId: Long, memberId: Long) { + emitters.remove(emitterKey(roomId, memberId)) + stringRedisTemplate.delete(presenceKey(roomId, memberId)) + } + + fun isMemberInRoom(roomId: Long, memberId: Long): Boolean { + return stringRedisTemplate.hasKey(presenceKey(roomId, memberId)) + } + + fun sendMessage(roomId: Long, memberId: Long, message: UserCreatorChatMessageItemDto): Boolean { + val emitter = emitters[emitterKey(roomId, memberId)] ?: return false + return try { + emitter.send( + SseEmitter.event() + .id(message.messageId.toString()) + .name("message") + .reconnectTime(SSE_RECONNECT_MILLIS) + .data(message) + ) + markPresent(roomId, memberId) + true + } catch (_: IOException) { + disconnect(roomId, memberId) + false + } catch (_: IllegalStateException) { + disconnect(roomId, memberId) + false + } + } + + private fun sendConnectEvent(emitter: SseEmitter) { + try { + emitter.send( + SseEmitter.event() + .name("connected") + .reconnectTime(SSE_RECONNECT_MILLIS) + .data("connected") + ) + } catch (e: IOException) { + emitter.completeWithError(e) + } + } + + private fun markPresent(roomId: Long, memberId: Long) { + stringRedisTemplate.opsForValue().set(presenceKey(roomId, memberId), "1", Duration.ofSeconds(PRESENCE_TTL_SECONDS)) + } + + private fun emitterKey(roomId: Long, memberId: Long) = "$roomId:$memberId" + + private fun presenceKey(roomId: Long, memberId: Long) = "v2:user-creator-chat:presence:$roomId:$memberId" + + companion object { + private const val SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L + private const val SSE_RECONNECT_MILLIS = 3000L + private const val PRESENCE_TTL_SECONDS = 60L + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt new file mode 100644 index 00000000..a4e1ed6c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt @@ -0,0 +1,244 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.service + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.utils.generateFileName +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorChatMessageResponse +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorVoiceMessageRequest +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatRoomOpenResponse +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.time.ZoneId + +@Service +@Transactional(readOnly = true) +class UserCreatorChatService( + private val roomRepository: UserCreatorChatRoomRepository, + private val participantRepository: UserCreatorChatParticipantRepository, + private val messageRepository: UserCreatorChatMessageRepository, + private val memberRepository: MemberRepository, + private val blockMemberRepository: BlockMemberRepository, + private val realtimeService: UserCreatorChatRealtimeService, + private val applicationEventPublisher: ApplicationEventPublisher, + private val objectMapper: ObjectMapper, + private val s3Uploader: S3Uploader, + + @Value("\${cloud.aws.s3.bucket}") + private val bucket: String, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional + fun createOrGetRoom(member: Member, creatorId: Long): CreateUserCreatorChatRoomResponse { + val creator = memberRepository.findById(creatorId).orElseThrow { + SodaException(messageKey = "message.error.recipient_not_found") + } + validateRecipient(member, creator) + + val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, creator.id!!) + if (existingRoom != null) { + return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!) + } + + val room = roomRepository.save(UserCreatorChatRoom()) + participantRepository.save(UserCreatorChatParticipant(room, member)) + participantRepository.save(UserCreatorChatParticipant(room, creator)) + return CreateUserCreatorChatRoomResponse(roomId = room.id!!) + } + + @Transactional + fun openRoom(member: Member, roomId: Long, limit: Int = 20): UserCreatorChatRoomOpenResponse { + val room = findRoom(roomId) + requireParticipant(roomId, member.id!!) + val page = getMessages(member, roomId, cursor = null, limit = limit) + return UserCreatorChatRoomOpenResponse( + roomId = room.id!!, + messages = page.messages, + hasMore = page.hasMore, + nextCursor = page.nextCursor + ) + } + + fun getMessages(member: Member, roomId: Long, cursor: Long?, limit: Int = 20): UserCreatorChatMessagesPageResponse { + val room = findRoom(roomId) + requireParticipant(roomId, member.id!!) + val pageable = PageRequest.of(0, limit.coerceIn(1, 100)) + val fetched = if (cursor != null) { + messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, cursor, pageable) + } else { + messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable) + } + val nextCursor = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id + val hasMore = nextCursor?.let { messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, it) } ?: false + return UserCreatorChatMessagesPageResponse( + messages = fetched.sortedBy { it.createdAt }.map { toMessageItemDto(it, member) }, + hasMore = hasMore, + nextCursor = nextCursor + ) + } + + @Transactional + fun sendTextMessage( + member: Member, + roomId: Long, + request: SendUserCreatorTextMessageRequest + ): SendUserCreatorChatMessageResponse { + if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request") + val context = resolveSendContext(member, roomId) + val message = messageRepository.save( + UserCreatorChatMessage( + chatRoom = context.room, + participant = context.senderParticipant, + messageType = UserCreatorChatMessageType.TEXT, + textMessage = request.textMessage + ) + ) + return deliverMessage(message, member, context.opponentParticipant) + } + + @Transactional + fun sendVoiceMessage( + member: Member, + roomId: Long, + voiceMessageFile: MultipartFile, + requestString: String + ): SendUserCreatorChatMessageResponse { + objectMapper.readValue(requestString, SendUserCreatorVoiceMessageRequest::class.java) + val context = resolveSendContext(member, roomId) + val message = messageRepository.save( + UserCreatorChatMessage( + chatRoom = context.room, + participant = context.senderParticipant, + messageType = UserCreatorChatMessageType.VOICE + ) + ) + val metadata = ObjectMetadata() + metadata.contentLength = voiceMessageFile.size + message.voiceMessage = s3Uploader.upload( + inputStream = voiceMessageFile.inputStream, + bucket = bucket, + filePath = "user_creator_chat_voice/${message.id}/${generateFileName(prefix = "${message.id}-message-")}", + metadata = metadata + ) + return deliverMessage(message, member, context.opponentParticipant) + } + + fun connect(member: Member, roomId: Long) = run { + requireParticipant(roomId, member.id!!) + realtimeService.connect(roomId, member.id!!) + } + + fun disconnectRealtime(member: Member, roomId: Long) { + requireParticipant(roomId, member.id!!) + realtimeService.disconnect(roomId, member.id!!) + } + + private fun resolveSendContext(member: Member, roomId: Long): SendContext { + val room = findRoom(roomId) + val senderParticipant = requireParticipant(roomId, member.id!!) + val opponentParticipant = participantRepository.findActiveOpponent(roomId, member.id!!) + ?: throw SodaException(messageKey = "chat.room.invalid_access") + validateRecipient(member, opponentParticipant.member) + return SendContext(room, senderParticipant, opponentParticipant) + } + + private fun deliverMessage( + message: UserCreatorChatMessage, + member: Member, + opponentParticipant: UserCreatorChatParticipant + ): SendUserCreatorChatMessageResponse { + val opponent = opponentParticipant.member + val item = toMessageItemDto(message, member) + val opponentPresent = realtimeService.isMemberInRoom(message.chatRoom.id!!, opponent.id!!) + if (opponentPresent) { + val delivered = realtimeService.sendMessage(message.chatRoom.id!!, opponent.id!!, item) + return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = delivered, pushSent = false) + } + + publishMessagePush(message, member, opponent) + return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true) + } + + private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) { + val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) { + "message.fcm.voice_received" + } else { + "message.fcm.text_received" + } + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.MESSAGE, + titleKey = "message.fcm.title", + messageKey = messageKey, + senderMemberId = sender.id, + args = listOf(sender.nickname), + recipients = listOf(opponent.id!!), + messageId = message.id + ) + ) + } + + private fun validateRecipient(sender: Member, recipient: Member) { + if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive") + if (sender.id == recipient.id) throw SodaException(messageKey = "common.error.invalid_request") + if (blockMemberRepository.isBlocked(blockedMemberId = sender.id!!, memberId = recipient.id!!)) { + throw SodaException(messageKey = "message.error.blocked_by_recipient") + } + } + + private fun findRoom(roomId: Long): UserCreatorChatRoom { + return roomRepository.findByIdAndIsActiveTrue(roomId) + ?: throw SodaException(messageKey = "chat.error.room_not_found") + } + + private fun requireParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant { + return participantRepository.findActiveByRoomIdAndMemberId(roomId, memberId) + ?: throw SodaException(messageKey = "chat.room.invalid_access") + } + + private fun toMessageItemDto(message: UserCreatorChatMessage, member: Member): UserCreatorChatMessageItemDto { + val sender = message.participant.member + val profilePath = sender.profileImage ?: "profile/default-profile.png" + return UserCreatorChatMessageItemDto( + messageId = message.id!!, + messageType = message.messageType.name, + mine = sender.id == member.id, + createdAt = message.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L, + textMessage = message.textMessage, + voiceMessageUrl = message.voiceMessage?.let { "$cloudFrontHost/$it" }, + senderId = sender.id!!, + senderNickname = sender.nickname, + senderProfileImageUrl = "$cloudFrontHost/$profilePath" + ) + } + + private data class SendContext( + val room: UserCreatorChatRoom, + val senderParticipant: UserCreatorChatParticipant, + val opponentParticipant: UserCreatorChatParticipant + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt new file mode 100644 index 00000000..13b77ef2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt @@ -0,0 +1,264 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatRealtimeService +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import java.time.LocalDateTime +import java.util.Optional + +class UserCreatorChatServiceTest { + private lateinit var roomRepository: UserCreatorChatRoomRepository + private lateinit var participantRepository: UserCreatorChatParticipantRepository + private lateinit var messageRepository: UserCreatorChatMessageRepository + private lateinit var memberRepository: MemberRepository + private lateinit var blockMemberRepository: BlockMemberRepository + private lateinit var realtimeService: UserCreatorChatRealtimeService + private lateinit var eventPublisher: ApplicationEventPublisher + private lateinit var service: UserCreatorChatService + + @BeforeEach + fun setUp() { + roomRepository = Mockito.mock(UserCreatorChatRoomRepository::class.java) + participantRepository = Mockito.mock(UserCreatorChatParticipantRepository::class.java) + messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java) + realtimeService = Mockito.mock(UserCreatorChatRealtimeService::class.java) + eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + service = UserCreatorChatService( + roomRepository = roomRepository, + participantRepository = participantRepository, + messageRepository = messageRepository, + memberRepository = memberRepository, + blockMemberRepository = blockMemberRepository, + realtimeService = realtimeService, + applicationEventPublisher = eventPublisher, + objectMapper = ObjectMapper(), + s3Uploader = Mockito.mock(S3Uploader::class.java), + bucket = "test-bucket", + cloudFrontHost = "https://cdn.test" + ) + } + + @Test + @DisplayName("활성 유저-크리에이터 방이 없으면 새 방과 참여자를 생성한다") + fun shouldCreateRoomAndParticipantsWhenActiveRoomDoesNotExist() { + val user = member(1L, "user") + val creator = member(2L, "creator") + Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(creator)) + Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null) + Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatRoom).apply { id = 10L } + } + + val response = service.createOrGetRoom(user, 2L) + + assertEquals(10L, response.roomId) + val roomCaptor = ArgumentCaptor.forClass(UserCreatorChatRoom::class.java) + Mockito.verify(roomRepository).save(roomCaptor.capture()) + Mockito.verify(participantRepository, Mockito.times(2)).save(Mockito.any(UserCreatorChatParticipant::class.java)) + } + + @Test + @DisplayName("상대방이 같은 방에 입장 중이면 텍스트 메시지를 실시간 전송하고 푸시를 보내지 않는다") + fun shouldSendRealtimeMessageWithoutPushWhenOpponentIsPresent() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val senderParticipant = participant(100L, room, user) + val recipientParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) + Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true) + Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())).thenReturn(true) + Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 200L } + } + + val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) + + assertEquals(200L, response.message.messageId) + assertEquals("hello", response.message.textMessage) + assertTrue(response.deliveredRealtime) + assertFalse(response.pushSent) + Mockito.verify(realtimeService).sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem()) + Mockito.verifyNoInteractions(eventPublisher) + } + + @Test + @DisplayName("상대방이 같은 방에 없으면 텍스트 메시지를 저장하고 푸시 이벤트를 발행한다") + fun shouldPublishPushEventWhenOpponentIsNotPresent() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val senderParticipant = participant(100L, room, user) + val recipientParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) + Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(false) + Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 201L } + } + + val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) + + assertFalse(response.deliveredRealtime) + assertTrue(response.pushSent) + val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) + assertEquals(listOf(2L), eventCaptor.value.recipients) + assertEquals(201L, eventCaptor.value.messageId) + } + + @Test + @DisplayName("상대방이 입장 중이면 실시간 전송 실패 시에도 보완 푸시를 보내지 않는다") + fun shouldNotFallbackToPushWhenRealtimeDeliveryFailsForPresentOpponent() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val senderParticipant = participant(100L, room, user) + val recipientParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) + Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true) + Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())) + .thenReturn(false) + Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 202L } + } + + val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) + + assertFalse(response.deliveredRealtime) + assertFalse(response.pushSent) + Mockito.verifyNoInteractions(eventPublisher) + } + + @Test + @DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다") + fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val userParticipant = participant(100L, room, user) + val creatorParticipant = participant(101L, room, creator) + val olderMessage = textMessage( + id = 298L, + room = room, + participant = userParticipant, + text = "older", + createdAt = LocalDateTime.of(2026, 5, 13, 10, 0) + ) + val newerMessage = textMessage( + id = 299L, + room = room, + participant = creatorParticipant, + text = "newer", + createdAt = LocalDateTime.of(2026, 5, 13, 10, 1) + ) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant) + Mockito.`when`( + messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( + room, + 300L, + PageRequest.of(0, 20) + ) + ).thenReturn(listOf(newerMessage, olderMessage)) + Mockito.`when`(messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, 298L)).thenReturn(true) + + val response = service.getMessages(user, roomId = 10L, cursor = 300L) + + assertEquals(listOf(298L, 299L), response.messages.map { it.messageId }) + assertEquals(listOf("older", "newer"), response.messages.map { it.textMessage }) + assertTrue(response.hasMore) + assertEquals(298L, response.nextCursor) + Mockito.verify(messageRepository).findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( + room, + 300L, + PageRequest.of(0, 20) + ) + } + + @Test + @DisplayName("실시간 연결 해제는 참여자를 제거하지 않고 presence만 해제한다") + fun shouldDisconnectRealtimeWithoutLeavingRoom() { + val user = member(1L, "user") + val room = room(10L) + val participant = participant(100L, room, user) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(participant) + + service.disconnectRealtime(user, 10L) + + Mockito.verify(realtimeService).disconnect(10L, 1L) + Mockito.verify(participantRepository, Mockito.never()).save(Mockito.any(UserCreatorChatParticipant::class.java)) + } + + private fun member(id: Long, nickname: String) = Member(password = "pw", nickname = nickname).apply { this.id = id } + + private fun anyMessageItem(): UserCreatorChatMessageItemDto { + return Mockito.any(UserCreatorChatMessageItemDto::class.java) ?: UserCreatorChatMessageItemDto( + messageId = 0L, + messageType = "TEXT", + mine = false, + createdAt = 0L, + textMessage = null, + voiceMessageUrl = null, + senderId = 0L, + senderNickname = "", + senderProfileImageUrl = "" + ) + } + + private fun room(id: Long) = UserCreatorChatRoom().apply { + this.id = id + } + + private fun participant( + id: Long, + room: UserCreatorChatRoom, + member: Member + ) = UserCreatorChatParticipant(chatRoom = room, member = member).apply { this.id = id } + + private fun textMessage( + id: Long, + room: UserCreatorChatRoom, + participant: UserCreatorChatParticipant, + text: String, + createdAt: LocalDateTime + ) = UserCreatorChatMessage( + chatRoom = room, + participant = participant, + messageType = UserCreatorChatMessageType.TEXT, + textMessage = text + ).apply { + this.id = id + this.createdAt = createdAt + } +} From 3a2c21c8961364a512ed24aed9b02fec1ff23428 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 May 2026 11:43:41 +0900 Subject: [PATCH 04/10] =?UTF-8?q?docs(agent):=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agent-guides/코드스타일.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/agent-guides/코드스타일.md b/docs/agent-guides/코드스타일.md index 953e5eb2..5dafc249 100644 --- a/docs/agent-guides/코드스타일.md +++ b/docs/agent-guides/코드스타일.md @@ -22,37 +22,41 @@ - Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다. - 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다. -### 4) 타입/널 처리 +### 4) 패키지/코드 배치 규칙 +- 기존 로직을 수정하는 경우에는 기존 패키지 구조를 따른다. +- 기존 로직 수정이 아닌 신규 API나 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다. + +### 5) 타입/널 처리 - Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다. - 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다. - 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다. -### 5) API/응답 규칙 +### 6) API/응답 규칙 - API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다. - 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다. - 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다. -### 6) 예외 처리 규칙 +### 7) 예외 처리 규칙 - 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용. - 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다. - 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다. - 예외를 삼키는 빈 `catch` 블록을 금지한다. -### 7) 트랜잭션 규칙 +### 8) 트랜잭션 규칙 - 서비스 계층에서 `@Transactional`을 사용한다. - 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다. - 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다. -### 8) 비동기/동시성 규칙 +### 9) 비동기/동시성 규칙 - 비동기 처리는 Kotlin Coroutines 패턴을 따른다. - `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다. - 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다. -### 9) 의존성 주입 +### 10) 의존성 주입 - 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다. - 필드 주입보다 명시적 생성자 주입을 우선한다. -### 10) 주석 +### 11) 주석 - 의미 단위별로 주석을 작성한다. - 주석은 한 문장으로 간결하게 작성한다. - 주석은 코드의 의도와 구조를 설명한다. From acd0393a0eb6ee23f5b29ccf2135eda1a3b91e87 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 May 2026 16:12:14 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260513_유저크리에이터채팅방개편.md | 106 +++++++++++ .../20260513_유저크리에이터채팅방개편_prd.md | 56 +++++- .../room/repository/ChatRoomRepository.kt | 49 +++++ .../chat/controller/ChatRoomListController.kt | 28 +++ .../sodalive/v2/chat/dto/ChatRoomListDtos.kt | 28 +++ .../v2/chat/service/ChatRoomListService.kt | 156 +++++++++++++++ .../UserCreatorChatRoomRepository.kt | 50 +++++ .../v2/chat/ChatRoomListServiceTest.kt | 179 ++++++++++++++++++ 8 files changed, 650 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/dto/ChatRoomListDtos.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt diff --git a/docs/plan-task/20260513_유저크리에이터채팅방개편.md b/docs/plan-task/20260513_유저크리에이터채팅방개편.md index 74006ec4..c9e5239a 100644 --- a/docs/plan-task/20260513_유저크리에이터채팅방개편.md +++ b/docs/plan-task/20260513_유저크리에이터채팅방개편.md @@ -12,6 +12,12 @@ - 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료, 타임아웃 시 실시간 수신 상태를 해제한다. - typing indicator는 요구사항에서 제거한다. - 상대방이 방에 입장 중이라고 판단된 상태에서 실시간 전송이 실패해도 보완 푸시는 발송하지 않는다. +- 채팅 리스트 API는 전체, AI 채팅, DM 채팅 필터를 하나의 통합 API에서 제공한다. +- DM은 유저-크리에이터 채팅방을 의미하는 클라이언트/문서 표기명으로 사용한다. +- 채팅 리스트에는 내가 참여 중인 방만 노출하고, 최종 대화 시간은 UTC 기준으로 내려 클라이언트가 표시 방식을 결정한다. +- 채팅 리스트 API는 `/api/v2/chat/rooms`를 사용하고 최신순 30개씩 cursor 기반으로 페이징한다. +- 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않고, 음성 메시지의 마지막 대화 요약은 `[음성 메시지]`를 사용한다. +- 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 내려준다. ## 접근안 비교 @@ -30,6 +36,22 @@ - 단점: 신규 엔티티, API, 실시간 연결 관리 구현이 필요하다. - 결론: 이번 개편의 권장안으로 채택한다. +- [x] Option D: AI/DM 채팅 리스트 API를 각각 작성 검토 + - 장점: 각 도메인의 조회 조건은 단순하게 유지할 수 있다. + - 단점: 클라이언트가 전체 리스트를 만들기 위해 두 API를 호출하고 병합/정렬해야 한다. + - 결론: 전체 필터 요구사항과 맞지 않아 채택하지 않는다. + +- [x] Option E: 통합 채팅 리스트 API에서 필터로 구분 검토 + - 장점: 클라이언트는 하나의 API로 전체, AI, DM 탭을 처리할 수 있다. + - 장점: 참여 중인 방만 노출, 최신 대화순 정렬, 마지막 메시지 요약 정책을 서버에서 일관되게 적용할 수 있다. + - 단점: 서버에서 AI 채팅방과 DM 채팅방의 응답 모델을 하나로 맞추는 조립 계층이 필요하다. + - 결론: 이번 채팅 리스트 API의 채택안이다. + +- [x] Option F: 기존 AI 채팅 리스트 API에 DM을 추가 검토 + - 장점: 새 endpoint 수를 줄일 수 있다. + - 단점: 기존 AI 채팅 API의 의미가 넓어지고, DM 도메인 결합이 생긴다. + - 결론: 공개 API 의미가 불명확해지므로 채택하지 않는다. + ## 구현 계획 항목 - [x] 신규 도메인 패키지와 엔티티 설계 @@ -88,6 +110,36 @@ - 기존 AI `chat/room` API 동작을 변경하지 않는다. - 신규 도메인에서 필요한 공통 로직만 재사용하고, 외부 AI 세션 또는 쿼터 정책을 끌어오지 않는다. +- [x] 채팅 리스트 API 설계 + - API는 `GET /api/v2/chat/rooms`를 사용한다. + - query parameter는 `filter`와 `limit`, `cursor`를 둔다. + - `filter` 값은 `ALL`, `AI`, `DM` 중 하나이며 기본값은 `ALL`이다. + - 인증된 회원이 참여 중인 AI 채팅방과 DM 채팅방만 조회한다. + - 기본 정렬은 최종 대화 시간 내림차순이다. + - 최신순 30개씩 조회한다. + - 페이징은 기존 채팅 메시지 조회 관례와 맞춰 cursor 기반으로 설계한다. + - 마지막 메시지가 없는 방은 리스트에서 제외한다. + +- [x] 채팅 리스트 응답 DTO 설계 + - 페이지 응답 필드는 `rooms`, `hasMore`, `nextCursor`를 사용한다. + - 각 방 응답 필드는 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`을 사용한다. + - `chatType`은 `AI` 또는 `DM`이다. + - `roomId`는 해당 타입의 방 입장 API에 전달할 식별자이다. + - `targetName`은 AI 채팅이면 캐릭터명, DM이면 나를 제외한 참여 회원 닉네임이다. + - `targetImageUrl`은 AI 채팅이면 캐릭터 대표 이미지, DM이면 나를 제외한 참여 회원 프로필 이미지이다. + - 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 `targetImageUrl`에는 기본 이미지 URL을 내려준다. + - `lastMessage`는 서버에서 15글자까지 자르고, 원문이 15글자를 초과하면 말줄임표를 붙인다. + - 음성 메시지의 `lastMessage` 문구는 `[음성 메시지]`로 둔다. + - `lastMessageAt`은 UTC 기준 ISO-8601 문자열을 사용한다. + +- [x] 채팅 리스트 조회 정책 설계 + - DM 방은 `UserCreatorChatParticipant` 기준으로 현재 회원이 활성 참여자인 방만 조회한다. + - AI 방은 기존 AI 채팅 참여자 모델 기준으로 현재 회원이 참여자인 방만 조회한다. + - 비활성 메시지는 마지막 메시지 산정에서 제외한다. + - 상대방 정보가 비활성 또는 삭제 상태일 때의 닉네임 표시 정책은 기존 회원/캐릭터 응답 관례를 따른다. + - 상대방 회원 또는 AI 캐릭터 프로필 이미지가 없으면 기본 이미지를 사용한다. + - 통합 조회 시 도메인별 최신 메시지 후보를 조회한 뒤 공통 DTO로 변환하고, `lastMessageAt` 기준으로 병합 정렬한다. + ## 참고 파일 - `src/main/kotlin/kr/co/vividnext/sodalive/message/Message.kt` @@ -153,6 +205,13 @@ CREATE TABLE user_creator_chat_message ( - SSE 연결이 끊기면 3초부터 재연결하고, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초까지 지수 백오프한다. 연동할 API: +0. 채팅방 리스트 조회 + - `GET /api/v2/chat/rooms?filter=ALL&limit=30` + - `filter`: `ALL`, `AI`, `DM` + - 최신순 30개씩 cursor 기반으로 조회한다. + - response data: `{ "rooms", "hasMore", "nextCursor" }` + - room item: `{ "roomId", "chatType", "targetName", "targetImageUrl", "lastMessage", "lastMessageAt" }` + 1. 방 생성/조회 - `POST /api/v2/user-creator-chat/rooms/create` - body: `{ "creatorId": number }` @@ -200,6 +259,33 @@ CREATE TABLE user_creator_chat_message ( - `senderProfileImageUrl`: string ``` +## 채팅 리스트 API 응답 예시 + +```json +{ + "rooms": [ + { + "roomId": 123, + "chatType": "DM", + "targetName": "creator_nick", + "targetImageUrl": "https://cdn.example.com/profile/creator.png", + "lastMessage": "안녕하세요. 문의드...", + "lastMessageAt": "2026-05-14T03:12:30Z" + }, + { + "roomId": 456, + "chatType": "AI", + "targetName": "AI 캐릭터", + "targetImageUrl": "https://cdn.example.com/default/profile.png", + "lastMessage": "[음성 메시지]", + "lastMessageAt": "2026-05-14T02:40:10Z" + } + ], + "hasMore": true, + "nextCursor": "2026-05-14T02:40:10Z:456:AI" +} +``` + ## 검증 기록 ### 1차 문서 작성 @@ -246,3 +332,23 @@ CREATE TABLE user_creator_chat_message ( - 무엇을: 실제 채팅방 탈퇴로 오해될 수 있는 `enter`, `leave` 표현을 제거하고, 방 화면 열기는 `open`, 실시간 수신 해제는 `events/disconnect`로 변경했다. - 왜: 현재 기능은 DB 참여자 삭제/비활성화가 아니라 최신 메시지 조회와 SSE/presence 해제이므로, 일반적인 채팅방 입장/탈퇴 의미와 혼동되지 않게 하기 위해서다. - 어떻게: 테스트에서 `disconnectRealtime` 메서드가 필요하도록 먼저 변경해 컴파일 실패를 확인한 뒤, 컨트롤러 URL과 서비스/DTO 함수명을 수정했다. 클라이언트 연동 문서도 새 API 의미와 URL로 갱신했다. + +### 10차 채팅 리스트 API 문서화 +- 무엇을: 전체, AI 채팅, DM 채팅 필터를 지원하는 통합 채팅 리스트 API 요구사항과 응답 DTO 초안을 문서에 추가했다. +- 왜: 클라이언트가 내가 참여 중인 채팅방만 최신 대화순으로 표시하고, 방 입장에 필요한 `roomId`와 상대방 정보, 마지막 대화 요약, UTC 기준 최종 대화 시간을 받아야 하기 때문이다. +- 어떻게: 기존 유저-크리에이터 채팅방 개편 문서를 후속 요구사항으로 재사용하고, 통합 API 접근안을 채택했다. 응답 필드는 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`으로 정리했으며, 마지막 대화는 서버에서 15글자 초과 시 말줄임표를 붙이고 최종 대화 시간은 UTC ISO-8601 문자열로 내려주도록 기록했다. + +### 11차 채팅 리스트 API 정책 확정 +- 무엇을: 채팅 리스트 API 접근안 Option B 채택, `/api/v2/chat/rooms` URL, 30개 단위 최신순 페이징, 마지막 메시지 없는 방 제외, 음성 메시지 요약 문구, 기본 이미지 정책, 짧은 DTO 필드명을 문서에 반영했다. +- 왜: 사용자 결정사항을 구현 전 계약으로 고정하고, 상대방 표시 정보의 응답 필드명을 더 짧게 만들기 위해서다. +- 어떻게: 응답 필드명을 `targetName`, `targetImageUrl`로 변경하고, 음성 메시지 요약은 `[음성 메시지]`, 이미지가 없는 경우 기본 이미지 URL 사용, 기본 조회 개수는 30개로 갱신했다. + +### 12차 채팅 리스트 API 구현 +- 무엇을: `/api/v2/chat/rooms` 통합 채팅 리스트 API, 응답 DTO, 서비스, AI/DM 조회 쿼리, 단위 테스트를 추가했다. +- 왜: 문서에서 확정한 전체/AI/DM 필터, 내가 참여 중인 방만 조회, 최신순 30개 cursor 페이징, 마지막 메시지 요약, 기본 이미지, UTC ISO-8601 시간 응답을 구현하기 위해서다. +- 어떻게: 실패 테스트를 먼저 작성해 신규 서비스/DTO/쿼리 부재로 컴파일 실패를 확인한 뒤 최소 구현을 추가했다. 이후 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --rerun-tasks`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. Kotlin LSP는 이 환경에 서버가 없어 diagnostics를 수행할 수 없었다. + +### 13차 코드 리뷰 반영 +- 무엇을: 채팅 리스트 cursor 조건, UTC ISO 시간 변환, 빈 이미지 경로 기본 이미지 처리를 보완했다. +- 왜: 같은 `lastMessageAt`을 가진 방이 여러 개일 때 cursor 이후 항목이 누락되지 않아야 하고, 문서 기준 UTC 시간과 기본 이미지 정책을 정확히 지켜야 하기 때문이다. +- 어떻게: cursor를 `lastMessageAt`, `chatType`, `roomId` tuple 기준으로 적용하고 repository seek 조건도 같은 정렬 기준에 맞췄다. `lastMessageAt`은 `ZoneOffset.UTC` 기준 ISO 문자열로 변환하고, 빈 이미지 경로도 기본 이미지로 대체했다. 동일 timestamp cursor 테스트를 추가한 뒤 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --rerun-tasks`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build` 실행 결과 `BUILD SUCCESSFUL`을 확인했고, 재리뷰에서 남은 Critical/Important 결함 없음 승인을 받았다. diff --git a/docs/prd/20260513_유저크리에이터채팅방개편_prd.md b/docs/prd/20260513_유저크리에이터채팅방개편_prd.md index 1ad0d37e..e1be5e5f 100644 --- a/docs/prd/20260513_유저크리에이터채팅방개편_prd.md +++ b/docs/prd/20260513_유저크리에이터채팅방개편_prd.md @@ -20,6 +20,9 @@ - 메시지 발송 시 상대방이 해당 방에 입장해 있으면 푸시를 보내지 않고 실시간으로 메시지를 표시한다. - 메시지 발송 시 상대방이 해당 방에 입장해 있지 않으면 푸시 알림을 발송한다. - 기존 AI 채팅 도메인의 엔티티라도 재활용할 수 있는지 코드 근거에 따라 결정한다. +- 사용자가 참여 중인 AI 채팅방과 DM 채팅방을 하나의 채팅 리스트 API에서 조회할 수 있게 한다. +- 채팅 리스트는 전체, AI 채팅, DM 채팅 필터를 지원한다. +- 채팅 리스트 응답은 방 입장에 필요한 `roomId`, 상대방 표시 정보, 마지막 메시지 요약, 최종 대화 시간을 제공한다. --- @@ -30,6 +33,7 @@ - 텍스트/음성 외 이미지, 파일, 이모티콘, 읽음 확인, 메시지 수정 기능은 이번 범위에 포함하지 않는다. - typing indicator는 이번 범위에 포함하지 않는다. - 관리자 화면 개편은 이번 범위에 포함하지 않는다. +- 채팅 리스트 API에서 메시지 본문 전체, 읽지 않은 메시지 수, 고정/숨김/삭제 상태, 검색 기능은 이번 범위에 포함하지 않는다. --- @@ -46,6 +50,9 @@ - 크리에이터는 방에 들어와 있지 않을 때 새 메시지가 오면 푸시로 알림을 받고 싶다. - 크리에이터는 방에 들어와 있을 때 새 메시지가 오면 푸시 없이 화면에 바로 표시되기를 원한다. - 양쪽 사용자는 상대방이 방에 들어와 있을 때 새 메시지를 즉시 확인하고 싶다. +- 사용자는 내가 참여 중인 모든 채팅방을 최신 대화순으로 보고 싶다. +- 사용자는 AI 채팅방만 또는 DM 채팅방만 필터링해서 보고 싶다. +- 사용자는 리스트에서 상대방 닉네임, 프로필 이미지, 마지막 대화 요약, 최종 대화 시간을 확인한 뒤 방에 입장하고 싶다. --- @@ -111,6 +118,26 @@ - 한 사용자가 여러 기기에서 같은 방에 입장할 수 있다면, 하나 이상의 활성 연결이 있을 때 입장 중으로 판단한다. - 앱 백그라운드 전환 또는 화면 이탈 이벤트가 서버에 도달하지 못해도 SSE 연결 종료와 Redis TTL 만료로 방 입장 상태가 해제되어야 한다. +### 채팅 리스트 API + +#### Requirements +- 인증된 회원이 참여 중인 채팅방만 조회한다. +- 필터는 `ALL`, `AI`, `DM` 3가지를 지원한다. +- `AI`는 기존 AI 캐릭터 채팅방을 의미한다. +- `DM`은 유저-크리에이터 채팅방을 의미하며, API 문서와 클라이언트 표시 용어에서 User-Creator 채팅 대신 DM으로 명명한다. +- 기본 정렬은 최종 대화 시간 내림차순이다. +- 채팅 리스트는 최신순 30개씩 조회하고 cursor 기반으로 다음 페이지를 조회한다. +- 응답 항목은 방 입장을 위한 `roomId`, `chatType`, 상대방 닉네임, 상대방 프로필 이미지, 마지막 대화 요약, 최종 대화 시간을 포함한다. +- 마지막 대화 요약은 서버에서 15글자까지 내려주고, 15글자를 초과하면 말줄임표를 붙인다. +- 최종 대화 시간은 UTC 기준 값을 내려주고, 클라이언트가 표시 방식과 로컬 타임존 변환을 처리한다. +- 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않는다. + +#### Edge Cases +- 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 사용한다. +- 마지막 메시지가 음성 메시지이면 본문 요약 대신 `[음성 메시지]`를 내려준다. +- 마지막 메시지가 비활성화되었거나 표시할 수 없는 상태라면 해당 메시지를 제외하고 다음 최신 표시 가능 메시지를 기준으로 요약한다. +- DM 채팅방에서 현재 회원이 유저인지 크리에이터인지와 관계없이 상대방은 나를 제외한 참여자로 계산한다. + --- ## 8. Technical Constraints @@ -147,6 +174,24 @@ - 단점: 신규 엔티티, 저장소, 실시간 연결 관리, API가 필요해 초기 구현량이 늘어난다. - 판단: 이번 요구사항의 권장안이다. +### 채팅 리스트 API 접근안 + +#### Option A: AI/DM 리스트 API를 각각 작성 +- 장점: 각 도메인의 조회 조건과 응답 조립을 단순하게 유지할 수 있다. +- 단점: 클라이언트가 전체 리스트를 만들기 위해 두 API를 호출하고 병합/정렬해야 한다. +- 판단: 전체 필터 요구사항과 맞지 않아 권장하지 않는다. + +#### Option B: 통합 리스트 API에서 필터로 구분 +- 장점: 클라이언트는 하나의 API로 전체, AI, DM 탭을 처리할 수 있다. +- 장점: 참여 중인 방만 노출, 최신 대화순 정렬, 마지막 메시지 요약 정책을 서버에서 일관되게 적용할 수 있다. +- 단점: 서버에서 AI 채팅방과 DM 채팅방의 응답 모델을 하나로 맞추는 조립 계층이 필요하다. +- 판단: 이번 채팅 리스트 API의 채택안이다. + +#### Option C: 기존 AI 채팅 리스트 API에 DM을 추가 +- 장점: 새 endpoint 수를 줄일 수 있다. +- 단점: 기존 AI 채팅 API의 의미가 넓어지고, DM 도메인 결합이 생긴다. +- 판단: 공개 API 의미가 불명확해지므로 권장하지 않는다. + --- ## 10. Metrics @@ -158,5 +203,12 @@ --- -## 11. Open Questions -- 없음. URL prefix와 DTO 필드명은 구현 직전 추천안을 제시해 확정하고, SSE 인증과 재연결 간격은 본 문서의 Technical Constraints 기준을 따른다. +## 11. Confirmed Defaults +- 채팅 리스트에서 마지막 메시지가 없는 방은 노출하지 않는다. +- 음성 메시지의 마지막 대화 요약 문구는 `[음성 메시지]`를 사용한다. +- 채팅 리스트 API URL prefix는 `/api/v2/chat/rooms`를 사용한다. +- 채팅 리스트 DTO 필드명은 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`을 사용한다. +- `targetName`은 AI 채팅이면 캐릭터명, DM이면 나를 제외한 참여 회원 닉네임이다. +- `targetImageUrl`은 AI 채팅이면 캐릭터 대표 이미지, DM이면 나를 제외한 참여 회원 프로필 이미지이며, 이미지가 없으면 기본 이미지 URL을 내려준다. +- 채팅 리스트는 최신순 30개씩 cursor 기반으로 페이징한다. +- SSE 인증과 재연결 간격은 본 문서의 Technical Constraints 기준을 따른다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index 07d4a690..70ae1858 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -9,6 +9,8 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository +import java.time.LocalDateTime +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto as V2ChatRoomListQueryDto @Repository interface ChatRoomRepository : JpaRepository { @@ -64,5 +66,52 @@ interface ChatRoomRepository : JpaRepository { pageable: Pageable ): List + @Query( + """ + SELECT new kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto( + r.id, + 'AI', + pc.character.name, + pc.character.imagePath, + m.message, + str(m.messageType), + m.createdAt + ) + FROM ChatRoom r + JOIN r.participants p + JOIN r.participants pc + JOIN r.messages m + WHERE p.member.id = :memberId + AND p.isActive = true + AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER + AND pc.isActive = true + AND r.isActive = true + AND m.isActive = true + AND ( + :cursorAt IS NULL + OR m.createdAt < :cursorAt + OR (m.createdAt = :cursorAt AND 'AI' < :cursorChatType) + OR (m.createdAt = :cursorAt AND 'AI' = :cursorChatType AND r.id < :cursorRoomId) + ) + AND NOT EXISTS ( + SELECT 1 FROM ChatMessage newer + WHERE newer.chatRoom = r + AND newer.isActive = true + AND ( + newer.createdAt > m.createdAt + OR (newer.createdAt = m.createdAt AND newer.id > m.id) + ) + ) + ORDER BY m.createdAt DESC, r.id DESC + """ + ) + fun findAiChatListRooms( + @Param("memberId") memberId: Long, + @Param("cursorAt") cursorAt: LocalDateTime?, + @Param("cursorChatType") cursorChatType: String?, + @Param("cursorRoomId") cursorRoomId: Long?, + pageable: Pageable + ): List + fun findByIdAndIsActiveTrue(id: Long): ChatRoom? } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt new file mode 100644 index 00000000..cabb5a45 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.v2.chat.controller + +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.chat.service.ChatRoomListService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +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/chat/rooms") +class ChatRoomListController( + private val service: ChatRoomListService +) { + @GetMapping + fun getRooms( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestParam(defaultValue = "ALL") filter: String, + @RequestParam(required = false) cursor: String?, + @RequestParam(defaultValue = "30") limit: Int + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(service.getRooms(member, filter, cursor, limit)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/dto/ChatRoomListDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/dto/ChatRoomListDtos.kt new file mode 100644 index 00000000..cff85658 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/dto/ChatRoomListDtos.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.v2.chat.dto + +import java.time.LocalDateTime + +data class ChatRoomListPageResponse( + val rooms: List, + val hasMore: Boolean, + val nextCursor: String? +) + +data class ChatRoomListItemResponse( + val roomId: Long, + val chatType: String, + val targetName: String, + val targetImageUrl: String, + val lastMessage: String, + val lastMessageAt: String +) + +data class ChatRoomListQueryDto( + val roomId: Long, + val chatType: String, + val targetName: String, + val targetImagePath: String?, + val lastMessage: String?, + val messageType: String, + val lastMessageAt: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt new file mode 100644 index 00000000..20bd7986 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt @@ -0,0 +1,156 @@ +package kr.co.vividnext.sodalive.v2.chat.service + +import kr.co.vividnext.sodalive.chat.room.ChatMessageType +import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneOffset + +@Service +@Transactional(readOnly = true) +class ChatRoomListService( + private val aiRoomRepository: ChatRoomRepository, + private val dmRoomRepository: UserCreatorChatRoomRepository, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getRooms( + member: Member, + filter: String = ChatRoomListFilter.ALL.name, + cursor: String? = null, + limit: Int = DEFAULT_LIMIT + ): ChatRoomListPageResponse { + val type = ChatRoomListFilter.from(filter) + val safeLimit = limit.coerceIn(1, DEFAULT_LIMIT) + val pageable = PageRequest.of(0, safeLimit + 1) + val parsedCursor = parseCursor(cursor) + val rows = when (type) { + ChatRoomListFilter.ALL -> { + aiRoomRepository.findAiChatListRooms( + member.id!!, + parsedCursor?.lastMessageAt, + parsedCursor?.chatType, + parsedCursor?.roomId, + pageable + ) + dmRoomRepository.findDmChatListRooms( + member.id!!, + parsedCursor?.lastMessageAt, + parsedCursor?.chatType, + parsedCursor?.roomId, + pageable + ) + } + ChatRoomListFilter.AI -> { + aiRoomRepository.findAiChatListRooms( + member.id!!, + parsedCursor?.lastMessageAt, + parsedCursor?.chatType, + parsedCursor?.roomId, + pageable + ) + } + ChatRoomListFilter.DM -> { + dmRoomRepository.findDmChatListRooms( + member.id!!, + parsedCursor?.lastMessageAt, + parsedCursor?.chatType, + parsedCursor?.roomId, + pageable + ) + } + }.sortedWith( + compareByDescending { it.lastMessageAt } + .thenByDescending { it.chatType } + .thenByDescending { it.roomId } + ) + .filter { parsedCursor == null || it.isAfter(parsedCursor) } + + val pageRows = rows.take(safeLimit) + return ChatRoomListPageResponse( + rooms = pageRows.map { it.toResponse() }, + hasMore = rows.size > safeLimit, + nextCursor = if (rows.size > safeLimit) pageRows.lastOrNull()?.toCursor() else null + ) + } + + private fun ChatRoomListQueryDto.toResponse(): ChatRoomListItemResponse { + return ChatRoomListItemResponse( + roomId = roomId, + chatType = chatType, + targetName = targetName, + targetImageUrl = imageUrl(targetImagePath), + lastMessage = previewMessage(), + lastMessageAt = lastMessageAt.toUtcIsoString() + ) + } + + private fun ChatRoomListQueryDto.previewMessage(): String { + if (messageType == UserCreatorChatMessageType.VOICE.name) return VOICE_PREVIEW + if (messageType == ChatMessageType.IMAGE.name) return lastMessage.orEmpty().ifBlank { "이미지 메시지" } + val message = lastMessage.orEmpty() + return if (message.length > PREVIEW_LENGTH) message.take(PREVIEW_LENGTH) + "..." else message + } + + private fun imageUrl(path: String?): String { + return "$cloudFrontHost/${path?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_IMAGE_PATH}" + } + + private fun ChatRoomListQueryDto.toCursor(): String { + return "$lastMessageAt:$chatType:$roomId" + } + + private fun parseCursor(cursor: String?): ChatRoomListCursor? { + if (cursor == null) return null + val roomId = cursor.substringAfterLast(":").toLong() + val cursorWithoutRoomId = cursor.substringBeforeLast(":") + val chatType = cursorWithoutRoomId.substringAfterLast(":") + val lastMessageAt = LocalDateTime.parse(cursorWithoutRoomId.substringBeforeLast(":")) + return ChatRoomListCursor(lastMessageAt, chatType, roomId) + } + + private fun LocalDateTime.toUtcIsoString(): String { + return atOffset(ZoneOffset.UTC).toInstant().toString() + } + + private fun ChatRoomListQueryDto.isAfter(cursor: ChatRoomListCursor): Boolean { + if (lastMessageAt.isBefore(cursor.lastMessageAt)) return true + if (lastMessageAt.isAfter(cursor.lastMessageAt)) return false + if (chatType < cursor.chatType) return true + if (chatType > cursor.chatType) return false + return roomId < cursor.roomId + } + + private data class ChatRoomListCursor( + val lastMessageAt: LocalDateTime, + val chatType: String, + val roomId: Long + ) + + private enum class ChatRoomListFilter { + ALL, AI, DM; + + companion object { + fun from(value: String): ChatRoomListFilter { + return values().firstOrNull { it.name == value.uppercase() } + ?: throw SodaException(messageKey = "common.error.invalid_request") + } + } + } + + companion object { + private const val DEFAULT_LIMIT = 30 + private const val PREVIEW_LENGTH = 15 + private const val DEFAULT_PROFILE_IMAGE_PATH = "profile/default-profile.png" + private const val VOICE_PREVIEW = "[음성 메시지]" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt index 5870bc72..ceb1ceea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt @@ -1,10 +1,13 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.repository +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository +import java.time.LocalDateTime @Repository interface UserCreatorChatRoomRepository : JpaRepository { @@ -32,4 +35,51 @@ interface UserCreatorChatRoomRepository : JpaRepository :memberId + and m.isActive = true + and ( + :cursorAt is null + or m.createdAt < :cursorAt + or (m.createdAt = :cursorAt and 'DM' < :cursorChatType) + or (m.createdAt = :cursorAt and 'DM' = :cursorChatType and r.id < :cursorRoomId) + ) + and not exists ( + select 1 from UserCreatorChatMessage newer + where newer.chatRoom = r + and newer.isActive = true + and ( + newer.createdAt > m.createdAt + or (newer.createdAt = m.createdAt and newer.id > m.id) + ) + ) + order by m.createdAt desc, r.id desc + """ + ) + fun findDmChatListRooms( + @Param("memberId") memberId: Long, + @Param("cursorAt") cursorAt: LocalDateTime?, + @Param("cursorChatType") cursorChatType: String?, + @Param("cursorRoomId") cursorRoomId: Long?, + pageable: Pageable + ): List } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt new file mode 100644 index 00000000..1273a4f0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt @@ -0,0 +1,179 @@ +package kr.co.vividnext.sodalive.v2.chat + +import kr.co.vividnext.sodalive.chat.room.ChatMessageType +import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto +import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType +import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest +import java.time.LocalDateTime + +class ChatRoomListServiceTest { + private lateinit var aiRoomRepository: ChatRoomRepository + private lateinit var dmRoomRepository: UserCreatorChatRoomRepository + private lateinit var service: ChatRoomListService + + @BeforeEach + fun setUp() { + aiRoomRepository = Mockito.mock(ChatRoomRepository::class.java) + dmRoomRepository = Mockito.mock(UserCreatorChatRoomRepository::class.java) + service = ChatRoomListService( + aiRoomRepository = aiRoomRepository, + dmRoomRepository = dmRoomRepository, + cloudFrontHost = "https://cdn.test" + ) + } + + @Test + @DisplayName("전체 채팅 리스트는 AI와 DM을 최신순으로 병합하고 30개 페이징한다") + fun shouldMergeAiAndDmRoomsByLastMessageAt() { + val member = member(1L) + val aiLastAt = LocalDateTime.of(2026, 5, 14, 11, 0) + val dmLastAt = LocalDateTime.of(2026, 5, 14, 12, 0) + Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn( + listOf( + ChatRoomListQueryDto( + roomId = 10L, + chatType = "AI", + targetName = "AI 캐릭터", + targetImagePath = "character/a.png", + lastMessage = "AI hello", + messageType = ChatMessageType.TEXT.name, + lastMessageAt = aiLastAt + ) + ) + ) + Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn( + listOf( + ChatRoomListQueryDto( + roomId = 20L, + chatType = "DM", + targetName = "creator", + targetImagePath = null, + lastMessage = "안녕하세요. 문의드립니다. 길게 보냅니다.", + messageType = UserCreatorChatMessageType.TEXT.name, + lastMessageAt = dmLastAt + ) + ) + ) + + val response = service.getRooms(member, filter = "ALL", cursor = null, limit = 30) + + assertFalse(response.hasMore) + assertEquals(listOf("DM", "AI"), response.rooms.map { it.chatType }) + assertEquals("creator", response.rooms[0].targetName) + assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl) + assertEquals("안녕하세요. 문의드립니다. ...", response.rooms[0].lastMessage) + assertEquals("2026-05-14T12:00:00Z", response.rooms[0].lastMessageAt) + } + + @Test + @DisplayName("DM 필터는 DM 방만 조회하고 음성 메시지 요약 문구를 사용한다") + fun shouldReturnOnlyDmRoomsWithVoicePreview() { + val member = member(1L) + Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn( + listOf( + ChatRoomListQueryDto( + roomId = 20L, + chatType = "DM", + targetName = "creator", + targetImagePath = "profile/creator.png", + lastMessage = null, + messageType = UserCreatorChatMessageType.VOICE.name, + lastMessageAt = LocalDateTime.of(2026, 5, 14, 12, 0) + ) + ) + ) + + val response = service.getRooms(member, filter = "DM", cursor = null, limit = 30) + + assertEquals(1, response.rooms.size) + assertEquals("DM", response.rooms[0].chatType) + assertEquals("[음성 메시지]", response.rooms[0].lastMessage) + assertEquals("https://cdn.test/profile/creator.png", response.rooms[0].targetImageUrl) + Mockito.verifyNoInteractions(aiRoomRepository) + } + + @Test + @DisplayName("31번째 항목이 있으면 hasMore와 nextCursor를 반환한다") + fun shouldReturnNextCursorWhenMoreThanLimit() { + val member = member(1L) + val rows = (1L..31L).map { index -> + ChatRoomListQueryDto( + roomId = index, + chatType = "AI", + targetName = "AI $index", + targetImagePath = null, + lastMessage = "message $index", + messageType = ChatMessageType.TEXT.name, + lastMessageAt = LocalDateTime.of(2026, 5, 14, 12, 0).minusMinutes(index) + ) + } + Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(rows) + + val response = service.getRooms(member, filter = "AI", cursor = null, limit = 30) + + assertEquals(30, response.rooms.size) + assertTrue(response.hasMore) + assertEquals("AI", response.rooms.last().chatType) + assertEquals("30", response.nextCursor?.substringAfterLast(":")) + } + + @Test + @DisplayName("커서는 동일 시간의 다음 정렬 항목을 누락하지 않는다") + fun shouldKeepRoomsWithSameTimestampAfterCursor() { + val member = member(1L) + val cursorAt = LocalDateTime.of(2026, 5, 14, 12, 0) + Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn( + listOf( + ChatRoomListQueryDto( + roomId = 30L, + chatType = "AI", + targetName = "AI same time", + targetImagePath = "", + lastMessage = "same time", + messageType = ChatMessageType.TEXT.name, + lastMessageAt = cursorAt + ) + ) + ) + Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn( + listOf( + ChatRoomListQueryDto( + roomId = 20L, + chatType = "DM", + targetName = "cursor row", + targetImagePath = "profile/cursor.png", + lastMessage = "cursor", + messageType = UserCreatorChatMessageType.TEXT.name, + lastMessageAt = cursorAt + ), + ChatRoomListQueryDto( + roomId = 10L, + chatType = "DM", + targetName = "older", + targetImagePath = "profile/older.png", + lastMessage = "older", + messageType = UserCreatorChatMessageType.TEXT.name, + lastMessageAt = cursorAt.minusMinutes(1) + ) + ) + ) + + val response = service.getRooms(member, filter = "ALL", cursor = "2026-05-14T12:00:00:DM:20", limit = 30) + + assertEquals(listOf(30L, 10L), response.rooms.map { it.roomId }) + assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl) + } + + private fun member(id: Long) = Member(password = "pw", nickname = "user").apply { this.id = id } +} From 810b143c9e1764f660b007d03b3d1d6b006ff681 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 18 May 2026 13:34:12 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix(charge):=20=EC=B6=A9=EC=A0=84=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B3=B4=EB=84=88=EC=8A=A4=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=EC=9D=84=20=EC=95=88=EC=A0=95=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260518_charge_event_job_ddl.sql | 37 +++ .../charge/AdminChargeEventJobController.kt | 25 ++ .../charge/AdminChargeEventJobResponse.kt | 42 ++++ .../charge/AdminChargeEventJobService.kt | 28 +++ .../sodalive/can/charge/ChargeRepository.kt | 10 +- .../sodalive/can/charge/ChargeService.kt | 123 ++++------ .../can/charge/event/ChargeEventJob.kt | 86 +++++++ .../charge/event/ChargeEventJobRepository.kt | 56 +++++ .../can/charge/event/ChargeEventJobService.kt | 222 ++++++++++++++++++ .../can/charge/event/ChargeEventJobWorker.kt | 73 ++++++ .../can/charge/event/ChargeEventRepository.kt | 24 +- .../can/charge/event/ChargeSpringEvent.kt | 21 -- .../charge/AdminChargeEventJobServiceTest.kt | 73 ++++++ .../charge/event/ChargeEventJobServiceTest.kt | 89 +++++++ .../charge/event/ChargeEventJobWorkerTest.kt | 122 ++++++++++ 15 files changed, 929 insertions(+), 102 deletions(-) create mode 100644 docs/plan-task/20260518_charge_event_job_ddl.sql create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJob.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorker.kt delete mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeSpringEvent.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobServiceTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobServiceTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorkerTest.kt diff --git a/docs/plan-task/20260518_charge_event_job_ddl.sql b/docs/plan-task/20260518_charge_event_job_ddl.sql new file mode 100644 index 00000000..8ef7981f --- /dev/null +++ b/docs/plan-task/20260518_charge_event_job_ddl.sql @@ -0,0 +1,37 @@ +CREATE TABLE charge_event_job +( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK', + source_charge_id BIGINT NOT NULL COMMENT '이벤트 보너스 지급의 기준이 되는 원본 충전 ID(charge.id)', + result_charge_id BIGINT NULL COMMENT '이벤트 보너스로 생성된 Charge ID(charge.id)', + member_id BIGINT NOT NULL COMMENT '이벤트 보너스를 지급받을 회원 ID(member.id)', + charge_event_id BIGINT NULL COMMENT '적용된 충전 이벤트 ID(charge_event.id), 첫 충전 이벤트처럼 별도 이벤트 row가 없으면 NULL', + job_type VARCHAR(30) NOT NULL COMMENT '작업 유형(FIRST_CHARGE, ACTIVE_CHARGE_EVENT)', + idempotency_key VARCHAR(100) NOT NULL COMMENT '중복 작업 방지 키(예: charge-event:{sourceChargeId}:{jobType}:{chargeEventId 또는 none})', + additional_can INT NOT NULL COMMENT '추가 지급할 보너스 캔 수', + payment_gateway VARCHAR(30) NOT NULL COMMENT '원본 충전의 결제 게이트웨이(PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP 등)', + container VARCHAR(10) NOT NULL COMMENT '회원 캔 잔액 반영 대상(pg, aos, ios)', + method_snapshot VARCHAR(100) NOT NULL COMMENT '이벤트 보너스 Charge.payment.method에 기록할 지급 사유 스냅샷', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '작업 상태(PENDING, PROCESSING, DONE, FAILED)', + retry_count INT NOT NULL DEFAULT 0 COMMENT '재시도 횟수', + next_retry_at TIMESTAMP NULL COMMENT '다음 재시도 가능 시각', + processing_started_at TIMESTAMP NULL COMMENT 'PROCESSING 상태로 선점한 시각', + processed_at TIMESTAMP NULL COMMENT '지급 성공 처리 시각', + last_error TEXT 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_charge_event_job_idempotency_key (idempotency_key), + KEY idx_charge_event_job_status_next_retry_at (status, next_retry_at), + KEY idx_charge_event_job_source_charge_id (source_charge_id), + KEY idx_charge_event_job_result_charge_id (result_charge_id), + KEY idx_charge_event_job_member_id (member_id), + KEY idx_charge_event_job_charge_event_id (charge_event_id), + CONSTRAINT fk_charge_event_job_source_charge_id + FOREIGN KEY (source_charge_id) REFERENCES charge (id), + CONSTRAINT fk_charge_event_job_result_charge_id + FOREIGN KEY (result_charge_id) REFERENCES charge (id), + CONSTRAINT fk_charge_event_job_member_id + FOREIGN KEY (member_id) REFERENCES member (id), + CONSTRAINT fk_charge_event_job_charge_event_id + FOREIGN KEY (charge_event_id) REFERENCES charge_event (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='충전 이벤트 보너스 지급 작업'; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobController.kt new file mode 100644 index 00000000..48e6b36e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobController.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.admin.event.charge + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.security.access.prepost.PreAuthorize +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.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/admin/charge/event-jobs") +@PreAuthorize("hasRole('ADMIN')") +class AdminChargeEventJobController( + private val service: AdminChargeEventJobService +) { + @GetMapping + fun getJobs() = ApiResponse.ok(service.getJobs()) + + @PostMapping("/{jobId}/retry") + fun retry(@PathVariable jobId: Long): ApiResponse { + service.retry(jobId) + return ApiResponse.ok(Unit) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobResponse.kt new file mode 100644 index 00000000..b51546e0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobResponse.kt @@ -0,0 +1,42 @@ +package kr.co.vividnext.sodalive.admin.event.charge + +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJob +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobType +import java.time.LocalDateTime + +data class AdminChargeEventJobResponse( + val id: Long, + val sourceChargeId: Long, + val resultChargeId: Long?, + val memberId: Long, + val chargeEventId: Long?, + val jobType: ChargeEventJobType, + val additionalCan: Int, + val status: ChargeEventJobStatus, + val retryCount: Int, + val nextRetryAt: LocalDateTime?, + val lastError: String?, + val createdAt: LocalDateTime?, + val updatedAt: LocalDateTime? +) { + companion object { + fun from(job: ChargeEventJob): AdminChargeEventJobResponse { + return AdminChargeEventJobResponse( + id = job.id!!, + sourceChargeId = job.sourceChargeId, + resultChargeId = job.resultChargeId, + memberId = job.memberId, + chargeEventId = job.chargeEventId, + jobType = job.jobType, + additionalCan = job.additionalCan, + status = job.status, + retryCount = job.retryCount, + nextRetryAt = job.nextRetryAt, + lastError = job.lastError, + createdAt = job.createdAt, + updatedAt = job.updatedAt + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobService.kt new file mode 100644 index 00000000..63182b11 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobService.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.admin.event.charge + +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobRepository +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class AdminChargeEventJobService( + private val repository: ChargeEventJobRepository +) { + fun getJobs(): List { + return repository.findVisibleAdminJobs().map(AdminChargeEventJobResponse::from) + } + + @Transactional + fun retry(jobId: Long) { + val job = repository.findByIdForUpdate(jobId) ?: return + if (job.status != ChargeEventJobStatus.FAILED) return + + job.status = ChargeEventJobStatus.PENDING + job.retryCount = 0 + job.nextRetryAt = LocalDateTime.now() + job.lastError = null + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt index 621eb6dd..86e1d615 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt @@ -8,11 +8,19 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.can.payment.QPayment.payment import kr.co.vividnext.sodalive.member.QMember.member 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 org.springframework.stereotype.Repository import java.time.LocalDateTime +import javax.persistence.LockModeType @Repository -interface ChargeRepository : JpaRepository, ChargeQueryRepository +interface ChargeRepository : JpaRepository, ChargeQueryRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from Charge c where c.id = :chargeId") + fun findByIdForUpdate(@Param("chargeId") chargeId: Long): Charge? +} interface ChargeQueryRepository { fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge? diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index 944c9d53..50f8a4e5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.can.charge import com.fasterxml.jackson.databind.ObjectMapper import kr.co.bootpay.Bootpay import kr.co.vividnext.sodalive.can.CanRepository -import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobService import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository import kr.co.vividnext.sodalive.can.coupon.CouponType import kr.co.vividnext.sodalive.can.payment.Payment @@ -27,7 +27,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.apache.commons.codec.digest.DigestUtils import org.json.JSONObject import org.springframework.beans.factory.annotation.Value -import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpHeaders import org.springframework.retry.annotation.Backoff @@ -52,7 +51,7 @@ class ChargeService( private val objectMapper: ObjectMapper, private val okHttpClient: OkHttpClient, - private val applicationEventPublisher: ApplicationEventPublisher, + private val chargeEventJobService: ChargeEventJobService, private val googlePlayService: GooglePlayService, private val messageSource: SodaMessageSource, @@ -102,7 +101,7 @@ class ChargeService( @Transactional fun payverseWebhook(request: PayverseWebhookRequest): Boolean { val chargeId = request.orderId.toLongOrNull() ?: return false - val charge = chargeRepository.findByIdOrNull(chargeId) ?: return false + val charge = chargeRepository.findByIdForUpdate(chargeId) ?: return false // 결제수단 확인 if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) { @@ -154,15 +153,9 @@ class ChargeService( charge.payment?.status = PaymentStatus.COMPLETE charge.payment?.locale = request.requestCurrency - val member = charge.member!! + val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false member.charge(charge.chargeCan, charge.rewardCan, "pg") - - applicationEventPublisher.publishEvent( - ChargeSpringEvent( - chargeId = charge.id!!, - memberId = member.id!! - ) - ) + chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) true } else { false @@ -307,9 +300,9 @@ class ChargeService( @Transactional fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse { - val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) + val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") - val member = memberRepository.findByIdOrNull(memberId) + val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") val currency = charge.can?.currency @@ -372,13 +365,7 @@ class ChargeService( charge.payment?.locale = verifyResponse.requestCurrency member.charge(charge.chargeCan, charge.rewardCan, "pg") - - applicationEventPublisher.publishEvent( - ChargeSpringEvent( - chargeId = charge.id!!, - memberId = member.id!! - ) - ) + chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) return ChargeCompleteResponse( price = charge.payment!!.price, @@ -429,12 +416,16 @@ class ChargeService( @Transactional fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { - val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) + val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") - val member = memberRepository.findByIdOrNull(memberId) + val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { + if (charge.payment?.status == PaymentStatus.COMPLETE) { + return completeResponse(charge, memberId) + } + val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) try { @@ -449,19 +440,9 @@ class ChargeService( charge.payment?.method = verifyResult.method charge.payment?.status = PaymentStatus.COMPLETE member.charge(charge.chargeCan, charge.rewardCan, "pg") + chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) - applicationEventPublisher.publishEvent( - ChargeSpringEvent( - chargeId = charge.id!!, - memberId = member.id!! - ) - ) - - return ChargeCompleteResponse( - price = charge.payment!!.price, - currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", - isFirstCharged = chargeRepository.isFirstCharged(memberId) - ) + return completeResponse(charge, memberId) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -475,12 +456,16 @@ class ChargeService( @Transactional fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { - val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) + val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") - val member = memberRepository.findByIdOrNull(memberId) + val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { + if (charge.payment?.status == PaymentStatus.COMPLETE) { + return completeResponse(charge, memberId) + } + val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey) try { @@ -500,18 +485,8 @@ class ChargeService( charge.payment?.status = PaymentStatus.COMPLETE member.charge(charge.chargeCan, charge.rewardCan, "pg") - applicationEventPublisher.publishEvent( - ChargeSpringEvent( - chargeId = charge.id!!, - memberId = member.id!! - ) - ) - - return ChargeCompleteResponse( - price = charge.payment!!.price, - currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", - isFirstCharged = chargeRepository.isFirstCharged(memberId) - ) + chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) + return completeResponse(charge, memberId) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -547,12 +522,16 @@ class ChargeService( @Transactional fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse { - val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId) + val charge = chargeRepository.findByIdForUpdate(verifyRequest.chargeId) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") - val member = memberRepository.findByIdOrNull(memberId) + val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) { + if (charge.payment?.status == PaymentStatus.COMPLETE) { + return completeResponse(charge, memberId) + } + // 검증로직 if (requestRealServerVerify(verifyRequest)) { charge.payment?.receiptId = verifyRequest.receiptString @@ -562,18 +541,8 @@ class ChargeService( charge.payment?.status = PaymentStatus.COMPLETE member.charge(charge.chargeCan, charge.rewardCan, "ios") - applicationEventPublisher.publishEvent( - ChargeSpringEvent( - chargeId = charge.id!!, - memberId = member.id!! - ) - ) - - return ChargeCompleteResponse( - price = charge.payment!!.price, - currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", - isFirstCharged = chargeRepository.isFirstCharged(memberId) - ) + chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) + return completeResponse(charge, memberId) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -619,9 +588,9 @@ class ChargeService( productId: String, purchaseToken: String ): ChargeCompleteResponse { - val charge = chargeRepository.findByIdOrNull(id = chargeId) + val charge = chargeRepository.findByIdForUpdate(chargeId) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") - val member = memberRepository.findByIdOrNull(id = memberId) + val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.status == PaymentStatus.REQUEST) { @@ -631,21 +600,13 @@ class ChargeService( charge.payment!!.status = PaymentStatus.COMPLETE member.charge(charge.chargeCan, 0, "aos") - applicationEventPublisher.publishEvent( - ChargeSpringEvent( - chargeId = charge.id!!, - memberId = member.id!! - ) - ) - - return ChargeCompleteResponse( - price = charge.payment!!.price, - currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", - isFirstCharged = chargeRepository.isFirstCharged(memberId) - ) + chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) + return completeResponse(charge, memberId) } else { throw SodaException(messageKey = "can.charge.purchase_failed_contact") } + } else if (charge.payment!!.status == PaymentStatus.COMPLETE) { + return completeResponse(charge, memberId) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -727,6 +688,14 @@ class ChargeService( return String.format(template, *args) } + private fun completeResponse(charge: Charge, memberId: Long): ChargeCompleteResponse { + return ChargeCompleteResponse( + price = charge.payment!!.price, + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) + } + // Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환 private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? { val cardCodes = setOf( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJob.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJob.kt new file mode 100644 index 00000000..0aa0050a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJob.kt @@ -0,0 +1,86 @@ +package kr.co.vividnext.sodalive.can.charge.event + +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.common.BaseEntity +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 + +enum class ChargeEventJobStatus { + PENDING, + PROCESSING, + DONE, + FAILED +} + +enum class ChargeEventJobType { + FIRST_CHARGE, + ACTIVE_CHARGE_EVENT +} + +@Entity +@Table( + name = "charge_event_job", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_charge_event_job_idempotency_key", + columnNames = ["idempotency_key"] + ) + ] +) +class ChargeEventJob( + @Column(name = "source_charge_id", nullable = false) + val sourceChargeId: Long, + + @Column(name = "result_charge_id") + var resultChargeId: Long? = null, + + @Column(name = "member_id", nullable = false) + val memberId: Long, + + @Column(name = "charge_event_id") + val chargeEventId: Long? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "job_type", nullable = false, length = 30) + val jobType: ChargeEventJobType, + + @Column(name = "idempotency_key", nullable = false, length = 100) + val idempotencyKey: String, + + @Column(name = "additional_can", nullable = false) + val additionalCan: Int, + + @Enumerated(EnumType.STRING) + @Column(name = "payment_gateway", nullable = false, length = 30) + val paymentGateway: PaymentGateway, + + @Column(name = "container", nullable = false, length = 10) + val container: String, + + @Column(name = "method_snapshot", nullable = false, length = 100) + val methodSnapshot: String, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + var status: ChargeEventJobStatus = ChargeEventJobStatus.PENDING, + + @Column(name = "retry_count", nullable = false) + var retryCount: Int = 0, + + @Column(name = "next_retry_at") + var nextRetryAt: LocalDateTime? = null, + + @Column(name = "processing_started_at") + var processingStartedAt: LocalDateTime? = null, + + @Column(name = "processed_at") + var processedAt: LocalDateTime? = null, + + @Column(name = "last_error", columnDefinition = "text") + var lastError: String? = null +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobRepository.kt new file mode 100644 index 00000000..b90533bc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobRepository.kt @@ -0,0 +1,56 @@ +package kr.co.vividnext.sodalive.can.charge.event + +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.LocalDateTime +import javax.persistence.LockModeType + +interface ChargeEventJobRepository : JpaRepository { + fun findByIdempotencyKey(idempotencyKey: String): ChargeEventJob? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select j from ChargeEventJob j where j.id = :jobId") + fun findByIdForUpdate(@Param("jobId") jobId: Long): ChargeEventJob? + + @Query( + value = """ + select j.id + from charge_event_job j + where j.status = 'PENDING' + and (j.next_retry_at is null or j.next_retry_at <= :now) + order by j.created_at asc + limit :limit + for update skip locked + """, + nativeQuery = true + ) + fun findNextPendingJobIdsForUpdate( + @Param("now") now: LocalDateTime, + @Param("limit") limit: Int + ): List + + @Query( + value = """ + select j.id + from charge_event_job j + where j.status = 'PENDING' + and j.job_type = 'FIRST_CHARGE' + and (j.next_retry_at is null or j.next_retry_at <= :now) + order by j.created_at asc + limit 1 + """, + nativeQuery = true + ) + fun findPendingFirstChargeJobId(@Param("now") now: LocalDateTime): Long? + + @Query( + """ + select j from ChargeEventJob j + where j.status in (kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus.PENDING, kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus.FAILED) + order by j.createdAt desc + """ + ) + fun findVisibleAdminJobs(): List +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobService.kt new file mode 100644 index 00000000..9d66c595 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobService.kt @@ -0,0 +1,222 @@ +package kr.co.vividnext.sodalive.can.charge.event + +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.auth.AuthRepository +import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import kotlin.math.ceil +import kotlin.math.round + +@Service +@Transactional(readOnly = true) +class ChargeEventJobService( + private val jobRepository: ChargeEventJobRepository, + private val chargeRepository: ChargeRepository, + private val memberRepository: MemberRepository, + private val chargeEventRepository: ChargeEventRepository? = null, + private val authRepository: AuthRepository? = null, + private val applicationEventPublisher: ApplicationEventPublisher? = null, + transactionManager: PlatformTransactionManager? = null +) { + private val transactionTemplate = transactionManager?.let { TransactionTemplate(it) } + + @Transactional + fun createAndProcessImmediate(sourceChargeId: Long, memberId: Long): ChargeEventJob? { + val job = createProcessingJob(sourceChargeId, memberId) ?: return null + if (transactionTemplate == null || !TransactionSynchronizationManager.isSynchronizationActive()) { + processJob(job.id!!) + return job + } + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + processImmediateAfterCommit(job.id!!) + } + } + ) + return job + } + + private fun processImmediateAfterCommit(jobId: Long) { + try { + transactionTemplate?.executeWithoutResult { processJob(jobId) } + } catch (ex: Exception) { + transactionTemplate?.executeWithoutResult { markImmediateFailure(jobId, ex) } + } + } + + private fun markImmediateFailure(jobId: Long, ex: Exception) { + val job = jobRepository.findByIdForUpdate(jobId) ?: return + if (job.status == ChargeEventJobStatus.DONE) return + job.status = ChargeEventJobStatus.PENDING + job.nextRetryAt = LocalDateTime.now().plusMinutes(5) + job.lastError = ex.message?.take(MAX_ERROR_LENGTH) + } + + @Transactional + fun createProcessingJob(sourceChargeId: Long, memberId: Long): ChargeEventJob? { + val charge = chargeRepository.findByIdForUpdate(sourceChargeId) + ?: throw SodaException(messageKey = "can.charge.event.not_applied_contact") + val member = memberRepository.findByIdForUpdate(memberId) + ?: throw SodaException(messageKey = "can.charge.event.not_applied_contact") + val snapshot = buildSnapshot(charge, member) ?: return null + val existing = jobRepository.findByIdempotencyKey(snapshot.idempotencyKey) + if (existing != null) return existing + + return try { + jobRepository.saveAndFlush(snapshot) + } catch (_: DataIntegrityViolationException) { + jobRepository.findByIdempotencyKey(snapshot.idempotencyKey) + } + } + + @Transactional + fun processJob(jobId: Long) { + val job = jobRepository.findByIdForUpdate(jobId) ?: return + if (job.status == ChargeEventJobStatus.DONE) return + + val member = memberRepository.findByIdForUpdate(job.memberId) + ?: throw SodaException(messageKey = "can.charge.event.not_applied_contact") + val eventCharge = Charge(0, job.additionalCan, status = ChargeStatus.EVENT) + eventCharge.title = "${job.additionalCan} 캔" + eventCharge.member = member + eventCharge.payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = job.paymentGateway).also { + it.method = job.methodSnapshot + } + val savedCharge = chargeRepository.save(eventCharge) + + member.charge(0, job.additionalCan, job.container) + job.status = ChargeEventJobStatus.DONE + job.resultChargeId = savedCharge.id + job.processedAt = LocalDateTime.now() + job.nextRetryAt = null + job.lastError = null + publishPaidNotification(job) + } + + private fun buildSnapshot(charge: Charge, member: Member): ChargeEventJob? { + val paymentGateway = charge.payment?.paymentGateway + ?: throw SodaException(messageKey = "can.charge.event.not_applied_contact") + return if (member.auth != null) { + val authRepository = authRepository ?: return null + val authDate = authRepository.getOldestCreatedAtByDi(member.auth!!.di) + val chargeCount = authRepository.getMemberIdsByDi(member.auth!!.di) + .sumOf { id -> chargeRepository.getChargeCountAfterDate(memberId = id, authDate) } + if (chargeCount > 1) { + buildActiveEventSnapshot(charge, member, paymentGateway) + } else { + buildFirstChargeSnapshot(charge, member, paymentGateway) + } + } else { + buildActiveEventSnapshot(charge, member, paymentGateway) + } + } + + private fun buildFirstChargeSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob { + val additionalCan = ceil(charge.chargeCan * 0.15).toInt() + return ChargeEventJob( + sourceChargeId = charge.id!!, + memberId = member.id!!, + chargeEventId = null, + jobType = ChargeEventJobType.FIRST_CHARGE, + idempotencyKey = idempotencyKey(charge.id!!, ChargeEventJobType.FIRST_CHARGE, null), + additionalCan = additionalCan, + paymentGateway = paymentGateway, + container = container(paymentGateway), + methodSnapshot = FIRST_CHARGE_METHOD, + status = ChargeEventJobStatus.PROCESSING, + processingStartedAt = LocalDateTime.now() + ) + } + + private fun buildActiveEventSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob? { + val repository = chargeEventRepository ?: return null + val chargeEvent = repository.getChargeEvent() ?: return null + val eventChargeCount = repository.getPaymentCount( + member = member, + method = chargeEvent.title, + startDate = chargeEvent.startDate, + endDate = chargeEvent.endDate + ) + if (eventChargeCount >= chargeEvent.availableCount) return null + + val additionalCan = round(charge.chargeCan * chargeEvent.addPercent).toInt() + return ChargeEventJob( + sourceChargeId = charge.id!!, + memberId = member.id!!, + chargeEventId = chargeEvent.id!!, + jobType = ChargeEventJobType.ACTIVE_CHARGE_EVENT, + idempotencyKey = idempotencyKey(charge.id!!, ChargeEventJobType.ACTIVE_CHARGE_EVENT, chargeEvent.id), + additionalCan = additionalCan, + paymentGateway = paymentGateway, + container = container(paymentGateway), + methodSnapshot = chargeEvent.title, + status = ChargeEventJobStatus.PROCESSING, + processingStartedAt = LocalDateTime.now() + ) + } + + private fun publishPaidNotification(job: ChargeEventJob) { + val publisher = applicationEventPublisher ?: return + if (job.jobType == ChargeEventJobType.FIRST_CHARGE) { + publisher.publishEvent( + FcmEvent( + type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.SYSTEM, + titleKey = "can.charge.event.first_title", + messageKey = "can.charge.event.additional_can_paid", + args = listOf(job.additionalCan), + recipients = listOf(job.memberId), + isAuth = null + ) + ) + return + } + publisher.publishEvent( + FcmEvent( + type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.SYSTEM, + title = job.methodSnapshot, + messageKey = "can.charge.event.additional_can_paid", + args = listOf(job.additionalCan), + recipients = listOf(job.memberId), + isAuth = null + ) + ) + } + + private fun container(paymentGateway: PaymentGateway): String { + return when (paymentGateway) { + PaymentGateway.GOOGLE_IAP -> "aos" + PaymentGateway.APPLE_IAP -> "ios" + else -> "pg" + } + } + + private fun idempotencyKey(sourceChargeId: Long, jobType: ChargeEventJobType, chargeEventId: Long?): String { + return "charge-event:$sourceChargeId:$jobType:${chargeEventId ?: "none"}" + } + + companion object { + private const val FIRST_CHARGE_METHOD = "첫 충전 이벤트" + private const val MAX_ERROR_LENGTH = 1000 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorker.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorker.kt new file mode 100644 index 00000000..e623e272 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorker.kt @@ -0,0 +1,73 @@ +package kr.co.vividnext.sodalive.can.charge.event + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime + +@Component +class ChargeEventJobWorker( + private val jobRepository: ChargeEventJobRepository, + private val jobService: ChargeEventJobService, + transactionManager: PlatformTransactionManager +) { + private val log = LoggerFactory.getLogger(javaClass) + private val transactionTemplate = TransactionTemplate(transactionManager) + + @Scheduled(fixedDelayString = "\${sodalive.charge-event-job.fixed-delay-ms:300000}") + fun runPendingJobs() { + val now = LocalDateTime.now() + claimPendingJobs(now).forEach { jobId -> + try { + jobService.processJob(jobId) + } catch (ex: Exception) { + failJob(jobId, ex) + } + } + } + + private fun claimPendingJobs(now: LocalDateTime): List { + return transactionTemplate.execute { + val jobIds = jobRepository.findNextPendingJobIdsForUpdate(now, BATCH_SIZE) + jobIds.forEach { jobId -> + val job = jobRepository.findById(jobId).orElse(null) ?: return@forEach + job.status = ChargeEventJobStatus.PROCESSING + job.processingStartedAt = LocalDateTime.now() + jobRepository.save(job) + } + jobIds + }.orEmpty() + } + + private fun failJob(jobId: Long, ex: Exception) { + log.warn("Failed to process charge event job. jobId={}, error={}", jobId, ex.message) + transactionTemplate.executeWithoutResult { + val job = jobRepository.findById(jobId).orElse(null) ?: return@executeWithoutResult + job.retryCount += 1 + job.lastError = ex.message?.take(MAX_ERROR_LENGTH) + if (job.retryCount >= MAX_RETRY_COUNT) { + job.status = ChargeEventJobStatus.FAILED + return@executeWithoutResult + } + job.status = ChargeEventJobStatus.PENDING + job.nextRetryAt = LocalDateTime.now().plusMinutes(backoffMinutes(job.retryCount)) + jobRepository.save(job) + } + } + + private fun backoffMinutes(retryCount: Int): Long { + return when (retryCount) { + 1 -> 5L + 2 -> 10L + else -> 15L + } + } + + companion object { + private const val BATCH_SIZE = 30 + private const val MAX_RETRY_COUNT = 3 + private const val MAX_ERROR_LENGTH = 1000 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventRepository.kt index c7b84fbf..157ddb45 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventRepository.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can.charge.event import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.admin.event.charge.ChargeEvent import kr.co.vividnext.sodalive.admin.event.charge.QChargeEvent.chargeEvent +import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.charge.QCharge.charge import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.can.payment.QPayment.payment @@ -14,6 +15,7 @@ interface ChargeEventRepository : JpaRepository, ChargeEventQ interface ChargeEventQueryRepository { fun getChargeEvent(): ChargeEvent? + fun existsActiveChargeEvent(): Boolean fun getPaymentCount(member: Member, method: String, startDate: LocalDateTime, endDate: LocalDateTime): Int } @@ -31,6 +33,19 @@ class ChargeEventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) .fetchFirst() } + override fun existsActiveChargeEvent(): Boolean { + val now = LocalDateTime.now() + return queryFactory + .select(chargeEvent.id) + .from(chargeEvent) + .where( + chargeEvent.isActive.isTrue + .and(chargeEvent.startDate.loe(now)) + .and(chargeEvent.endDate.goe(now)) + ) + .fetchFirst() != null + } + override fun getPaymentCount( member: Member, method: String, @@ -39,16 +54,19 @@ class ChargeEventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) ): Int { val where = charge.member.eq(member) .and(charge.payment.method.eq(method)) + .and(charge.status.eq(ChargeStatus.EVENT)) + .and(charge.createdAt.between(startDate, endDate)) .and( charge.payment.status.eq(PaymentStatus.COMPLETE) .or(charge.payment.status.eq(PaymentStatus.RETURN)) ) return queryFactory - .selectFrom(charge) + .select(charge.id.count()) + .from(charge) .innerJoin(charge.payment, payment) .where(where) - .fetch() - .count() + .fetchOne() + ?.toInt() ?: 0 } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeSpringEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeSpringEvent.kt deleted file mode 100644 index 5a6b0142..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeSpringEvent.kt +++ /dev/null @@ -1,21 +0,0 @@ -package kr.co.vividnext.sodalive.can.charge.event - -import org.springframework.scheduling.annotation.Async -import org.springframework.stereotype.Component -import org.springframework.transaction.event.TransactionalEventListener - -class ChargeSpringEvent( - val chargeId: Long, - val memberId: Long -) - -@Component -class ChargeSpringEventListener( - private val chargeEventService: ChargeEventService -) { - @Async - @TransactionalEventListener - fun applyChargeEvent(event: ChargeSpringEvent) { - chargeEventService.applyChargeEvent(event.chargeId, event.memberId) - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobServiceTest.kt new file mode 100644 index 00000000..09ba4485 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobServiceTest.kt @@ -0,0 +1,73 @@ +package kr.co.vividnext.sodalive.admin.event.charge + +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJob +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobRepository +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobType +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class AdminChargeEventJobServiceTest { + @Test + fun shouldReturnOnlyPendingAndFailedJobs() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val service = AdminChargeEventJobService(repository) + val pending = job(1L, ChargeEventJobStatus.PENDING) + val failed = job(2L, ChargeEventJobStatus.FAILED) + + Mockito.`when`(repository.findVisibleAdminJobs()).thenReturn(listOf(pending, failed)) + + val responses = service.getJobs() + + assertEquals(listOf(1L, 2L), responses.map { it.id }) + assertEquals(listOf(ChargeEventJobStatus.PENDING, ChargeEventJobStatus.FAILED), responses.map { it.status }) + } + + @Test + fun shouldRetryOnlyFailedJob() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val service = AdminChargeEventJobService(repository) + val failed = job(1L, ChargeEventJobStatus.FAILED) + + Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(failed) + + service.retry(1L) + + assertEquals(ChargeEventJobStatus.PENDING, failed.status) + assertEquals(0, failed.retryCount) + } + + @Test + fun shouldNotChangeNonFailedJobOnRetryRequest() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val service = AdminChargeEventJobService(repository) + val pending = job(1L, ChargeEventJobStatus.PENDING) + + Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(pending) + + service.retry(1L) + + assertEquals(ChargeEventJobStatus.PENDING, pending.status) + assertEquals(2, pending.retryCount) + } + + private fun job(id: Long, status: ChargeEventJobStatus): ChargeEventJob { + return ChargeEventJob( + sourceChargeId = 100L, + memberId = 10L, + chargeEventId = 20L, + jobType = ChargeEventJobType.ACTIVE_CHARGE_EVENT, + idempotencyKey = "charge-event:100:ACTIVE_CHARGE_EVENT:20", + additionalCan = 10, + paymentGateway = PaymentGateway.PG, + container = "pg", + methodSnapshot = "봄 이벤트", + status = status, + retryCount = 2, + nextRetryAt = LocalDateTime.now() + ).also { it.id = id } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobServiceTest.kt new file mode 100644 index 00000000..f2828baa --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobServiceTest.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.can.charge.event + +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class ChargeEventJobServiceTest { + @Test + fun shouldPayBonusOnceFromJobSnapshot() { + val jobRepository = Mockito.mock(ChargeEventJobRepository::class.java) + val chargeRepository = Mockito.mock(ChargeRepository::class.java) + val memberRepository = Mockito.mock(MemberRepository::class.java) + val service = ChargeEventJobService(jobRepository, chargeRepository, memberRepository) + val member = member(id = 10L) + val job = job(id = 1L, memberId = 10L, additionalCan = 15, paymentGateway = PaymentGateway.PAYVERSE) + + Mockito.`when`(jobRepository.findByIdForUpdate(1L)).thenReturn(job) + Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member) + Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as Charge).also { it.id = 200L } + } + + service.processJob(1L) + + assertEquals(ChargeEventJobStatus.DONE, job.status) + assertEquals(15, member.pgRewardCan) + assertEquals(200L, job.resultChargeId) + assertNotNull(job.processedAt) + Mockito.verify(chargeRepository).save( + Mockito.argThat { charge -> + charge.status == ChargeStatus.EVENT && + charge.rewardCan == 15 && + charge.member == member && + charge.payment?.paymentGateway == PaymentGateway.PAYVERSE && + charge.payment?.method == "봄 이벤트" + } + ) + } + + @Test + fun shouldSkipAlreadyDoneJobWithoutPayingAgain() { + val jobRepository = Mockito.mock(ChargeEventJobRepository::class.java) + val chargeRepository = Mockito.mock(ChargeRepository::class.java) + val memberRepository = Mockito.mock(MemberRepository::class.java) + val service = ChargeEventJobService(jobRepository, chargeRepository, memberRepository) + val job = job(id = 1L, status = ChargeEventJobStatus.DONE) + + Mockito.`when`(jobRepository.findByIdForUpdate(1L)).thenReturn(job) + + service.processJob(1L) + + Mockito.verify(chargeRepository, Mockito.never()).save(Mockito.any(Charge::class.java)) + Mockito.verify(memberRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong()) + } + + private fun job( + id: Long, + memberId: Long = 10L, + additionalCan: Int = 10, + paymentGateway: PaymentGateway = PaymentGateway.PG, + status: ChargeEventJobStatus = ChargeEventJobStatus.PROCESSING + ): ChargeEventJob { + return ChargeEventJob( + sourceChargeId = 100L, + memberId = memberId, + chargeEventId = 20L, + jobType = ChargeEventJobType.ACTIVE_CHARGE_EVENT, + idempotencyKey = "charge-event:100:ACTIVE_CHARGE_EVENT:20", + additionalCan = additionalCan, + paymentGateway = paymentGateway, + container = "pg", + methodSnapshot = "봄 이벤트", + status = status, + nextRetryAt = LocalDateTime.now() + ).also { it.id = id } + } + + private fun member(id: Long): Member { + return Member(password = "pw", nickname = "tester").also { it.id = id } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorkerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorkerTest.kt new file mode 100644 index 00000000..336e9037 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorkerTest.kt @@ -0,0 +1,122 @@ +package kr.co.vividnext.sodalive.can.charge.event + +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.transaction.support.AbstractPlatformTransactionManager +import org.springframework.transaction.support.DefaultTransactionStatus +import java.time.LocalDateTime +import java.util.Optional + +class ChargeEventJobWorkerTest { + @Test + fun shouldRunEveryFiveMinutesByDefault() { + val scheduled = ChargeEventJobWorker::class.java + .getDeclaredMethod("runPendingJobs") + .getAnnotation(Scheduled::class.java) + + assertEquals("\${sodalive.charge-event-job.fixed-delay-ms:300000}", scheduled.fixedDelayString) + } + + @Test + fun shouldClaimAtMostThirtyPendingJobs() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val service = Mockito.mock(ChargeEventJobService::class.java) + val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager()) + val jobIds = (1L..30L).toList() + + Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenReturn(jobIds) + jobIds.forEach { jobId -> + Mockito.`when`(repository.findById(jobId)).thenReturn(Optional.of(job(jobId))) + } + + worker.runPendingJobs() + + Mockito.verify(repository).findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30)) + Mockito.verify(service, Mockito.times(30)).processJob(Mockito.anyLong()) + } + + @Test + fun shouldReturnWhenNoPendingJobsExist() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val service = Mockito.mock(ChargeEventJobService::class.java) + val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager()) + + Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenReturn(emptyList()) + + worker.runPendingJobs() + + Mockito.verify(service, Mockito.never()).processJob(Mockito.anyLong()) + } + + @Test + fun shouldRetryFailedJobWithBackoffAndFailAfterThirdFailure() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val service = Mockito.mock(ChargeEventJobService::class.java) + val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager()) + val retryJob = job(1L, retryCount = 2) + val before = retryJob.nextRetryAt + + Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenReturn(listOf(1L)) + Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(retryJob)) + Mockito.doThrow(IllegalStateException("bonus down")).`when`(service).processJob(1L) + + worker.runPendingJobs() + + assertEquals(ChargeEventJobStatus.FAILED, retryJob.status) + assertEquals(3, retryJob.retryCount) + assertEquals("bonus down", retryJob.lastError) + assertTrue(retryJob.nextRetryAt == before) + } + + @Test + fun shouldUseFiveMinutesForFirstWorkerFailureBackoff() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val service = Mockito.mock(ChargeEventJobService::class.java) + val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager()) + val retryJob = job(1L, retryCount = 0) + val before = LocalDateTime.now() + + Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenReturn(listOf(1L)) + Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(retryJob)) + Mockito.doThrow(IllegalStateException("bonus down")).`when`(service).processJob(1L) + + worker.runPendingJobs() + + assertEquals(ChargeEventJobStatus.PENDING, retryJob.status) + assertEquals(1, retryJob.retryCount) + assertTrue(retryJob.nextRetryAt!!.isAfter(before.plusMinutes(4))) + assertTrue(retryJob.nextRetryAt!!.isBefore(LocalDateTime.now().plusMinutes(6))) + } + + private fun job(id: Long, retryCount: Int = 0): ChargeEventJob { + return ChargeEventJob( + sourceChargeId = 100L, + memberId = 10L, + chargeEventId = 20L, + jobType = ChargeEventJobType.ACTIVE_CHARGE_EVENT, + idempotencyKey = "charge-event:100:ACTIVE_CHARGE_EVENT:20", + additionalCan = 10, + paymentGateway = PaymentGateway.PG, + container = "pg", + methodSnapshot = "봄 이벤트", + status = ChargeEventJobStatus.PENDING, + retryCount = retryCount, + nextRetryAt = LocalDateTime.now() + ).also { it.id = id } + } + + private fun anyLocalDateTime(): LocalDateTime { + return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + } +} + +private class ChargeEventJobTestTransactionManager : AbstractPlatformTransactionManager() { + override fun doGetTransaction(): Any = Any() + override fun doBegin(transaction: Any, definition: org.springframework.transaction.TransactionDefinition) {} + override fun doCommit(status: DefaultTransactionStatus) {} + override fun doRollback(status: DefaultTransactionStatus) {} +} From 56acf257e0a8bd44dd770e94b80a45c8712f44ec Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 18 May 2026 13:46:26 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor(charge):=20=EC=B6=A9=EC=A0=84=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=9E=91=EC=97=85=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/can/charge/ChargeService.kt | 2 +- .../event/charge/AdminChargeEventJobController.kt | 2 +- .../admin/event/charge/AdminChargeEventJobResponse.kt | 8 ++++---- .../admin/event/charge/AdminChargeEventJobService.kt | 6 +++--- .../{ => v2}/can/charge/event/ChargeEventJob.kt | 2 +- .../can/charge/event/ChargeEventJobRepository.kt | 4 ++-- .../{ => v2}/can/charge/event/ChargeEventJobService.kt | 3 ++- .../{ => v2}/can/charge/event/ChargeEventJobWorker.kt | 2 +- .../event/charge/AdminChargeEventJobServiceTest.kt | 10 +++++----- .../can/charge/event/ChargeEventJobServiceTest.kt | 2 +- .../can/charge/event/ChargeEventJobWorkerTest.kt | 2 +- 11 files changed, 22 insertions(+), 21 deletions(-) rename src/main/kotlin/kr/co/vividnext/sodalive/{ => v2}/admin/event/charge/AdminChargeEventJobController.kt (93%) rename src/main/kotlin/kr/co/vividnext/sodalive/{ => v2}/admin/event/charge/AdminChargeEventJobResponse.kt (82%) rename src/main/kotlin/kr/co/vividnext/sodalive/{ => v2}/admin/event/charge/AdminChargeEventJobService.kt (78%) rename src/main/kotlin/kr/co/vividnext/sodalive/{ => v2}/can/charge/event/ChargeEventJob.kt (97%) rename src/main/kotlin/kr/co/vividnext/sodalive/{ => v2}/can/charge/event/ChargeEventJobRepository.kt (87%) rename src/main/kotlin/kr/co/vividnext/sodalive/{ => v2}/can/charge/event/ChargeEventJobService.kt (98%) rename src/main/kotlin/kr/co/vividnext/sodalive/{ => v2}/can/charge/event/ChargeEventJobWorker.kt (98%) rename src/test/kotlin/kr/co/vividnext/sodalive/{ => v2}/admin/event/charge/AdminChargeEventJobServiceTest.kt (87%) rename src/test/kotlin/kr/co/vividnext/sodalive/{ => v2}/can/charge/event/ChargeEventJobServiceTest.kt (98%) rename src/test/kotlin/kr/co/vividnext/sodalive/{ => v2}/can/charge/event/ChargeEventJobWorkerTest.kt (99%) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index 50f8a4e5..64a57f4e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.can.charge import com.fasterxml.jackson.databind.ObjectMapper import kr.co.bootpay.Bootpay import kr.co.vividnext.sodalive.can.CanRepository -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobService import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository import kr.co.vividnext.sodalive.can.coupon.CouponType import kr.co.vividnext.sodalive.can.payment.Payment @@ -20,6 +19,7 @@ import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.point.PointGrantLog import kr.co.vividnext.sodalive.point.PointGrantLogRepository import kr.co.vividnext.sodalive.useraction.ActionType +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobController.kt similarity index 93% rename from src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobController.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobController.kt index 48e6b36e..28e07e6f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobController.kt @@ -1,4 +1,4 @@ -package kr.co.vividnext.sodalive.admin.event.charge +package kr.co.vividnext.sodalive.v2.admin.event.charge import kr.co.vividnext.sodalive.common.ApiResponse import org.springframework.security.access.prepost.PreAuthorize diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobResponse.kt similarity index 82% rename from src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobResponse.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobResponse.kt index b51546e0..235e6e81 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobResponse.kt @@ -1,8 +1,8 @@ -package kr.co.vividnext.sodalive.admin.event.charge +package kr.co.vividnext.sodalive.v2.admin.event.charge -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJob -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobType +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJob +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobType import java.time.LocalDateTime data class AdminChargeEventJobResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobService.kt similarity index 78% rename from src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobService.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobService.kt index 63182b11..58e1e855 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobService.kt @@ -1,7 +1,7 @@ -package kr.co.vividnext.sodalive.admin.event.charge +package kr.co.vividnext.sodalive.v2.admin.event.charge -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobRepository -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJob.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJob.kt similarity index 97% rename from src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJob.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJob.kt index 0aa0050a..9371ebbb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJob.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJob.kt @@ -1,4 +1,4 @@ -package kr.co.vividnext.sodalive.can.charge.event +package kr.co.vividnext.sodalive.v2.can.charge.event import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.common.BaseEntity diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobRepository.kt similarity index 87% rename from src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobRepository.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobRepository.kt index b90533bc..cfa11eda 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobRepository.kt @@ -1,4 +1,4 @@ -package kr.co.vividnext.sodalive.can.charge.event +package kr.co.vividnext.sodalive.v2.can.charge.event import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock @@ -48,7 +48,7 @@ interface ChargeEventJobRepository : JpaRepository { @Query( """ select j from ChargeEventJob j - where j.status in (kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus.PENDING, kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus.FAILED) + where j.status in (kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus.PENDING, kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus.FAILED) order by j.createdAt desc """ ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt similarity index 98% rename from src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobService.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt index 9d66c595..8f0b3b9e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt @@ -1,8 +1,9 @@ -package kr.co.vividnext.sodalive.can.charge.event +package kr.co.vividnext.sodalive.v2.can.charge.event import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.ChargeRepository import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventRepository import kr.co.vividnext.sodalive.can.payment.Payment import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentStatus diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorker.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorker.kt similarity index 98% rename from src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorker.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorker.kt index e623e272..5c6a2e6d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorker.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorker.kt @@ -1,4 +1,4 @@ -package kr.co.vividnext.sodalive.can.charge.event +package kr.co.vividnext.sodalive.v2.can.charge.event import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt similarity index 87% rename from src/test/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobServiceTest.kt rename to src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt index 09ba4485..fa0f3fd9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventJobServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt @@ -1,10 +1,10 @@ -package kr.co.vividnext.sodalive.admin.event.charge +package kr.co.vividnext.sodalive.v2.admin.event.charge -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJob -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobRepository -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus -import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobType import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJob +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mockito.Mockito diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt similarity index 98% rename from src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobServiceTest.kt rename to src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt index f2828baa..fa954182 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt @@ -1,4 +1,4 @@ -package kr.co.vividnext.sodalive.can.charge.event +package kr.co.vividnext.sodalive.v2.can.charge.event import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.ChargeRepository diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorkerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt similarity index 99% rename from src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorkerTest.kt rename to src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt index 336e9037..3fa5cef9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventJobWorkerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt @@ -1,4 +1,4 @@ -package kr.co.vividnext.sodalive.can.charge.event +package kr.co.vividnext.sodalive.v2.can.charge.event import kr.co.vividnext.sodalive.can.payment.PaymentGateway import org.junit.jupiter.api.Assertions.assertEquals From 9a9fdfe0a14b619ed6089ba949806c5c9b86dbbc Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 18 May 2026 14:18:10 +0900 Subject: [PATCH 08/10] =?UTF-8?q?docs(charge):=20=EC=B6=A9=EC=A0=84=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B3=B4=EB=84=88=EC=8A=A4=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=EC=95=88=EC=A0=95=ED=99=94=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260518_충전이벤트보너스지급안정화.md | 170 ++++++++++++++++++ ...20260518_충전이벤트보너스지급안정화_prd.md | 107 +++++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/plan-task/20260518_충전이벤트보너스지급안정화.md create mode 100644 docs/prd/20260518_충전이벤트보너스지급안정화_prd.md diff --git a/docs/plan-task/20260518_충전이벤트보너스지급안정화.md b/docs/plan-task/20260518_충전이벤트보너스지급안정화.md new file mode 100644 index 00000000..dc41b141 --- /dev/null +++ b/docs/plan-task/20260518_충전이벤트보너스지급안정화.md @@ -0,0 +1,170 @@ +# 충전 이벤트 보너스 지급 안정화 작업 계획 + +## 목적 +- 충전 완료 후 백그라운드로 지급되는 충전 이벤트 보너스가 누락되거나 중복 지급되지 않도록 한다. +- 결제 완료 처리, 이벤트 보너스 지급, 재시도, 운영 확인이 모두 DB 기준으로 추적 가능하도록 한다. + +## 현재 확인된 문제 +- `ChargeSpringEventListener.applyChargeEvent`가 `@Async @TransactionalEventListener`로 동작해 결제 완료 트랜잭션 커밋 후 별도 스레드에서 보너스를 지급한다. +- 비동기 이벤트 처리 실패가 원 결제 API 응답에 드러나지 않아, 결제는 성공했지만 이벤트 보너스만 누락될 수 있다. +- `ChargeEventRepository.getPaymentCount(member, method, startDate, endDate)`가 `startDate`, `endDate`를 파라미터로 받지만 실제 쿼리 조건에 사용하지 않는다. +- `Member.charge(...)`는 단순 필드 증가(`+=`)인데 원본 충전 완료와 이벤트 지급 흐름 모두 회원 row lock 없이 실행되면 동시 충전 시 잔액 유실이 발생할 수 있다. +- 원본 충전 건(`sourceChargeId`) 기준으로 이벤트 보너스 지급 여부를 고유하게 보장하는 DB 제약이 없다. + +## 추가 캔 수 기록 여부 판단 +- `charge_event_job`에는 `additional_can`을 저장하는 것이 좋다. +- 이유는 다음과 같다. + - 작업 생성 시점의 이벤트 조건을 스냅샷으로 보존할 수 있다. + - 재시도 시점에 관리자가 `addPercent`, 이벤트 제목, 기간을 수정해도 최초 충전 완료 당시 계산된 보너스 캔을 그대로 지급할 수 있다. + - 운영자가 실패 작업을 확인할 때 “얼마를 지급해야 했는지”를 별도 재계산 없이 알 수 있다. + - 재시도 worker가 이벤트 설정을 다시 읽어 계산하는 과정에서 발생할 수 있는 불일치를 줄인다. +- 따라서 `source_charge_id`, `member_id`, `charge_event_id`, `job_type`, `additional_can`, `method_snapshot`을 함께 저장한다. + +## 확정 운영 정책 +- worker 실행 주기는 5분으로 한다. +- worker batch size는 30건으로 한다. +- worker backoff는 5분, 10분, 15분 순서로 적용한다. +- 최대 재시도 횟수는 3회로 한다. +- 3회 재시도 후에도 실패하면 `FAILED`로 전환하고 자동 재시도를 중단한다. +- `DONE`, `PROCESSING` 상태를 제외한 작업을 확인할 수 있는 관리자 API를 추가한다. +- `FAILED` 작업을 다시 재시도할 수 있는 관리자 API를 추가한다. +- worker는 활성 충전 이벤트가 없으면 `charge_event_job` 조회를 수행하지 않고 종료한다. +- 충전 완료 처리(`verify`) 시 충전 이벤트가 있으면 `charge_event_job`을 항상 기록한다. +- 원본 충전 완료 처리에서도 `MemberRepository.findByIdForUpdate(...)`로 회원 row를 잠근 뒤 `member.charge(...)`를 수행한다. +- 충전 완료 처리(`verify`) 시 `charge_event_job` 기록 후 이벤트 보너스를 즉시 지급한다. +- 즉시 지급 중인 작업은 `PROCESSING` 상태로 선점해 worker가 실행하지 않도록 한다. +- 즉시 지급에 성공하면 `DONE`, 실패하면 `PENDING`과 `next_retry_at = now + 5분`으로 전환해 worker 재시도 대상으로 둔다. +- 관리자 API는 기존 관리자 컨트롤러 관례에 맞춰 `/admin/...` 경로, `@PreAuthorize("hasRole('ADMIN')")`, `ApiResponse.ok(...)` 응답을 사용한다. + +## DB 설계 + +### `charge_event_job` +- 충전 완료 후 이벤트 보너스 지급 작업을 영속화하는 테이블이다. +- `idempotency_key`에 unique key를 걸어 같은 원본 충전 건의 같은 이벤트 보너스 작업이 중복 생성되지 않도록 한다. +- `status`, `retry_count`, `next_retry_at`, `last_error`로 실패 작업을 재시도하고 운영자가 확인할 수 있게 한다. +- 현재 DDL에는 boolean 컬럼이 필요하지 않아 포함하지 않는다. 추후 boolean 컬럼을 추가할 경우 MySQL 기준 `TINYINT(1)`로 작성한다. + +```sql +CREATE TABLE charge_event_job +( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK', + source_charge_id BIGINT NOT NULL COMMENT '이벤트 보너스 지급의 기준이 되는 원본 충전 ID(charge.id)', + result_charge_id BIGINT NULL COMMENT '이벤트 보너스로 생성된 Charge ID(charge.id)', + member_id BIGINT NOT NULL COMMENT '이벤트 보너스를 지급받을 회원 ID(member.id)', + charge_event_id BIGINT NULL COMMENT '적용된 충전 이벤트 ID(charge_event.id), 첫 충전 이벤트처럼 별도 이벤트 row가 없으면 NULL', + job_type VARCHAR(30) NOT NULL COMMENT '작업 유형(FIRST_CHARGE, ACTIVE_CHARGE_EVENT)', + idempotency_key VARCHAR(100) NOT NULL COMMENT '중복 작업 방지 키(예: charge-event:{sourceChargeId}:{jobType}:{chargeEventId 또는 none})', + additional_can INT NOT NULL COMMENT '추가 지급할 보너스 캔 수', + payment_gateway VARCHAR(30) NOT NULL COMMENT '원본 충전의 결제 게이트웨이(PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP 등)', + container VARCHAR(10) NOT NULL COMMENT '회원 캔 잔액 반영 대상(pg, aos, ios)', + method_snapshot VARCHAR(100) NOT NULL COMMENT '이벤트 보너스 Charge.payment.method에 기록할 지급 사유 스냅샷', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '작업 상태(PENDING, PROCESSING, DONE, FAILED)', + retry_count INT NOT NULL DEFAULT 0 COMMENT '재시도 횟수', + next_retry_at TIMESTAMP NULL COMMENT '다음 재시도 가능 시각', + processing_started_at TIMESTAMP NULL COMMENT 'PROCESSING 상태로 선점한 시각', + processed_at TIMESTAMP NULL COMMENT '지급 성공 처리 시각', + last_error TEXT 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_charge_event_job_idempotency_key (idempotency_key), + KEY idx_charge_event_job_status_next_retry_at (status, next_retry_at), + KEY idx_charge_event_job_source_charge_id (source_charge_id), + KEY idx_charge_event_job_result_charge_id (result_charge_id), + KEY idx_charge_event_job_member_id (member_id), + KEY idx_charge_event_job_charge_event_id (charge_event_id), + CONSTRAINT fk_charge_event_job_source_charge_id + FOREIGN KEY (source_charge_id) REFERENCES charge (id), + CONSTRAINT fk_charge_event_job_result_charge_id + FOREIGN KEY (result_charge_id) REFERENCES charge (id), + CONSTRAINT fk_charge_event_job_member_id + FOREIGN KEY (member_id) REFERENCES member (id), + CONSTRAINT fk_charge_event_job_charge_event_id + FOREIGN KEY (charge_event_id) REFERENCES charge_event (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='충전 이벤트 보너스 지급 작업'; +``` + +## 구현 항목 +- [x] `ChargeEventRepository.getPaymentCount(...)` 쿼리 수정 + - [x] `charge.createdAt`이 이벤트 `startDate`, `endDate` 사이인지 조건에 추가한다. + - [x] 이벤트 보너스 지급 건만 세도록 `charge.status.eq(ChargeStatus.EVENT)` 조건을 추가한다. + - [x] `fetch().count()` 대신 DB count 쿼리를 사용한다. + +- [x] 결제 완료 처리 idempotency 강화 + - [x] `ChargeRepository`에 원본 `Charge` row를 `PESSIMISTIC_WRITE`로 조회하는 메서드를 추가한다. + - [x] `payverseWebhook`, `payverseVerify`, `verify`, `verifyHecto`, `appleVerify`, `processGoogleIap`에서 `PaymentStatus.REQUEST -> COMPLETE` 전이일 때만 회원 잔액 반영과 이벤트 작업 생성을 수행한다. + - [x] 각 완료 경로는 `MemberRepository.findByIdForUpdate(...)`로 회원 row를 잠근 뒤 원본 충전의 `member.charge(...)`를 호출한다. + - [x] 이미 `COMPLETE`인 경우 원 결제와 이벤트 작업을 다시 만들지 않고 완료 응답만 반환한다. + +- [x] 회원 잔액 업데이트 직렬화 + - [x] 원본 충전 완료 시 `MemberRepository.findByIdForUpdate(...)`로 회원 row를 잠근 뒤 `member.charge(...)`를 호출한다. + - [x] 이벤트 보너스 즉시 지급 시에도 같은 방식으로 회원 row를 잠근 뒤 보너스 캔을 반영한다. + - [x] 이벤트 보너스 worker 재시도에서도 같은 방식으로 회원 row를 잠근 뒤 보너스 캔을 반영한다. + +- [x] `charge_event_job` 기반 이벤트 보너스 작업 생성 + - [x] 충전 완료 트랜잭션 안에서 이벤트 적용 대상인지 판단하고 `charge_event_job` row를 항상 생성한다. + - [x] 즉시 지급을 시작하는 작업은 `PROCESSING`으로 저장해 worker가 조회하지 않도록 한다. + - [x] `additional_can`, `method_snapshot`, `payment_gateway`, `container`는 작업 생성 시점 값으로 저장한다. + - [x] `idempotency_key` unique key 충돌 시 이미 생성된 작업으로 보고 중복 생성하지 않는다. + - [x] 즉시 지급 성공 시 `DONE`, `processed_at`, `result_charge_id`를 기록한다. + - [x] 즉시 지급 실패 시 `PENDING`, `next_retry_at = now + 5분`, `last_error`를 기록한다. + +- [x] 이벤트 보너스 worker 구현 + - [x] 기존 `TranslationJobWorker`의 `@Scheduled`, 상태 선점, retry/backoff 구조를 참고한다. + - [x] `@Scheduled(fixedDelayString = "\${sodalive.charge-event-job.fixed-delay-ms:300000}")` 방식으로 5분 주기를 기본값으로 둔다. + - [x] worker는 한 번에 최대 30건만 처리한다. + - [x] worker 시작 시 활성 충전 이벤트가 없으면 `charge_event_job` 조회 없이 즉시 종료한다. + - [x] `PENDING` 작업 중 `next_retry_at IS NULL OR next_retry_at <= now`인 작업을 조회한다. + - [x] 작업을 `PROCESSING`으로 선점한 뒤 별도 트랜잭션에서 보너스 `Charge(status = EVENT)` 생성과 `member.charge(...)`를 수행한다. + - [x] 성공하면 `DONE`으로 변경하고 `processed_at`을 기록한다. + - [x] 1회 재시도 실패 시 `retry_count = 1`, `next_retry_at = now + 10분`, `last_error`를 기록하고 `PENDING`으로 되돌린다. + - [x] 2회 재시도 실패 시 `retry_count = 2`, `next_retry_at = now + 15분`, `last_error`를 기록하고 `PENDING`으로 되돌린다. + - [x] 3회 재시도 실패 시 `retry_count = 3`, `status = FAILED`, `last_error`를 기록하고 자동 재시도를 중단한다. + +- [x] 이벤트 작업 관리자 API 추가 + - [x] `AdminChargeEventJobController`와 `AdminChargeEventJobService`를 추가한다. + - [x] 컨트롤러는 `@RequestMapping("/admin/charge/event-jobs")`와 `@PreAuthorize("hasRole('ADMIN')")`를 사용한다. + - [x] API 응답은 기존 관리자 API 관례에 맞춰 `ApiResponse.ok(...)`를 사용한다. + - [x] `GET /admin/charge/event-jobs`로 `DONE`, `PROCESSING` 상태를 제외한 작업 목록을 조회한다. + - [x] 조회 대상에는 재시도 대기 중인 `PENDING` 작업과 운영 확인이 필요한 `FAILED` 작업을 포함한다. + - [x] 응답에는 `id`, `sourceChargeId`, `resultChargeId`, `memberId`, `chargeEventId`, `jobType`, `additionalCan`, `status`, `retryCount`, `nextRetryAt`, `lastError`, `createdAt`, `updatedAt`을 포함한다. + - [x] `POST /admin/charge/event-jobs/{jobId}/retry`로 `FAILED` 작업을 재시도 대상으로 변경한다. + - [x] 재시도 API는 `FAILED` 상태 작업만 `PENDING`으로 변경하고 `next_retry_at`을 현재 시각으로 갱신한다. + - [x] 재시도 API는 `DONE`, `PROCESSING`, `PENDING` 상태 작업에 대해서는 상태를 변경하지 않는다. + +- [x] 기존 `@Async @TransactionalEventListener` 역할 정리 + - [x] 기존 `ChargeSpringEventListener`가 직접 `ChargeEventService.applyChargeEvent(...)`를 호출해 보너스를 지급하는 구조는 제거한다. + - [x] 즉시 지급은 충전 완료 처리(`verify`) 흐름에서 생성한 `charge_event_job`을 기준으로 `ChargeEventJobService`가 수행한다. + - [x] `ChargeSpringEventListener`는 제거하거나, 남기더라도 보너스 지급을 직접 수행하지 않는다. + - [x] 보너스 지급은 즉시 지급과 worker 재시도 모두 DB에 저장된 `charge_event_job`을 기준으로 처리한다. + +- [ ] 테스트 항목 + - [ ] 같은 `source_charge_id`로 이벤트 작업 생성이 중복 요청되어도 `charge_event_job`이 1건만 생성되는지 검증한다. + - [ ] 충전 완료 처리에서 `charge_event_job` 생성 후 즉시 지급 성공 시 `DONE`으로 기록되는지 검증한다. + - [ ] 즉시 지급 중인 `PROCESSING` 작업을 worker가 처리하지 않는지 검증한다. + - [ ] 즉시 지급 실패 시 `PENDING`, `next_retry_at = now + 5분`으로 전환되는지 검증한다. + - [ ] worker 실패 시 `retry_count`, `next_retry_at`, `last_error`가 갱신되는지 검증한다. + - [ ] worker가 30건까지만 처리하는지 검증한다. + - [ ] 활성 충전 이벤트가 없으면 worker가 `charge_event_job` 조회 없이 종료하는지 검증한다. + - [ ] worker 재시도 성공 시 보너스 `Charge(status = EVENT)`와 회원 보너스 캔이 1회만 반영되는지 검증한다. + - [ ] worker가 3회 재시도 실패 후 `FAILED`로 전환하고 자동 재시도를 중단하는지 검증한다. + - [ ] 이벤트 작업 조회 관리자 API가 `PENDING`, `FAILED` 작업을 반환하고 `DONE`, `PROCESSING` 작업을 제외하는지 검증한다. + - [ ] `FAILED` 재시도 관리자 API가 작업을 `PENDING`으로 되돌리는지 검증한다. + - [ ] 동시 충전 요청에서 회원 잔액이 유실되지 않는지 검증한다. + - [ ] `getPaymentCount(...)`가 이벤트 기간 밖의 과거 보너스 지급 건을 세지 않는지 검증한다. + +## 검증 항목 +- [x] `./gradlew test` +- [x] `./gradlew ktlintCheck` +- [ ] 스테이징에서 결제 완료 후 `charge_event_job` 생성 확인 +- [ ] 스테이징에서 결제 완료 후 즉시 지급 성공 시 worker가 해당 작업을 중복 처리하지 않는지 확인 +- [ ] 스테이징에서 worker 실패 강제 후 재시도 성공 확인 +- [ ] 스테이징에서 3회 실패 후 `FAILED` 전환 및 관리자 API 재시도 확인 +- [ ] 같은 결제 검증 요청을 중복 호출해도 원 결제와 이벤트 보너스가 중복 지급되지 않는지 확인 + +## 검증 로그 +- [ ] 문서 작성 검증: 계획 문서에 수정 대상 4가지, MySQL DDL, 컬럼 COMMENT, `TIMESTAMP`, `TINYINT(1)` 규칙 반영 여부를 확인했다. +- [x] 구현 검증: global worktree(`/Users/klaus/.config/superpowers/worktrees/sodalive/charge-event-job-stabilization`)에서 `charge_event_job` 엔티티/리포지토리/서비스/worker/관리자 API를 추가하고, 결제 완료 경로가 `ChargeEventJobService.createAndProcessImmediate(...)`를 호출하도록 변경했다. +- [x] 테스트 검증: `./gradlew test`를 실행해 전체 테스트 통과를 확인했다. +- [x] 린트 검증: `./gradlew ktlintCheck`를 실행해 ktlint 통과를 확인했다. +- [x] LSP 검증: Kotlin LSP 서버가 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가했고, 대신 Gradle 컴파일/테스트와 ktlint로 검증했다. diff --git a/docs/prd/20260518_충전이벤트보너스지급안정화_prd.md b/docs/prd/20260518_충전이벤트보너스지급안정화_prd.md new file mode 100644 index 00000000..c519ab5e --- /dev/null +++ b/docs/prd/20260518_충전이벤트보너스지급안정화_prd.md @@ -0,0 +1,107 @@ +# PRD: 충전 이벤트 보너스 지급 안정화 + +## 1. Overview +- 충전 완료 후 지급되는 충전 이벤트 보너스가 누락되거나 중복 지급되지 않도록 안정화한다. +- 상세 구현 방향은 `docs/plan-task/20260518_충전이벤트보너스지급안정화.md`를 기준으로 한다. +- 이 PRD는 충전 이벤트 보너스 지급 작업의 재시도, worker, 관리자 API 운영 정책을 확정한다. + +--- + +## 2. Problem +- 현재 충전 이벤트 보너스 지급은 결제 완료 흐름과 분리된 비동기 이벤트에서 처리된다. +- 비동기 처리 실패가 발생하면 원 결제는 성공했지만 이벤트 보너스만 누락될 수 있다. +- 실패 작업을 DB에 남기고, 정해진 주기와 횟수 안에서 재시도하며, 최종 실패 건은 관리자 API로 확인/재시도할 수 있어야 한다. + +--- + +## 3. Goals +- 이벤트 보너스 지급 실패가 DB에 남고 재시도 가능해야 한다. +- 중복 결제 검증 또는 worker 재시도에도 보너스가 중복 지급되지 않아야 한다. +- 원본 충전 완료 처리에서도 회원 잔액 변경은 회원 row lock을 잡은 뒤 수행해 동시 충전 시 잔액 유실을 방지해야 한다. +- 최대 재시도 후에도 실패한 작업은 운영자가 원인과 지급 대상, 지급할 캔 수를 확인할 수 있어야 한다. +- 충전 완료 검증 시 이벤트 작업을 기록하고 즉시 지급을 시도하되, 즉시 수행 중인 작업을 worker가 중복 처리하지 않아야 한다. + +--- + +## 4. Non-Goals +- 결제 PG 검증 방식 자체를 교체하지 않는다. +- 충전 상품 가격, 이벤트 보너스율, 첫 충전 이벤트율을 변경하지 않는다. +- 관리자 화면 신규 개발은 포함하지 않는다. 단, 실패 작업 조회/재시도용 관리자 API는 포함한다. + +--- + +## 5. Core Features + +### Feature A. 충전 이벤트 보너스 지급 작업 추적 + +#### Requirements +- 충전 완료 후 이벤트 보너스 지급 대상이면 DB 작업으로 추적한다. +- 지급 대상, 원본 충전, 추가 지급 캔 수, 처리 상태, 실패 사유를 확인할 수 있어야 한다. +- 원본 충전 완료 처리(`verify`, `verifyHecto`, `appleVerify`, `payverseVerify`, `payverseWebhook`, `processGoogleIap`)는 회원 row lock 안에서 `PaymentStatus.REQUEST -> COMPLETE` 전이와 `member.charge(...)`를 수행한다. +- 충전 완료 처리(`verify`) 시 `charge_event_job`을 기록하고, 같은 흐름에서 이벤트 보너스 지급을 즉시 시도한다. +- 즉시 지급 중인 작업은 worker가 처리하지 않도록 `PROCESSING` 상태로 선점한다. +- 상세 테이블 구조와 구현 항목은 plan-task 문서를 따른다. + +#### Edge Cases +- 같은 결제 검증 요청이 여러 번 들어와도 보너스 작업은 중복 생성되지 않아야 한다. +- worker가 같은 작업을 재시도해도 보너스는 1회만 지급되어야 한다. +- 이벤트 설정이 재시도 시점에 변경되어도 최초 작업 생성 시점의 추가 캔 수를 유지해야 한다. +- 즉시 지급 실패 후 worker가 재시도할 수 있도록 작업은 `PENDING`과 `next_retry_at`으로 전환되어야 한다. + +--- + +### Feature B. 이벤트 보너스 worker 재시도 + +#### Requirements +- worker 실행 주기는 5분으로 한다. +- worker batch size는 30건으로 한다. +- worker는 활성 충전 이벤트가 없으면 `charge_event_job` 조회를 수행하지 않고 종료한다. +- worker는 `PENDING` 상태이면서 `next_retry_at IS NULL OR next_retry_at <= now`인 작업만 처리한다. +- backoff 정책은 5분, 10분, 15분 순서로 적용한다. +- 최대 재시도 횟수는 3회로 한다. +- 3회 재시도 후에도 실패하면 `FAILED`로 전환하고 자동 재시도를 중단한다. + +#### Edge Cases +- 같은 시간에 다른 scheduler/worker가 실행되어도 worker는 최대 30건만 처리한다. +- 이미 `PROCESSING`, `DONE`, `FAILED`인 작업은 자동 worker가 처리하지 않는다. +- `FAILED` 작업은 관리자 재시도 API를 통해서만 다시 재시도 대상으로 전환한다. + +--- + +### Feature C. 이벤트 작업 관리자 API + +#### Requirements +- `DONE`, `PROCESSING` 상태를 제외한 `charge_event_job` 목록을 조회할 수 있는 관리자 API를 추가한다. +- 조회 API는 재시도 대기 중인 `PENDING` 작업과 운영 확인이 필요한 `FAILED` 작업을 함께 보여준다. +- `FAILED` 상태의 단일 작업을 재시도 대상으로 되돌리는 관리자 API를 추가한다. +- 재시도 API는 대상 작업을 `PENDING`으로 변경하고 `next_retry_at`을 즉시 실행 가능하도록 갱신한다. +- 재시도 API는 이미 `DONE` 또는 `PROCESSING`인 작업을 변경하지 않아야 한다. + +#### Edge Cases +- 관리자가 같은 실패 작업 재시도 API를 여러 번 호출해도 중복 지급되지 않아야 한다. +- 이미 성공 처리된 작업은 재시도 API에서 거부되어야 한다. +- 즉시 지급 중인 `PROCESSING` 작업과 지급 완료된 `DONE` 작업은 관리자 조회 목록에 노출하지 않는다. + +--- + +## 7. Technical Constraints +- DB는 MySQL을 기준으로 한다. +- 날짜/시간 컬럼은 `TIMESTAMP`를 사용한다. +- boolean 컬럼이 필요해질 경우 `TINYINT(1)`을 사용한다. +- 각 DB 컬럼에는 `COMMENT`를 작성한다. +- 구현 계획과 DDL은 `docs/plan-task/20260518_충전이벤트보너스지급안정화.md`를 기준으로 관리한다. + +--- + +## 8. Metrics +- 결제 완료 후 이벤트 보너스 지급 작업 생성 성공률 +- 이벤트 보너스 지급 성공률 +- 재시도 후 성공한 작업 수 +- 최종 `FAILED` 상태로 남은 작업 수 +- 중복 지급 방지 위반 건수 +- 관리자 API로 재시도된 작업 수 + +--- + +## 9. Related Documents +- `docs/plan-task/20260518_충전이벤트보너스지급안정화.md` From fefb5c24eb8a4eea0f4c79307e7f1388b1d2395e Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 18 May 2026 14:52:08 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix(charge):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=97=85=20=EC=A6=89=EC=8B=9C=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../can/charge/event/ChargeEventJobService.kt | 7 ++++- .../charge/event/ChargeEventJobServiceTest.kt | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt index 8f0b3b9e..60da8940 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt @@ -18,6 +18,7 @@ import org.springframework.context.ApplicationEventPublisher import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager @@ -37,7 +38,11 @@ class ChargeEventJobService( private val applicationEventPublisher: ApplicationEventPublisher? = null, transactionManager: PlatformTransactionManager? = null ) { - private val transactionTemplate = transactionManager?.let { TransactionTemplate(it) } + private val transactionTemplate = transactionManager?.let { + TransactionTemplate(it).also { template -> + template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + } @Transactional fun createAndProcessImmediate(sourceChargeId: Long, memberId: Long): ChargeEventJob? { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt index fa954182..8f581f6a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt @@ -10,6 +10,10 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test import org.mockito.Mockito +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.AbstractPlatformTransactionManager +import org.springframework.transaction.support.DefaultTransactionStatus +import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDateTime class ChargeEventJobServiceTest { @@ -61,6 +65,19 @@ class ChargeEventJobServiceTest { Mockito.verify(memberRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong()) } + @Test + fun shouldUseRequiresNewTransactionForAfterCommitImmediateProcessing() { + val service = ChargeEventJobService( + jobRepository = Mockito.mock(ChargeEventJobRepository::class.java), + chargeRepository = Mockito.mock(ChargeRepository::class.java), + memberRepository = Mockito.mock(MemberRepository::class.java), + transactionManager = RecordingTransactionManager() + ) + val transactionTemplate = service.transactionTemplateForTest() + + assertEquals(TransactionDefinition.PROPAGATION_REQUIRES_NEW, transactionTemplate.propagationBehavior) + } + private fun job( id: Long, memberId: Long = 10L, @@ -87,3 +104,16 @@ class ChargeEventJobServiceTest { return Member(password = "pw", nickname = "tester").also { it.id = id } } } + +private fun ChargeEventJobService.transactionTemplateForTest(): TransactionTemplate { + val field = ChargeEventJobService::class.java.getDeclaredField("transactionTemplate") + field.isAccessible = true + return field.get(this) as TransactionTemplate +} + +private class RecordingTransactionManager : AbstractPlatformTransactionManager() { + override fun doGetTransaction(): Any = Any() + override fun doBegin(transaction: Any, definition: TransactionDefinition) {} + override fun doCommit(status: DefaultTransactionStatus) {} + override fun doRollback(status: DefaultTransactionStatus) {} +} From ddac78a666ed92c54f5f4232ac734c6931811248 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 18 May 2026 15:39:11 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix(charge):=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EC=B6=A9=EC=A0=84=20=ED=9A=8C=EC=9B=90=20=EB=9D=BD=EC=9D=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan-task/20260518_쿠폰충전회원락보강.md | 50 ++++++ docs/prd/20260518_쿠폰충전회원락보강_prd.md | 69 ++++++++ .../sodalive/can/charge/ChargeService.kt | 6 +- .../sodalive/can/charge/ChargeServiceTest.kt | 166 ++++++++++++++++++ 4 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 docs/plan-task/20260518_쿠폰충전회원락보강.md create mode 100644 docs/prd/20260518_쿠폰충전회원락보강_prd.md create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt diff --git a/docs/plan-task/20260518_쿠폰충전회원락보강.md b/docs/plan-task/20260518_쿠폰충전회원락보강.md new file mode 100644 index 00000000..62582828 --- /dev/null +++ b/docs/plan-task/20260518_쿠폰충전회원락보강.md @@ -0,0 +1,50 @@ +# 쿠폰 충전 회원 락 보강 작업 계획 + +## 목적 +- 쿠폰 캔 지급 흐름에서도 일반 결제 완료 흐름과 동일하게 회원 row lock을 잡은 뒤 회원 잔액을 증가시킨다. +- 동시 충전/쿠폰 사용 상황에서 `Member.charge(...)`의 `+=` 기반 잔액 증가가 유실되지 않도록 한다. + +## 현재 확인된 문제 +- `ChargeService.chargeByCoupon(...)`은 `CouponType.CAN` 분기에서 컨트롤러/인증 계층이 전달한 `member` 인스턴스에 바로 `member.charge(0, coupon.can, "pg")`를 호출한다. +- 일반 결제 완료 경로(`payverseWebhook`, `payverseVerify`, `verify`, `verifyHecto`, `appleVerify`, `processGoogleIap`)는 `memberRepository.findByIdForUpdate(...)`로 회원 row를 잠근 뒤 `member.charge(...)`를 호출한다. +- 쿠폰 충전만 이 패턴과 달라, 같은 회원에게 쿠폰 충전과 다른 잔액 증가 작업이 동시에 실행될 경우 회원 캔 잔액 유실 가능성이 남는다. + +## 확정 범위 +- `ChargeService.chargeByCoupon(...)`의 `CouponType.CAN` 분기만 최소 수정한다. +- 쿠폰 번호 검증, 이미 사용된 쿠폰 검증, `Charge(status = COUPON)` 생성, 성공 메시지 반환 동작은 유지한다. +- 포인트 쿠폰(`CouponType.POINT`) 로직은 변경하지 않는다. +- 공개 API 요청/응답 스키마는 변경하지 않는다. +- 본 문서를 기준으로 최소 구현을 진행한다. + +## 구현 항목 +- [x] 쿠폰 캔 지급 lock 패턴 적용 + - [x] `chargeByCoupon(...)`에서 `member.id!!`를 기준으로 `memberRepository.findByIdForUpdate(...)`를 호출한다. + - [x] lock 조회로 얻은 회원 엔티티를 `couponCharge.member`에 연결한다. + - [x] lock 조회로 얻은 회원 엔티티에 `member.charge(0, coupon.can, "pg")`를 호출한다. + - [x] lock 조회 실패 시 기존 인증 실패 계열 예외 메시지 패턴을 따른다. + +- [x] 회귀 테스트 추가 + - [x] `CouponType.CAN` 쿠폰 사용 시 `memberRepository.findByIdForUpdate(...)`가 호출되는지 검증한다. + - [x] lock 조회로 얻은 회원에 쿠폰 캔이 반영되는지 검증한다. + - [x] 이미 사용된 쿠폰이면 lock 조회와 캔 지급이 진행되지 않는지 검증한다. + - [x] `CouponType.POINT` 쿠폰 동작이 변경되지 않았는지 기존 또는 신규 테스트로 확인한다. + +- [x] 최소 구현 검증 + - [x] 관련 테스트를 먼저 실행해 실패를 확인한다. + - [x] 구현 후 관련 테스트가 통과하는지 확인한다. + - [x] `./gradlew ktlintCheck`를 실행한다. + - [x] 필요 시 `./gradlew test`로 전체 회귀를 확인한다. + +## 검증 항목 +- [x] 쿠폰 캔 지급 테스트 통과 +- [x] 쿠폰 포인트 지급 기존 동작 유지 확인 +- [x] `./gradlew ktlintCheck` +- [x] `./gradlew test` + +## 검증 로그 +- [x] 문서 작성 검증: `ChargeService.chargeByCoupon(...)`의 `CouponType.CAN` 분기와 일반 결제 완료 경로의 `memberRepository.findByIdForUpdate(...)` 사용 패턴을 확인하고 작업 범위를 문서화했다. +- [x] RED 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest'` 실행 시 `WantedButNotInvoked`로 `memberRepository.findByIdForUpdate(10L)` 미호출 실패를 확인했다. +- [x] 구현 검증: `CouponType.CAN` 분기에서 locked member를 조회하고, `couponCharge.member`와 `member.charge(...)` 모두 locked member를 사용하도록 변경했다. +- [x] 관련 테스트 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest'` 통과를 확인했다. +- [x] 린트 검증: `./gradlew ktlintCheck` 통과를 확인했다. +- [x] 전체 테스트 검증: `./gradlew test` 통과를 확인했다. diff --git a/docs/prd/20260518_쿠폰충전회원락보강_prd.md b/docs/prd/20260518_쿠폰충전회원락보강_prd.md new file mode 100644 index 00000000..835975a8 --- /dev/null +++ b/docs/prd/20260518_쿠폰충전회원락보강_prd.md @@ -0,0 +1,69 @@ +# PRD: 쿠폰 충전 회원 락 보강 + +## 1. Overview +- 쿠폰 사용으로 캔을 지급하는 흐름에서도 회원 잔액 변경 전에 회원 row lock을 확보하도록 보강한다. +- 상세 구현 방향은 `docs/plan-task/20260518_쿠폰충전회원락보강.md`를 기준으로 한다. +- 이번 문서는 구현 전 요구사항과 최소 작업 범위를 확정하기 위한 신규 작업 문서다. + +--- + +## 2. Problem +- 일반 결제 완료 흐름은 `memberRepository.findByIdForUpdate(...)`로 회원 row lock을 잡은 뒤 `member.charge(...)`를 호출한다. +- 쿠폰 충전 흐름인 `ChargeService.chargeByCoupon(...)`은 컨트롤러/인증 계층에서 전달받은 `member` 인스턴스에 바로 `member.charge(...)`를 호출한다. +- 같은 회원에게 쿠폰 충전과 다른 충전/보너스 지급이 동시에 발생하면 회원 잔액 갱신의 동시성 안전성이 일반 결제 흐름과 달라질 수 있다. + +--- + +## 3. Goals +- `CouponType.CAN` 쿠폰 사용 시 회원 row lock을 잡은 엔티티에 `member.charge(...)`를 호출한다. +- 기존 쿠폰 사용 성공/실패 응답과 메시지는 변경하지 않는다. +- 쿠폰 중복 사용 방지 로직은 기존 동작을 유지한다. +- 일반 결제 완료 흐름의 락 패턴과 일관되게 동작하도록 최소 수정한다. +- 회귀 테스트로 쿠폰 캔 지급 시 `memberRepository.findByIdForUpdate(...)`가 사용되는지 검증한다. + +--- + +## 4. Non-Goals +- 쿠폰 정책, 쿠폰 타입, 쿠폰 발급/조회 로직을 변경하지 않는다. +- 포인트 쿠폰 지급 로직은 이번 범위에서 변경하지 않는다. +- `Member.charge(...)` 구현 방식과 잔액 컬럼 구조를 변경하지 않는다. +- 신규 API나 관리자 기능을 추가하지 않는다. + +--- + +## 5. Core Features + +### Feature A. 쿠폰 캔 지급 회원 row lock 적용 + +#### Requirements +- `ChargeService.chargeByCoupon(...)`의 `CouponType.CAN` 분기에서 회원 ID로 `memberRepository.findByIdForUpdate(...)`를 호출한다. +- lock 조회에 실패하면 기존 인증 실패 계열 예외 메시지 패턴을 따른다. +- `Charge`의 `member` 연결과 `member.charge(...)` 호출은 lock 조회로 얻은 회원 엔티티를 기준으로 수행한다. +- `member.charge(...)`는 기존처럼 `+=` 기반 증가 로직을 그대로 사용한다. + +#### Edge Cases +- 이미 사용된 쿠폰이면 기존처럼 `can.coupon.already_used` 예외가 우선 발생해야 한다. +- 유효하지 않은 쿠폰 번호이면 기존처럼 `can.coupon.invalid_number_contact` 예외가 발생해야 한다. +- 회원 row lock 조회 결과가 없으면 쿠폰 사용과 캔 지급이 진행되지 않아야 한다. + +--- + +## 6. Technical Constraints +- Kotlin/Spring/JPA 기존 스타일을 따른다. +- `ChargeService`의 기존 트랜잭션 경계를 유지한다. +- 공개 API 스키마와 응답 DTO를 변경하지 않는다. +- 불필요한 리팩터링 없이 쿠폰 캔 지급 경로만 최소 수정한다. + +--- + +## 7. Metrics +- 쿠폰 캔 지급 성공률 +- 쿠폰 중복 사용 차단 건수 +- 쿠폰 캔 지급 중 회원 lock 조회 실패 건수 +- 동시 충전 상황에서 회원 캔 잔액 유실 재발 여부 + +--- + +## 8. Related Documents +- `docs/plan-task/20260518_쿠폰충전회원락보강.md` +- `docs/prd/20260518_충전이벤트보너스지급안정화_prd.md` diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index 64a57f4e..192f50bf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -188,9 +188,11 @@ class ChargeService( when (coupon.couponType) { CouponType.CAN -> { + val lockedMember = memberRepository.findByIdForUpdate(member.id!!) + ?: throw SodaException(messageKey = "common.error.bad_credentials") val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON) couponCharge.title = formatMessage("can.charge.title", coupon.can) - couponCharge.member = member + couponCharge.member = lockedMember val payment = Payment( status = PaymentStatus.COMPLETE, @@ -200,7 +202,7 @@ class ChargeService( couponCharge.payment = payment chargeRepository.save(couponCharge) - member.charge(0, coupon.can, "pg") + lockedMember.charge(0, coupon.can, "pg") return formatMessage("can.coupon.use_complete", coupon.can) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt new file mode 100644 index 00000000..8249adfd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt @@ -0,0 +1,166 @@ +package kr.co.vividnext.sodalive.can.charge + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.coupon.CanCoupon +import kr.co.vividnext.sodalive.can.coupon.CanCouponNumber +import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository +import kr.co.vividnext.sodalive.can.coupon.CouponType +import kr.co.vividnext.sodalive.google.GooglePlayService +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.point.MemberPoint +import kr.co.vividnext.sodalive.point.MemberPointRepository +import kr.co.vividnext.sodalive.point.PointGrantLog +import kr.co.vividnext.sodalive.point.PointGrantLogRepository +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import java.time.LocalDateTime + +class ChargeServiceTest { + private lateinit var chargeRepository: ChargeRepository + private lateinit var canRepository: CanRepository + private lateinit var memberRepository: MemberRepository + private lateinit var couponNumberRepository: CanCouponNumberRepository + private lateinit var grantLogRepository: PointGrantLogRepository + private lateinit var memberPointRepository: MemberPointRepository + private lateinit var messageSource: SodaMessageSource + private lateinit var service: ChargeService + + @BeforeEach + fun setUp() { + chargeRepository = Mockito.mock(ChargeRepository::class.java) + canRepository = Mockito.mock(CanRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + couponNumberRepository = Mockito.mock(CanCouponNumberRepository::class.java) + grantLogRepository = Mockito.mock(PointGrantLogRepository::class.java) + memberPointRepository = Mockito.mock(MemberPointRepository::class.java) + messageSource = Mockito.mock(SodaMessageSource::class.java) + + Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer { it.arguments[0] } + Mockito.`when`(messageSource.getMessage("can.charge.title", LangContext().lang)).thenReturn("%d 캔") + Mockito.`when`(messageSource.getMessage("can.coupon.use_complete", LangContext().lang)).thenReturn("%d 캔 충전 완료") + Mockito.`when`(messageSource.getMessage("can.coupon.use_complete_point", LangContext().lang)) + .thenReturn("%d 포인트 충전 완료") + + service = ChargeService( + chargeRepository = chargeRepository, + canRepository = canRepository, + memberRepository = memberRepository, + couponNumberRepository = couponNumberRepository, + grantLogRepository = grantLogRepository, + memberPointRepository = memberPointRepository, + objectMapper = Mockito.mock(ObjectMapper::class.java), + okHttpClient = Mockito.mock(OkHttpClient::class.java), + chargeEventJobService = Mockito.mock(ChargeEventJobService::class.java), + googlePlayService = Mockito.mock(GooglePlayService::class.java), + messageSource = messageSource, + langContext = LangContext(), + bootpayApplicationId = "bootpayApplicationId", + bootpayPrivateKey = "bootpayPrivateKey", + bootpayHectoApplicationId = "bootpayHectoApplicationId", + bootpayHectoPrivateKey = "bootpayHectoPrivateKey", + appleInAppVerifySandBoxUrl = "https://sandbox.example.com", + appleInAppVerifyUrl = "https://apple.example.com", + payverseMid = "payverseMid", + payverseClientKey = "payverseClientKey", + payverseSecretKey = "payverseSecretKey", + payverseUsdMid = "payverseUsdMid", + payverseUsdClientKey = "payverseUsdClientKey", + payverseUsdSecretKey = "payverseUsdSecretKey", + payverseJpyMid = "payverseJpyMid", + payverseJpyClientKey = "payverseJpyClientKey", + payverseJpySecretKey = "payverseJpySecretKey", + payverseHost = "https://payverse.example.com", + serverEnv = "test" + ) + } + + @Test + fun shouldUseLockedMemberWhenChargingCanCoupon() { + val originalMember = member(id = 10L) + val lockedMember = member(id = 10L) + val couponNumber = canCouponNumber(couponType = CouponType.CAN, can = 300) + + Mockito.`when`(couponNumberRepository.findByCouponNumber("COUPON1234")).thenReturn(couponNumber) + Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(lockedMember) + + service.chargeByCoupon("COUPON1234", originalMember) + + Mockito.verify(memberRepository).findByIdForUpdate(10L) + assertEquals(300, lockedMember.pgRewardCan) + assertEquals(0, originalMember.pgRewardCan) + } + + @Test + fun shouldSaveCouponChargeWithLockedMember() { + val originalMember = member(id = 11L) + val lockedMember = member(id = 11L) + val couponNumber = canCouponNumber(couponType = CouponType.CAN, can = 150) + val captor = ArgumentCaptor.forClass(Charge::class.java) + + Mockito.`when`(couponNumberRepository.findByCouponNumber("COUPON5678")).thenReturn(couponNumber) + Mockito.`when`(memberRepository.findByIdForUpdate(11L)).thenReturn(lockedMember) + + service.chargeByCoupon("COUPON5678", originalMember) + + Mockito.verify(chargeRepository).save(captor.capture()) + assertEquals(lockedMember, captor.value.member) + } + + @Test + fun shouldNotLockMemberWhenCanCouponAlreadyUsed() { + val member = member(id = 12L) + val couponNumber = canCouponNumber(couponType = CouponType.CAN, can = 100).also { + it.member = member(id = 99L) + } + + Mockito.`when`(couponNumberRepository.findByCouponNumber("USED1234")).thenReturn(couponNumber) + + org.junit.jupiter.api.assertThrows { + service.chargeByCoupon("USED1234", member) + } + + Mockito.verify(memberRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong()) + } + + @Test + fun shouldKeepPointCouponBehaviorWithoutMemberLock() { + val member = member(id = 13L) + val couponNumber = canCouponNumber(couponType = CouponType.POINT, can = 500) + + Mockito.`when`(couponNumberRepository.findByCouponNumber("POINT1234")).thenReturn(couponNumber) + + val result = service.chargeByCoupon("POINT1234", member) + + assertEquals("500 포인트 충전 완료", result) + Mockito.verify(memberRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong()) + Mockito.verify(grantLogRepository).save(Mockito.any(PointGrantLog::class.java)) + Mockito.verify(memberPointRepository).save(Mockito.any(MemberPoint::class.java)) + Mockito.verify(chargeRepository, Mockito.never()).save(Mockito.any(Charge::class.java)) + } + + private fun canCouponNumber(couponType: CouponType, can: Int): CanCouponNumber { + val coupon = CanCoupon( + couponName = "테스트 쿠폰", + couponType = couponType, + can = can, + couponCount = 1, + validity = LocalDateTime.now().plusDays(1), + isActive = true, + isMultipleUse = false + ) + return CanCouponNumber("COUPON").also { it.canCoupon = coupon } + } + + private fun member(id: Long): Member { + return Member(password = "password", nickname = "member$id").also { it.id = id } + } +}