Compare commits

..
450 Commits
Author SHA1 Message Date
klaus 200dc6264a Merge pull request 'Test' (#451) from test into main
Reviewed-on: #451
2026-09-18 02:15:25 +00:00
klaus 6f48aa156a Merge pull request '크리에이터가 먼저 DM을 보낼 수 있도록 요구조건 수정' (#450) from test into main
Reviewed-on: #450
2026-09-15 01:56:13 +00:00
klaus c1000a6c68 Merge pull request 'test' (#449) from test into main
Reviewed-on: #449
2026-09-10 07:58:31 +00:00
klaus b978f5bc8d Merge pull request 'test' (#448) from test into main
Reviewed-on: #448
2026-08-20 10:04:02 +00:00
klaus 3f660e81f9 Merge pull request 'test' (#447) from test into main
Reviewed-on: #447
2026-08-19 08:56:01 +00:00
klaus c9b0bd13b5 Merge pull request 'test' (#446) from test into main
Reviewed-on: #446
2026-08-11 08:40:26 +00:00
klaus 92402419c5 Merge pull request 'test' (#445) from test into main
Reviewed-on: #445
2026-08-05 12:36:10 +00:00
klaus c4fa2fa2f8 Merge pull request 'test' (#444) from test into main
Reviewed-on: #444
2026-08-05 07:45:05 +00:00
klaus b4027fdee7 Merge pull request 'test' (#443) from test into main
Reviewed-on: #443
2026-08-03 06:20:48 +00:00
klaus eb262b8d53 Merge pull request 'test' (#442) from test into main
Reviewed-on: #442
2026-07-22 13:34:38 +00:00
klaus f5397df65e Merge pull request 'fix(community): 게시글 알림 딥링크를 보강한다' (#441) from test into main
Reviewed-on: #441
2026-07-16 09:26:27 +00:00
klaus 0c6aeb5c8a Merge pull request 'test' (#440) from test into main
Reviewed-on: #440
2026-07-15 12:24:50 +00:00
klaus d8083e34ea Merge pull request 'test' (#439) from test into main
Reviewed-on: #439
2026-07-12 14:05:39 +00:00
klaus 8eeade49d1 Merge pull request 'test' (#438) from test into main
Reviewed-on: #438
2026-07-12 10:42:33 +00:00
klaus b36764abff Merge pull request 'test' (#437) from test into main
Reviewed-on: #437
2026-07-12 08:54:14 +00:00
klaus 7844ea74d7 Merge pull request 'test' (#436) from test into main
Reviewed-on: #436
2026-07-12 04:18:00 +00:00
klaus e04fa67d1d Merge pull request 'test' (#435) from test into main
Reviewed-on: #435
2026-07-12 00:25:13 +00:00
klaus bd1ec54d36 Merge pull request 'chore(build): 테스트 JVM 메모리 설정을 조정한다' (#434) from test into main
Reviewed-on: #434
2026-07-10 08:06:41 +00:00
klaus 4442fa6d91 Merge pull request 'test' (#433) from test into main
Reviewed-on: #433
2026-07-10 07:20:06 +00:00
klaus 795c25159f Merge pull request 'test' (#432) from test into main
Reviewed-on: #432
2026-07-07 18:07:27 +00:00
klaus cdc7cb8612 Merge pull request 'test' (#431) from test into main
Reviewed-on: #431
2026-07-01 05:35:12 +00:00
klaus efefef51f4 Merge pull request 'test' (#430) from test into main
Reviewed-on: #430
2026-06-30 12:54:07 +00:00
klaus e2b9eb8bf3 Merge pull request 'test' (#429) from test into main
Reviewed-on: #429
2026-06-30 07:37:43 +00:00
klaus 1aa7a89f7b Merge pull request 'test' (#428) from test into main
Reviewed-on: #428
2026-06-29 08:06:51 +00:00
klaus 45aa26f1cb Merge pull request 'test' (#427) from test into main
Reviewed-on: #427
2026-06-27 02:30:26 +00:00
klaus 626f02b45e Merge pull request 'test' (#426) from test into main
Reviewed-on: #426
2026-06-27 00:35:29 +00:00
klaus 540c5cb317 Merge pull request 'test' (#425) from test into main
Reviewed-on: #425
2026-05-18 06:53:12 +00:00
klaus 3aa6a35adb Merge pull request 'fix(admin): 크리에이터 관리자 로그아웃 AGENT 권한을 허용한다' (#424) from test into main
Reviewed-on: #424
2026-05-08 10:44:04 +00:00
klaus 81c0d2586c Merge pull request '콘텐츠 관리자 권한 추가' (#423) from test into main
Reviewed-on: #423
2026-05-07 06:35:15 +00:00
klaus 965bb068fc Merge pull request 'fix(i18n): 지원하지 않는 원문 언어 번역 작업을 제외한다' (#422) from test into main
Reviewed-on: #422
2026-05-07 03:26:24 +00:00
klaus d082e0b745 Merge pull request '번역 작업 큐와 언어 감지 캐시를 도입한다' (#421) from test into main
Reviewed-on: #421
2026-05-07 01:31:02 +00:00
klaus c8b83272a3 Merge pull request 'test' (#420) from test into main
Reviewed-on: #420
2026-05-01 06:40:58 +00:00
klaus 1939fdcb33 Merge pull request 'fix(member): 강제 KR 매핑 대상에 회원 17958 추가' (#419) from test into main
Reviewed-on: #419
2026-04-28 05:00:06 +00:00
klaus f521a240c2 Merge pull request 'test' (#418) from test into main
Reviewed-on: #418
2026-04-22 01:19:41 +00:00
klaus 0c35624dfb Merge pull request 'fix(agent-calculate): 에이전트 기본 정산 비율을 7퍼센트로 조정한다' (#417) from test into main
Reviewed-on: #417
2026-04-15 05:37:24 +00:00
klaus 5c24addf31 Merge pull request '에이전트 기능' (#416) from test into main
Reviewed-on: #416
2026-04-14 06:29:22 +00:00
klaus 8671c8efc7 Merge pull request 'test' (#415) from test into main
Reviewed-on: #415
2026-04-08 01:50:50 +00:00
klaus 16c17f4bfa Merge pull request 'feat(live-room): 라이브 캡쳐 녹화 가능 여부를 생성 조회에 반영한다' (#414) from test into main
Reviewed-on: #414
2026-03-30 12:58:19 +00:00
klaus 0e821fae1b Merge pull request 'fix(member-social): 애플 로그인 aud 검증에 serviceId를 포함한다' (#413) from test into main
Reviewed-on: #413
2026-03-30 01:00:13 +00:00
klaus 6a10eff15f Merge pull request 'fix(live-room): 진행중 목록 성인 노출 정책과 JP 강제 매핑 검증을 정리한다' (#412) from test into main
Reviewed-on: #412
2026-03-28 14:11:08 +00:00
klaus fea329e637 Merge pull request 'fix(channel-donation): 후원 목록 탈퇴 닉네임 접두사를 제거한다' (#411) from test into main
Reviewed-on: #411
2026-03-28 10:14:16 +00:00
klaus 681e4a4036 Merge pull request 'test' (#410) from test into main
Reviewed-on: #410
2026-03-28 09:27:28 +00:00
klaus c23f574162 Merge pull request 'fix(member): 회원 차단을 요청 ID 단건만 적용한다' (#409) from test into main
Reviewed-on: #409
2026-03-26 02:01:43 +00:00
klaus c884d7d6c9 Merge pull request 'test' (#408) from test into main
Reviewed-on: #408
2026-03-24 10:41:41 +00:00
klaus 116e8cbca3 Merge pull request 'feat(deploy): EC2 배포 스크립트에 JVM 옵션 로드 기능 추가' (#407) from test into main
Reviewed-on: #407
2026-03-23 09:45:25 +00:00
klaus c8187ba147 Merge pull request 'feat(db): Aurora Serverless v2(0.5~2 ACU) 최적화용 Hikari 풀 설정 추가' (#406) from test into main
Reviewed-on: #406
2026-03-23 05:13:58 +00:00
klaus 676bd0b79e Merge pull request 'test' (#405) from test into main
Reviewed-on: #405
2026-03-19 09:33:40 +00:00
klaus 7522f06bf3 Merge pull request 'fix(live-room): 라이브 방 후원 랭킹 조회에 기간 설정을 반영한다' (#404) from test into main
Reviewed-on: #404
2026-03-17 07:15:17 +00:00
klaus a9d2d1ab48 Merge pull request 'feat(creator-community): 커뮤니티 게시물 고정 기능을 추가한다' (#403) from test into main
Reviewed-on: #403
2026-03-17 02:40:30 +00:00
klaus e0e371cdc9 Merge pull request 'feat(admin-calculate): 관리자 라이브 환불 처리와 정산 응답 식별자를 추가한다' (#402) from test into main
Reviewed-on: #402
2026-03-16 07:13:26 +00:00
klaus b49344d0e9 Merge pull request 'fix(admin-chat-character): JP 리전 캐릭터 등록 성별 값을 일본어로 변환한다' (#401) from test into main
Reviewed-on: #401
2026-03-16 02:37:51 +00:00
klaus 5cc152307a Merge pull request 'test' (#400) from test into main
Reviewed-on: #400
2026-03-13 14:08:53 +00:00
klaus 1fd3d41d7e Merge pull request 'test' (#399) from test into main
Reviewed-on: #399
2026-03-13 13:18:26 +00:00
klaus c1b9dd730d Merge pull request 'fix(admin-calculate): 관리자 정산 조회 캐시를 제거하고 응답 직렬화를 명시한다' (#398) from test into main
Reviewed-on: #398
2026-03-06 03:31:24 +00:00
klaus d40cd32c50 Merge pull request 'test' (#397) from test into main
Reviewed-on: #397
2026-03-05 09:17:37 +00:00
klaus 0289607fd9 Merge pull request 'fix(channel-donation): 관리자 채널후원 정산 조회를 날짜별과 크리에이터별로 분리하고 엑셀 다운로드를 추가한다' (#396) from test into main
Reviewed-on: #396
2026-03-03 05:59:42 +00:00
klaus 1bec644372 Merge pull request 'fix(channel-donation): 후원 조회 월 경계를 UTC 전달 기준으로 보정한다' (#395) from test into main
Reviewed-on: #395
2026-03-03 03:20:46 +00:00
klaus f8a6d1b221 Merge pull request 'fix(channel-donation): 기부 목록 조회 월 범위를 한국 시간 기준으로 계산한다' (#394) from test into main
Reviewed-on: #394
2026-03-03 02:23:01 +00:00
klaus ec769a1307 Merge pull request 'test' (#393) from test into main
Reviewed-on: #393
2026-02-27 06:16:51 +00:00
klaus 8e4fb0d313 Merge pull request 'test' (#392) from test into main
Reviewed-on: #392
2026-02-26 11:18:33 +00:00
klaus cc3a620642 Merge pull request 'fix(recommend-live): 차단 관계를 추천 조회에 반영하고 캐시를 무효화한다' (#391) from test into main
Reviewed-on: #391
2026-02-25 18:40:55 +00:00
klaus be0884e974 Merge pull request 'test' (#390) from test into main
Reviewed-on: #390
2026-02-25 16:55:31 +00:00
klaus 34456395fd Merge pull request 'test' (#389) from test into main
Reviewed-on: #389
2026-02-25 13:55:22 +00:00
klaus 26ddeb9591 Merge pull request 'test' (#388) from test into main
Reviewed-on: #388
2026-02-13 09:14:19 +00:00
klaus cd535a628c Merge pull request 'test' (#387) from test into main
Reviewed-on: #387
2026-02-11 10:03:08 +00:00
klaus de32b537f4 Merge pull request 'test' (#386) from test into main
Reviewed-on: #386
2026-02-08 07:42:31 +00:00
klaus 9c271fc1f6 Merge pull request 'test' (#385) from test into main
Reviewed-on: #385
2026-02-06 05:04:37 +00:00
klaus 2ddbfbccd6 Merge pull request 'test' (#384) from test into main
Reviewed-on: #384
2026-02-04 12:52:24 +00:00
klaus 80786deb72 Merge pull request 'test' (#383) from test into main
Reviewed-on: #383
2026-01-28 15:40:25 +00:00
klaus 8ca2e185ac Merge pull request '라이브 예약 반환 값 - beginDateTimeUtc 추가' (#382) from test into main
Reviewed-on: #382
2026-01-21 10:08:55 +00:00
klaus 484711ad1b Merge pull request '라이브 예약 Response에 utc 시간 변수 beginDateTimeUtc 추가' (#381) from test into main
Reviewed-on: #381
2026-01-21 07:54:05 +00:00
klaus e80ceca0c5 Merge pull request '충전 이벤트 - langContext 문제로 충전이 되지 않는 현상을 langContext를 사용하지 않고 이전 방식으로 기록하도록 롤백하여 임시 해결' (#380) from test into main
Reviewed-on: #380
2026-01-21 02:36:21 +00:00
klaus 33293a6533 Merge pull request '라이브 방 상세 - 날짜 포맷 변경으로 유료방 입장이 불가한 문제를 해결하기 위해 이전으로 롤백' (#379) from test into main
Reviewed-on: #379
2026-01-21 02:16:42 +00:00
klaus f0c1d4e32a Merge pull request '라이브 룸 일시 포맷에 다국어 설정 적용' (#378) from test into main
Reviewed-on: #378
2026-01-20 10:41:23 +00:00
klaus 6cd319ec76 Merge pull request 'memberId가 특정 번호일 때 currency와 관계없이 모든 구매 가능한 캔이 출력되도록 수정' (#377) from test into main
Reviewed-on: #377
2026-01-16 02:39:08 +00:00
klaus 6557ec2aed Merge pull request '푸시 알림 전송 언어 처리' (#376) from test into main
Reviewed-on: #376
2026-01-15 08:45:46 +00:00
klaus f2f8a34319 Merge pull request '국가 컨텍스트로 캔 조회' (#375) from test into main
Reviewed-on: #375
2026-01-14 06:46:51 +00:00
klaus c50ac6ed2c Merge pull request '캔 사용 시 국가 코드 기록 기능 추가' (#374) from test into main
Reviewed-on: #374
2026-01-12 02:31:46 +00:00
klaus 11b9c349d1 Merge pull request '번역 이벤트 커밋 후 처리 분기' (#373) from test into main
Reviewed-on: #373
2026-01-07 09:54:32 +00:00
klaus ef9f8d65e1 Merge pull request 'test' (#372) from test into main
Reviewed-on: #372
2026-01-07 07:42:42 +00:00
klaus 299f2100e9 Merge pull request 'test' (#371) from test into main
Reviewed-on: #371
2026-01-05 06:33:29 +00:00
klaus fd5c794480 Merge pull request 'test' (#370) from test into main
Reviewed-on: #370
2025-12-31 11:01:28 +00:00
klaus 68197de095 Merge pull request 'test' (#369) from test into main
Reviewed-on: #369
2025-12-31 05:44:54 +00:00
klaus 587f3d6b58 Merge pull request 'test' (#368) from test into main
Reviewed-on: #368
2025-11-20 16:02:25 +00:00
klaus 9b6167d46d Merge pull request 'test' (#367) from test into main
Reviewed-on: #367
2025-11-20 12:53:00 +00:00
klaus 008ee3b4e5 Merge pull request 'test' (#366) from test into main
Reviewed-on: #366
2025-11-20 06:27:02 +00:00
klaus 3a57ad23bb Merge pull request 'test' (#365) from test into main
Reviewed-on: #365
2025-11-19 08:56:28 +00:00
klaus 729552335a Merge pull request 'test' (#364) from test into main
Reviewed-on: #364
2025-11-18 10:38:50 +00:00
klaus 02ae507c87 Merge pull request 'fix(series-list): 시리즈 리스트 조회시 정렬 수정' (#363) from test into main
Reviewed-on: #363
2025-11-17 13:38:59 +00:00
klaus 5818abf69d Merge pull request 'fix(series-list): creator의 시리즈를 볼 떄와 다른 페이지에서 시리즈 리스트를 볼 때 정렬 순서 분리' (#362) from test into main
Reviewed-on: #362
2025-11-17 12:25:38 +00:00
klaus ee403915f0 Merge pull request 'test' (#361) from test into main
Reviewed-on: #361
2025-11-17 07:13:11 +00:00
klaus 1a660088de Merge pull request 'test' (#360) from test into main
Reviewed-on: #360
2025-11-13 20:49:12 +00:00
klaus 5196c80ca8 Merge pull request 'test' (#359) from test into main
Reviewed-on: #359
2025-11-13 19:45:52 +00:00
klaus c9c09c2998 Merge pull request 'test' (#358) from test into main
Reviewed-on: #358
2025-11-10 06:53:41 +00:00
klaus 3ea33c4c7b Merge pull request 'feat(home-latest-content): 최신 콘텐츠 조회시 정렬 조건 변경' (#357) from test into main
Reviewed-on: #357
2025-11-07 12:00:59 +00:00
klaus 451a1aa4f2 Merge pull request 'test' (#356) from test into main
Reviewed-on: #356
2025-11-06 08:48:23 +00:00
klaus 90555fd34f Merge pull request 'feat(can-use-status): PAYVERSE로 충전한 캔을 사용한 내역도 포함되도록 수정' (#355) from test into main
Reviewed-on: #355
2025-10-22 14:26:02 +00:00
klaus 0dc430b098 Merge pull request 'UseCalculate에 PAYVERSE로 충전한 캔 로그 데이터를 쌓도록 수정' (#354) from test into main
Reviewed-on: #354
2025-10-22 13:31:46 +00:00
klaus 1f2103c7fa Merge pull request 'fix(can-use): PAYVERSE로 충전한 캔이 사용되지 않는 버그 수정' (#353) from test into main
Reviewed-on: #353
2025-10-22 12:41:13 +00:00
klaus 062c17c51e Merge pull request 'feat(chat): 채팅권 구매 가격과 채팅횟수 변경' (#352) from test into main
Reviewed-on: #352
2025-10-22 07:55:13 +00:00
klaus de169b79a1 Merge pull request 'feat(home): 인기 캐릭터 추가' (#351) from test into main
Reviewed-on: #351
2025-10-20 06:07:49 +00:00
klaus aa24de0a5a Merge pull request 'test' (#350) from test into main
Reviewed-on: #350
2025-10-17 05:46:36 +00:00
klaus e5937d573a Merge pull request 'test' (#349) from test into main
Reviewed-on: #349
2025-10-10 20:49:52 +00:00
klaus 6da86e12bd Merge pull request 'test' (#348) from test into main
Reviewed-on: #348
2025-10-10 19:19:47 +00:00
klaus 9049022a74 Merge pull request 'fix(admin-charge-status-detail): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정' (#347) from test into main
Reviewed-on: #347
2025-10-10 14:39:40 +00:00
klaus 7b6f3a7a5f Merge pull request 'fix(admin-charge-status): pgChargeAmount와 can의 가격을 가져와서 사용하는 로직을 제거하고 payment에 기록된 가격으로 계산하도록 수정' (#346) from test into main
Reviewed-on: #346
2025-10-10 13:53:23 +00:00
klaus 53e9678efa Merge pull request 'fix(verify-hecto): 데이터 검증시 가격비교 제거' (#345) from test into main
Reviewed-on: #345
2025-10-10 09:58:11 +00:00
klaus e4f547fa92 Merge pull request 'payverse 적용' (#344) from test into main
Reviewed-on: #344
2025-10-10 07:44:07 +00:00
klaus b69756ef81 Merge pull request 'test' (#343) from test into main
Reviewed-on: #343
2025-09-18 19:25:50 +00:00
klaus 1a3a9149a2 Merge pull request 'test' (#342) from test into main
Reviewed-on: #342
2025-09-16 06:11:32 +00:00
klaus ce120a6d5d Merge pull request 'test' (#341) from test into main
Reviewed-on: #341
2025-09-14 20:33:50 +00:00
klaus 08b5fd23ab Merge pull request 'test' (#340) from test into main
Reviewed-on: #340
2025-09-14 08:51:11 +00:00
klaus eb18e2d009 Merge pull request 'test' (#339) from test into main
Reviewed-on: #339
2025-09-11 17:05:45 +00:00
klaus a27852ed44 Merge pull request '캐릭터 챗봇' (#338) from test into main
Reviewed-on: #338
2025-09-10 06:08:47 +00:00
klaus c7925c1706 Merge pull request 'feat: 최근 공지사항 API 추가' (#337) from test into main
Reviewed-on: #337
2025-07-28 02:16:19 +00:00
klaus be59bd7e89 Merge pull request 'fix: 크리에이터 팔로우 API' (#336) from test into main
Reviewed-on: #336
2025-07-21 13:52:34 +00:00
klaus 51ce143fc2 Merge pull request 'test' (#335) from test into main
Reviewed-on: #335
2025-07-21 11:46:56 +00:00
klaus 89eb11f808 Merge pull request 'fix: 라이브 메인 API - 최근 종료된 라이브' (#334) from test into main
Reviewed-on: #334
2025-07-21 10:59:38 +00:00
klaus 30d89987a4 Merge pull request 'test' (#333) from test into main
Reviewed-on: #333
2025-07-21 09:54:56 +00:00
klaus 7959d3e5ed Merge pull request 'test' (#332) from test into main
Reviewed-on: #332
2025-07-18 12:33:22 +00:00
klaus 1e29573ef7 Merge pull request 'fix: 검색 API' (#331) from test into main
Reviewed-on: #331
2025-07-16 10:58:56 +00:00
klaus cc2f533dc6 Merge pull request 'fix: 메인 홈 API - 요일별 시리즈' (#330) from test into main
Reviewed-on: #330
2025-07-14 19:14:06 +00:00
klaus 32b0c19f9d Merge pull request 'test' (#329) from test into main
Reviewed-on: #329
2025-07-14 17:57:26 +00:00
klaus 9af2d768e8 Merge pull request 'test' (#327) from test into main
Reviewed-on: #327
2025-07-14 11:07:57 +00:00
klaus 5677824cde Merge pull request 'test' (#326) from test into main
Reviewed-on: #326
2025-06-13 11:37:26 +00:00
klaus e8f1bc09f9 Merge pull request 'test' (#325) from test into main
Reviewed-on: #325
2025-06-12 05:00:31 +00:00
klaus d1a936d55b Merge pull request 'test' (#324) from test into main
Reviewed-on: #324
2025-06-10 11:01:31 +00:00
klaus dc97eaa835 Merge pull request 'fix: 앱 콘텐츠 수정' (#323) from test into main
Reviewed-on: #323
2025-06-05 02:36:25 +00:00
klaus dcbe57806c Merge pull request 'test' (#322) from test into main
Reviewed-on: #322
2025-06-02 12:41:46 +00:00
klaus b14438cc15 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#321) from test into main
Reviewed-on: #321
2025-05-28 07:19:27 +00:00
klaus b27d3bd5c6 Merge pull request 'fix: 유저 행동 기록, 포인트 지급' (#320) from test into main
Reviewed-on: #320
2025-05-26 10:33:16 +00:00
klaus 03ebc9cfe9 Merge pull request 'fix: 큐레이션 아이템 조회' (#319) from test into main
Reviewed-on: #319
2025-05-23 05:43:37 +00:00
klaus 24841b9850 Merge pull request 'fix: 코루틴 내 트랜잭션 간 조회 안 되는 문제 해결' (#318) from test into main
Reviewed-on: #318
2025-05-22 04:31:42 +00:00
klaus d35a3d1a8c Merge pull request 'test' (#317) from test into main
Reviewed-on: #317
2025-05-20 10:26:16 +00:00
klaus 60c4e0b528 Merge pull request 'test' (#316) from test into main
Reviewed-on: #316
2025-05-20 06:03:10 +00:00
klaus 84f33d1bc2 Merge pull request 'fix: 소셜로그인시 유저 행동데이터 SIGN_UP 중복 기록 버그' (#315) from test into main
Reviewed-on: #315
2025-05-12 08:24:53 +00:00
klaus c4e1709b99 Merge pull request 'test' (#314) from test into main
Reviewed-on: #314
2025-05-12 02:12:47 +00:00
klaus e7a5fd5819 Merge pull request 'fix: 구글/카카오 로그인 회원가입 오류 수정' (#313) from test into main
Reviewed-on: #313
2025-05-02 10:58:04 +00:00
klaus 4bde03643c Merge pull request 'test' (#312) from test into main
Reviewed-on: #312
2025-04-29 02:56:16 +00:00
klaus 1bc52b56af Merge pull request 'fix: 콘텐츠 업로드 - 제목과 내용에서 trim 함수를 적용하여 앞/뒤 빈칸 제거' (#311) from test into main
Reviewed-on: #311
2025-04-25 09:43:31 +00:00
klaus 9c33fd93f7 Merge pull request 'refactor: 본인인증 - 본인인증이 완료된 후 유저 행동 데이터를 기록하도록 수정' (#310) from test into main
Reviewed-on: #310
2025-04-24 11:10:17 +00:00
klaus 3c087bc275 Merge pull request '유저 행동 데이터, 포인트 추가' (#309) from test into main
Reviewed-on: #309
2025-04-24 02:44:57 +00:00
klaus 8ad13c289e Merge pull request '회원탈퇴' (#308) from test into main
Reviewed-on: #308
2025-04-15 10:42:37 +00:00
klaus 7577f48a09 Merge pull request '한정판 콘텐츠' (#307) from test into main
Reviewed-on: #307
2025-04-15 09:44:12 +00:00
klaus 0251906964 Merge pull request '비밀번호 찾기' (#306) from test into main
Reviewed-on: #306
2025-04-10 06:28:57 +00:00
klaus 2723a5f134 Merge pull request '일별 전체 회원 수' (#305) from test into main
Reviewed-on: #305
2025-04-10 02:30:00 +00:00
klaus c3c60605fd Merge pull request '관리자 - 회원리스트, 크리에이터 리스트' (#304) from test into main
Reviewed-on: #304
2025-04-09 10:35:01 +00:00
klaus 238f704b22 Merge pull request '소셜 로그인, 회원가입 - 이메일 체크 로직 수정' (#303) from test into main
Reviewed-on: #303
2025-04-08 07:04:11 +00:00
klaus 5639d8ac8e Merge pull request 'test' (#302) from test into main
Reviewed-on: #302
2025-04-07 10:23:13 +00:00
klaus 9aac591591 Merge pull request 'test' (#301) from test into main
Reviewed-on: #301
2025-04-01 13:31:24 +00:00
klaus ffa8e5aebb Merge pull request '일별 전체 회원 수 통계' (#300) from test into main
Reviewed-on: #300
2025-03-31 03:50:18 +00:00
klaus cbbfe014cc Merge pull request '광고 통계' (#299) from test into main
Reviewed-on: #299
2025-03-28 05:29:40 +00:00
klaus 83028f7817 Merge pull request 'test' (#298) from test into main
Reviewed-on: #298
2025-03-26 21:08:29 +00:00
klaus 70d1795557 Merge pull request 'test' (#297) from test into main
Reviewed-on: #297
2025-03-26 04:23:28 +00:00
klaus 8c6c681424 Merge pull request 'marketing 정보 업데이트 시 pid 값이 있으면 항상 로그인 기록 남기기' (#296) from test into main
Reviewed-on: #296
2025-03-25 11:25:46 +00:00
klaus 50bc9f4ff3 Merge pull request '라이브 방 - 예약 중 조회' (#295) from test into main
Reviewed-on: #295
2025-03-24 10:04:08 +00:00
klaus f00ea03fad Merge pull request 'test' (#294) from test into main
Reviewed-on: #294
2025-03-24 09:09:16 +00:00
klaus f22e7b9ad1 Merge pull request '자동생성 닉네임에 사용될 형용사, 명사 값 추가' (#293) from test into main
Reviewed-on: #293
2025-03-21 10:27:30 +00:00
klaus c7ec95f4bb Merge pull request 'test' (#292) from test into main
Reviewed-on: #292
2025-03-20 19:24:03 +00:00
klaus 229e7a8ccc Merge pull request '시리즈 상세, 채널 상세' (#291) from test into main
Reviewed-on: #291
2025-03-19 09:43:06 +00:00
klaus 3c616474ff Merge pull request 'test' (#290) from test into main
Reviewed-on: #290
2025-03-19 07:51:25 +00:00
klaus 56eb6b3ce3 Merge pull request '19금 콘텐츠 보기 설정 적용' (#289) from test into main
Reviewed-on: #289
2025-03-19 02:05:17 +00:00
klaus 545836d43c Merge pull request '관리자 광고통계, 일별 전체 회원 수' (#288) from test into main
Reviewed-on: #288
2025-03-17 08:50:59 +00:00
klaus 219f83dec0 Merge pull request 'test' (#287) from test into main
Reviewed-on: #287
2025-03-17 05:54:05 +00:00
klaus a76a841238 Merge pull request 'test' (#286) from test into main
Reviewed-on: #286
2025-03-14 16:11:17 +00:00
klaus c26680de84 Merge pull request '이벤트 배너, 충전 이벤트 - 기간 설정에 시간 추가' (#285) from test into main
Reviewed-on: #285
2025-03-14 03:40:07 +00:00
klaus 8fffad9d3a Merge pull request 'test' (#284) from test into main
Reviewed-on: #284
2025-03-13 12:25:35 +00:00
klaus f4f0f203a2 Merge pull request '유저 정보 조회' (#283) from test into main
Reviewed-on: #283
2025-03-12 08:00:13 +00:00
klaus b7196f5a0c Merge pull request 'test' (#282) from test into main
Reviewed-on: #282
2025-03-11 08:01:05 +00:00
klaus 5d33a18890 Merge pull request 'test' (#281) from test into main
Reviewed-on: #281
2025-03-10 05:35:30 +00:00
klaus 96186a1a50 Merge pull request '마케팅 - 매체 파트너 코드 조회 API - link 값 수정' (#280) from test into main
Reviewed-on: #280
2025-03-07 06:27:08 +00:00
klaus bc8bc479d1 Merge pull request 'test' (#279) from test into main
Reviewed-on: #279
2025-03-06 17:58:32 +00:00
klaus 47595b1291 Merge pull request 'test' (#278) from test into main
Reviewed-on: #278
2025-03-05 14:05:47 +00:00
klaus 01a88964df Merge pull request 'test' (#277) from test into main
Reviewed-on: #277
2025-03-05 09:44:59 +00:00
klaus 3a2b77379f Merge pull request '콘텐츠 업로드' (#276) from test into main
Reviewed-on: #276
2025-02-28 04:45:04 +00:00
klaus dc4e5f75cd Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#275) from test into main
Reviewed-on: #275
2025-02-26 03:14:33 +00:00
klaus d0178d551c Merge pull request '콘텐츠 메인 콘텐츠 탭 - 채널별 추천 단편' (#274) from test into main
Reviewed-on: #274
2025-02-25 14:54:53 +00:00
klaus 827333108d Merge pull request '콘텐츠 대여기간' (#273) from test into main
Reviewed-on: #273
2025-02-25 14:02:18 +00:00
klaus 587b90bd27 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 콘텐츠' (#272) from test into main
Reviewed-on: #272
2025-02-22 01:56:49 +00:00
klaus 4dc20c5e90 Merge pull request '콘텐츠 메인 무료 탭' (#271) from test into main
Reviewed-on: #271
2025-02-22 00:39:09 +00:00
klaus ac25782f2b Merge pull request '관리자 태그 큐레이션 - 콘텐츠 검색' (#270) from test into main
Reviewed-on: #270
2025-02-21 21:46:15 +00:00
klaus 20437d56e7 Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#269) from test into main
Reviewed-on: #269
2025-02-21 21:15:52 +00:00
klaus f0b412828a Merge pull request '메인 시리즈 탭 - 완결 시리즈' (#268) from test into main
Reviewed-on: #268
2025-02-21 19:27:33 +00:00
klaus 367faac5c3 Merge pull request 'test' (#267) from test into main
Reviewed-on: #267
2025-02-20 18:24:35 +00:00
klaus 84deaaa970 Merge pull request '콘텐츠 메인 시리즈 탭 - 장르별 시리즈' (#266) from test into main
Reviewed-on: #266
2025-02-19 12:52:17 +00:00
klaus a2b39466c2 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#265) from test into main
Reviewed-on: #265
2025-02-19 11:34:02 +00:00
klaus 03586c4005 Merge pull request '기존 콘텐츠 메인 - 새로운 콘텐츠' (#264) from test into main
Reviewed-on: #264
2025-02-19 09:49:04 +00:00
klaus 6ea69e1510 Merge pull request '콘텐츠 메인 무료 탭 - 새로운 무료 콘텐츠' (#263) from test into main
Reviewed-on: #263
2025-02-19 09:24:24 +00:00
klaus 553c6dc539 Merge pull request '콘텐츠 메인 단편 탭 - 새로운 단편' (#262) from test into main
Reviewed-on: #262
2025-02-19 08:20:14 +00:00
klaus 6cc22f5b6d Merge pull request '콘텐츠 메인 홈, 무료 탭' (#261) from test into main
Reviewed-on: #261
2025-02-19 06:34:53 +00:00
klaus 9103d67cc1 Merge pull request 'test' (#260) from test into main
Reviewed-on: #260
2025-02-18 18:13:25 +00:00
klaus 25083fb0e4 Merge pull request 'test' (#259) from test into main
Reviewed-on: #259
2025-02-18 14:48:09 +00:00
klaus d2dc045255 Merge pull request 'test' (#258) from test into main
Reviewed-on: #258
2025-02-14 18:09:11 +00:00
klaus b8621dfbb0 Merge pull request 'test' (#257) from test into main
Reviewed-on: #257
2025-02-09 13:36:21 +00:00
klaus 93633940dd Merge pull request 'test' (#256) from test into main
Reviewed-on: #256
2025-02-03 07:20:32 +00:00
klaus b6f5325351 Merge pull request 'test' (#255) from test into main
Reviewed-on: #255
2025-01-31 15:22:23 +00:00
klaus 7c32c08f1f Merge pull request 'test' (#254) from test into main
Reviewed-on: #254
2025-01-17 05:46:00 +00:00
klaus 1d268da08d Merge pull request '오디션 등록 푸시알림 메시지 수정' (#253) from test into main
Reviewed-on: #253
2025-01-10 10:23:19 +00:00
klaus 797666ae0d Merge pull request 'test' (#252) from test into main
Reviewed-on: #252
2025-01-08 14:11:08 +00:00
klaus dcf470997e Merge pull request 'test' (#251) from test into main
Reviewed-on: #251
2025-01-08 06:29:33 +00:00
klaus 0974d1dbf8 Merge pull request '관리자 오디션 지원 리스트' (#250) from test into main
Reviewed-on: #250
2025-01-07 19:44:39 +00:00
klaus 12a35db6cd Merge pull request '오디션' (#249) from test into main
Reviewed-on: #249
2025-01-07 17:24:40 +00:00
klaus 9abbb05ad8 Merge pull request 'test' (#248) from test into main
Reviewed-on: #248
2024-12-18 07:10:01 +00:00
klaus 1ecaf69b0b Merge pull request 'test' (#247) from test into main
Reviewed-on: #247
2024-12-17 13:43:45 +00:00
klaus e334d1e5d9 Merge pull request '콘텐츠 댓글 푸시 대상자' (#246) from test into main
Reviewed-on: #246
2024-12-03 15:54:34 +00:00
klaus b735e861d0 Merge pull request '콘텐츠 댓글 푸시 대상자 조회' (#245) from test into main
Reviewed-on: #245
2024-12-02 15:06:49 +00:00
klaus 4eb433d372 Merge pull request 'test' (#244) from test into main
Reviewed-on: #244
2024-12-02 12:05:30 +00:00
klaus 2416ae61f3 Merge pull request 'test' (#243) from test into main
Reviewed-on: #243
2024-12-02 04:29:50 +00:00
klaus 01fb336985 Merge pull request '콘텐츠 등록' (#242) from test into main
Reviewed-on: #242
2024-11-26 12:46:24 +00:00
klaus b6af88a732 Merge pull request 'test' (#241) from test into main
Reviewed-on: #241
2024-11-26 05:33:45 +00:00
klaus 58a2a17d6d Merge pull request 'test' (#240) from test into main
Reviewed-on: #240
2024-11-23 17:59:23 +00:00
klaus 79f5a0f520 Merge pull request '내 콘텐츠 수정, 삭제 시 콘텐츠 조회 함수' (#239) from test into main
Reviewed-on: #239
2024-11-21 06:34:30 +00:00
klaus 7f6c0f7f04 Merge pull request 'Redis connection 수정' (#238) from test into main
Reviewed-on: #238
2024-11-20 09:58:56 +00:00
klaus f658df4dca Merge pull request 'Redis connection' (#237) from test into main
Reviewed-on: #237
2024-11-20 07:52:58 +00:00
klaus 9d43b8e23a Merge pull request 'Redis connection' (#236) from test into main
Reviewed-on: #236
2024-11-20 06:47:52 +00:00
klaus 4270aef79b Merge pull request 'test' (#235) from test into main
Reviewed-on: #235
2024-11-11 15:34:35 +00:00
klaus 1c0dc82d44 Merge pull request '콘텐츠 구매 - 소장만 추가' (#234) from test into main
Reviewed-on: #234
2024-11-08 12:40:29 +00:00
klaus c1e325aadf Merge pull request 'test' (#233) from test into main
Reviewed-on: #233
2024-11-05 07:26:19 +00:00
klaus cec87da69d Merge pull request '콘텐츠 대여가격' (#232) from test into main
Reviewed-on: #232
2024-10-31 05:23:01 +00:00
klaus f68f24cb2c Merge pull request 'test' (#231) from test into main
Reviewed-on: #231
2024-10-31 03:09:13 +00:00
klaus ed094347fc Merge pull request '라이브 방 후원랭킹' (#230) from test into main
Reviewed-on: #230
2024-10-30 05:16:05 +00:00
klaus b8afdffbe1 Merge pull request 'test' (#229) from test into main
Reviewed-on: #229
2024-10-29 08:35:58 +00:00
klaus f6ba79f31c Merge pull request 'test' (#228) from test into main
Reviewed-on: #228
2024-10-25 03:45:26 +00:00
klaus 5f3b1663d2 Merge pull request '관리자 - 콘텐츠 리스트' (#227) from test into main
Reviewed-on: #227
2024-10-16 03:33:23 +00:00
klaus 66e786b4bb Merge pull request '관리자 - 시리즈 리스트 API' (#226) from test into main
Reviewed-on: #226
2024-10-14 15:39:45 +00:00
klaus f671114574 Merge pull request '관리자 - 시리즈 리스트 API' (#225) from test into main
Reviewed-on: #225
2024-10-14 10:37:28 +00:00
klaus ce37060d94 Merge pull request '관리자 - 시리즈 리스트 API 추가' (#224) from test into main
Reviewed-on: #224
2024-10-14 10:10:43 +00:00
klaus 7d19a4d184 Merge pull request 'test' (#223) from test into main
Reviewed-on: #223
2024-10-13 17:30:25 +00:00
klaus 22f28a2f8a Merge pull request '콘텐츠 메인 - 추천시리즈, 새로운 콘텐츠, 큐레이션' (#222) from test into main
Reviewed-on: #222
2024-10-13 16:33:15 +00:00
klaus ceef9ca979 Merge pull request 'test' (#221) from test into main
Reviewed-on: #221
2024-10-11 05:06:22 +00:00
klaus efe8f4f939 Merge pull request '콘텐츠 메인 - 새로운 콘텐츠 섹션 두번째 정렬 조건 추가' (#220) from test into main
Reviewed-on: #220
2024-10-04 07:21:38 +00:00
klaus ba692a1195 Merge pull request '시리즈 상세 - 콘텐츠 리스트 두번째 정렬 조건 추가' (#219) from test into main
Reviewed-on: #219
2024-10-04 02:41:28 +00:00
klaus d732bad042 Merge pull request '시리즈 상세' (#218) from test into main
Reviewed-on: #218
2024-10-02 09:18:19 +00:00
klaus 4c935c3bee Merge pull request '예약 라이브 개수 제한' (#217) from test into main
Reviewed-on: #217
2024-09-25 05:42:45 +00:00
klaus c160dd791f Merge pull request 'test' (#216) from test into main
Reviewed-on: #216
2024-09-24 10:17:58 +00:00
klaus 23cd1b4601 Merge pull request '라이브 후원현황 API' (#215) from test into main
Reviewed-on: #215
2024-09-23 13:58:14 +00:00
klaus 031fc8ba1b Merge pull request 'test' (#214) from test into main
Reviewed-on: #214
2024-09-23 06:24:12 +00:00
klaus c6853289ad Merge pull request 'test' (#213) from test into main
Reviewed-on: #213
2024-09-11 08:23:08 +00:00
klaus 2497bb69bc Merge pull request 'test' (#212) from test into main
Reviewed-on: #212
2024-09-11 07:47:35 +00:00
klaus a58a67e0a2 Merge pull request 'test' (#211) from test into main
Reviewed-on: #211
2024-09-11 06:00:31 +00:00
klaus 4315fe12a5 Merge pull request 'test' (#210) from test into main
Reviewed-on: #210
2024-09-06 19:00:39 +00:00
klaus 42f10a8899 Merge pull request 'test' (#209) from test into main
Reviewed-on: #209
2024-09-05 10:12:14 +00:00
klaus 1e4b47f989 Merge pull request 'test' (#208) from test into main
Reviewed-on: #208
2024-08-30 09:17:41 +00:00
klaus ff255dbfae Merge pull request 'test' (#207) from test into main
Reviewed-on: #207
2024-08-27 07:31:05 +00:00
klaus dbe9b72feb Merge pull request 'test' (#206) from test into main
Reviewed-on: #206
2024-08-23 13:48:24 +00:00
klaus 95a714b391 Merge pull request '탐색' (#205) from test into main
Reviewed-on: #205
2024-08-19 13:21:31 +00:00
klaus 28f58c7f56 Merge pull request '라이브' (#204) from test into main
Reviewed-on: #204
2024-08-14 09:34:52 +00:00
klaus 8bd46d8f21 Merge pull request '크리에이터 관리자 시리즈' (#203) from test into main
Reviewed-on: #203
2024-08-14 07:41:33 +00:00
klaus e1bb8e54ed Merge pull request '크리에이터 커뮤니티' (#202) from test into main
Reviewed-on: #202
2024-08-06 11:41:11 +00:00
klaus 1de705b063 Merge pull request '크리에이터 커뮤니티' (#201) from test into main
Reviewed-on: #201
2024-08-06 06:37:30 +00:00
klaus f6926ad356 Merge pull request '남/여 크리에이터에서 특정 크리에이터 제거' (#200) from test into main
Reviewed-on: #200
2024-07-26 07:54:18 +00:00
klaus 2cdbbb1b37 Merge pull request 'test' (#199) from test into main
Reviewed-on: #199
2024-07-25 16:08:20 +00:00
klaus 4dce8c8f03 Merge pull request '크리에이터 커뮤니티' (#198) from test into main
Reviewed-on: #198
2024-07-10 05:24:58 +00:00
klaus 97a5bace6f Merge pull request 'test' (#197) from test into main
Reviewed-on: #197
2024-07-08 14:17:42 +00:00
klaus d4d51ec48f Merge pull request 'test' (#196) from test into main
Reviewed-on: #196
2024-07-02 08:57:11 +00:00
klaus fb91398462 Merge pull request '커뮤니티 게시물' (#195) from test into main
Reviewed-on: #195
2024-06-17 14:09:26 +00:00
klaus 105dadd798 Merge pull request '커뮤니티 게시물' (#194) from test into main
Reviewed-on: #194
2024-06-15 11:57:33 +00:00
klaus 2abf2837d3 Merge pull request '커뮤니티 게시물' (#193) from test into main
Reviewed-on: #193
2024-06-11 12:13:47 +00:00
klaus 422aa67af6 Merge pull request '커뮤니티 게시물' (#192) from test into main
Reviewed-on: #192
2024-06-11 11:55:05 +00:00
klaus 7fffab6985 Merge pull request '크리에이터 정산 - 입력된 비율로 계산' (#191) from test into main
Reviewed-on: #191
2024-06-11 08:07:22 +00:00
klaus 5a4be3d2c1 Merge pull request '콘텐츠 상세' (#190) from test into main
Reviewed-on: #190
2024-06-07 10:30:06 +00:00
klaus f39a7681db Merge pull request 'test' (#189) from test into main
Reviewed-on: #189
2024-06-04 03:39:23 +00:00
klaus c60a7580ba Merge pull request 'test' (#188) from test into main
Reviewed-on: #188
2024-06-03 22:13:56 +00:00
klaus 97edb56edc Merge pull request 'test' (#187) from test into main
Reviewed-on: #187
2024-05-29 17:04:39 +00:00
klaus 6ebca8d22b Merge pull request '관리자 - 라이브 리스트' (#186) from test into main
Reviewed-on: #186
2024-05-28 18:18:44 +00:00
klaus 95371ad934 Merge pull request '(크리에이터)관리자 커뮤니티 게시물 정산' (#185) from test into main
Reviewed-on: #185
2024-05-28 16:54:44 +00:00
klaus 2c176825fd Merge pull request 'test' (#184) from test into main
Reviewed-on: #184
2024-05-28 16:09:51 +00:00
klaus fae7de48d3 Merge pull request 'test' (#183) from test into main
Reviewed-on: #183
2024-05-27 08:28:27 +00:00
klaus b8230646a2 Merge pull request '커뮤니티 게시글 유료화' (#182) from test into main
Reviewed-on: #182
2024-05-24 14:44:14 +00:00
klaus 43279541dd Merge pull request '콘텐츠별 누적정산' (#181) from test into main
Reviewed-on: #181
2024-05-21 06:37:51 +00:00
klaus b4791977c1 Merge pull request 'PG 심사를 위한 캔 충전 로직 추가' (#180) from test into main
Reviewed-on: #180
2024-05-20 06:38:40 +00:00
klaus ef917ecc25 Merge pull request '라이브 방 - 크리에이터 입장 가능 설정 추가' (#179) from test into main
Reviewed-on: #179
2024-05-14 12:09:53 +00:00
klaus a93faad951 Merge pull request '룰렛 방식 수정' (#178) from test into main
Reviewed-on: #178
2024-05-10 18:00:40 +00:00
klaus fd001d24d3 Merge pull request '추천시리즈 API 추가' (#177) from test into main
Reviewed-on: #177
2024-05-07 10:34:10 +00:00
klaus 7aa5884797 Merge pull request '구글 인 앱 결제 검증코드 수정' (#176) from test into main
Reviewed-on: #176
2024-05-03 10:06:39 +00:00
klaus 5b237a1547 Merge pull request 'test' (#175) from test into main
Reviewed-on: #175
2024-05-03 06:08:55 +00:00
klaus 2e37990d87 Merge pull request '탐색 - 남/여 크리에이터 리스트' (#174) from test into main
Reviewed-on: #174
2024-05-02 17:24:18 +00:00
klaus dd07d724a8 Merge pull request 'test' (#173) from test into main
Reviewed-on: #173
2024-05-02 16:41:50 +00:00
klaus 03ce8618e7 Merge pull request '관리자 시그니처 설정' (#172) from test into main
Reviewed-on: #172
2024-05-02 07:13:21 +00:00
klaus db1a7a7fd6 Merge pull request '시그니처 후원 시간 추가' (#171) from test into main
Reviewed-on: #171
2024-05-02 06:23:22 +00:00
klaus 36a82d7f53 Merge pull request '시리즈, 시리즈 콘텐츠' (#170) from test into main
Reviewed-on: #170
2024-04-30 14:00:01 +00:00
klaus 3a34401113 Merge pull request '시리즈 상세' (#169) from test into main
Reviewed-on: #169
2024-04-30 09:44:55 +00:00
klaus 9927268330 Merge pull request '구글 인 앱 결제' (#168) from test into main
Reviewed-on: #168
2024-04-30 08:24:03 +00:00
klaus c45c97e29d Merge pull request '시리즈' (#167) from test into main
Reviewed-on: #167
2024-04-26 18:51:10 +00:00
klaus c64a315226 Merge pull request 'test' (#166) from test into main
Reviewed-on: #166
2024-04-18 16:40:55 +00:00
klaus a4cafca6ab Merge pull request 'test' (#165) from test into main
Reviewed-on: #165
2024-04-18 10:02:45 +00:00
klaus 46284a0660 Merge pull request 'test' (#164) from test into main
Reviewed-on: #164
2024-04-15 12:31:42 +00:00
klaus 05df86e15a Merge pull request 'test' (#163) from test into main
Reviewed-on: #163
2024-04-09 14:40:33 +00:00
klaus 8b433027e2 Merge pull request 'test' (#162) from test into main
Reviewed-on: #162
2024-04-09 13:27:24 +00:00
klaus 5bd4ff7610 Merge pull request '결제 테이블에 구글결제의 경우 orderId 추가' (#161) from test into main
Reviewed-on: #161
2024-04-05 03:10:00 +00:00
klaus d693c397ea Merge pull request '.' (#160) from test into main
Reviewed-on: #160
2024-04-03 06:49:36 +00:00
klaus 1d8d1ec9a5 Merge pull request 'test' (#159) from test into main
Reviewed-on: #159
2024-04-03 06:27:26 +00:00
klaus 5e491f11ee Merge pull request '크리에이터 관리자 라이브 정산' (#158) from test into main
Reviewed-on: #158
2024-04-03 03:45:31 +00:00
klaus 7cedea06ac Merge pull request '캔 사용' (#157) from test into main
Reviewed-on: #157
2024-04-01 12:42:44 +00:00
klaus 2e5f750e50 Merge pull request 'test' (#156) from test into main
Reviewed-on: #156
2024-04-01 10:20:09 +00:00
klaus 20289cad10 Merge pull request '콘텐츠 상세' (#155) from test into main
Reviewed-on: #155
2024-03-29 10:10:04 +00:00
klaus e0d64c31c7 Merge pull request '콘텐츠 상세' (#154) from test into main
Reviewed-on: #154
2024-03-29 08:18:15 +00:00
klaus 8c1b95dc97 Merge pull request '구글 인 앱구매 검증' (#153) from test into main
Reviewed-on: #153
2024-03-28 17:13:57 +00:00
klaus fb5641343e Merge pull request 'test' (#152) from test into main
Reviewed-on: #152
2024-03-28 06:31:43 +00:00
klaus 87765941eb Merge pull request '구글 인 앱 결제 검증' (#151) from test into main
Reviewed-on: #151
2024-03-22 20:10:01 +00:00
klaus 1809862c16 Merge pull request '구글 인 앱 결제 처리과정 축소' (#150) from test into main
Reviewed-on: #150
2024-03-22 15:27:25 +00:00
klaus 300f784f7d Merge pull request '구글 인 앱 결제 검증 과정 try/catch로 예외 처리' (#149) from test into main
Reviewed-on: #149
2024-03-22 11:59:37 +00:00
klaus 67a045eae6 Merge pull request '구글 인 앱 결제 검증 수정' (#148) from test into main
Reviewed-on: #148
2024-03-22 11:36:35 +00:00
klaus 2a79903a28 Merge pull request 'test' (#147) from test into main
Reviewed-on: #147
2024-03-22 10:08:00 +00:00
klaus d3222ce083 Merge pull request '관리자 캔 충전현황' (#146) from test into main
Reviewed-on: #146
2024-03-21 15:56:02 +00:00
klaus 406a421742 Merge pull request '구글 인 앱 결제 검증 코드 수정' (#145) from test into main
Reviewed-on: #145
2024-03-21 15:22:34 +00:00
klaus 10bf728faf Merge pull request '구글 인 앱 결제 검증 코드 수정' (#144) from test into main
Reviewed-on: #144
2024-03-21 14:37:09 +00:00
klaus 607617747c Merge pull request '구글 인 앱 결제 검증 코드 수정' (#143) from test into main
Reviewed-on: #143
2024-03-21 12:47:51 +00:00
klaus f0a69eb1a2 Merge pull request 'test' (#142) from test into main
Reviewed-on: #142
2024-03-21 07:45:02 +00:00
klaus 6b307a6e17 Merge pull request 'test' (#141) from test into main
Reviewed-on: #141
2024-03-13 11:28:13 +00:00
klaus 08d08a934a Merge pull request 'test' (#140) from test into main
Reviewed-on: #140
2024-03-12 06:20:02 +00:00
klaus c500c12668 Merge pull request 'test' (#139) from test into main
Reviewed-on: #139
2024-03-08 13:40:27 +00:00
klaus 62060adeba Merge pull request '채널 후원 랭킹' (#138) from test into main
Reviewed-on: #138
2024-02-29 10:28:51 +00:00
klaus b2fc75edb8 Merge pull request '룰렛 정렬 순서 수정' (#137) from test into main
Reviewed-on: #137
2024-02-27 17:29:36 +00:00
klaus a999dd2085 Merge pull request 'test' (#136) from test into main
Reviewed-on: #136
2024-02-27 16:16:30 +00:00
klaus 49f95ab100 Merge pull request '회원테이블에 adid 추가' (#135) from test into main
Reviewed-on: #135
2024-02-27 05:49:47 +00:00
klaus 1a84d5b30c Merge pull request 'test' (#134) from test into main
Reviewed-on: #134
2024-02-24 20:35:57 +00:00
klaus 3b65050632 Merge pull request 'redis ssl false' (#133) from test into main
Reviewed-on: #133
2024-02-17 13:00:25 +00:00
klaus d0df31674c Merge pull request 'test' (#132) from test into main
Reviewed-on: #132
2024-02-17 12:44:52 +00:00
klaus 1fe88402e2 Merge pull request 'test' (#131) from test into main
Reviewed-on: #131
2024-02-14 07:12:41 +00:00
klaus 67097696e6 Merge pull request '커뮤니티 게시물 시간' (#130) from test into main
Reviewed-on: #130
2024-02-12 08:14:24 +00:00
klaus 8e7e77067a Merge pull request 'test' (#129) from test into main
Reviewed-on: #129
2024-02-12 07:53:26 +00:00
klaus 9899390b61 Merge pull request '관리자 콘텐츠 리스트, 수정' (#128) from test into main
Reviewed-on: #128
2024-02-08 18:19:50 +00:00
klaus 80c476a908 Merge pull request '관리자 콘텐츠 리스트' (#127) from test into main
Reviewed-on: #127
2024-02-08 14:45:54 +00:00
klaus 59da1d6e49 Merge pull request '카테고리 콘텐츠' (#126) from test into main
Reviewed-on: #126
2024-02-07 13:33:37 +00:00
klaus 5aef7dac33 Merge pull request 'test' (#125) from test into main
Reviewed-on: #125
2024-02-07 09:39:09 +00:00
klaus faf7aa06b6 Merge pull request 'test' (#124) from test into main
Reviewed-on: #124
2024-02-05 07:03:45 +00:00
klaus 38ef6e5583 Merge pull request 'test' (#123) from test into main
Reviewed-on: #123
2024-02-05 02:12:10 +00:00
klaus c0b15b5d94 Merge pull request '콘텐츠 업로드' (#122) from test into main
Reviewed-on: #122
2024-01-30 03:45:31 +00:00
klaus 2cfc067ea1 Merge pull request '콘텐츠 전체 리스트' (#121) from test into main
Reviewed-on: #121
2024-01-29 09:00:23 +00:00
klaus a91db4f956 Merge pull request '콘텐츠 상단 고정 기능 추가' (#120) from test into main
Reviewed-on: #120
2024-01-29 02:45:41 +00:00
klaus 8a09780a02 Merge pull request 'test' (#119) from test into main
Reviewed-on: #119
2024-01-26 06:19:49 +00:00
klaus 45e8ec6505 Merge pull request '콘텐츠 정렬 기준' (#118) from test into main
Reviewed-on: #118
2024-01-24 15:03:06 +00:00
klaus 4554b85914 Merge pull request '회원가입 시 닉네임 validation 조건' (#117) from test into main
Reviewed-on: #117
2024-01-24 07:11:23 +00:00
klaus 8aa79c4a9c Merge pull request '콘텐츠 등록 - 태그 등록' (#116) from test into main
Reviewed-on: #116
2024-01-16 15:19:59 +00:00
klaus c8d3210b57 Merge pull request 'test' (#115) from test into main
Reviewed-on: #115
2024-01-11 09:05:44 +00:00
klaus 2282a49563 Merge pull request 'test' (#114) from test into main
Reviewed-on: #114
2024-01-11 03:49:53 +00:00
klaus b82fdfb2c8 Merge pull request '예약 업로드' (#113) from test into main
Reviewed-on: #113
2024-01-10 16:59:51 +00:00
klaus 2d17eac199 Merge pull request '19세 미만이 인증처리 되던 버그 수정' (#112) from test into main
Reviewed-on: #112
2024-01-08 10:07:21 +00:00
klaus e482bc3aad Merge pull request '콘텐츠를 올린 크리에이터가 댓글을 삭제할 수 있도록 수정' (#111) from test into main
Reviewed-on: #111
2024-01-04 11:36:43 +00:00
klaus ec022b74d1 Merge pull request '캔 쿠폰 조회 로직 수정' (#110) from test into main
Reviewed-on: #110
2024-01-04 09:59:20 +00:00
klaus dc42c09ce3 Merge pull request '캔 쿠폰 조회' (#109) from test into main
Reviewed-on: #109
2024-01-03 15:48:09 +00:00
klaus 046a34d2a4 Merge pull request 'test' (#108) from test into main
Reviewed-on: #108
2024-01-03 15:19:42 +00:00
klaus 9ff6ec1888 Merge pull request '캔 쿠폰 시스템' (#107) from test into main
Reviewed-on: #107
2024-01-03 11:28:48 +00:00
klaus d2950106ec Merge pull request '콘텐츠 랭킹 - 후원 순위 제거, 룰렛 아이템 개수 10로 변경' (#106) from test into main
Reviewed-on: #106
2023-12-26 12:50:09 +00:00
klaus 962f800d2e Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 인증하지 않은 사람은 19금이 아닌 최신 게시물이 조회되도록 수정' (#105) from test into main
Reviewed-on: #105
2023-12-25 08:19:43 +00:00
klaus 962107e507 Merge pull request '팔로우 한 크리에이터 커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#104) from test into main
Reviewed-on: #104
2023-12-21 19:28:10 +00:00
klaus 039bd11963 Merge pull request '커뮤니티 게시물 조회 - 차단된 유저는 조회되지 않도록 수정' (#103) from test into main
Reviewed-on: #103
2023-12-21 19:05:12 +00:00
klaus 5c250ea4ae Merge pull request '크리에이터 커뮤니티' (#102) from test into main
Reviewed-on: #102
2023-12-21 15:10:55 +00:00
klaus e3405bcec6 Merge pull request 'test' (#101) from test into main
Reviewed-on: #101
2023-12-13 16:14:50 +00:00
klaus 0fd1c2235f Merge pull request '라이브 정산 - 정렬 순서 추가 (라이브 방 id, 구분)' (#100) from test into main
Reviewed-on: #100
2023-12-10 16:58:05 +00:00
klaus b20c29b022 Merge pull request 'test' (#99) from test into main
Reviewed-on: #99
2023-12-10 12:34:51 +00:00
klaus 12d5dcd298 Merge pull request 'test' (#98) from test into main
Reviewed-on: #98
2023-12-10 09:02:29 +00:00
klaus 2c305dc6c6 Merge pull request 'test' (#97) from test into main
Reviewed-on: #97
2023-12-10 06:48:43 +00:00
klaus 62f76f7433 Merge pull request 'test' (#96) from test into main
Reviewed-on: #96
2023-12-07 01:46:33 +00:00
klaus 858ce524f9 Merge pull request 'test' (#95) from test into main
Reviewed-on: #95
2023-11-27 12:48:24 +00:00
klaus 3795fb4a40 Merge pull request 'test' (#94) from test into main
Reviewed-on: #94
2023-11-24 07:03:10 +00:00
klaus 0c01aeec50 Merge pull request '관리자 - 이벤트 배너 등록' (#93) from test into main
Reviewed-on: #93
2023-11-21 16:21:19 +00:00
klaus 892206744d Merge pull request '이벤트 배너, 팝업' (#92) from test into main
Reviewed-on: #92
2023-11-21 12:59:30 +00:00
klaus 9e2c1474db Merge pull request '메시지 보내기 유저 검색' (#91) from test into main
Reviewed-on: #91
2023-11-20 05:41:57 +00:00
klaus 16328f73d9 Merge pull request '크리에이터 관리자, 관리자 - 일자별 콘텐츠 후원 정산 API' (#90) from test into main
Reviewed-on: #90
2023-11-14 13:14:19 +00:00
klaus e0d4f53cf4 Merge pull request 'test' (#89) from test into main
Reviewed-on: #89
2023-11-14 12:15:51 +00:00
klaus e09a59c5b4 Merge pull request 'test' (#88) from test into main
Reviewed-on: #88
2023-11-14 11:09:14 +00:00
klaus 049e654535 Merge pull request '관리자 일자별 콘텐츠 후원 정산 - 크리에이터 순으로 정렬' (#87) from test into main
Reviewed-on: #87
2023-11-14 09:22:38 +00:00
klaus c927dc4ecd Merge pull request '관리자 - 일자별 콘텐츠 후원 정산 페이지 추가' (#86) from test into main
Reviewed-on: #86
2023-11-14 09:03:35 +00:00
klaus fe4ecd0ad8 Merge pull request '크리에이터 관리자 - 일자별 콘텐츠 정산 페이징 안되는 버그 수정' (#85) from test into main
Reviewed-on: #85
2023-11-14 05:17:58 +00:00
klaus 78d476fe80 Merge pull request '라이브 상세 - 시작 시간 dateformat yyyy.MM.dd E hh:mm a 로 복구' (#84) from test into main
Reviewed-on: #84
2023-11-14 03:31:54 +00:00
klaus a11c8465d5 Merge pull request '크리에이터 관리자 - @JsonProperty 추가' (#83) from test into main
Reviewed-on: #83
2023-11-13 16:10:57 +00:00
klaus 366304a9b7 Merge pull request '크리에이터 관리자 - 정산 API 캐시 추가' (#82) from test into main
Reviewed-on: #82
2023-11-13 15:56:58 +00:00
klaus 4356663688 Merge pull request '크리에이터 관리자 - 콘텐츠 누적 매출 API' (#81) from test into main
Reviewed-on: #81
2023-11-13 15:23:53 +00:00
klaus 26b55e6fcf Merge pull request '콘텐츠 누적 매출 API - orderType 추가' (#80) from test into main
Reviewed-on: #80
2023-11-13 14:48:47 +00:00
klaus 0d743f7204 Merge pull request '콘텐츠 누적 매출 API 추가' (#79) from test into main
Reviewed-on: #79
2023-11-13 13:42:17 +00:00
klaus 6cbe113b3e Merge pull request '크리에이터 콘텐츠 정산 - API 추가' (#78) from test into main
Reviewed-on: #78
2023-11-13 10:03:02 +00:00
klaus 6409b69d6c Merge pull request '콘텐츠 정산 - 결과값에 JsonProperty 를 추가하여 데이터 파싱이 진행 되도록 수정' (#77) from test into main
Reviewed-on: #77
2023-11-10 13:19:06 +00:00
klaus c5164c76fc Merge pull request '콘텐츠 정산 - group by 날짜 수정' (#76) from test into main
Reviewed-on: #76
2023-11-10 12:46:07 +00:00
klaus baade8e138 Merge pull request 'test' (#75) from test into main
Reviewed-on: #75
2023-11-10 10:47:11 +00:00
klaus b848d6b4e0 Merge pull request 'test' (#74) from test into main
Reviewed-on: #74
2023-11-09 11:18:20 +00:00
klaus d8139d2ab0 Merge pull request '라이브 리스트, 라이브 상세' (#73) from test into main
Reviewed-on: #73
2023-11-07 16:48:25 +00:00
klaus e96d8f7469 Merge pull request 'test' (#72) from test into main
Reviewed-on: #72
2023-11-07 16:23:11 +00:00
klaus 2acffd8afc Merge pull request '콘텐츠 메인 API - 캐싱을 적용하기 위해 AudioContentMainManageService 추가' (#71) from test into main
Reviewed-on: #71
2023-11-07 11:24:40 +00:00
klaus 3c8e72073c Merge pull request '콘텐츠 메인 API - @Transactional(readOnly = true) 추가' (#70) from test into main
Reviewed-on: #70
2023-11-07 08:47:47 +00:00
klaus 724d7a9d9b Merge pull request 'test' (#69) from test into main
Reviewed-on: #69
2023-11-07 08:21:56 +00:00
klaus 2da3b0db78 Merge pull request 'test' (#68) from test into main
Reviewed-on: #68
2023-11-06 09:26:36 +00:00
klaus 685ad7afaf Merge pull request '콘텐츠 메인 캐싱전략 수정' (#67) from test into main
Reviewed-on: #67
2023-11-06 08:46:55 +00:00
klaus 264cf75964 Merge pull request '콘텐츠 메인 - 큐레이션 개수 15개만 노출' (#66) from test into main
Reviewed-on: #66
2023-11-06 08:11:00 +00:00
klaus c773dbc7b5 Merge pull request '콘텐츠 랭킹 - 후원 랭킹 조회 로직 수정' (#65) from test into main
Reviewed-on: #65
2023-11-04 14:24:46 +00:00
klaus 37cbc64f52 Merge pull request '본인인증' (#64) from test into main
Reviewed-on: #64
2023-11-03 07:48:02 +00:00
klaus cb1dde17bb Merge pull request 'test' (#63) from test into main
Reviewed-on: #63
2023-11-02 12:18:29 +00:00
klaus c29988acf4 Merge pull request '콘텐츠 주문 - 대여만 가능한 콘텐츠의 경우 소장으로 주문이 들어오더라도 대여로 처리되도록 로직 수정' (#62) from test into main
Reviewed-on: #62
2023-11-01 04:49:18 +00:00
klaus eadbf56dae Merge pull request '정산테이블에 무료충전 코인도 반영되도록 수정' (#61) from test into main
Reviewed-on: #61
2023-10-28 08:42:29 +00:00
klaus 4b3b455135 Merge pull request '캔 사용 시 제휴보상 캔도 사용할 수 있도록 수정' (#60) from test into main
Reviewed-on: #60
2023-10-27 13:48:33 +00:00
klaus e6ac177396 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#59) from test into main
Reviewed-on: #59
2023-10-26 18:48:46 +00:00
klaus 3d0e29003f Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#58) from test into main
Reviewed-on: #58
2023-10-26 18:22:10 +00:00
klaus 78b9b00f77 Merge pull request '충전내역 - 결제수단에 "제휴보상" 표시' (#57) from test into main
Reviewed-on: #57
2023-10-26 18:02:16 +00:00
klaus 0ee7faa551 Merge pull request '카울리를 이용한 무료충전 테이블 adProfit 과 point 타입 int -> float 로 변경' (#56) from test into main
Reviewed-on: #56
2023-10-26 16:55:39 +00:00
klaus e5fdced681 Merge pull request '콘텐츠 메인 캐싱 전략 변경' (#55) from test into main
Reviewed-on: #55
2023-10-26 16:14:37 +00:00
klaus afb99fef64 Merge pull request 'GetAudioContentMainItem - adult를 isAdult로 변경, 캐시 제거' (#54) from test into main
Reviewed-on: #54
2023-10-24 11:39:56 +00:00
klaus 7dfaa36024 Merge pull request 'test' (#53) from test into main
Reviewed-on: #53
2023-10-24 10:42:10 +00:00
klaus 0496f665aa Merge pull request 'getAudioContentMainBannerList 부분 캐시 제거' (#52) from test into main
Reviewed-on: #52
2023-10-17 10:02:53 +00:00
klaus 0d19e1be74 Merge pull request 'audio content banner - lazy 옵션으로 인해 발생하는 com.fasterxml.jackson.databind.exc.InvalidDefinitionException 문제 수정' (#51) from test into main
Reviewed-on: #51
2023-10-17 09:47:44 +00:00
klaus 4aff0111aa Merge pull request '로딩 속도를 위해 @Cacheable 적용' (#50) from test into main
Reviewed-on: #50
2023-10-17 09:31:08 +00:00
klaus 63b3ba2bb2 Merge pull request '인기 콘텐츠 전체보기 집계날짜 수정' (#49) from test into main
Reviewed-on: #49
2023-10-16 13:59:28 +00:00
klaus 7444b41f60 Merge pull request '콘텐츠 메인 - 인기 콘텐츠 집계날짜 수정' (#48) from test into main
Reviewed-on: #48
2023-10-16 13:42:31 +00:00
klaus 8e90dbc8b6 Merge pull request '구매목록 - isActive 가 true 인 것만 조회되도록 수정' (#47) from test into main
Reviewed-on: #47
2023-10-16 03:30:10 +00:00
klaus 9f70722521 Merge pull request '탐색 인기 크리에이터 - 날짜 설명 글 수정' (#46) from test into main
Reviewed-on: #46
2023-10-14 21:43:48 +00:00
klaus 52fae596fa Merge pull request '콘텐츠 랭킹 데이터 전체보기 API - 페이징 추가' (#45) from test into main
Reviewed-on: #45
2023-10-14 21:20:19 +00:00
klaus ccb67957bc Merge pull request '콘텐츠 랭킹 추가' (#44) from test into main
Reviewed-on: #44
2023-10-14 19:37:55 +00:00
klaus fb82538d0d Merge pull request '캔 소비 - 콘텐츠 주문시 캔 소비내역에 콘텐츠 내용 추가' (#43) from test into main
Reviewed-on: #43
2023-10-14 11:33:59 +00:00
klaus 72ee39612e Merge pull request '탐색 - 인기 급상승 제거, 인기 크리에이터 섹션 추가' (#42) from test into main
Reviewed-on: #42
2023-10-13 15:41:25 +00:00
klaus 51fd5408dc Merge pull request 'test' (#41) from test into main
Reviewed-on: #41
2023-10-06 08:50:32 +00:00
klaus 3fae40fbef Merge pull request '콘텐츠 상세 - 댓글 수 로직 답글 포함하지 않도록 수정' (#40) from test into main
Reviewed-on: #40
2023-10-05 02:49:22 +00:00
klaus 0745890af0 Merge pull request 'test' (#39) from test into main
Reviewed-on: #39
2023-10-04 03:24:41 +00:00
klaus 4abe1730a7 Merge pull request '관리자 라이브 정산 API - 인원 추가' (#38) from test into main
Reviewed-on: #38
2023-10-03 12:10:51 +00:00
klaus 626f0e6989 Merge pull request '관리자 - 라이브 정산 API 추가' (#37) from test into main
Reviewed-on: #37
2023-10-03 09:28:04 +00:00
klaus 9f42d9d173 Merge pull request '라이브 시작 알림 - 알림 받을 유저 조회에서 에러가 발생하는 버그 수정' (#36) from test into main
Reviewed-on: #36
2023-10-02 13:00:11 +00:00
klaus f90a93c4bc Merge pull request '후원순위 - 유료라이브 입장 캔 반영' (#35) from test into main
Reviewed-on: #35
2023-09-27 14:57:27 +00:00
klaus 8000ad6c6a Merge pull request 'test' (#34) from test into main
Reviewed-on: #34
2023-09-27 06:49:21 +00:00
klaus 1f1f1bea1a Merge pull request 'test' (#33) from test into main
Reviewed-on: #33
2023-09-27 05:28:04 +00:00
klaus d95460c7cd Merge pull request '닉네임 변경 가격 100 캔으로 변경' (#32) from test into main
Reviewed-on: #32
2023-09-22 07:01:27 +00:00
klaus a3d93d4b08 Merge pull request 'test' (#31) from test into main
Reviewed-on: #31
2023-09-19 06:32:22 +00:00
klaus 07a92af982 Merge pull request '라이브 시작시간 4시간이 지나도 라이브를 시작하지 않은 경우 자동취소로직 추가' (#30) from test into main
Reviewed-on: #30
2023-09-12 14:09:37 +00:00
klaus f4618877d4 Merge pull request '주문목록 - 크리에이터 닉네임 추가' (#29) from test into main
Reviewed-on: #29
2023-09-08 16:29:30 +00:00
klaus 2b914fd222 Merge pull request 'test' (#28) from test into main
Reviewed-on: #28
2023-09-02 16:12:08 +00:00
klaus 109e42a5a3 Merge pull request 'test' (#27) from test into main
Reviewed-on: #27
2023-08-31 08:37:42 +00:00
klaus fa515ad39c Merge pull request '관리자 캔 충전내역 - 애플 인 앱 결제에 PG결제가 같이 나오던 버그 수정' (#26) from test into main
Reviewed-on: #26
2023-08-30 10:02:32 +00:00
klaus f09673a795 Merge pull request 'test' (#25) from test into main
Reviewed-on: #25
2023-08-30 08:10:29 +00:00
klaus f71536c614 Merge pull request 'test' (#24) from test into main
Reviewed-on: #24
2023-08-30 07:24:14 +00:00
klaus 7bdddc7ae8 Merge pull request '후원 전체보기 - 하단 랭킹에 콘텐츠 후원도 포함' (#23) from test into main
Reviewed-on: #23
2023-08-29 15:22:09 +00:00
klaus aa8926a624 Merge pull request '후원 전체보기 - 상단 캔 현황 을 후원 캔만 반영하도록 수정' (#22) from test into main
Reviewed-on: #22
2023-08-29 14:53:44 +00:00
klaus be71e59be2 Merge pull request '무료 콘텐츠를 못올리는 버그 수정' (#21) from test into main
Reviewed-on: #21
2023-08-29 13:32:13 +00:00
klaus 4d7753378f Merge pull request 'test' (#20) from test into main
Reviewed-on: #20
2023-08-29 07:33:57 +00:00
klaus 60257c4ef4 Merge pull request 'test' (#19) from test into main
Reviewed-on: #19
2023-08-28 08:54:08 +00:00
klaus 1e0b79bf62 Merge pull request 'test' (#18) from test into main
Reviewed-on: #18
2023-08-27 12:28:42 +00:00
klaus 6883434d0d Merge pull request '유저 관심사, 라이브 관심사 - 연령제한 설정 추가' (#17) from test into main
Reviewed-on: #17
2023-08-25 08:38:51 +00:00
klaus eda2193e64 Merge pull request 'test' (#16) from test into main
Reviewed-on: #16
2023-08-25 07:50:55 +00:00
klaus 99bf829c88 Merge pull request '첫 충전 이벤트 - 본인인증한 전체 계정 중 첫 충전 시에만 첫충전 이벤트 적용' (#15) from test into main
Reviewed-on: #15
2023-08-24 16:26:54 +00:00
klaus 5feafe1b48 Merge pull request 'test' (#14) from test into main
Reviewed-on: #14
2023-08-24 14:54:37 +00:00
klaus c9292b7d04 Merge pull request 'test' (#13) from test into main
Reviewed-on: #13
2023-08-23 14:05:00 +00:00
klaus ae7e1a91c1 Merge pull request '캔 충전로직 수정' (#12) from test into main
Reviewed-on: #12
2023-08-21 15:12:44 +00:00
klaus 3e1887e0d1 Merge pull request 'test' (#11) from test into main
Reviewed-on: #11
2023-08-21 13:19:03 +00:00
klaus 474646db47 Merge pull request '스피커 최대 10 -> 5명으로 수정' (#10) from test into main
Reviewed-on: #10
2023-08-20 20:40:23 +00:00
klaus 56f7b6c449 Merge pull request 'test' (#9) from test into main
Reviewed-on: #9
2023-08-20 19:22:23 +00:00
klaus 76b2b5f7e3 Merge pull request '캔 사용내역 - 후원을 콘텐츠 후원, 라이브 후원으로 분리' (#8) from test into main
Reviewed-on: #8
2023-08-20 15:46:30 +00:00
klaus e918d809eb Merge pull request '충전내역 - 관리자 지급 추가' (#7) from test into main
Reviewed-on: #7
2023-08-20 15:08:35 +00:00
klaus 7af059e543 Merge pull request 'test' (#6) from test into main
Reviewed-on: #6
2023-08-20 14:45:39 +00:00
klaus 897726e1ec Merge pull request 'test' (#5) from test into main
Reviewed-on: #5
2023-08-19 17:47:16 +00:00
klaus 8b98a2dd07 Merge pull request '비밀번호 찾기 API 추가' (#4) from test into main
Reviewed-on: #4
2023-08-19 07:05:17 +00:00
klaus cca75420f0 Merge pull request '큐레이션 - 조건 추가' (#3) from test into main
Reviewed-on: #3
2023-08-18 14:07:34 +00:00
klaus 86c627ed1d Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2023-08-18 12:54:09 +00:00
klaus d55514e3a7 Merge pull request 'test' (#1) from test into main
Reviewed-on: #1
2023-08-16 02:30:36 +00:00
67 changed files with 29 additions and 12115 deletions
@@ -1,62 +0,0 @@
# 캔 조회 회원 2 강제 통화 제거 구현 계획
- 상태: 구현 완료
- 작성일: 2026-09-18
- 요구사항: [prd.md](prd.md)
- 현재 Phase: 1 완료
- 활성 Goal: 없음
- 실행 순서: CAN-P1-T1 → CAN-P1-GATE
### Phase 1: 회원 2의 기본 통화 적용과 회귀 확인
- [x] **Task 1.1: 회원 2의 강제 통화 제거** (`CAN-P1-T1`)
- Objective: 회원 2가 다른 일반 회원처럼 요청 국가에 따른 통화의 캔 상품을 조회한다.
- 시작 조건: PRD 확정, 기존 컨트롤러·서비스 및 테스트 확인 완료.
- 완료 증거: RED/GREEN 결과, 실제 컨트롤러·서비스·저장소 통합 테스트와 캔 패키지 회귀 통과.
- 범위 밖: 다른 회원 예외 제거, 국가 결정·API 스키마·서비스 리팩터링.
- 수정: `src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt`
- 생성: `src/test/kotlin/kr/co/vividnext/sodalive/can/CanControllerIntegrationTest.kt`
- 확인: `src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt`
- [x] **RED:** 회원 2의 KRW/USD, 유지할 예외·일반 회원·비로그인·국가 누락 통합 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanControllerIntegrationTest'`로 회원 2의 JPY 반환 assertion 실패를 확인한다.
- [x] **GREEN:** 컨트롤러 조건에서 `member.id == 2L ||`만 제거한다.
- [x] **GREEN 확인:** 동일 focused test가 통과하는지 확인한다.
- [x] **REFACTOR:** 불필요한 정리 없이 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.*'` 및 변경 파일 lint로 회귀를 확인한다.
#### Task 검증 기록
- 2026-09-18: 기존 `getCans` 및 `/can` 직접 테스트가 없음을 확인했다. 새 테스트는 `@SpringBootTest`와 H2, 요청 범위의 실제 CountryContext를 사용하고 트랜잭션 롤백으로 상품 데이터를 격리한다.
- 2026-09-18 RED: 테스트 작성 중 `Member.password` 필수 인자 누락을 수정한 뒤 production/test 컴파일이 통과했다. 이후 공통 JWT 기본 키의 길이 부족으로 발생한 컨텍스트 로딩 실패는 테스트 실행에만 임시 키를 제공하여 해결했다. 이 두 실패는 동작 RED에 포함하지 않았다.
- 2026-09-18 RED 확인: 아래 환경 설정 후 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanControllerIntegrationTest' --console=plain` 실행. 13개 중 회원 2의 4개만 assertion 실패(exit 1): KR은 KRW, US/JP/국가 누락은 USD 기대였으나 실제 JPY. 나머지 9개 통과.
- 2026-09-18 GREEN 확인: 조건 제거 후 같은 focused 명령으로 13개 전부 통과(exit 0), production/test 컴파일 성공.
- 2026-09-18 회귀 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.*' --console=plain` 성공(exit 0). 신규 13개와 기존 14개, 총 5개 클래스 27개 테스트 통과(실패·오류·skip 0). 기존 테스트를 완화하거나 수정할 필요가 없었다.
- [x] **Phase Gate: 변경 범위와 검증 기록 확인** (`CAN-P1-GATE`)
- Objective: 요구사항과 최소 변경, 검증 증거가 일치함을 확인한다.
- 시작 조건: CAN-P1-T1 완료.
- 완료 증거: 캔 패키지 테스트 통과, 문서 명령·diff 확인 및 PRD 체크박스 완료.
- 범위 밖: 전체 서비스 실행과 관련 없는 기능 수정.
- 확인 파일: 이 문서, `docs/20260918_캔조회_회원2_강제통화제거/prd.md`, 위 Task의 코드·테스트.
- TDD 예외 사유: 구현 없는 최종 검증 단계.
- 대체 검증 방법: `./gradlew tasks --all`, `git diff --check`, 변경 파일 대조.
## 검증 범위
- 단일 루트 프로젝트이며 변경은 컨트롤러의 조건 하나에 한정된다. 전체 회귀 실행 조건(공통 코드·여러 도메인 변경·영향 불명확 실패·사용자 명시 요청)이 발생하지 않으면 전체 테스트는 생략하고 캔 패키지 전체를 실행한다.
- 테스트 실행으로 production/test 컴파일을 함께 확인한다. 새 API나 실행 환경 변경이 없어 별도 애플리케이션 구동은 하지 않는다.
## Progress
- 2026-09-18: 요구사항 및 TDD 계획 작성, CAN-P1-T1 준비.
- 2026-09-18: CAN-P1-T1 RED 확인 완료, 회원 2 조건 제거 진행.
- 2026-09-18: CAN-P1-T1 완료. 조건 한 항만 제거했고 신규 13개 및 기존 14개 테스트가 통과했다.
- 2026-09-18: CAN-P1-GATE 완료. API 스키마·다른 회원 예외가 유지되는 diff와 PRD 수용 기준, 검증 기록을 대조했다. 남은 작업 없음.
## 공통 검증 기록
- 셸에 기본 Java가 없어 기존 Gradle 캐시의 Java 17을 사용했다. 설정 파일은 변경하지 않았다.
- 테스트 JWT 기본값은 디코딩 후 키 길이가 부족하므로 실행 프로세스에만 임시 키를 전달했다. 키 값은 저장·출력하지 않는다. 아래 환경 설정 뒤 문서의 검증 명령을 실행한다.
```bash
export JAVA_HOME=~/.gradle/jdks/eclipse_adoptium-17-x86_64-os_x.2/jdk-17.0.19+10/Contents/Home
export JWT_SECRET="$(openssl rand -base64 64 | tr -d '\n')"
```
- 2026-09-18: `./gradlew tasks --all --console=plain`, `git diff --check` 성공(exit 0). 변경 Kotlin 파일의 IDE 정적 검사에서 오류 없음. 신규 테스트는 경고도 없으며 기존 컨트롤러의 수정하지 않은 인증 SpEL 식에는 변수 해석 경고 8개가 있다.
- 2026-09-18: 전체 테스트·전체 ktlint 및 별도 서버 실행은 생략했다. 변경은 컨트롤러 조건 한 곳이고 다른 도메인·공통 코드·하위 모듈 변경이 없어 focused 통합 테스트, 캔 패키지 전체 회귀, 변경 파일 정적 검사로 검증했다.
@@ -1,27 +0,0 @@
# 캔 조회 회원 2 강제 통화 제거
## 문서 정보
- 상태: 구현 완료
- 작성일: 2026-09-18
- 관련 계획: [plan-task.md](plan-task.md)
## 문제와 목표
`GET /can`의 `getCans`는 회원 2·4·44144에게 JPY를 강제 적용한다.
사용자 요청에 따라 회원 2만 제외하여 기존 국가별 통화 선택을 따르게 한다.
## 확정 요구사항과 수용 기준
| ID | 요구사항 | 수용 기준 | Goal |
|---|---|---|---|
| CAN-CURRENCY-001 | 회원 2의 JPY 강제 조건 제거 | KR 요청은 KRW, 그 외 국가는 USD 상품 반환 | CAN-P1-T1 |
| CAN-CURRENCY-002 | 나머지 동작 유지 | 회원 4·44144는 JPY, 일반 회원·비로그인은 기존 국가별 통화 적용 | CAN-P1-T1 |
| CAN-CURRENCY-003 | 관련 테스트 검증 | 변경 전 실패 재현, 변경 후 캔 패키지 테스트 통과 | CAN-P1-GATE |
## 범위 밖
- 다른 진입점의 회원 ID 조건, 국가 판정 및 성인콘텐츠 정책 변경.
- 서비스의 KR → KRW / 그 외 → USD 정책 변경.
- 공개 API 요청·응답 스키마, 인증 및 상품 판매 상태 필터 변경.
## 성공 기준
- [x] 회원 2에 대한 회귀 테스트가 변경 전 실패하고 변경 후 통과한다.
- [x] 회원 4·44144, 일반 회원, 비로그인 및 국가 누락의 기존 동작이 유지된다.
- [x] 관련 기존 테스트가 통과하고 검증 결과를 계획 문서에 기록한다.
@@ -1,549 +0,0 @@
# 선물 관리자 페이지 구현 프롬프트
이 문서는 관리자 페이지의 선물 관련 4개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
공통 전제:
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 4개 하위 메뉴가 있다.
- `선물함 리스트` → `/gift/list`
- `받을 주소` → `/gift/mailbox`
- `선물 카테고리` → `/gift/category`
- `선물 사이즈` → `/gift/size`
- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다.
- 인증/권한 처리는 기존 관리자 페이지의 API 클라이언트, 토큰 저장 방식, 에러 처리 방식을 따른다.
- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 테이블, 필터, 버튼, 모달, 폼, 토스트, 확인창 패턴을 재사용한다.
- 화면 문구는 한국어로 작성한다.
- API 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
---
## 1. 선물함 리스트 페이지 프롬프트
```text
관리자 선물함 리스트 페이지를 구현해줘.
목표:
- 관리자 메뉴 `선물함 관리 > 선물함 리스트`에서 전체 선물 신청 목록을 조회한다.
- 상태, 신청번호, 닉네임으로 필터링할 수 있다.
- 목록 row에서 선물 상세를 확인하고, 현재 상태에서 가능한 운영 액션을 실행할 수 있다.
- 기존 관리자 페이지의 테이블/필터/페이지네이션/모달/확인창 스타일을 그대로 따른다.
라우트:
- `/gift/list`
필수 화면 구성:
- 상단 제목: `선물함 리스트`
- 필터 영역:
- 상태 select: 전체, 접수 완료, 발송 확인, 사서함 도착, 검수 완료, 전달 완료, 전달 불가, 신청 취소
- 신청번호 검색 input: `applicationNo` 부분 검색
- 닉네임 검색 input: 발송인 또는 수취인 닉네임 부분 검색
- 검색 버튼, 초기화 버튼
- 목록 테이블 컬럼:
- 신청번호 `applicationNo`
- 발송인 닉네임 `senderNickname`
- 수취인 닉네임 `recipientNickname`
- 사이즈 `sizeName` / `sizeCode`
- 카테고리 `categoryName`
- 분류번호 `classificationNumber`
- 택배사 `courierCompanyName`
- 운송장번호 `trackingNumber`
- 상태 `statusName`
- 액션 버튼
- 페이지네이션:
- 서버의 `page`, `size`, `totalCount`, `hasNext` 기준으로 기존 관리자 페이지 패턴에 맞춘다.
목록 조회 API:
- Method: `GET`
- URL: `/api/v2/admin/gifts`
- Query:
- `status?: GiftStatus`
- `applicationNo?: string`
- `nickname?: string`
- `page?: number` 기본 `0`
- `size?: number` 기본 `20`
- 필터 조합:
- 선택 필터는 AND 조건이다.
- `nickname`은 발송인/수취인 닉네임 중 하나에 포함되면 매칭된다.
목록 조회 response `data`:
```ts
type AdminGiftListResponse = {
totalCount: number;
items: AdminGiftListItemResponse[];
page: number;
size: number;
hasNext: boolean;
};
type AdminGiftListItemResponse = {
applicationNo: string;
senderNickname: string;
recipientNickname: string;
sizeCode: string;
sizeName: string;
categoryName: string;
classificationNumber: string;
courierCompanyName: string | null;
trackingNumber: string | null;
status: GiftStatus;
statusName: string;
availableActions: AdminGiftAction[];
};
```
상태 enum:
```ts
type GiftStatus =
| "RECEIVED"
| "TRACKING_REGISTERED"
| "ARRIVED_AT_MAILBOX"
| "INSPECTION_COMPLETED"
| "DELIVERED"
| "UNDELIVERABLE"
| "CANCELED";
```
상태 표시명:
- `RECEIVED`: `접수 완료`
- `TRACKING_REGISTERED`: `발송 확인`
- `ARRIVED_AT_MAILBOX`: `사서함 도착`
- `INSPECTION_COMPLETED`: `검수 완료`
- `DELIVERED`: `전달 완료`
- `UNDELIVERABLE`: `전달 불가`
- `CANCELED`: `신청 취소`
상세 조회 API:
- Method: `GET`
- URL: `/api/v2/admin/gifts/{applicationNo}`
- 용도:
- 목록 row 클릭 또는 `상세` 버튼 클릭 시 상세 모달/상세 패널을 연다.
상세 response `data`:
```ts
type AdminGiftDetailResponse = {
applicationNo: string;
senderInfo: AdminGiftMemberInfoResponse;
recipientInfo: AdminGiftMemberInfoResponse;
productInfo: AdminGiftProductInfoResponse;
inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse;
status: GiftStatus;
statusName: string;
availableActions: AdminGiftAction[];
};
type AdminGiftMemberInfoResponse = {
nickname: string;
name: string;
phoneNumber: string;
address: string;
};
type AdminGiftProductInfoResponse = {
sizeCode: string;
sizeName: string;
categoryName: string;
classificationNumber: string;
};
type AdminGiftInboundDeliveryInfoResponse = {
courierCompanyName: string | null;
trackingNumber: string | null;
};
```
상세 표시 요구사항:
- 발송인 정보: 닉네임, 이름, 전화번호, 주소
- 수취인 정보: 닉네임, 이름, 전화번호, 주소
- 상품 정보: 사이즈, 카테고리, 분류번호
- 입고 배송 정보: 택배사, 운송장번호
- 현재 상태와 가능한 액션
- 수취인이 배송지를 아직 입력하지 않은 경우 `recipientInfo.nickname`은 표시하고 `name`, `phoneNumber`, `address`는 빈 문자열로 온다. 화면에서는 `미입력`으로 표시해도 된다.
운영 액션 enum:
```ts
type AdminGiftAction =
| "ARRIVE_MAILBOX"
| "COMPLETE_INSPECTION"
| "COMPLETE_DELIVERY"
| "MARK_UNDELIVERABLE";
```
상태별 `availableActions`:
- `RECEIVED`: 없음
- `TRACKING_REGISTERED`: `ARRIVE_MAILBOX`, `MARK_UNDELIVERABLE`
- `ARRIVED_AT_MAILBOX`: `COMPLETE_INSPECTION`, `MARK_UNDELIVERABLE`
- `INSPECTION_COMPLETED`: `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`
- `DELIVERED`: 없음
- `UNDELIVERABLE`: 없음
- `CANCELED`: 없음
액션 버튼 문구:
- `ARRIVE_MAILBOX`: `사서함 도착 처리`
- `COMPLETE_INSPECTION`: `검수 완료 처리`
- `COMPLETE_DELIVERY`: `전달 완료 처리`
- `MARK_UNDELIVERABLE`: `전달 불가 처리`
액션 API:
- 사서함 도착 처리
- Method: `POST`
- URL: `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox`
- Body: 없음
- 검수 완료 처리
- Method: `POST`
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-inspection`
- Body: 없음
- 전달 완료 처리
- Method: `POST`
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-delivery`
- Body: 없음
- 전달 불가 처리
- Method: `POST`
- URL: `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable`
- Body:
```ts
type AdminGiftMarkUndeliverableRequest = {
reason: string;
};
```
액션 response `data`:
```ts
type AdminGiftOperationStatusResponse = {
applicationNo: string;
status: GiftStatus;
statusName: string;
occurredAt: string; // UTC ISO string, e.g. 2026-09-30T12:00:00Z
};
```
액션 UX:
- 모든 상태 변경 액션은 확인창을 띄운다.
- `MARK_UNDELIVERABLE`은 사유 입력 모달을 띄운다.
- 전달 불가 사유는 공백만 입력할 수 없고 255자 이내로 제한한다.
- 액션 성공 후:
- 상세 모달이 열려 있으면 상세를 다시 조회한다.
- 목록도 현재 필터/페이지 기준으로 다시 조회한다.
- 성공 토스트를 표시한다.
- 액션 실패 시 기존 관리자 페이지 에러 토스트/알림 패턴을 따른다.
빈 값 표시:
- `courierCompanyName`, `trackingNumber`가 null이면 `-`로 표시한다.
- 수취인 개인정보가 빈 문자열이면 `미입력`으로 표시한다.
테스트/검증:
- API 클라이언트 함수 단위 테스트 또는 페이지 테스트에서 query parameter가 올바르게 전달되는지 확인한다.
- `availableActions`에 따라 버튼 노출이 달라지는지 확인한다.
- 전달 불가 사유 빈 값 validation을 확인한다.
```
---
## 2. 받을 주소 설정 페이지 프롬프트
```text
관리자 선물 받을 주소 설정 페이지를 구현해줘.
목표:
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
- 이 주소는 팬의 보낸 선물 상세 API에서 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 `mailbox`로 노출된다.
- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다.
라우트:
- `/gift/mailbox`
필수 화면 구성:
- 상단 제목: `받을 주소`
- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.`
- 입력 폼:
- 받을 사람 이름 `name`
- 연락처 `phoneNumber`
- 우편번호 `zipCode`
- 주소 `address`
- 상세주소 `addressDetail`
- 저장 버튼: `저장`
초기 조회 API:
- Method: `GET`
- URL: `/api/v2/admin/gift-mailbox`
- response `data`:
```ts
type AdminGiftMailboxResponse = {
name: string;
address: string;
phoneNumber: string;
} | null;
```
- `data`가 null이면 빈 폼을 표시한다.
저장 API:
- Method: `PUT`
- URL: `/api/v2/admin/gift-mailbox`
- Request:
```ts
type AdminGiftMailboxRequest = {
name: string;
phoneNumber: string;
zipCode: string;
address: string;
addressDetail: string | null;
};
```
- Response `data`:
```ts
type AdminGiftMailboxResponse = {
name: string;
address: string;
phoneNumber: string;
};
```
Validation:
- `name`, `phoneNumber`, `zipCode`, `address`는 필수다.
- 공백만 입력할 수 없다.
- 저장 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
저장 성공 UX:
- 성공 토스트를 표시한다.
- 저장 API response 기준으로 화면 값을 갱신한다.
- `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식으로 표시해도 되고, 입력 필드는 사용자가 입력한 값을 유지해도 된다.
테스트/검증:
- 초기 조회 시 null이면 빈 폼을 표시하는지 확인한다.
- 저장 시 PUT body가 정확히 전달되는지 확인한다.
- 필수값 공백 validation을 확인한다.
```
---
## 3. 선물 카테고리 CRUD 페이지 프롬프트
```text
관리자 선물 카테고리 CRUD 페이지를 구현해줘.
목표:
- 관리자 메뉴 `선물함 관리 > 선물 카테고리`에서 선물 카테고리를 조회, 등록, 수정, 비활성화한다.
- 기존 관리자 페이지의 CRUD 테이블, 등록/수정 모달, 삭제 확인창, 토스트 패턴을 그대로 따른다.
라우트:
- `/gift/category`
필수 화면 구성:
- 상단 제목: `선물 카테고리`
- 상단 우측 `카테고리 등록` 버튼
- 목록 테이블 컬럼:
- 카테고리 ID `categoryId`
- 분류번호 `classificationNumber`
- 카테고리 코드 `categoryCode`
- 카테고리명 `name`
- 접수 코드 `receiptCode`
- 대표 품목 `representativeItem`
- 파손면책 동의 필요 여부 `requiresDamageWaiver`
- 활성 여부 `isActive`
- 관리 버튼: 수정, 비활성화
- 등록/수정 폼 필드:
- `classificationNumber`
- `categoryCode`
- `name`
- `receiptCode`
- `representativeItem`
- `requiresDamageWaiver`
- `isActive`
목록 조회 API:
- Method: `GET`
- URL: `/api/v2/admin/gift-categories`
- Request: 없음
- Response `data`:
```ts
type AdminGiftCategoryResponse = {
categoryId: number;
classificationNumber: string;
categoryCode: string;
name: string;
receiptCode: string;
representativeItem: string;
requiresDamageWaiver: boolean;
isActive: boolean;
};
```
등록 API:
- Method: `POST`
- URL: `/api/v2/admin/gift-categories`
- Body:
```ts
type AdminGiftCategoryRequest = {
classificationNumber: string;
categoryCode: string;
name: string;
receiptCode: string;
representativeItem: string;
requiresDamageWaiver: boolean;
isActive: boolean;
};
```
- Response `data`: `AdminGiftCategoryResponse`
수정 API:
- Method: `PUT`
- URL: `/api/v2/admin/gift-categories/{categoryId}`
- Body: `AdminGiftCategoryRequest`
- Response `data`: `AdminGiftCategoryResponse`
비활성화 API:
- Method: `DELETE`
- URL: `/api/v2/admin/gift-categories/{categoryId}`
- Body: 없음
- Response `data`: `AdminGiftCategoryResponse`
- 서버는 실제 삭제가 아니라 `isActive=false` 논리 삭제로 처리한다.
폼 validation:
- `classificationNumber`: 필수, 숫자 3자리. 예: `100`
- `categoryCode`: 필수, 50자 이하. 예: `DOLL`
- `name`: 필수, 50자 이하. 예: `인형`
- `receiptCode`: 필수, 영문 대문자 1~9자. 예: `A`
- `representativeItem`: 필수, 100자 이하. 예: `피규어`
- `requiresDamageWaiver`: boolean
- `isActive`: boolean
UX 요구사항:
- 등록 성공 후 목록을 다시 조회하고 모달을 닫는다.
- 수정 성공 후 목록을 다시 조회하고 모달을 닫는다.
- 비활성화 버튼은 확인창을 띄운 뒤 호출한다.
- 이미 비활성화된 row는 비활성화 버튼을 disabled 처리한다.
- boolean 값은 기존 관리자 페이지 표현에 맞춰 `예/아니오`, `활성/비활성` 또는 badge로 표시한다.
- `categoryCode`는 수정 폼에 표시하되 서버 쪽 엔티티는 코드 변경을 실제로 반영하지 않을 수 있으므로, 가능하면 수정 시 읽기 전용으로 두고 나머지 운영 필드만 수정하도록 UX를 구성한다.
테스트/검증:
- 목록 조회가 `GET /api/v2/admin/gift-categories`를 호출하는지 확인한다.
- 등록/수정 body가 `AdminGiftCategoryRequest` 형태와 일치하는지 확인한다.
- validation 실패 시 API를 호출하지 않고 폼 에러를 표시한다.
- 비활성화 성공 후 해당 row의 `isActive=false`가 반영되는지 확인한다.
```
---
## 4. 선물 사이즈 가격 CRUD 페이지 프롬프트
```text
관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘.
목표:
- 관리자 메뉴 `선물함 관리 > 선물 사이즈`에서 선물 사이즈별 기본가/판매가/활성 여부를 조회하고 수정한다.
- 현재 API는 사이즈 가격의 목록 조회와 수정만 제공한다. 신규 생성과 물리 삭제 기능은 만들지 않는다.
- 기존 관리자 페이지의 테이블, 수정 모달, 저장 확인/토스트 패턴을 그대로 따른다.
라우트:
- `/gift/size`
필수 화면 구성:
- 상단 제목: `선물 사이즈`
- 목록 테이블 컬럼:
- 사이즈 코드 `sizeCode`
- 사이즈명 `name`
- 기본가 `basePriceCan`
- 판매가 `salePriceCan`
- 활성 여부 `isActive`
- 관리 버튼: 수정
- 수정 폼 필드:
- `sizeCode`: 읽기 전용
- `name`: 읽기 전용
- `basePriceCan`: number input
- `salePriceCan`: number input
- `isActive`: checkbox/switch
목록 조회 API:
- Method: `GET`
- URL: `/api/v2/admin/gift-size-prices`
- Request: 없음
- Response `data`:
```ts
type AdminGiftSizePriceResponse = {
sizeCode: GiftSize;
name: string;
basePriceCan: number;
salePriceCan: number;
isActive: boolean;
};
```
수정 API:
- Method: `PUT`
- URL: `/api/v2/admin/gift-size-prices/{sizeCode}`
- Body:
```ts
type AdminGiftSizePriceRequest = {
basePriceCan: number;
salePriceCan: number;
isActive: boolean;
};
```
- Response `data`: `AdminGiftSizePriceResponse`
사이즈 enum:
```ts
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
```
서버 validation:
- `salePriceCan`은 0보다 커야 한다.
- `salePriceCan`은 `basePriceCan`보다 클 수 없다.
프론트 validation:
- `basePriceCan`: 필수, 1 이상 정수
- `salePriceCan`: 필수, 1 이상 정수
- `salePriceCan <= basePriceCan`
- validation 실패 시 API 호출을 막고 필드 에러를 표시한다.
UX 요구사항:
- 가격은 숫자 입력이지만 테이블 표시에서는 기존 관리자 페이지의 숫자 포맷을 따른다.
- 수정 버튼 클릭 시 현재 row 값을 폼 초기값으로 넣는다.
- 저장 전 확인창은 기존 관리자 페이지 패턴이 있으면 따른다.
- 저장 성공 후 목록을 다시 조회하고 모달을 닫는다.
- 신규 생성/삭제 버튼은 만들지 않는다.
테스트/검증:
- 목록 조회가 `GET /api/v2/admin/gift-size-prices`를 호출하는지 확인한다.
- 수정 저장 시 `PUT /api/v2/admin/gift-size-prices/{sizeCode}`와 request body가 정확한지 확인한다.
- `salePriceCan > basePriceCan`일 때 API를 호출하지 않고 validation 메시지를 표시하는지 확인한다.
- 저장 성공 후 목록 refresh가 일어나는지 확인한다.
```
---
## 구현 시 참고할 공통 타입
```ts
type ApiResponse<T> = {
success: boolean;
data: T;
message?: string;
};
type GiftStatus =
| "RECEIVED"
| "TRACKING_REGISTERED"
| "ARRIVED_AT_MAILBOX"
| "INSPECTION_COMPLETED"
| "DELIVERED"
| "UNDELIVERABLE"
| "CANCELED";
type AdminGiftAction =
| "ARRIVE_MAILBOX"
| "COMPLETE_INSPECTION"
| "COMPLETE_DELIVERY"
| "MARK_UNDELIVERABLE";
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
```
## 구현 순서 추천
1. API client 함수와 TypeScript 타입을 먼저 추가한다.
2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다.
3. `/gift/mailbox` 받을 주소 설정 화면을 구현한다.
4. `/gift/category` 카테고리 CRUD를 구현한다.
5. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
6. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
7. 각 페이지별 loading/empty/error/success 상태를 확인한다.
@@ -1,368 +0,0 @@
# 선물하기 클라이언트 API 요약
## 문서 목적
- 이 문서는 클라이언트 구현 프롬프트 작성용 빠른 참조다.
- 상세 request/response 계약은 `prd.md`의 `8. API 계약`을 기준으로 한다.
- 여기서는 사용자 역할별 URI, 역할, 핵심 request/response 필드만 요약한다.
## 문서 분리 결정
PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한다. 클라이언트 프롬프트용 역할별 인벤토리는 별도 문서로 둔다.
- 이유: PRD에 같은 URI 정보를 역할별로 다시 넣으면 상세 계약과 요약표를 동시에 갱신해야 한다.
- 기준: PRD는 제품 요구사항과 API 계약의 원본, 이 문서는 클라이언트 구현을 위한 탐색용 색인이다.
## 공통/조회 API
| 사용자 | Method | URI | 역할 | Request | Response |
|---|---:|---|---|---|---|
| 비회원/회원 | GET | `/api/v2/gifts/form-options` | 선물 신청 폼에 필요한 활성 카테고리와 사이즈별 가격 옵션을 조회한다. | 없음 | `sizes[]`, `categories[]` |
| 팬/크리에이터 | GET | `/api/v2/gifts?type=ALL&page=0&size=20` | 내 선물함 목록을 조회하고 `type=ALL|SENT|RECEIVED`로 전체/보낸/받은 선물을 필터링한다. | query: `type`, `page`, `size` | `items[]`, `page`, `size`, `hasNext` |
| 팬/크리에이터 | GET | `/api/v2/gifts/{applicationNo}` | 선물 상세를 조회하며 로그인 회원이 팬인지 크리에이터인지에 따라 노출 정보와 CTA flag가 달라진다. | path: `applicationNo` | `applicationNo`, `direction`, `status`, `statusName`, `giftInfo`, `senderInfo`, `recipientAddress`, `mailbox`, `trackingRequired`, `recipientAddressRequired`, `delivery`, `statusTimeline`, `review` |
## 팬 API
| 사용자 | Method | URI | 역할 | Request | Response |
|---|---:|---|---|---|---|
| 팬 | POST | `/api/v2/gifts` | 크리에이터에게 선물을 신청하고 최종 결제 금액만큼 캔을 차감한다. | body: `recipientMemberId`, `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `sizeCode`, `categoryId`, `senderTermsAgreed`, `senderPrivacyAgreed`, `damageWaiverAgreed` | `applicationNo`, `status`, `statusName`, `priceCan`, `trackingDeadlineAt` |
| 팬 | POST | `/api/v2/gifts/{applicationNo}/cancel` | 운송장 등록 전 선물 신청을 취소하고 전액 환불한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `priceCan`, `canceledAt` |
| 팬 | POST | `/api/v2/gifts/{applicationNo}/tracking` | 발송 후 택배사와 운송장 번호를 등록한다. | path: `applicationNo`, body: `courierCompanyName`, `trackingNumber` | `applicationNo`, `status`, `statusName`, `courierCompanyName`, `trackingNumber`, `trackingRegisteredAt`, `recipientAddressDeadlineAt` |
| 팬 | POST | `/api/v2/gifts/{applicationNo}/review` | 전달 완료된 선물에 리뷰를 작성한다. | path: `applicationNo`, body: `rating`, `keywords`, `comment` | `reviewId`, `applicationNo`, `rating`, `keywords`, `comment`, `createdAt` |
## 크리에이터 API
| 사용자 | Method | URI | 역할 | Request | Response |
|---|---:|---|---|---|---|
| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/recipient-address` | 운송장 등록 이후 배송지와 수령 약관 동의를 등록한다. | path: `applicationNo`, body: `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `recipientTermsAgreed`, `recipientPrivacyAgreed` | `applicationNo`, `status`, `statusName`, `recipientAddressRegisteredAt` |
| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/delivery-complete` | 검수 완료된 선물을 직접 수령 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `deliveredAt` |
## 관리자 API
| 사용자 | Method | URI | 역할 | Request | Response |
|---|---:|---|---|---|---|
| 관리자 | GET | `/api/v2/admin/gift-categories` | 선물 카테고리 목록을 조회한다. | 없음 | `AdminGiftCategoryResponse[]` |
| 관리자 | GET | `/api/v2/admin/gifts` | 전체 선물함 목록을 조회하고 상태/신청번호/닉네임으로 검색한다. | query: `status`, `applicationNo`, `nickname`, `page`, `size` | `totalCount`, `items[]`, `page`, `size`, `hasNext` |
| 관리자 | GET | `/api/v2/admin/gifts/{applicationNo}` | 선물 상세와 발송인/수취인 개인정보, 상품/배송/상태 정보를 조회한다. | path: `applicationNo` | `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions` |
| 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` |
| 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` |
| 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive=false` |
| 관리자 | GET | `/api/v2/admin/gift-size-prices` | 사이즈별 기본가와 판매가를 조회한다. | 없음 | `AdminGiftSizePriceResponse[]` |
| 관리자 | PUT | `/api/v2/admin/gift-size-prices/{sizeCode}` | 사이즈별 기본가와 판매가를 수정한다. | path: `sizeCode`, body: `basePriceCan`, `salePriceCan`, `isActive` | `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` |
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` | 발송 확인 선물을 사서함 도착 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | 사서함 도착 선물을 검수 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | 진행 중 선물을 전달 불가 처리하고 사유를 저장한다. | path: `applicationNo`, body: `reason` | `applicationNo`, `status`, `statusName`, `occurredAt` |
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | 검수 완료 선물을 운영자가 전달 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
관리자 응답 DTO 요약:
- `AdminGiftCategoryResponse`: `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive`
- `AdminGiftListResponse`: `totalCount`, `items`, `page`, `size`, `hasNext`
- `AdminGiftListItemResponse`: `applicationNo`, `senderNickname`, `recipientNickname`, `sizeCode`, `sizeName`, `categoryName`, `classificationNumber`, `courierCompanyName`, `trackingNumber`, `status`, `statusName`, `availableActions`
- `AdminGiftDetailResponse`: `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions`
- `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive`
- `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt`
## Kotlin DTO 형태
아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. 응답 시각 필드는 UTC ISO 문자열이다. 예: `2026-09-30T12:00:00Z`.
### 사용자 Request DTO
```kotlin
data class GiftApplicationRequest(
val recipientMemberId: Long,
val senderName: String,
val senderPhoneNumber: String,
val senderZipCode: String,
val senderAddress: String,
val senderAddressDetail: String?,
val sizeCode: GiftSize,
val categoryId: Long,
val senderTermsAgreed: Boolean,
val senderPrivacyAgreed: Boolean,
val damageWaiverAgreed: Boolean
)
data class GiftTrackingRegistrationRequest(
val courierCompanyName: String,
val trackingNumber: String
)
data class GiftRecipientAddressRegistrationRequest(
val recipientName: String,
val recipientPhoneNumber: String,
val recipientZipCode: String,
val recipientAddress: String,
val recipientAddressDetail: String?,
val recipientTermsAgreed: Boolean,
val recipientPrivacyAgreed: Boolean
)
data class GiftReviewRequest(
val rating: Int,
val keywords: List<String>,
val comment: String?
)
```
### 사용자 Response DTO
```kotlin
data class GiftFormOptionsResponse(
val sizes: List<GiftSizeOptionResponse>,
val categories: List<GiftCategoryOptionResponse>
)
data class GiftSizeOptionResponse(
val sizeCode: String,
val name: String,
val basePriceCan: Int,
val salePriceCan: Int
)
data class GiftCategoryOptionResponse(
val categoryId: Long,
val name: String,
val requiresDamageWaiver: Boolean
)
// name은 사용자 폼 옵션에서만 "분류명/대표품목" 형태로 조합된다.
data class GiftListResponse(
val totalCount: Long,
val items: List<GiftListItemResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class GiftListItemResponse(
val applicationNo: String,
val direction: String,
val status: String,
val statusName: String,
val priceCan: Int,
val categoryName: String,
val sizeName: String,
val createdAt: String?
)
data class GiftDetailResponse(
val applicationNo: String,
val direction: String,
val status: String,
val statusName: String,
val giftInfo: GiftDetailInfoResponse,
val senderInfo: GiftAddressResponse?,
val recipientAddress: GiftAddressResponse?,
val mailbox: GiftMailboxResponse?,
val trackingRequired: Boolean,
val recipientAddressRequired: Boolean,
val recipientAddressDeadlineAt: String?,
val delivery: GiftDeliveryInfoResponse,
val statusTimeline: List<GiftStatusTimelineResponse>
)
data class GiftDetailInfoResponse(
val recipientCreatorNickname: String?,
val senderNickname: String?,
val sizeName: String,
val categoryName: String,
val applicationNo: String?,
val paidCan: Int?,
val tracking: String?,
val shippingRequestedAt: String?
)
data class GiftAddressResponse(
val name: String,
val phoneNumber: String,
val address: String
)
data class GiftMailboxResponse(
val name: String,
val address: String,
val phoneNumber: String
)
// mailbox는 direction=SENT이고 status가 RECEIVED, TRACKING_REGISTERED,
// ARRIVED_AT_MAILBOX, INSPECTION_COMPLETED일 때만 값이 있다.
// DELIVERED, UNDELIVERABLE, CANCELED 또는 direction=RECEIVED이면 null이다.
data class GiftDeliveryInfoResponse(
val canceledAt: String?,
val undeliverableAt: String?,
val undeliverableReason: String?
)
data class GiftStatusTimelineResponse(
val status: String,
val statusName: String,
val occurredAt: String?
)
data class GiftApplicationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val priceCan: Int,
val trackingDeadlineAt: String
)
data class GiftCancellationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val priceCan: Int,
val canceledAt: String
)
data class GiftTrackingRegistrationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val courierCompanyName: String,
val trackingNumber: String,
val trackingRegisteredAt: String,
val recipientAddressDeadlineAt: String
)
data class GiftRecipientAddressRegistrationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val recipientAddressRegisteredAt: String
)
data class GiftDeliveryConfirmationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val deliveredAt: String
)
data class GiftReviewResponse(
val reviewId: Long,
val applicationNo: String,
val rating: Int,
val keywords: List<String>,
val comment: String?,
val createdAt: String?
)
```
### 관리자 Request DTO
```kotlin
data class AdminGiftCategoryRequest(
val classificationNumber: String,
val categoryCode: String,
val name: String,
val receiptCode: String,
val representativeItem: String,
val requiresDamageWaiver: Boolean,
val isActive: Boolean
)
data class AdminGiftSizePriceRequest(
val basePriceCan: Int,
val salePriceCan: Int,
val isActive: Boolean
)
data class AdminGiftMarkUndeliverableRequest(
val reason: String
)
```
### 관리자 Response DTO
```kotlin
data class AdminGiftCategoryResponse(
val categoryId: Long,
val classificationNumber: String,
val categoryCode: String,
val name: String,
val receiptCode: String,
val representativeItem: String,
val requiresDamageWaiver: Boolean,
val isActive: Boolean
)
data class AdminGiftListResponse(
val totalCount: Long,
val items: List<AdminGiftListItemResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class AdminGiftListItemResponse(
val applicationNo: String,
val senderNickname: String,
val recipientNickname: String,
val sizeCode: String,
val sizeName: String,
val categoryName: String,
val classificationNumber: String,
val courierCompanyName: String?,
val trackingNumber: String?,
val status: String,
val statusName: String,
val availableActions: List<String>
)
data class AdminGiftDetailResponse(
val applicationNo: String,
val senderInfo: AdminGiftMemberInfoResponse,
val recipientInfo: AdminGiftMemberInfoResponse,
val productInfo: AdminGiftProductInfoResponse,
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse,
val status: String,
val statusName: String,
val availableActions: List<String>
)
data class AdminGiftMemberInfoResponse(
val nickname: String,
val name: String,
val phoneNumber: String,
val address: String
)
data class AdminGiftProductInfoResponse(
val sizeCode: String,
val sizeName: String,
val categoryName: String,
val classificationNumber: String
)
data class AdminGiftInboundDeliveryInfoResponse(
val courierCompanyName: String?,
val trackingNumber: String?
)
data class AdminGiftSizePriceResponse(
val sizeCode: String,
val name: String,
val basePriceCan: Int,
val salePriceCan: Int,
val isActive: Boolean
)
data class AdminGiftOperationStatusResponse(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val occurredAt: String
)
```
## 클라이언트 구현 참고
- 선물 푸시 딥링크는 `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`를 사용한다.
- 딥링크 진입 후에는 `GET /api/v2/gifts/{applicationNo}`로 상세를 조회해 `direction`, `trackingRequired`, `recipientAddressRequired`를 기준으로 화면과 CTA를 결정한다.
- 운송장 미등록 자동취소는 서버 스케줄러가 처리하며, 팬 클라이언트는 상세 조회의 `status=CANCELED`와 푸시를 기준으로 표시한다.
- 모든 응답은 기존 `ApiResponse.ok(...)` envelope 안의 `data`로 내려간다.
@@ -1,433 +0,0 @@
# 보낸 선물 상세 및 운송장 등록 페이지 생성 프롬프트
이 문서는 Android/iOS 앱의 `선물함` 리스트에서 보낸 선물 상세 페이지와 운송장 등록 페이지를 생성할 때 사용하는 프롬프트다.
참고 Figma:
- 선물 보내기 신청 상세 운송장 등록 전 UI: `2481:19011`
- 선물 보내기 신청 상세 운송장 등록 후 UI: `2481:19152`
- 선물 보내기 신청 상세 전달 완료 UI: `2481:19320`
- 전달 불가 UI: `2481:19456`
- 취소 버튼 다이얼로그 UI: `2481:19500`
- 취소 완료 페이지 UI: `2481:19521`
- 운송장 등록 UI: `2481:19066`
- 운송장 정보를 채우고 난 후 UI: `2481:19109`
전제:
- 선물함 리스트 item 탭 시 `applicationNo`를 전달받아 상세 페이지로 이동한다.
- 새 디자인 시스템을 만들지 말고 Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `response.data`다.
- Figma의 진행 상태는 4단계처럼 보이지만 실제 구현은 서버의 `statusTimeline` 5개 상태를 모두 사용한다.
- 운송장 등록의 택배사 선택지는 서버 API가 없으므로 앱 로컬 상수로 관리한다.
- 취소 확인 팝업은 앱의 `v2 dialog` 컴포넌트를 기본으로 사용한다.
---
## 모바일 앱 구현 프롬프트
```text
Android/iOS 앱의 선물 상세 페이지와 운송장 등록 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘.
범위:
- 선물함 리스트에서 `applicationNo`를 받아 선물 상세 페이지로 이동한다.
- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}` 응답으로 렌더링한다.
- `trackingRequired=true`인 보낸 선물 상세에서는 운송장 등록 CTA를 표시하고 운송장 등록 페이지로 이동한다.
- 운송장 등록 페이지는 같은 상세 API를 조회해 신청 요약과 보낼 주소를 표시한 뒤 `POST /api/v2/gifts/{applicationNo}/tracking`으로 운송장 정보를 등록한다.
중요 전제:
- Figma의 visual structure와 문구를 우선 따르되, 상태 개수와 데이터 노출은 API 계약을 우선한다.
- 상세 진행 단계는 Figma처럼 4단계로 고정하지 말고 API `statusTimeline`의 5개 정상 진행 상태를 모두 렌더링한다.
- 택배사 목록은 API로 조회하지 않는다. 앱 로컬 상수로 관리하고 선택한 한글 표시명을 `courierCompanyName`으로 전송한다.
- 운송장 등록 완료 후 수정 기능은 만들지 않는다.
상세 페이지 진입:
- 선물함 리스트 item에서 받은 `applicationNo`로 진입한다.
- 화면 진입 시 `GET /api/v2/gifts/{applicationNo}`를 호출한다.
- 조회 결과 `status=CANCELED`이면 일반 신청 상세 화면을 렌더링하지 말고 취소 완료 페이지 UI를 보여준다.
- 로딩 중에는 기존 앱 상세 화면 로딩/스켈레톤 패턴을 사용한다.
- 실패 시 기존 앱 toast/dialog와 재시도 패턴을 사용한다.
상세 페이지 공통 레이아웃:
1. 상단 고정 내비게이션
- 타이틀: `신청 상세`
- 뒤로가기 버튼은 기존 앱 패턴을 사용한다.
2. 스크롤 콘텐츠
- 배송 상태 hero
- 진행 상태 progress
- 상태별 action button 영역
- 선물 정보
- 필요한 경우 보내는 사람, 받는 사서함, 사유 섹션
3. 하단 safe area
- 기존 앱 패턴을 따른다.
상태별 hero 문구:
| status | chip | title | body |
|---|---|---|---|
| `RECEIVED` | 없음 또는 `접수 완료` | `선물 신청 완료!` | `선물이 정상적으로 접수되었어요!` |
| `TRACKING_REGISTERED` | `발송 확인` | `사서함으로 이동 중이에요` | 없음 |
| `ARRIVED_AT_MAILBOX` | `사서함 도착` | `선물이 사서함에 도착했어요` | 없음 |
| `INSPECTION_COMPLETED` | `검수 완료` | `선물 검수를 완료했어요` | 없음 |
| `DELIVERED` | `전달 완료` | `크리에이터에게 선물을 전달했어요` | 없음 |
| `UNDELIVERABLE` | `전달 불가` | `전달할 수 없는 품목입니다` | `보내주신 선물은 SODALIVE 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다.` |
| `CANCELED` | `신청 취소` | `선물 신청이 취소되었어요` | `사용한 캔은 환불 처리됩니다.` |
진행 상태 progress:
- `statusTimeline` 배열을 그대로 사용한다.
- 항상 아래 5개 단계를 이 순서로 표시한다.
1. `RECEIVED`: `신청 접수`
2. `TRACKING_REGISTERED`: `발송 확인`
3. `ARRIVED_AT_MAILBOX`: `사서함 도착`
4. `INSPECTION_COMPLETED`: `검수 완료`
5. `DELIVERED`: `전달 완료`
- `occurredAt`이 있으면 해당 단계는 완료 색상으로 표시하고 `MM.DD`를 표시한다.
- `occurredAt`이 null이면 비활성 색상으로 표시하고 날짜는 표시하지 않는다.
- `UNDELIVERABLE`, `CANCELED`는 정상 진행 단계가 아니므로 progress step으로 추가하지 않는다.
- 종료 상태에서도 `statusTimeline`에 있는 정상 진행 이력은 그대로 표시한다.
상세 페이지 섹션 표시 규칙:
- `선물 정보`는 항상 표시한다.
- `direction=SENT`이면 크리에이터명은 `giftInfo.recipientCreatorNickname`을 사용한다.
- `direction=RECEIVED`이면 보낸 팬명은 `giftInfo.senderNickname`을 사용한다.
- `senderInfo`가 있으면 `보내는 사람` 섹션을 표시한다.
- `mailbox`가 있으면 `받는 사서함` 또는 `보내실 주소` 섹션을 표시한다.
- `recipientAddress`가 있으면 `받는 사람` 또는 `배송지` 섹션을 표시한다.
- `delivery.undeliverableReason`이 있으면 `사유` 섹션을 표시한다.
선물 정보 표시 필드:
- 크리에이터 또는 보낸 팬 닉네임
- 선물 사이즈: `giftInfo.sizeName`
- 카테고리: `giftInfo.categoryName`
- 신청번호: `applicationNo` 또는 `giftInfo.applicationNo`
- 캔: `giftInfo.paidCan`이 null이 아닐 때만 표시
- 운송장 번호: `giftInfo.tracking`이 null이 아닐 때 표시
- 검수일: `statusTimeline`에서 `INSPECTION_COMPLETED.occurredAt`이 있으면 표시
- 전달 완료일: `statusTimeline`에서 `DELIVERED.occurredAt`이 있으면 표시
- 취소일: `delivery.canceledAt`이 있으면 표시
받는 사서함/보내실 주소 표시:
- `mailbox.name`을 사서함/받는 분으로 표시한다.
- `mailbox.address`를 주소로 표시한다.
- `mailbox.phoneNumber`를 연락처로 표시한다.
- `mailbox`는 관리자가 등록한 전역 단일 주소다. 상대방 주소가 아니다.
- `mailbox`는 `direction=SENT`이고 `status`가 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`일 때 내려온다.
- `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 또는 `direction=RECEIVED`에서는 `mailbox=null`이다.
- `mailbox=null`이면 해당 섹션을 표시하지 않는다.
보내는 사람 표시:
- `senderInfo.name`
- `senderInfo.phoneNumber`
- `senderInfo.address`
- 이 정보는 보낸 팬 본인이 신청 시 입력한 정보다.
전달 불가 표시:
- `status=UNDELIVERABLE`이면 Figma `2481:19456` 패턴을 따른다.
- 사유 섹션의 제목은 `delivery.undeliverableReason`을 사용한다.
- `delivery.undeliverableReason`이 null이면 사유 섹션 제목은 `전달 불가`로 표시한다.
- 안내 문구는 `해당 선물은 정책에 따라 폐기되며 반송되지 않습니다.`를 사용한다.
상세 페이지 action:
- `trackingRequired=true`이면 하단 주요 CTA `운송장 작성하기`를 표시하고 운송장 등록 페이지로 이동한다.
- `direction=SENT && status=RECEIVED`이면 보조 action `신청 취소`를 표시할 수 있다.
- 신청 취소 버튼을 누르면 즉시 API를 호출하지 말고 `v2 dialog` 확인 팝업을 먼저 표시한다.
- `status=DELIVERED && direction=SENT`이면 `리뷰 남기기` 버튼을 표시하고 기존 리뷰 작성 화면/플로우로 이동한다.
- `문의하기`, `선물 정책 확인` 버튼은 기존 앱에 해당 이동 경로가 있으면 연결하고, 없으면 기존 정책에 맞춰 숨기거나 비활성 처리한다.
신청 취소 확인 다이얼로그:
- Figma `2481:19500`을 참고한다.
- 앱의 기본 `v2 dialog` 컴포넌트를 사용한다. 새 dialog 컴포넌트를 만들지 않는다.
- 제목: `신청 취소`
- 본문:
- `{크리에이터}에게 보내는 선물을 취소할까요?`
- `선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다.`
- 왼쪽 action: `나가기`
- dialog만 닫고 상세/운송장 등록 화면 상태를 유지한다.
- 오른쪽 destructive action: `취소하기`
- `POST /api/v2/gifts/{applicationNo}/cancel`을 호출한다.
- 호출 중에는 중복 탭을 막고 loading 상태를 표시한다.
- 실패 시 dialog를 닫지 말고 기존 앱 에러 toast/dialog 패턴으로 안내한다.
- 성공 시 취소 완료 페이지로 replace navigation 한다.
취소 완료 페이지:
- Figma `2481:19521`을 참고한다.
- 취소 API 성공 직후뿐 아니라 상세 페이지 접근 시 `GET /api/v2/gifts/{applicationNo}` 응답이 `status=CANCELED`인 경우에도 이 화면을 보여준다.
- 상단 타이틀: `취소 완료`
- hero chip: `취소 완료`
- hero title: `선물 신청이 취소되었어요`
- hero body: `{크리에이터}에게 보내는 선물 신청({applicationNo})이 취소되었습니다. 취소한 신청은 되돌릴 수 없으며, 다시 보내려면 새로 신청해 주세요.`
- 섹션 제목: `취소 상세`
- 취소 상세 카드 표시 필드:
- 크리에이터: `giftInfo.recipientCreatorNickname`
- 선물 사이즈: `giftInfo.sizeName`
- 카테고리: `giftInfo.categoryName`
- 신청번호: `applicationNo`
- 신청일: `statusTimeline`의 `RECEIVED.occurredAt`
- 취소일: 취소 API response의 `canceledAt` 또는 상세 API 조회 시 `delivery.canceledAt`
- 환불 캔: 취소 API response의 `priceCan` 또는 상세 API의 `giftInfo.paidCan`
- 하단 또는 카드 아래에 `문의하기` 버튼을 표시한다.
- 뒤로가기 동작은 취소 전 상세/운송장 등록 화면으로 돌아가지 않게 한다. 기존 앱 navigation 정책에 맞춰 선물함 리스트 또는 이전 안전 화면으로 이동한다.
운송장 등록 페이지 진입:
- 상세 페이지에서 `trackingRequired=true`일 때만 진입시킨다.
- 진입 시 `GET /api/v2/gifts/{applicationNo}`를 다시 조회한다.
- 조회 결과에서 `trackingRequired=false`이면 이미 등록 불가 상태이므로 기존 오류 처리 후 상세 페이지로 돌아간다.
운송장 등록 페이지 레이아웃:
1. 상단 고정 내비게이션
- 타이틀: `운송장 등록`
2. 신청 건 요약 카드
- `applicationNo`
- 크리에이터 닉네임: `giftInfo.recipientCreatorNickname`
- `giftInfo.sizeName · giftInfo.categoryName · 신청일`
- 신청일은 `statusTimeline`의 `RECEIVED.occurredAt`을 `YYYY.MM.DD`로 표시한다.
3. 보내실 주소
- `mailbox`를 사용한다.
- `mailbox=null`이면 운송장 등록을 막고 `받을 주소가 등록되어 있지 않습니다.` 오류를 표시한다.
4. 운송장 정보
- 택배사 select
- 운송장 번호 input
5. 등록 안내
- Figma의 안내 문구를 유지한다.
6. 하단 action bar
- 왼쪽: `신청 취소`
- 오른쪽: `등록 완료`
택배사 로컬 상수:
- 서버에서 택배사 옵션을 조회하지 않는다.
- 앱에 아래 로컬 옵션을 둔다. 이미 앱 공통 택배사 상수가 있으면 그것을 우선 사용한다.
```ts
const giftCourierCompanyNames = [
"CJ대한통운",
"우체국택배",
"GS25 편의점택배",
"CU 편의점택배",
"한진택배",
"롯데택배",
"로젠택배",
"경동택배",
"대신택배",
"일양로지스",
"천일택배",
"합동택배",
"건영택배",
"농협택배"
];
```
- 선택 UI는 기존 앱의 bottom sheet, picker, dialog 중 이미 쓰는 방식을 사용한다.
- API에는 선택된 한글 표시명을 그대로 `courierCompanyName`으로 보낸다.
운송장 등록 CTA 활성 조건:
- 택배사를 선택했다.
- 운송장 번호를 입력했다.
- 운송장 번호는 공백만 입력할 수 없다.
- submit 중에는 중복 탭을 막고 loading 상태를 표시한다.
- 비활성 상태는 Figma `2481:19066`, 활성 상태는 Figma `2481:19109`를 따른다.
운송장 등록 성공 처리:
- `POST /api/v2/gifts/{applicationNo}/tracking` 성공 후 성공 toast를 표시한다.
- 상세 페이지로 돌아가거나 replace navigation으로 상세 페이지를 다시 연다.
- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}`를 다시 호출해 `TRACKING_REGISTERED` 상태를 표시한다.
API 명세:
선물 상세 조회:
- Method: `GET`
- URL: `/api/v2/gifts/{applicationNo}`
- 인증: 로그인 필요
- Response envelope 예시:
```json
{
"success": true,
"data": {
"applicationNo": "A-1002609300001",
"direction": "SENT",
"status": "RECEIVED",
"statusName": "접수 완료",
"giftInfo": {
"recipientCreatorNickname": "달빛수집가",
"senderNickname": null,
"sizeName": "소형",
"categoryName": "잡화",
"applicationNo": "A-1002609300001",
"paidCan": 100,
"tracking": null,
"shippingRequestedAt": null
},
"senderInfo": {
"name": "김소다",
"phoneNumber": "01012345678",
"address": "(04030) 서울특별시 마포구 양화로 000, 3층"
},
"recipientAddress": null,
"mailbox": {
"name": "소다라이브 사서함",
"address": "(06174) 서울특별시 강남구 테헤란로108길 8, 유민빌딩 4층",
"phoneNumber": "02-2055-1477"
},
"trackingRequired": true,
"recipientAddressRequired": false,
"recipientAddressDeadlineAt": null,
"delivery": {
"canceledAt": null,
"undeliverableAt": null,
"undeliverableReason": null
},
"statusTimeline": [
{
"status": "RECEIVED",
"statusName": "접수 완료",
"occurredAt": "2026-09-18T03:00:00Z"
},
{
"status": "TRACKING_REGISTERED",
"statusName": "발송 확인",
"occurredAt": null
},
{
"status": "ARRIVED_AT_MAILBOX",
"statusName": "사서함 도착",
"occurredAt": null
},
{
"status": "INSPECTION_COMPLETED",
"statusName": "검수완료",
"occurredAt": null
},
{
"status": "DELIVERED",
"statusName": "전달완료",
"occurredAt": null
}
]
},
"message": ""
}
```
Response `data` 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
| `applicationNo` | string | 선물 신청번호 |
| `direction` | string | `SENT` 또는 `RECEIVED` |
| `status` | string | 선물 상태 코드 |
| `statusName` | string | 서버 상태 표시명 |
| `giftInfo` | object | 선물 기본 정보 |
| `giftInfo.recipientCreatorNickname` | string 또는 null | 보낸 선물 관점에서 받는 크리에이터 닉네임 |
| `giftInfo.senderNickname` | string 또는 null | 받은 선물 관점에서 보낸 팬 닉네임 |
| `giftInfo.sizeName` | string | 선물 사이즈명 |
| `giftInfo.categoryName` | string | 카테고리명 |
| `giftInfo.applicationNo` | string 또는 null | 보낸 선물 관점 신청번호 |
| `giftInfo.paidCan` | number 또는 null | 보낸 선물 관점 사용 캔 |
| `giftInfo.tracking` | string 또는 null | 운송장 표시 문자열. 예: `한진택배_123456789012` |
| `giftInfo.shippingRequestedAt` | string 또는 null | 받은 선물 관점 배송신청일. UTC ISO 문자열 |
| `senderInfo` | object 또는 null | 보낸 팬 본인이 신청 시 입력한 보내는 사람 정보 |
| `senderInfo.name` | string | 이름 |
| `senderInfo.phoneNumber` | string | 휴대폰 번호 |
| `senderInfo.address` | string | `(우편번호) 주소, 상세주소` 형식 주소 |
| `recipientAddress` | object 또는 null | 받는 크리에이터 본인이 입력한 배송지 |
| `mailbox` | object 또는 null | 관리자가 등록한 전역 선물 받을 주소. 보낸 선물의 `DELIVERED` 전 정상 진행 상태에서만 값이 있다 |
| `mailbox.name` | string | 사서함/받는 분 이름 |
| `mailbox.address` | string | `(우편번호) 주소, 상세주소` 형식 주소 |
| `mailbox.phoneNumber` | string | 연락처 |
| `trackingRequired` | boolean | 보낸 팬이 운송장 등록을 해야 하는 상태인지 여부 |
| `recipientAddressRequired` | boolean | 받는 크리에이터가 배송지를 입력해야 하는 상태인지 여부 |
| `recipientAddressDeadlineAt` | string 또는 null | 배송지 입력 마감일. UTC ISO 문자열 |
| `delivery.canceledAt` | string 또는 null | 취소일. UTC ISO 문자열 |
| `delivery.undeliverableAt` | string 또는 null | 전달 불가 처리일. UTC ISO 문자열 |
| `delivery.undeliverableReason` | string 또는 null | 전달 불가 사유 |
| `statusTimeline` | array | 정상 진행 5단계 타임라인 |
| `statusTimeline[].status` | string | 단계 상태 코드 |
| `statusTimeline[].statusName` | string | 단계 표시명 |
| `statusTimeline[].occurredAt` | string 또는 null | 해당 단계 도달 시각. UTC ISO 문자열 |
운송장 등록:
- Method: `POST`
- URL: `/api/v2/gifts/{applicationNo}/tracking`
- 인증: 로그인 필요
- Request:
```json
{
"courierCompanyName": "한진택배",
"trackingNumber": "123456789012"
}
```
- Response envelope 예시:
```json
{
"success": true,
"data": {
"applicationNo": "A-1002609300001",
"status": "TRACKING_REGISTERED",
"statusName": "발송 확인",
"courierCompanyName": "한진택배",
"trackingNumber": "123456789012",
"trackingRegisteredAt": "2026-09-19T03:00:00Z",
"recipientAddressDeadlineAt": "2026-09-26T03:00:00Z"
},
"message": ""
}
```
운송장 등록 response `data` 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
| `applicationNo` | string | 선물 신청번호 |
| `status` | string | 등록 후 상태. `TRACKING_REGISTERED` |
| `statusName` | string | 서버 상태 표시명 |
| `courierCompanyName` | string | 선택한 택배사 한글명 |
| `trackingNumber` | string | 운송장 번호 |
| `trackingRegisteredAt` | string | 운송장 등록일. UTC ISO 문자열 |
| `recipientAddressDeadlineAt` | string | 크리에이터 배송지 입력 마감일. UTC ISO 문자열 |
선물 보내기 취소:
- Method: `POST`
- URL: `/api/v2/gifts/{applicationNo}/cancel`
- Request: 없음
- 용도: `direction=SENT && status=RECEIVED`에서만 `신청 취소` action에 사용한다.
- Response envelope 예시:
```json
{
"success": true,
"data": {
"applicationNo": "A-1002609300001",
"status": "CANCELED",
"statusName": "신청 취소",
"priceCan": 100,
"canceledAt": "2026-09-18T04:00:00Z"
},
"message": ""
}
```
선물 보내기 취소 response `data` 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
| `applicationNo` | string | 선물 신청번호 |
| `status` | string | 취소 후 상태. `CANCELED` |
| `statusName` | string | 서버 상태 표시명 |
| `priceCan` | number | 환불되는 캔 수량 |
| `canceledAt` | string | 취소일. UTC ISO 문자열 |
테스트/검증:
- 리스트 item 탭 시 `applicationNo`로 상세 페이지가 열리는지 확인한다.
- 상세 진입 시 `GET /api/v2/gifts/{applicationNo}`가 호출되는지 확인한다.
- 상세 조회 결과 `status=CANCELED`이면 일반 상세가 아니라 취소 완료 페이지가 표시되는지 확인한다.
- `statusTimeline` 5개 단계가 모두 표시되는지 확인한다.
- Figma처럼 4단계로 하드코딩하지 않았는지 확인한다.
- `trackingRequired=true`이면 `운송장 작성하기`가 표시되는지 확인한다.
- 신청 취소 버튼 탭 시 `v2 dialog` 확인 팝업이 뜨고 즉시 API를 호출하지 않는지 확인한다.
- 취소 확인 dialog에서 `나가기`를 누르면 dialog만 닫히는지 확인한다.
- 취소 확인 dialog에서 `취소하기`를 누르면 `POST /api/v2/gifts/{applicationNo}/cancel`을 호출하는지 확인한다.
- 취소 성공 후 취소 완료 페이지가 표시되고 취소 전 상세/등록 화면으로 돌아가지 않는지 확인한다.
- `mailbox`가 있을 때만 사서함 주소 섹션이 표시되는지 확인한다.
- `direction=SENT`의 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 상태에서 `mailbox`가 표시되는지 확인한다.
- `DELIVERED`, `UNDELIVERABLE`, `CANCELED`, `direction=RECEIVED`에서는 `mailbox`가 표시되지 않는지 확인한다.
- `UNDELIVERABLE`이면 전달 불가 hero와 사유 섹션이 표시되는지 확인한다.
- 운송장 등록 페이지에서 택배사/운송장 번호 누락 시 API를 호출하지 않는지 확인한다.
- 운송장 등록 시 선택한 택배사 한글명과 운송장 번호가 request body로 전송되는지 확인한다.
- 운송장 등록 성공 후 상세 페이지가 재조회되어 `TRACKING_REGISTERED` 상태로 보이는지 확인한다.
- 모든 날짜는 UTC ISO 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다.
```
@@ -1,274 +0,0 @@
# 선물함 리스트 페이지 생성 프롬프트
이 문서는 Android/iOS 앱의 `선물함` 리스트 페이지를 생성할 때 사용하는 프롬프트다.
참고 Figma:
- 팬 모드, 신청한 내역이 있을 때: `2481:19382`
- 팬 모드, 신청한 내역이 없을 때: `2520:35587`
- 크리에이터 모드, 받은 선물 내역이 없을 때: `2531:36283`
- 크리에이터 모드, 받은 선물 내역이 있을 때: `2531:36194`
- 크리에이터 모드, 받은 선물 상태별 카드 표시: `2531:36237`
전제:
- Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
- Figma의 visual structure를 기준으로 구현하되, 아래 사용자 요구사항을 Figma보다 우선한다.
- 상단 뒤로가기 내비게이션 영역은 고정한다.
- 상단 뒤로가기 내비게이션 영역을 제외한 전체 콘텐츠 영역은 스크롤되어야 한다.
- `신청 내역`, `선물 내역` 섹션 타이틀은 표시하지 않는다.
- 리스트는 신청일/취소일 최신순으로 보여야 한다.
- request/response 설명은 모바일 공통 JSON 기준이다.
---
## 모바일 앱 구현 프롬프트
```text
Android/iOS 앱의 선물함 리스트 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘.
Figma는 두 관점의 상태를 보여준다.
- 보낸 선물 관점: 사용자가 본인이 신청한 선물 내역을 확인하는 상태다. Figma `2481:19382`, `2520:35587`를 기준으로 한다.
- 받은 선물 관점: 사용자가 본인이 받을 선물 내역을 확인하는 상태다. Figma `2531:36283`, `2531:36194`, `2531:36237`를 기준으로 한다.
- 실제 선물함 API는 두 관점을 나누어 호출하지 않고 `type=ALL`로 한 번 호출한다.
- 응답 item의 `direction` 값이 `SENT`이면 보낸 선물 카드로, `RECEIVED`이면 받은 선물 카드로 렌더링한다.
- 각 카드에는 상대방 프로필 이미지와 닉네임을 표시한다.
- `direction=SENT`: 상대방은 선물을 받는 크리에이터다.
- `direction=RECEIVED`: 상대방은 선물을 보낸 팬이다.
중요 요구사항:
- 상단 뒤로가기 내비게이션은 고정한다.
- 상단 내비게이션 아래의 전체 콘텐츠만 스크롤되게 한다.
- Figma에 보이는 `신청 내역`, `선물 내역` 섹션 타이틀은 실제 구현에서 표시하지 않는다.
- 선물함 리스트는 신청일/취소일 최신순으로 정렬한다.
- 현재 API 응답만으로 취소일 기준 정렬/표시는 완전하지 않으므로, API가 취소일을 내려주지 않으면 서버 응답 순서를 우선 사용하고 취소 건 날짜 라벨은 `createdAt` 기준으로 표시한다. 취소일 표시가 필수이면 API 보강이 필요하다.
API 호출:
- 선물함 리스트는 항상 `GET /api/v2/gifts?type=ALL&page=0&size=20`로 조회한다.
- `SENT`, `RECEIVED`로 따로 호출하지 않는다.
- `items[].direction`으로 보낸 선물/받은 선물 UI를 결정한다.
- 페이지 진입 시 첫 페이지를 조회한다.
- `hasNext=true`이면 스크롤 하단에서 다음 page를 추가 조회한다.
- pull-to-refresh가 기존 앱에 있으면 page를 0으로 초기화하고 다시 조회한다.
공통 레이아웃:
1. 상단 고정 내비게이션
- 타이틀: `선물함`
- 뒤로가기 버튼은 기존 앱 패턴을 사용한다.
- 이 영역은 스크롤되지 않는다.
2. 스크롤 콘텐츠
- nav 아래부터 시작한다.
- 안내 배너, 빈 상태, 리스트, 하단 여백 또는 하단 문의 버튼까지 스크롤 영역에 포함한다.
- iOS safe area와 Android navigation bar 여백은 기존 앱 패턴을 따른다.
보낸 선물 UI:
- `direction=SENT`인 item에 적용한다.
- 사용자가 신청한 선물 내역을 보여준다.
- 보낸 선물만 있는 상태는 Figma `2481:19382`를 따른다.
- 상단 안내 배너를 표시한다.
- 제목: `사서함을 통해 안전하게 전달됩니다`
- 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 선물은 소다라이브 사서함에 도착한 뒤 크리에이터에게 전달됩니다.`
- `신청 내역` 섹션 타이틀은 표시하지 않는다.
- 카드 구성:
- 상단 왼쪽: `statusName`
- 상단 오른쪽: 날짜 라벨
- 기본: `{createdAt} 신청`
- 상태가 `CANCELED`이고 취소일 필드가 있으면 `{canceledAt} 취소`
- 현재 리스트 API에는 `canceledAt`이 없으므로 기존 API만 사용할 때는 `{createdAt} 취소` 또는 `{createdAt} 신청` 중 제품 정책에 맞춰 하나로 통일한다.
- 프로필 row 내부: `counterpartProfileImageUrl`, `counterpartNickname`을 사용해 받는 크리에이터 프로필을 표시한다.
- 프로필 row 아래 보조 문구: `sizeName · categoryName`
- 프로필 이미지가 없으면 기존 앱의 기본 프로필 이미지를 표시한다.
- 우측 chevron
- 탭 시 선물 상세 페이지로 이동하며 `applicationNo`를 전달한다.
- 리스트가 비어 있고 보낸 선물 관점으로 진입한 화면이면 Figma `2520:35587`를 따른다.
- 상단 안내 배너만 표시한다.
- 별도 empty title/body를 추가하지 않는다.
받은 선물 UI:
- `direction=RECEIVED`인 item에 적용한다.
- 사용자가 받을 선물 내역을 보여준다.
- 리스트가 비어 있고 받은 선물 관점으로 진입한 화면이면 Figma `2531:36283`를 따른다.
- 가운데 empty title: `전달 예정인 선물이 없어요`
- empty body: `베타 기간에는 팬이 선물을 보내는 기능만 제공돼요. 더 다양해진 선물 기능으로 곧 다시 만나요!`
- 버튼: `의견 남기기`
- 의견 남기기 동작은 기존 앱의 문의/피드백 이동 패턴을 사용한다.
- 받은 선물이 있는 상태는 Figma `2531:36194`, `2531:36237`를 따른다.
- 상단 안내 배너를 표시한다.
- 제목: `배송지 입력 기간 안내`
- 본문: `알림을 받은 날부터 7일 이내에 배송지를 입력해 주세요. 기한 내 입력하지 않으면 선물이 반송됩니다.`
- `선물 내역` 섹션 타이틀은 표시하지 않는다.
- 배송지 입력이 필요한 선물이 있으면 파란 안내 배너를 리스트 상단에 표시한다.
- 제목: `팬이 보낸 선물이 있어요!`
- 본문: `선물이 늦지 않게 전달될 수 있도록 배송지를 입력해 주세요.`
- 보조 문구: `*n일 이내로 입력하지 않으면 선물이 사라져요.`
- 현재 리스트 API에는 배송지 입력 마감일이 없으므로 정확한 n일 계산이 필요하면 API 보강이 필요하다.
- 배너 탭 시 배송지 입력이 필요한 첫 번째 선물 상세로 이동한다.
- 카드 구성:
- 상단 왼쪽 프로필 row 내부: `counterpartProfileImageUrl`, `counterpartNickname`을 사용해 보낸 팬 프로필을 표시한다.
- 상단 오른쪽: `{createdAt} 신청`
- 본문 메인: 상태 표시 문구
- 본문 서브: 상태 보조 문구가 필요한 경우만 표시
- 우측 chevron
- 탭 시 선물 상세 페이지로 이동하며 `applicationNo`를 전달한다.
- 리스트 하단에는 Figma처럼 `문의하기` 하단 버튼을 둘 수 있다. 기존 앱 정책상 고정 버튼이면 하단 safe area를 포함하고, 스크롤 콘텐츠 내부 버튼이면 콘텐츠 하단에 둔다.
상태 표시 매핑:
보낸 선물 카드:
| API status | 표시 문구 |
|---|---|
| `RECEIVED` | `운송장 등록 필요` |
| `TRACKING_REGISTERED` | `발송 확인` |
| `ARRIVED_AT_MAILBOX` | `사서함 도착` |
| `INSPECTION_COMPLETED` | `선물 검수 완료` |
| `DELIVERED` | `전달 완료` |
| `UNDELIVERABLE` | `전달 불가` |
| `CANCELED` | `신청 취소` |
받은 선물 카드:
| API status | 메인 문구 | 서브 문구 |
|---|---|---|
| `TRACKING_REGISTERED` | `배송지 입력 필요` | `전달 예정` |
| `ARRIVED_AT_MAILBOX` | `선물 검수` | `선물 확인 중` |
| `INSPECTION_COMPLETED` | `선물 검수 완료` | `배송 중` |
| `DELIVERED` | `전달 완료` | 없음 |
| `UNDELIVERABLE` | `전달 불가` | 없음 |
API 명세:
선물함 리스트 조회 API:
- Method: `GET`
- URL: `/api/v2/gifts`
- 인증: 로그인 필요
- Query:
- `type`: `ALL`, `SENT`, `RECEIVED`
- `page`: 0부터 시작
- `size`: 페이지 크기. 기본 20
- 이 페이지에서는 `type=ALL`만 사용한다.
- Response envelope 예시:
```json
{
"success": true,
"data": {
"totalCount": 1,
"items": [],
"page": 0,
"size": 20,
"hasNext": false
},
"message": ""
}
```
Response `data` 예시:
```json
{
"totalCount": 2,
"items": [
{
"applicationNo": "A-1002609300001",
"direction": "SENT",
"status": "RECEIVED",
"statusName": "접수 완료",
"priceCan": 80,
"categoryName": "아크릴/스탠드",
"sizeName": "소형",
"counterpartMemberId": 200,
"counterpartNickname": "달빛수집가",
"counterpartProfileImageUrl": "https://example.com/profile.png",
"createdAt": "2026-09-18T03:00:00Z"
}
],
"page": 0,
"size": 20,
"hasNext": false
}
```
Response `data` 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
| `totalCount` | number | 전체 개수 |
| `items` | array | 선물함 리스트 |
| `items[].applicationNo` | string | 선물 신청번호. 상세 이동 ID |
| `items[].direction` | string | `SENT` 또는 `RECEIVED` |
| `items[].status` | string | 선물 상태 코드 |
| `items[].statusName` | string | 서버 상태 표시명 |
| `items[].priceCan` | number | 사용된 캔 |
| `items[].categoryName` | string | 카테고리명 |
| `items[].sizeName` | string | 사이즈명 |
| `items[].counterpartMemberId` | number | 상대방 회원 ID. `SENT`에서는 받는 크리에이터, `RECEIVED`에서는 보낸 팬 |
| `items[].counterpartNickname` | string | 상대방 닉네임 |
| `items[].counterpartProfileImageUrl` | string 또는 null | 상대방 프로필 이미지 URL. 없으면 기본 프로필 이미지 표시 |
| `items[].createdAt` | string 또는 null | 신청일. UTC ISO 문자열 |
| `page` | number | 현재 페이지 |
| `size` | number | 페이지 크기 |
| `hasNext` | boolean | 다음 페이지 존재 여부 |
정렬:
- 현재 서버 API는 `createdAt desc, id desc`로 정렬한다.
- 요구사항은 신청일/취소일 최신순이다.
- 취소일 최신순까지 정확히 맞추려면 리스트 API가 `canceledAt` 또는 `displayedAt` 같은 정렬 기준 시각을 내려주고 그 기준으로 정렬해야 한다.
- API 보강 전에는 서버 응답 순서를 그대로 사용하고, 클라이언트에서 임의 재정렬하지 않는다.
상대방 프로필 표시 규칙:
- `counterpartNickname`은 카드의 프로필 row 이름으로 표시한다.
- `counterpartProfileImageUrl`이 있으면 해당 이미지를 원형 프로필 이미지로 표시한다.
- `counterpartProfileImageUrl`이 `null`이거나 빈 값이면 기존 앱의 기본 프로필 이미지를 표시한다.
- `direction=SENT`에서는 받는 크리에이터 정보로 해석한다.
- `direction=RECEIVED`에서는 보낸 팬 정보로 해석한다.
현재 API만으로 부족한 Figma 표시값:
- 취소일 `canceledAt`
- 배송지 입력 마감일 또는 남은 일수
추가 API 보강이 가능하면 리스트 item에 아래 필드를 추가해달라고 요청한다.
```json
{
"canceledAt": "2026-09-18T03:00:00Z",
"recipientAddressDeadlineAt": "2026-09-25T03:00:00Z",
"sortAt": "2026-09-18T03:00:00Z"
}
```
- `canceledAt`: 취소 상태의 취소일 표시용
- `recipientAddressDeadlineAt`: 크리에이터 배송지 입력 안내 배너의 남은 일수 계산용
- `sortAt`: 신청/취소 최신순 정렬 기준. 취소 건은 취소일, 그 외에는 신청일
로딩/에러/빈 상태:
- 최초 로딩 중에는 기존 앱 리스트 스켈레톤 또는 loading 패턴을 사용한다.
- 실패 시 기존 앱 toast/dialog와 재시도 버튼을 사용한다.
- 빈 상태는 진입 관점에 따라 다르게 표시한다.
- 보낸 선물 관점으로 진입한 화면이면 안내 배너만 남긴다.
- 받은 선물 관점으로 진입한 화면이면 중앙 empty UI와 `의견 남기기` 버튼을 표시한다.
테스트/검증:
- 선물함 진입 시 `type=ALL`로 API를 한 번 호출하는지 확인한다.
- `direction=SENT` item은 보낸 선물 카드 UI로 표시되는지 확인한다.
- `direction=RECEIVED` item은 받은 선물 카드 UI로 표시되는지 확인한다.
- 각 item의 `counterpartNickname`, `counterpartProfileImageUrl`이 프로필 row에 표시되는지 확인한다.
- `counterpartProfileImageUrl=null`이면 기본 프로필 이미지가 표시되는지 확인한다.
- 상단 내비게이션은 고정되고 아래 콘텐츠만 스크롤되는지 확인한다.
- `신청 내역`, `선물 내역` 텍스트가 실제 화면에 노출되지 않는지 확인한다.
- 리스트 item 탭 시 `applicationNo`로 상세 화면 이동이 되는지 확인한다.
- 빈 상태가 진입 관점에 맞는 Figma 기준으로 표시되는지 확인한다.
- `hasNext=true`일 때 다음 page를 추가 조회하는지 확인한다.
- API 실패 시 입력/스크롤 상태를 깨지 않고 오류와 재시도를 제공하는지 확인한다.
```
---
## API 요약
| 목적 | Method | URL | Query | Response data |
|---|---|---|---|---|
| 선물함 리스트 조회 | GET | `/api/v2/gifts` | `type=ALL`, `page`, `size` | 선물 리스트, 페이지 정보 |
## Figma 반영 메모
- 보낸 선물 리스트 있음: 안내 배너 + 카드 리스트. 단, 실제 구현에서는 `신청 내역` 타이틀을 제거한다.
- 보낸 선물 관점 empty: 안내 배너만 표시한다.
- 받은 선물 관점 empty: 중앙 empty UI와 `의견 남기기` 버튼을 표시한다.
- 받은 선물 리스트 있음: 안내 배너 + 배송지 입력 CTA 배너 + 카드 리스트. 단, 실제 구현에서는 `선물 내역` 타이틀을 제거한다.
- 받은 선물 상태별 카드는 `배송지 입력 완료/전달 예정`, `전달 완료`, `전달 불가`, `선물 검수/선물 확인 중`, `선물 검수 완료/배송 중` 패턴을 사용한다.
@@ -1,114 +0,0 @@
# 선물 받을 주소 관리자 페이지 구현 프롬프트
이 문서는 관리자 페이지에서 전역 선물 받을 주소를 등록/수정하는 화면을 구현할 때 사용하는 프롬프트다.
## 전제
- 여기서 말하는 `선물 받을 주소`는 선물 상대방 주소가 아니다.
- 관리자가 등록하는 전역 단일 주소다.
- 팬이 보낸 선물 상세를 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)에 조회할 때, 이 주소가 `mailbox`로 노출된다.
- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 폼, 버튼, 토스트, 에러 표시 패턴을 재사용한다.
## 구현 프롬프트
```text
관리자 선물 받을 주소 설정 페이지를 구현해줘.
목표:
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
- 이 주소는 상대방 주소가 아니라 관리자가 등록하는 운영 주소다.
- 팬의 선물 상세 API에서는 `direction=SENT`이고 `status`가 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`일 때 이 주소가 `mailbox`로 노출된다.
- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다.
라우트:
- `/gift/mailbox`
메뉴:
- parent: `선물함 관리`
- label: `받을 주소`
- path: `/gift/mailbox`
필수 화면 구성:
- 상단 제목: `받을 주소`
- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.`
- 입력 폼:
- 받을 사람 이름 `name`
- 연락처 `phoneNumber`
- 우편번호 `zipCode`
- 주소 `address`
- 상세주소 `addressDetail`
- 저장 버튼: `저장`
초기 조회 API:
- Method: `GET`
- URL: `/api/v2/admin/gift-mailbox`
- 모든 API 응답은 기존 공통 envelope를 사용한다.
- 화면에서는 `response.data`를 실제 payload로 사용한다.
- response `data`:
```ts
type AdminGiftMailboxResponse = {
name: string;
address: string;
phoneNumber: string;
} | null;
```
- `data`가 null이면 아직 등록된 받을 주소가 없는 상태다.
- null이면 빈 폼을 표시한다.
저장 API:
- Method: `PUT`
- URL: `/api/v2/admin/gift-mailbox`
- Request:
```ts
type AdminGiftMailboxRequest = {
name: string;
phoneNumber: string;
zipCode: string;
address: string;
addressDetail: string | null;
};
```
- Response `data`:
```ts
type AdminGiftMailboxResponse = {
name: string;
address: string;
phoneNumber: string;
};
```
동작 규칙:
- 받을 주소는 전역 단일 설정이다.
- 등록과 수정은 같은 `PUT /api/v2/admin/gift-mailbox` API를 사용한다.
- 최초 저장이면 생성처럼 동작한다.
- 이미 저장된 값이 있으면 기존 주소를 수정한다.
- 별도 ID, 목록, 삭제 기능은 만들지 않는다.
폼 validation:
- `name`, `phoneNumber`, `zipCode`, `address`는 필수다.
- 필수값은 공백만 입력할 수 없다.
- `addressDetail`은 선택이다.
- validation 실패 시 API를 호출하지 않고 기존 관리자 페이지의 필드 에러 표시 방식을 따른다.
저장 성공 UX:
- 성공 토스트를 표시한다.
- 저장 API response 기준으로 화면 값을 갱신한다.
- response의 `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식이다.
- 입력 필드는 사용자가 입력한 `zipCode`, `address`, `addressDetail` 값을 유지해도 된다.
빈 상태 UX:
- 조회 결과 `data=null`이면 빈 폼을 보여준다.
- 필요하면 안내 문구 `등록된 받을 주소가 없습니다. 주소를 입력하고 저장해주세요.`를 표시한다.
에러 처리:
- API 실패 시 기존 관리자 페이지의 공통 에러 토스트/알림 패턴을 따른다.
- 인증/권한 처리는 기존 관리자 API client와 토큰 처리 방식을 따른다.
테스트/검증:
- `/gift/mailbox` route가 선물함 관리 메뉴에서 접근되는지 확인한다.
- 초기 조회 시 `GET /api/v2/admin/gift-mailbox`를 호출하는지 확인한다.
- 조회 결과가 null이면 빈 폼을 표시하는지 확인한다.
- 저장 시 `PUT /api/v2/admin/gift-mailbox`와 request body가 정확한지 확인한다.
- 필수값 공백 validation 시 API를 호출하지 않는지 확인한다.
- 저장 성공 후 성공 토스트와 화면 갱신이 일어나는지 확인한다.
```
@@ -1,421 +0,0 @@
# 받은 선물 상세 및 배송지 입력 페이지 생성 프롬프트
이 문서는 Android/iOS 앱의 `선물함` 리스트에서 받은 선물 상세 페이지와 배송지 입력 UI를 생성할 때 사용하는 프롬프트다.
참고 Figma:
- 배송지 입력 전 상세 UI: `2531:36290`
- 배송지 입력 완료 후 상세 UI: `2531:36399`
- 선물 전달 완료시 상세 UI: `2531:36330`
- 선물 전달 불가시 상세 UI: `2531:36422`
전제:
- 선물함 리스트 item 탭 시 `applicationNo`를 전달받아 상세 페이지로 이동한다.
- 새 디자인 시스템을 만들지 말고 Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `response.data`다.
- 이 프롬프트는 받는 크리에이터 관점(`direction=RECEIVED`)만 다룬다.
- API 예시는 TypeScript 타입이 아니라 JSON으로 작성한다.
---
## 모바일 앱 구현 프롬프트
```text
Android/iOS 앱의 받은 선물 상세 페이지와 배송지 입력 UI를 Figma 기준으로 생성하고 API 연동을 구현해줘.
범위:
- 선물함 리스트에서 `applicationNo`를 받아 받은 선물 상세 페이지로 이동한다.
- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}` 응답으로 렌더링한다.
- `direction=RECEIVED && recipientAddressRequired=true`이면 배송지 입력 UI를 표시한다.
- 배송지 입력은 `POST /api/v2/gifts/{applicationNo}/recipient-address`로 연결한다.
- 배송지 입력 완료 후에는 상세를 다시 조회해 배송지 입력 완료 상태 UI를 표시한다.
중요 전제:
- Figma의 visual structure와 문구를 우선 따르되, 데이터 노출과 CTA 조건은 API 계약을 우선한다.
- `direction=RECEIVED` 화면에서 `senderInfo`, `mailbox`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`은 표시하지 않는다.
- `recipientAddress`가 null이면 받는 주소 섹션 자체를 표시하지 않는다. 전달 완료 후 배송지 정보가 삭제될 예정이므로 빈 섹션, skeleton, `-` 값도 표시하지 않는다.
- 전달 불가 화면의 `문의하기`는 기존 앱의 카카오 채널 문의 연결과 동일하게 연결한다.
- 전달 불가 화면의 `선물 정책 확인`은 버튼/action만 만들어두고 실제 외부 페이지 연결은 하지 않는다. 임의 URL을 연결하지 않는다.
상세 페이지 진입:
- 화면 진입 시 `GET /api/v2/gifts/{applicationNo}`를 호출한다.
- 조회 결과 `direction`이 `RECEIVED`가 아니면 이 화면을 렌더링하지 말고 기존 앱의 잘못된 접근/상세 재라우팅 정책을 따른다.
- 로딩 중에는 기존 앱 상세 화면 로딩/스켈레톤 패턴을 사용한다.
- 실패 시 기존 앱 toast/dialog와 재시도 패턴을 사용한다.
배송지 입력 전 UI:
- 적용 조건: `direction=RECEIVED && recipientAddressRequired=true`.
- Figma `2531:36290`을 따른다.
- 상단 타이틀: `선물 받기`.
- 개인정보 이용 안내 배너를 표시한다.
- 제목: `개인정보 이용 안내`
- 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 입력한 배송지는 선물 전달 및 필요한 사고 처리에서 사용되며, 전달 완료 후 파기됩니다.`
- `전달 예정 선물` 섹션을 표시한다.
- 발송인: `giftInfo.senderNickname`
- 사이즈: `giftInfo.sizeName`
- 카테고리: `giftInfo.categoryName`
- `배송지 입력` 섹션을 표시한다.
- 이름 input: `recipientName`
- 휴대폰 번호 input: `recipientPhoneNumber`
- 우편번호 input: `recipientZipCode`
- 우편번호 검색 버튼: 기존 페이지와 동일하게 Kakao 우편번호 서비스를 사용한다.
- 주소 input: `recipientAddress`
- 상세 주소 input: `recipientAddressDetail`
- `이용 약관 동의` 섹션을 표시한다.
- `소다라이브 크리에이터 상품 전달 이용약관 및 전달 가능 제한 품목을 확인하고 동의합니다.(필수)`
- `상품 전달을 위해 수령인 성명·연락처·주소 등 필요한 개인정보를 일시적으로 수집·이용하는 것에 동의합니다.(필수)`
- 두 항목 모두 체크해야 제출 가능하다.
- 약관 상세 링크는 앱에 기존 약관/webview 경로가 있으면 연결하고, 없으면 새 페이지를 만들지 않는다.
- 하단 주요 CTA: `선물 받기`.
- CTA 활성 조건:
- 이름, 휴대폰 번호, 우편번호, 주소가 공백이 아니다.
- 상세 주소는 선택 입력이다.
- 약관 2개가 모두 체크됐다.
- submit 중에는 중복 탭을 막고 loading 상태를 표시한다.
배송지 입력 API 연결:
- CTA 탭 시 `POST /api/v2/gifts/{applicationNo}/recipient-address`를 호출한다.
- request body에는 화면 입력값과 약관 동의 boolean을 그대로 전송한다.
- 성공 시 성공 toast를 표시하고 상세 페이지를 다시 조회하거나 replace navigation으로 상세를 다시 연다.
- 실패 시 기존 앱 form error/toast/dialog 패턴을 따른다. 실패 상태에서 입력값은 유지한다.
주소 검색 구현:
- 기존 앱/페이지에 Kakao 우편번호 서비스 래퍼가 있으면 반드시 재사용한다.
- 신규 연결이 필요하면 Kakao 우편번호 서비스 공식 가이드 `https://postcode.map.kakao.com/guide`를 따른다.
- SDK script는 공식 가이드의 `//t1.kakaocdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js`를 사용한다. 임의 수정본이나 별도 주소 검색 API를 만들지 않는다.
- Android/iOS WebView 또는 모바일웹 환경에서는 `window.open` 팝업이 막힐 수 있으므로, 기존 페이지와 같은 방식이 없다면 Kakao 가이드의 `embed()` 기반 레이어/페이지 삽입 방식을 우선 검토한다.
- 검색 결과 선택 시:
- `data.zonecode`를 `recipientZipCode`에 넣는다.
- `data.userSelectedType === "R"`이면 `data.roadAddress`를, 그 외에는 `data.jibunAddress`를 `recipientAddress`에 넣는다.
- 주소 선택 후 `recipientAddressDetail` 입력으로 포커스를 이동한다.
- 상세 주소는 Kakao 결과에서 채우지 않고 사용자가 직접 입력하게 한다.
- 주소 검색 실패/취소 시 입력 중이던 이름, 휴대폰 번호, 약관 체크 상태를 초기화하지 않는다.
배송지 입력 완료 후 상세 UI:
- 적용 조건: `direction=RECEIVED && recipientAddressRequired=false && status`가 `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 중 하나이며 `recipientAddress`가 있다.
- Figma `2531:36399`를 따른다.
- 상단 타이틀: `선물 상세`.
- hero title: `배송지 입력 완료!`
- hero body: `빠른 시일 내로 선물을 전달 드릴게요.`
- `문의하기` 버튼을 표시하고 기존 카카오 채널 문의로 연결한다.
- `선물 정보` 섹션을 표시한다.
- 발송인: `giftInfo.senderNickname`
- 사이즈: `giftInfo.sizeName`
- 카테고리: `giftInfo.categoryName`
- 배송 신청일: `giftInfo.shippingRequestedAt`
- `받는 주소` 섹션은 `recipientAddress`가 있을 때만 표시한다.
- 이름: `recipientAddress.name`
- 휴대폰 번호: `recipientAddress.phoneNumber`
- 주소: `recipientAddress.address`
선물 전달 완료 상세 UI:
- 적용 조건: `direction=RECEIVED && status=DELIVERED`.
- Figma `2531:36330`을 따른다.
- 상단 타이틀: `선물 상세`.
- hero title: `선물 전달 완료!`
- hero body: `팬이 보낸 선물이 도착했어요. 선물이 도착하지 않은 경우, 문의해 주세요.`
- `문의하기` 버튼을 표시하고 기존 카카오 채널 문의로 연결한다.
- `선물 정보` 섹션을 표시한다.
- 발송인: `giftInfo.senderNickname`
- 사이즈: `giftInfo.sizeName`
- 카테고리: `giftInfo.categoryName`
- 배송 신청일: `giftInfo.shippingRequestedAt`
- 배송 완료일: `statusTimeline`에서 `DELIVERED.occurredAt`
- `받는 주소` 섹션은 `recipientAddress`가 있을 때만 표시한다.
- 섹션 보조 문구: `배송 완료 3일 후 해당 정보는 사라져요`
- 이름: `recipientAddress.name`
- 휴대폰 번호: `recipientAddress.phoneNumber`
- 주소: `recipientAddress.address`
- `recipientAddress=null`이면 `받는 주소` 섹션 전체를 숨긴다.
선물 전달 불가 상세 UI:
- 적용 조건: `direction=RECEIVED && status=UNDELIVERABLE`.
- Figma `2531:36422`를 따른다.
- 상단 타이틀: `선물 상세`.
- chip: `전달 불가`
- hero title: `전달할 수 없는 품목입니다`
- hero body: `해당 선물은 SODALIVE 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다.`
- action button:
- `문의하기`: 기존 카카오 채널 문의로 연결한다.
- `선물 정책 확인`: action handler만 연결하고 실제 외부 페이지/URL은 연결하지 않는다. 기존 앱 정책상 no-op이 금지되어 있으면 `준비 중입니다.` toast만 표시한다.
- `사유` 섹션을 표시한다.
- 사유 제목: `delivery.undeliverableReason`이 있으면 그 값을 사용한다.
- `delivery.undeliverableReason`이 null이면 `전달 불가`로 표시한다.
- 안내 문구: `해당 선물은 정책에 따라 폐기되며 반송되지 않습니다.`
- 전달 불가 상태에서는 배송지 입력 UI를 표시하지 않는다.
- `recipientAddress`가 응답에 있더라도 전달 불가 Figma에는 받는 주소 섹션이 없으므로 표시하지 않는다.
날짜 표시:
- API의 모든 날짜는 UTC ISO 문자열이다.
- 앱 공통 날짜 formatter가 있으면 재사용한다.
- Figma처럼 상세 필드 날짜는 `YYYY.MM.DD`로 표시한다.
- 값이 null이면 해당 row를 숨긴다.
API 명세:
선물 상세 조회:
- Method: `GET`
- URL: `/api/v2/gifts/{applicationNo}`
- 인증: 로그인 필요
- Response envelope 예시 - 배송지 입력 전:
```json
{
"success": true,
"data": {
"applicationNo": "A-1002609300001",
"direction": "RECEIVED",
"status": "TRACKING_REGISTERED",
"statusName": "발송 확인",
"giftInfo": {
"recipientCreatorNickname": null,
"senderNickname": "팬닉네임이들어가는부분",
"sizeName": "소형",
"categoryName": "신규 전자굿즈",
"applicationNo": null,
"paidCan": null,
"tracking": null,
"shippingRequestedAt": "2026-10-12T03:00:00Z"
},
"senderInfo": null,
"recipientAddress": null,
"mailbox": null,
"trackingRequired": false,
"recipientAddressRequired": true,
"recipientAddressDeadlineAt": "2026-10-19T03:00:00Z",
"delivery": {
"canceledAt": null,
"undeliverableAt": null,
"undeliverableReason": null
},
"statusTimeline": [
{
"status": "RECEIVED",
"statusName": "접수 완료",
"occurredAt": "2026-10-11T03:00:00Z"
},
{
"status": "TRACKING_REGISTERED",
"statusName": "발송 확인",
"occurredAt": "2026-10-12T03:00:00Z"
},
{
"status": "ARRIVED_AT_MAILBOX",
"statusName": "사서함 도착",
"occurredAt": null
},
{
"status": "INSPECTION_COMPLETED",
"statusName": "검수완료",
"occurredAt": null
},
{
"status": "DELIVERED",
"statusName": "전달완료",
"occurredAt": null
}
]
},
"message": ""
}
```
Response envelope 예시 - 배송지 입력 완료/전달 완료:
```json
{
"success": true,
"data": {
"applicationNo": "A-1002609300001",
"direction": "RECEIVED",
"status": "DELIVERED",
"statusName": "전달완료",
"giftInfo": {
"recipientCreatorNickname": null,
"senderNickname": "팬닉네임이들어가는부분",
"sizeName": "소형",
"categoryName": "신규 전자굿즈",
"applicationNo": null,
"paidCan": null,
"tracking": null,
"shippingRequestedAt": "2026-10-12T03:00:00Z"
},
"senderInfo": null,
"recipientAddress": {
"name": "크리에이터이름",
"phoneNumber": "01012345678",
"address": "(04030) 서울특별시 마포구 양화로 000, 3층"
},
"mailbox": null,
"trackingRequired": false,
"recipientAddressRequired": false,
"recipientAddressDeadlineAt": "2026-10-19T03:00:00Z",
"delivery": {
"canceledAt": null,
"undeliverableAt": null,
"undeliverableReason": null
},
"statusTimeline": [
{
"status": "RECEIVED",
"statusName": "접수 완료",
"occurredAt": "2026-10-11T03:00:00Z"
},
{
"status": "TRACKING_REGISTERED",
"statusName": "발송 확인",
"occurredAt": "2026-10-12T03:00:00Z"
},
{
"status": "ARRIVED_AT_MAILBOX",
"statusName": "사서함 도착",
"occurredAt": "2026-10-14T03:00:00Z"
},
{
"status": "INSPECTION_COMPLETED",
"statusName": "검수완료",
"occurredAt": "2026-10-16T03:00:00Z"
},
{
"status": "DELIVERED",
"statusName": "전달완료",
"occurredAt": "2026-10-22T03:00:00Z"
}
]
},
"message": ""
}
```
Response envelope 예시 - 전달 불가:
```json
{
"success": true,
"data": {
"applicationNo": "A-1002609300001",
"direction": "RECEIVED",
"status": "UNDELIVERABLE",
"statusName": "전달불가",
"giftInfo": {
"recipientCreatorNickname": null,
"senderNickname": "팬닉네임이들어가는부분",
"sizeName": "소형",
"categoryName": "신규 전자굿즈",
"applicationNo": null,
"paidCan": null,
"tracking": null,
"shippingRequestedAt": "2026-10-12T03:00:00Z"
},
"senderInfo": null,
"recipientAddress": null,
"mailbox": null,
"trackingRequired": false,
"recipientAddressRequired": false,
"recipientAddressDeadlineAt": "2026-10-19T03:00:00Z",
"delivery": {
"canceledAt": null,
"undeliverableAt": "2026-10-16T03:00:00Z",
"undeliverableReason": "음식물 또는 보관이 어려운 품목"
},
"statusTimeline": [
{
"status": "RECEIVED",
"statusName": "접수 완료",
"occurredAt": "2026-10-11T03:00:00Z"
},
{
"status": "TRACKING_REGISTERED",
"statusName": "발송 확인",
"occurredAt": "2026-10-12T03:00:00Z"
},
{
"status": "ARRIVED_AT_MAILBOX",
"statusName": "사서함 도착",
"occurredAt": "2026-10-14T03:00:00Z"
},
{
"status": "INSPECTION_COMPLETED",
"statusName": "검수완료",
"occurredAt": null
},
{
"status": "DELIVERED",
"statusName": "전달완료",
"occurredAt": null
}
]
},
"message": ""
}
```
선물 상세 response `data` 필드:
| 필드 | 설명 |
|---|---|
| `applicationNo` | 선물 신청번호. 화면 진입 key로 사용한다. |
| `direction` | 이 프롬프트에서는 `RECEIVED`만 렌더링한다. |
| `status` | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`, `DELIVERED`, `UNDELIVERABLE` 등을 상태별 UI 분기에 사용한다. |
| `giftInfo.senderNickname` | 받은 선물 관점의 발송인 팬 닉네임. |
| `giftInfo.sizeName` | 선물 사이즈명. |
| `giftInfo.categoryName` | 카테고리명. |
| `giftInfo.shippingRequestedAt` | 배송 신청일. UTC ISO 문자열. |
| `recipientAddress` | 크리에이터 본인이 입력한 배송지. null이면 받는 주소 섹션을 숨긴다. |
| `recipientAddressRequired` | 배송지 입력 UI 표시 여부. |
| `recipientAddressDeadlineAt` | 배송지 입력 마감일. 필요하면 안내 문구에 사용한다. |
| `delivery.undeliverableReason` | 전달 불가 사유. |
| `statusTimeline` | 배송 완료일 등 상태별 날짜를 찾는 데 사용한다. |
배송지 입력:
- Method: `POST`
- URL: `/api/v2/gifts/{applicationNo}/recipient-address`
- 인증: 로그인 필요
- Request:
```json
{
"recipientName": "크리에이터이름",
"recipientPhoneNumber": "01012345678",
"recipientZipCode": "04030",
"recipientAddress": "서울특별시 마포구 양화로 000",
"recipientAddressDetail": "3층",
"recipientTermsAgreed": true,
"recipientPrivacyAgreed": true
}
```
- Response envelope 예시:
```json
{
"success": true,
"data": {
"applicationNo": "A-1002609300001",
"status": "TRACKING_REGISTERED",
"statusName": "발송 확인",
"recipientAddressRegisteredAt": "2026-10-13T03:00:00Z"
},
"message": ""
}
```
배송지 입력 response `data` 필드:
| 필드 | 설명 |
|---|---|
| `applicationNo` | 선물 신청번호 |
| `status` | 등록 후 상태. 일반적으로 기존 진행 상태 유지 |
| `statusName` | 서버 상태 표시명 |
| `recipientAddressRegisteredAt` | 배송지 입력 완료 시각. UTC ISO 문자열 |
테스트/검증:
- 리스트 item 탭 시 `applicationNo`로 받은 선물 상세 페이지가 열리는지 확인한다.
- 상세 진입 시 `GET /api/v2/gifts/{applicationNo}`가 호출되는지 확인한다.
- `direction=RECEIVED && recipientAddressRequired=true`이면 배송지 입력 UI가 표시되는지 확인한다.
- 배송지 입력 필수값 또는 약관 동의가 누락되면 `POST /recipient-address`를 호출하지 않는지 확인한다.
- 배송지 입력 submit 시 request body가 API 명세와 일치하는지 확인한다.
- 배송지 입력 성공 후 상세가 재조회되고 배송지 입력 완료 UI가 표시되는지 확인한다.
- 배송지 입력 완료/전달 완료 상태에서 `recipientAddress`가 있으면 받는 주소 섹션이 표시되는지 확인한다.
- `recipientAddress=null`이면 받는 주소 섹션 자체가 표시되지 않는지 확인한다.
- `status=DELIVERED`이면 전달 완료 UI와 배송 완료일이 표시되는지 확인한다.
- `status=UNDELIVERABLE`이면 전달 불가 UI, 사유, 폐기/반송 불가 안내가 표시되는지 확인한다.
- 전달 불가 화면의 `문의하기`는 기존 카카오 채널 문의로 연결되는지 확인한다.
- 전달 불가 화면의 `선물 정책 확인`은 버튼 action만 있고 실제 외부 URL을 열지 않는지 확인한다.
- 모든 날짜는 UTC ISO 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다.
```
@@ -1,128 +0,0 @@
CREATE TABLE gift_category (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
classification_number VARCHAR(3) NOT NULL COMMENT '신청번호에 사용하는 3자리 분류번호',
category_code VARCHAR(50) NOT NULL COMMENT '내부 범용코드',
name VARCHAR(50) NOT NULL COMMENT '분류명',
receipt_code VARCHAR(9) NOT NULL COMMENT '신청번호에 사용하는 접수코드',
representative_item VARCHAR(100) NOT NULL COMMENT '대표품목',
requires_damage_waiver TINYINT(1) NOT NULL COMMENT '파손면책 동의 필요 여부',
is_active TINYINT(1) NOT NULL COMMENT '활성 여부',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_gift_category_classification_number (classification_number),
UNIQUE KEY uk_gift_category_code (category_code),
UNIQUE KEY uk_gift_category_receipt_code (receipt_code)
) COMMENT='선물 카테고리';
CREATE TABLE gift_mailbox (
id BIGINT NOT NULL COMMENT '전역 단일 설정 ID. 항상 1',
name VARCHAR(50) NOT NULL COMMENT '받을 사람 이름',
phone_number VARCHAR(30) NOT NULL COMMENT '받을 연락처',
zip_code VARCHAR(20) NOT NULL COMMENT '받을 우편번호',
address VARCHAR(255) NOT NULL COMMENT '받을 주소',
address_detail VARCHAR(255) NULL COMMENT '받을 상세주소',
PRIMARY KEY (id)
) COMMENT='선물 받을 주소 전역 설정';
CREATE TABLE gift (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
application_no VARCHAR(40) NOT NULL COMMENT '신청번호',
sender_member_id BIGINT NOT NULL COMMENT '보내는 팬 회원번호',
recipient_member_id BIGINT NOT NULL COMMENT '받는 크리에이터 회원번호',
status VARCHAR(30) NOT NULL COMMENT '선물 상태',
size_code VARCHAR(20) NOT NULL COMMENT '선물 사이즈 코드',
category_id BIGINT NOT NULL COMMENT '신청 시 카테고리 ID',
category_name_snapshot VARCHAR(50) NOT NULL COMMENT '신청 시 카테고리명 스냅샷',
sale_price_can INT NOT NULL COMMENT '신청 시 결제 금액 스냅샷',
can_usage_id BIGINT NOT NULL COMMENT '캔 사용내역 ID',
sender_terms_agreed TINYINT(1) NOT NULL COMMENT '팬 이용약관 동의 여부',
sender_privacy_agreed TINYINT(1) NOT NULL COMMENT '팬 개인정보 동의 여부',
sender_terms_agreed_at TIMESTAMP NOT NULL COMMENT '팬 이용약관 동의 시각',
sender_privacy_agreed_at TIMESTAMP NOT NULL COMMENT '팬 개인정보 동의 시각',
recipient_terms_agreed TINYINT(1) NOT NULL COMMENT '크리에이터 이용약관 동의 여부',
recipient_privacy_agreed TINYINT(1) NOT NULL COMMENT '크리에이터 개인정보 동의 여부',
recipient_terms_agreed_at TIMESTAMP NULL COMMENT '크리에이터 이용약관 동의 시각',
recipient_privacy_agreed_at TIMESTAMP NULL COMMENT '크리에이터 개인정보 동의 시각',
damage_waiver_agreed TINYINT(1) NOT NULL COMMENT '파손면책 동의 여부',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_gift_application_no (application_no),
CONSTRAINT fk_gift_sender_member FOREIGN KEY (sender_member_id) REFERENCES member (id),
CONSTRAINT fk_gift_recipient_member FOREIGN KEY (recipient_member_id) REFERENCES member (id),
CONSTRAINT fk_gift_category FOREIGN KEY (category_id) REFERENCES gift_category (id),
CONSTRAINT fk_gift_can_usage FOREIGN KEY (can_usage_id) REFERENCES use_can (id)
) COMMENT='선물 신청';
CREATE TABLE gift_delivery (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
gift_id BIGINT NOT NULL COMMENT '선물 ID',
sender_name VARCHAR(50) NOT NULL COMMENT '발신자 이름',
sender_phone_number VARCHAR(30) NOT NULL COMMENT '발신자 휴대폰 번호',
sender_zip_code VARCHAR(20) NOT NULL COMMENT '발신자 우편번호',
sender_address VARCHAR(255) NOT NULL COMMENT '발신자 주소',
sender_address_detail VARCHAR(255) NULL COMMENT '발신자 상세주소',
recipient_name VARCHAR(50) NULL COMMENT '수신자 이름',
recipient_phone_number VARCHAR(30) NULL COMMENT '수신자 휴대폰 번호',
recipient_zip_code VARCHAR(20) NULL COMMENT '수신자 우편번호',
recipient_address VARCHAR(255) NULL COMMENT '수신자 주소',
recipient_address_detail VARCHAR(255) NULL COMMENT '수신자 상세주소',
courier_company_name VARCHAR(50) NULL COMMENT '택배사 한글 표시명',
tracking_number VARCHAR(100) NULL COMMENT '운송장 번호',
tracking_deadline_at TIMESTAMP NOT NULL COMMENT '운송장 등록 기한',
recipient_address_deadline_at TIMESTAMP NULL COMMENT '수신자 배송지 입력 기한',
tracking_deadline_reminder_sent_at TIMESTAMP NULL COMMENT '운송장 등록 기한 안내 발송 시각',
recipient_address_deadline_reminder_sent_at TIMESTAMP NULL COMMENT '수신자 배송지 입력 기한 안내 발송 시각',
tracking_registered_at TIMESTAMP NULL COMMENT '운송장 등록 시각',
arrived_at_mailbox_at TIMESTAMP NULL COMMENT '사서함 도착 시각',
inspection_completed_at TIMESTAMP NULL COMMENT '검수완료 시각',
delivered_at TIMESTAMP NULL COMMENT '전달완료 시각',
canceled_at TIMESTAMP NULL COMMENT '취소 시각',
undeliverable_at TIMESTAMP NULL COMMENT '전달불가 시각',
undeliverable_reason VARCHAR(255) NULL COMMENT '전달불가 사유',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_gift_delivery_gift_id (gift_id),
CONSTRAINT fk_gift_delivery_gift FOREIGN KEY (gift_id) REFERENCES gift (id)
) COMMENT='선물 배송 정보';
CREATE TABLE gift_size_price (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
size_code VARCHAR(20) NOT NULL COMMENT '선물 사이즈 코드',
base_price_can INT NOT NULL COMMENT '기본 금액',
sale_price_can INT NOT NULL COMMENT '결제 금액',
is_active TINYINT(1) NOT NULL COMMENT '활성 여부',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_gift_size_price_size_code (size_code)
) COMMENT='선물 사이즈별 가격';
CREATE TABLE gift_review (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
gift_id BIGINT NOT NULL COMMENT '선물 ID',
sender_member_id BIGINT NOT NULL COMMENT '리뷰 작성 팬 회원번호',
rating INT NOT NULL COMMENT '별점',
keywords TEXT NULL COMMENT '리뷰 키워드 목록',
comment VARCHAR(255) NULL COMMENT '추가의견',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_gift_review_gift_id (gift_id),
CONSTRAINT fk_gift_review_gift FOREIGN KEY (gift_id) REFERENCES gift (id),
CONSTRAINT fk_gift_review_sender_member FOREIGN KEY (sender_member_id) REFERENCES member (id)
) COMMENT='선물 리뷰';
CREATE TABLE gift_application_no_sequence (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
category_id BIGINT NOT NULL COMMENT '채번 기준 카테고리 ID',
sequence_date VARCHAR(6) NOT NULL COMMENT '채번 기준일 yyMMdd',
last_sequence INT NOT NULL COMMENT '마지막 일련번호',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_gift_application_no_sequence_category_date (category_id, sequence_date),
CONSTRAINT fk_gift_application_no_sequence_category FOREIGN KEY (category_id) REFERENCES gift_category (id)
) COMMENT='선물 신청번호 날짜별 채번';
@@ -1,263 +0,0 @@
# 선물 보내기 페이지 생성 프롬프트
이 문서는 Android/iOS 앱의 `선물 보내기` 페이지를 생성하거나 기존 생성 UI에 API 연동을 붙일 때 사용하는 프롬프트다.
참고 Figma:
- 빈 입력 상태: `2481:18904`
- 입력 완료 및 CTA 활성화 상태: `2481:18957`
전제:
- 크리에이터 닉네임과 `memberId`를 받아 `선물 보내기` 화면으로 이동하는 흐름은 이미 생성되어 있다.
- 새 진입 흐름을 만들지 않는다.
- Figma에 있는 UI 구조와 문구를 유지한다.
- Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `data`에 있다.
- request/response 설명은 모바일 공통 JSON 기준이다.
---
## 모바일 앱 구현 프롬프트
```text
Android/iOS 앱의 선물 보내기 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘.
중요 전제:
- 크리에이터 닉네임과 memberId를 받아 이 화면으로 이동하는 기능은 이미 구현되어 있다.
- 이 작업에서는 이동 경로를 새로 만들지 말고, 전달받은 값만 화면과 API request에 연결한다.
- 전달받은 크리에이터 닉네임은 `받는 크리에이터` 영역에 표시한다.
- 전달받은 memberId는 선물 신청 API의 `recipientMemberId`로 보낸다.
Figma 참고 상태:
- 빈 입력 상태: node `2481:18904`
- 입력 완료 및 최하단 CTA 활성화 상태: node `2481:18957`
화면 구조:
1. 상단
- 타이틀: `선물 보내기`
- 뒤로가기 버튼은 기존 앱 패턴을 사용한다.
2. 안내 배너
- 제목: `선물 보내기 베타 서비스 안내`
- 본문: `베타 기간 동안은 일부 기능만 제공됩니다. 크리에이터에게 마음을 잘 전할 수 있도록 더 넓어진 선물 보내기로 곧 다시 만나요!`
3. 받는 크리에이터
- 이전 화면에서 전달받은 크리에이터 닉네임을 표시한다.
- Figma의 프로필 row 형태를 유지한다.
- memberId는 화면 표시용이 아니라 API request용으로만 사용한다.
4. 선물 사이즈
- 화면 진입 시 `GET /api/v2/gifts/form-options`를 호출해 `sizes`를 가져온다.
- 조회된 `sizes`를 사이즈 카드 목록에 바인딩한다.
- 카드에는 `name`, 사이즈 설명, 캔 가격을 표시한다.
- 서버 응답에는 사이즈 설명 필드가 없으므로 설명 문구는 앱에서 코드별로 매핑한다.
- `SMALL`: `세 변의 합 100cm 이하 · 5kg 이하`
- `MEDIUM`, `LARGE`: 정책 문구가 앱에 이미 있으면 기존 문구를 사용하고, 없으면 이름과 가격만 표시한다.
- 가격은 `salePriceCan`을 표시하고 submit 금액도 `salePriceCan`을 사용한다.
- `basePriceCan`과 `salePriceCan`이 다르면 기존 앱 할인/정가 표시 패턴이 있을 때만 정가를 함께 보여준다.
- 기본 선택은 서버가 내려준 첫 번째 사이즈로 둔다.
5. 카테고리
- 화면 진입 시 같은 `GET /api/v2/gifts/form-options` 응답의 `categories`를 사용한다.
- Figma의 select field를 유지한다.
- 빈 상태 문구: `카테고리 선택`
- 선택 후에는 선택한 카테고리 `name`을 표시한다.
- 카테고리 선택 UI는 기존 Android/iOS 앱 패턴에 맞는 bottom sheet, picker, dialog 중 이미 쓰는 방식을 사용한다.
- `requiresDamageWaiver=true`인 카테고리를 선택한 경우에만 카테고리 아래에 파손 면책 동의 row를 표시한다.
- 파손 면책 동의 문구: `파손 및 분실 면책 사항에 동의합니다. (필수)`
- `requiresDamageWaiver=false` 카테고리를 선택하면 파손 면책 동의 row를 숨기고 `damageWaiverAgreed=false`로 초기화한다.
6. 보내는 사람
- 이름 input → `senderName`
- 휴대폰 번호 input → `senderPhoneNumber`
- 우편번호 input → `senderZipCode`
- 주소 input → `senderAddress`
- 상세 주소 input → `senderAddressDetail`
- `우편번호 검색` 버튼은 기존 주소 검색 기능과 연결한다.
- 주소 검색 결과로 우편번호와 기본 주소를 채운다.
- 기본 주소는 사용자가 직접 수정하지 못하게 하는 기존 패턴이 있으면 그 패턴을 따른다.
7. 이용 약관 동의
- Figma의 발송 규정 요약 박스와 체크박스 2개를 유지한다.
- `소다라이브 크리에이터 상품 전달 이용약관에 동의합니다. (필수)` → `senderTermsAgreed`
- `상품 전달을 위한 개인정보 수집·이용에 동의합니다. (필수)` → `senderPrivacyAgreed`
8. 하단 CTA
- 빈 상태 또는 필수값 누락 상태는 Figma 빈 상태처럼 비활성 버튼을 표시한다.
- 활성 조건을 모두 만족하면 Figma 활성 상태처럼 soda 색상 CTA로 바꾼다.
- 활성 CTA 문구는 선택된 사이즈의 `salePriceCan`을 사용해 `{salePriceCan}캔으로 선물 보내기`로 표시한다.
- submit 중에는 중복 탭을 막고 loading 상태를 표시한다.
화면 진입 시 처리:
- 이미 전달받은 `creatorNickname`과 `memberId`를 읽는다.
- 둘 중 하나라도 없으면 기존 앱의 오류 처리 또는 뒤로가기 패턴을 따른다.
- 즉시 `GET /api/v2/gifts/form-options`를 호출한다.
- 로딩 중에는 기존 화면 스켈레톤/로딩 패턴을 사용한다.
- 조회 실패 시 toast/dialog와 재시도 동작을 기존 패턴으로 제공한다.
- `sizes` 또는 `categories`가 비어 있으면 선물 신청을 막고 CTA를 비활성화한다.
CTA 활성 조건:
- `memberId`가 있다.
- 사이즈가 선택되어 있다.
- 카테고리가 선택되어 있다.
- 이름이 입력되어 있다.
- 휴대폰 번호가 입력되어 있다.
- 우편번호가 입력되어 있다.
- 주소가 입력되어 있다.
- 이용약관 동의가 true다.
- 개인정보 수집·이용 동의가 true다.
- 선택한 카테고리의 `requiresDamageWaiver=true`이면 파손 면책 동의가 true다.
검증 실패 처리:
- CTA는 기본적으로 위 조건을 만족할 때만 활성화한다.
- 그래도 submit 시점에 한 번 더 validation 한다.
- 누락된 필드는 기존 앱의 input error, toast, dialog 중 현재 화면 패턴에 맞춰 안내한다.
- validation 실패 시 `POST /api/v2/gifts`를 호출하지 않는다.
폼 옵션 조회 API:
- Method: `GET`
- URL: `/api/v2/gifts/form-options`
- Request: 없음
- Response envelope 예시:
```json
{
"success": true,
"data": {
"sizes": [],
"categories": []
},
"message": ""
}
```
- Response `data` 예시:
```json
{
"sizes": [
{
"sizeCode": "SMALL",
"name": "소형",
"basePriceCan": 100,
"salePriceCan": 80
}
],
"categories": [
{
"categoryId": 1,
"name": "아크릴",
"requiresDamageWaiver": true
}
]
}
```
폼 옵션 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
| `sizes` | array | 선택 가능한 선물 사이즈 목록 |
| `sizes[].sizeCode` | string | 사이즈 코드. `SMALL`, `MEDIUM`, `LARGE` |
| `sizes[].name` | string | 사이즈 표시명 |
| `sizes[].basePriceCan` | number | 기본 가격, 단위는 캔 |
| `sizes[].salePriceCan` | number | 실제 결제 가격, 단위는 캔 |
| `categories` | array | 선택 가능한 활성 카테고리 목록 |
| `categories[].categoryId` | number | 카테고리 ID |
| `categories[].name` | string | 카테고리 표시명 |
| `categories[].requiresDamageWaiver` | boolean | 파손 면책 동의 필요 여부 |
선물 신청 접수 API:
- Method: `POST`
- URL: `/api/v2/gifts`
- 인증: 로그인 회원 필요. 기존 앱 인증 토큰/세션 처리 방식을 사용한다.
- Request body 예시:
```json
{
"recipientMemberId": 100,
"senderName": "김소다",
"senderPhoneNumber": "01000000000",
"senderZipCode": "12345",
"senderAddress": "서울시 강남구 ...",
"senderAddressDetail": "123동 456호",
"sizeCode": "SMALL",
"categoryId": 1,
"senderTermsAgreed": true,
"senderPrivacyAgreed": true,
"damageWaiverAgreed": true
}
```
Request body 필드:
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `recipientMemberId` | number | Y | 화면 진입 시 전달받은 memberId |
| `senderName` | string | Y | 보내는 사람 이름 |
| `senderPhoneNumber` | string | Y | 보내는 사람 휴대폰 번호 |
| `senderZipCode` | string | Y | 보내는 사람 우편번호 |
| `senderAddress` | string | Y | 보내는 사람 기본 주소 |
| `senderAddressDetail` | string 또는 null | N | 보내는 사람 상세 주소 |
| `sizeCode` | string | Y | 선택한 사이즈 코드 |
| `categoryId` | number | Y | 선택한 카테고리 ID |
| `senderTermsAgreed` | boolean | Y | 이용약관 동의 여부. 반드시 `true` |
| `senderPrivacyAgreed` | boolean | Y | 개인정보 수집·이용 동의 여부. 반드시 `true` |
| `damageWaiverAgreed` | boolean | Y | 파손 면책 동의 여부. 필요한 카테고리에서는 `true`, 필요 없는 카테고리에서는 `false` |
- Response `data` 예시:
```json
{
"applicationNo": "A-1002609300001",
"status": "RECEIVED",
"statusName": "접수 완료",
"priceCan": 80,
"trackingDeadlineAt": "2026-10-02T03:00:00Z"
}
```
Response `data` 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
| `applicationNo` | string | 선물 신청번호 |
| `status` | string | 신청 상태 코드 |
| `statusName` | string | 신청 상태 표시명 |
| `priceCan` | number | 실제 차감된 캔 금액 |
| `trackingDeadlineAt` | string | 운송장 등록 기한. UTC ISO 문자열 |
성공 처리:
- 신청 성공 시 기존 앱의 완료 dialog 또는 완료 화면 패턴을 사용한다.
- 최소 표시값은 `applicationNo`, `statusName`, `priceCan`, `trackingDeadlineAt`이다.
- 성공 이후 이동 CTA가 필요하면 기존 선물함 또는 선물 상세 이동 패턴을 사용한다.
실패 처리:
- 서버 오류 메시지가 있으면 기존 앱 에러 노출 방식으로 표시한다.
- 잔액 부족, 유효하지 않은 카테고리, 파손 면책 미동의, 약관 미동의 등의 오류는 서버 메시지를 우선 사용한다.
- 실패 시 입력값은 유지한다.
테스트/검증:
- 화면 진입 시 `GET /api/v2/gifts/form-options`가 호출되는지 확인한다.
- 전달받은 크리에이터 닉네임이 받는 크리에이터 영역에 표시되는지 확인한다.
- 전달받은 memberId가 `POST /api/v2/gifts`의 `recipientMemberId`로 들어가는지 확인한다.
- 빈 상태에서는 CTA가 비활성화되는지 확인한다.
- 필수 입력과 필수 동의를 모두 완료하면 CTA가 활성화되고 `{salePriceCan}캔으로 선물 보내기`가 표시되는지 확인한다.
- `requiresDamageWaiver=true` 카테고리 선택 시 파손 면책 동의 row가 표시되고 동의 전에는 CTA가 비활성인지 확인한다.
- `requiresDamageWaiver=false` 카테고리 선택 시 파손 면책 동의 row가 숨겨지고 request의 `damageWaiverAgreed`가 false인지 확인한다.
- submit 성공 시 요청 body와 성공 처리 값을 확인한다.
- submit 실패 시 입력값이 유지되고 기존 앱 방식으로 오류가 표시되는지 확인한다.
```
---
## API 요약
| 목적 | Method | URL | Request | Response data |
|---|---|---|---|---|
| 폼 옵션 조회 | GET | `/api/v2/gifts/form-options` | 없음 | 사이즈 목록, 카테고리 목록 |
| 선물 신청 접수 | POST | `/api/v2/gifts` | 선물 신청 JSON body | 신청번호, 상태, 차감 금액, 운송장 등록 기한 |
## Figma 반영 메모
- 빈 상태 Figma는 카테고리 미선택, 보내는 사람 정보 미입력, 약관 미동의, 하단 CTA 비활성 상태다.
- 활성 상태 Figma는 크리에이터 닉네임 표시, 카테고리 선택, 파손 면책 동의 노출, 보내는 사람 정보 입력, 약관 동의, 하단 CTA 활성 상태다.
- Figma의 하단 버튼 텍스트는 캔 금액이 포함된 형태이므로 API의 `salePriceCan`으로 동적으로 표시한다.
- Figma의 select, checkbox, radio, bottom action bar, safe area는 각 플랫폼의 기존 컴포넌트로 대응한다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@ class CanController(private val service: CanService) {
fun getCans(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<List<CanResponse>> {
val forcedCurrency = if (member != null && (member.id == 4L || member.id == 44144L)) {
val forcedCurrency = if (member != null && (member.id == 2L || member.id == 4L || member.id == 44144L)) {
"JPY"
} else {
null
@@ -120,7 +120,6 @@ class CanService(
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
CanUsage.GIFT -> "선물하기"
}
val createdAt = it.createdAt
@@ -54,7 +54,7 @@ class CanPaymentService(
communityPost: CreatorCommunity? = null,
auditionApplicant: AuditionApplicant? = null,
container: String
): UseCan {
) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "can.payment.invalid_request_retry")
val useRewardCan = spendRewardCan(member, needCan, container)
@@ -131,8 +131,6 @@ class CanPaymentService(
useCan.member = member
useCan.chatRoomId = chatRoomId
useCan.characterId = characterId
} else if (canUsage == CanUsage.GIFT) {
useCan.member = member
} else {
throw SodaException(messageKey = "common.error.invalid_request")
}
@@ -162,18 +160,6 @@ class CanPaymentService(
useCan,
paymentGateway = PaymentGateway.APPLE_IAP
)
return useCan
}
@Transactional
fun spendGiftCan(memberId: Long, needCan: Int, container: String): Long {
return spendCan(
memberId = memberId,
needCan = needCan,
canUsage = CanUsage.GIFT,
container = container
).id!!
}
private fun setUseCanCalculate(
@@ -338,24 +324,6 @@ class CanPaymentService(
roomId = roomId,
canUsage = CanUsage.LIVE
) ?: throw SodaException(messageKey = "can.payment.invalid_reservation")
refundUseCan(member, useCan)
}
@Transactional
fun refundGiftCan(memberId: Long, useCanId: Long) {
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException(messageKey = "common.error.invalid_request")
val useCan = useCanRepository.findByIdOrNull(useCanId)
?: throw SodaException(messageKey = "common.error.invalid_request")
if (useCan.member?.id != member.id || useCan.canUsage != CanUsage.GIFT || useCan.isRefund) {
throw SodaException(messageKey = "common.error.invalid_request")
}
refundUseCan(member, useCan)
}
private fun refundUseCan(member: Member, useCan: UseCan) {
useCan.isRefund = true
val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
@@ -14,6 +14,5 @@ enum class CanUsage {
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
CHAT_ROOM_RESET, // 채팅방 초기화 결제(별도 구분)
GIFT
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
}
@@ -25,8 +25,7 @@ enum class FcmDeepLinkValue(val value: String) {
SERIES("series"),
AUDITION("audition"),
COMMUNITY("community"),
CHAT("chat"),
GIFT_DETAIL("gift-detail")
CHAT("chat")
}
class FcmEvent(
@@ -51,7 +50,7 @@ class FcmEvent(
val creatorId: Long? = null,
val auditionId: Long? = null,
val deepLinkValue: FcmDeepLinkValue? = null,
val deepLinkId: Any? = null,
val deepLinkId: Long? = null,
val deepLinkCommentPostId: Long? = null,
val commentParentId: Long? = null,
val myMemberId: Long? = null,
@@ -32,7 +32,7 @@ class FcmService(
creatorId: Long? = null,
auditionId: Long? = null,
deepLinkValue: FcmDeepLinkValue? = null,
deepLinkId: Any? = null,
deepLinkId: Long? = null,
deepLinkCommentPostId: Long? = null,
chatType: String? = null
) {
@@ -118,7 +118,7 @@ class FcmService(
private fun createDeepLink(
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Any?,
deepLinkId: Long?,
deepLinkCommentPostId: Long?
): String? {
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)
@@ -194,7 +194,7 @@ class FcmService(
fun buildDeepLink(
serverEnv: String,
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Any?,
deepLinkId: Long?,
deepLinkCommentPostId: Long? = null
): String? {
if (deepLinkValue == null || deepLinkId == null) {
@@ -222,7 +222,7 @@ class FcmService(
creatorId: Long? = null,
auditionId: Long? = null,
deepLinkValue: FcmDeepLinkValue? = null,
deepLinkId: Any? = null,
deepLinkId: Long? = null,
deepLinkCommentPostId: Long? = null,
deepLink: String? = null,
chatType: String? = null
@@ -6,7 +6,6 @@ enum class PushNotificationCategory(val code: String) {
COMMUNITY("community"),
MESSAGE("message"),
AUDITION("audition"),
GIFT("gift"),
SYSTEM("system");
companion object {
@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.menu
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.stereotype.Service
@Service
@@ -9,20 +8,6 @@ class MenuService(
private val repository: MenuRepository
) {
fun getMenus(member: Member): List<GetMenuResponse> {
val menus = repository.getMenu(member.role)
if (member.role != MemberRole.ADMIN) return menus
return menus + giftManagementMenu
}
private companion object {
val giftManagementMenu = GetMenuResponse(
title = "선물함 관리",
items = listOf(
GetMenuResponse(title = "선물함 리스트", route = "/gift/list"),
GetMenuResponse(title = "선물 카테고리", route = "/gift/category"),
GetMenuResponse(title = "선물 사이즈", route = "/gift/size")
)
)
return repository.getMenu(member.role)
}
}
@@ -1,106 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftListResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceResponse
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryService
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/admin")
@PreAuthorize("hasRole('ADMIN')")
class AdminGiftController(
private val service: GiftAdminService,
private val queryService: GiftAdminQueryService
) {
@GetMapping("/gifts")
fun getGifts(
@RequestParam(required = false) status: GiftStatus?,
@RequestParam(required = false) applicationNo: String?,
@RequestParam(required = false) nickname: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = ApiResponse.ok(AdminGiftListResponse.from(queryService.getGifts(status, applicationNo, nickname, page, size)))
@GetMapping("/gifts/{applicationNo}")
fun getGiftDetail(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo))
)
@GetMapping("/gift-mailbox")
fun getMailbox() = ApiResponse.ok(service.getMailbox()?.let { AdminGiftMailboxResponse.from(it) })
@PutMapping("/gift-mailbox")
fun updateMailbox(@RequestBody request: AdminGiftMailboxRequest) = ApiResponse.ok(
AdminGiftMailboxResponse.from(service.updateMailbox(request.toCommand()))
)
@GetMapping("/gift-categories")
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
@PostMapping("/gift-categories")
fun createCategory(@RequestBody request: AdminGiftCategoryRequest) = ApiResponse.ok(
AdminGiftCategoryResponse.from(service.createCategory(request.toCommand()))
)
@PutMapping("/gift-categories/{categoryId}")
fun updateCategory(
@PathVariable categoryId: Long,
@RequestBody request: AdminGiftCategoryRequest
) = ApiResponse.ok(AdminGiftCategoryResponse.from(service.updateCategory(categoryId, request.toCommand())))
@DeleteMapping("/gift-categories/{categoryId}")
fun deleteCategory(@PathVariable categoryId: Long) = ApiResponse.ok(
AdminGiftCategoryResponse.from(service.deleteCategory(categoryId))
)
@GetMapping("/gift-size-prices")
fun getSizePrices() = ApiResponse.ok(service.getSizePrices().map { AdminGiftSizePriceResponse.from(it) })
@PutMapping("/gift-size-prices/{sizeCode}")
fun updateSizePrice(
@PathVariable sizeCode: GiftSize,
@RequestBody request: AdminGiftSizePriceRequest
) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand())))
@PostMapping("/gifts/{applicationNo}/arrive-mailbox")
fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo))
)
@PostMapping("/gifts/{applicationNo}/complete-inspection")
fun completeInspection(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftOperationStatusResponse.from(service.completeInspection(applicationNo))
)
@PostMapping("/gifts/{applicationNo}/mark-undeliverable")
fun markUndeliverable(
@PathVariable applicationNo: String,
@RequestBody request: AdminGiftMarkUndeliverableRequest
) = ApiResponse.ok(AdminGiftOperationStatusResponse.from(service.markUndeliverable(applicationNo, request.reason)))
@PostMapping("/gifts/{applicationNo}/complete-delivery")
fun completeDelivery(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftOperationStatusResponse.from(service.completeDelivery(applicationNo))
)
}
@@ -1,57 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand
data class AdminGiftCategoryRequest(
val classificationNumber: String,
val categoryCode: String,
val name: String,
val receiptCode: String,
val representativeItem: String,
val requiresDamageWaiver: Boolean,
val isActive: Boolean
) {
fun toCommand() = GiftCategoryCommand(
classificationNumber = classificationNumber,
categoryCode = categoryCode,
name = name,
receiptCode = receiptCode,
representativeItem = representativeItem,
requiresDamageWaiver = requiresDamageWaiver,
isActive = isActive
)
}
data class AdminGiftSizePriceRequest(
val basePriceCan: Int,
val salePriceCan: Int,
val isActive: Boolean
) {
fun toCommand() = GiftSizePriceCommand(
basePriceCan = basePriceCan,
salePriceCan = salePriceCan,
isActive = isActive
)
}
data class AdminGiftMailboxRequest(
val name: String,
val phoneNumber: String,
val zipCode: String,
val address: String,
val addressDetail: String?
) {
fun toCommand() = GiftMailboxCommand(
name = name,
phoneNumber = phoneNumber,
zipCode = zipCode,
address = address,
addressDetail = addressDetail
)
}
data class AdminGiftMarkUndeliverableRequest(
val reason: String
)
@@ -1,204 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftDetailResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftInboundDeliveryInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListItemResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftMemberInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
data class AdminGiftCategoryResponse(
val categoryId: Long,
val classificationNumber: String,
val categoryCode: String,
val name: String,
val receiptCode: String,
val representativeItem: String,
val requiresDamageWaiver: Boolean,
val isActive: Boolean
) {
companion object {
fun from(result: GiftCategoryResult) = AdminGiftCategoryResponse(
categoryId = result.categoryId,
classificationNumber = result.classificationNumber,
categoryCode = result.categoryCode,
name = result.name,
receiptCode = result.receiptCode,
representativeItem = result.representativeItem,
requiresDamageWaiver = result.requiresDamageWaiver,
isActive = result.isActive
)
}
}
data class AdminGiftSizePriceResponse(
val sizeCode: String,
val name: String,
val basePriceCan: Int,
val salePriceCan: Int,
val isActive: Boolean
) {
companion object {
fun from(result: GiftSizePriceResult) = AdminGiftSizePriceResponse(
sizeCode = result.sizeCode.name,
name = result.sizeName,
basePriceCan = result.basePriceCan,
salePriceCan = result.salePriceCan,
isActive = result.isActive
)
}
}
data class AdminGiftOperationStatusResponse(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val occurredAt: String
) {
companion object {
fun from(result: GiftOperationStatusResult) = AdminGiftOperationStatusResponse(
applicationNo = result.applicationNo,
status = result.status,
statusName = result.statusName,
occurredAt = result.occurredAt.toUtcIso()
)
}
}
data class AdminGiftMailboxResponse(
val name: String,
val address: String,
val phoneNumber: String
) {
companion object {
fun from(result: GiftMailboxResult) = AdminGiftMailboxResponse(
name = result.name,
address = result.address,
phoneNumber = result.phoneNumber
)
}
}
data class AdminGiftListResponse(
val totalCount: Long,
val items: List<AdminGiftListItemResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
) {
companion object {
fun from(result: AdminGiftListResult) = AdminGiftListResponse(
totalCount = result.totalCount,
items = result.items.map { AdminGiftListItemResponse.from(it) },
page = result.page,
size = result.size,
hasNext = result.hasNext
)
}
}
data class AdminGiftListItemResponse(
val applicationNo: String,
val senderNickname: String,
val recipientNickname: String,
val sizeCode: String,
val sizeName: String,
val categoryName: String,
val classificationNumber: String,
val courierCompanyName: String?,
val trackingNumber: String?,
val status: GiftStatus,
val statusName: String,
val availableActions: List<String>
) {
companion object {
fun from(result: AdminGiftListItemResult) = AdminGiftListItemResponse(
applicationNo = result.applicationNo,
senderNickname = result.senderNickname,
recipientNickname = result.recipientNickname,
sizeCode = result.sizeCode,
sizeName = result.sizeName,
categoryName = result.categoryName,
classificationNumber = result.classificationNumber,
courierCompanyName = result.courierCompanyName,
trackingNumber = result.trackingNumber,
status = result.status,
statusName = result.statusName,
availableActions = result.availableActions
)
}
}
data class AdminGiftDetailResponse(
val applicationNo: String,
val senderInfo: AdminGiftMemberInfoResponse,
val recipientInfo: AdminGiftMemberInfoResponse,
val productInfo: AdminGiftProductInfoResponse,
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse,
val status: GiftStatus,
val statusName: String,
val availableActions: List<String>
) {
companion object {
fun from(result: AdminGiftDetailResult) = AdminGiftDetailResponse(
applicationNo = result.applicationNo,
senderInfo = AdminGiftMemberInfoResponse.from(result.senderInfo),
recipientInfo = AdminGiftMemberInfoResponse.from(result.recipientInfo),
productInfo = AdminGiftProductInfoResponse.from(result.productInfo),
inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResponse.from(result.inboundDeliveryInfo),
status = result.status,
statusName = result.statusName,
availableActions = result.availableActions
)
}
}
data class AdminGiftMemberInfoResponse(
val nickname: String,
val name: String,
val phoneNumber: String,
val address: String
) {
companion object {
fun from(result: AdminGiftMemberInfoResult) = AdminGiftMemberInfoResponse(
nickname = result.nickname,
name = result.name,
phoneNumber = result.phoneNumber,
address = result.address
)
}
}
data class AdminGiftProductInfoResponse(
val sizeCode: String,
val sizeName: String,
val categoryName: String,
val classificationNumber: String
) {
companion object {
fun from(result: AdminGiftProductInfoResult) = AdminGiftProductInfoResponse(
sizeCode = result.sizeCode,
sizeName = result.sizeName,
categoryName = result.categoryName,
classificationNumber = result.classificationNumber
)
}
}
data class AdminGiftInboundDeliveryInfoResponse(
val courierCompanyName: String?,
val trackingNumber: String?
) {
companion object {
fun from(result: AdminGiftInboundDeliveryInfoResult) = AdminGiftInboundDeliveryInfoResponse(
courierCompanyName = result.courierCompanyName,
trackingNumber = result.trackingNumber
)
}
}
@@ -1,153 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationResponse
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftCancellationResponse
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDeliveryConfirmationResponse
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDetailResponse
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftFormOptionsResponse
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftListResponse
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationResponse
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewResponse
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationResponse
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
import kr.co.vividnext.sodalive.v2.gift.application.GiftQueryService
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/gifts")
class GiftController(
private val queryService: GiftQueryService,
private val commandService: GiftCommandService
) {
@GetMapping("/form-options")
fun getFormOptions() = ApiResponse.ok(GiftFormOptionsResponse.from(queryService.getFormOptions()))
@GetMapping
fun getGifts(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestParam(defaultValue = "ALL") type: GiftListType,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = ApiResponse.ok(
GiftListResponse.from(
queryService.getGifts(
memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"),
type = type,
page = page,
size = size
)
)
)
@GetMapping("/{applicationNo}")
fun getGiftDetail(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable applicationNo: String
) = ApiResponse.ok(
GiftDetailResponse.from(
queryService.getGiftDetail(
memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"),
applicationNo = applicationNo
)
)
)
@PostMapping
fun registerGift(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestBody request: GiftApplicationRequest
) = ApiResponse.ok(
GiftApplicationResponse.from(
commandService.registerGift(
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
command = request.toCommand()
)
)
)
@PostMapping("/{applicationNo}/cancel")
fun cancelGift(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable applicationNo: String
) = ApiResponse.ok(
GiftCancellationResponse.from(
commandService.cancelGift(
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
applicationNo = applicationNo
)
)
)
@PostMapping("/{applicationNo}/tracking")
fun registerTracking(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable applicationNo: String,
@RequestBody request: GiftTrackingRegistrationRequest
) = ApiResponse.ok(
GiftTrackingRegistrationResponse.from(
commandService.registerTracking(
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
applicationNo = applicationNo,
command = request.toCommand()
)
)
)
@PostMapping("/{applicationNo}/recipient-address")
fun registerRecipientAddress(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable applicationNo: String,
@RequestBody request: GiftRecipientAddressRegistrationRequest
) = ApiResponse.ok(
GiftRecipientAddressRegistrationResponse.from(
commandService.registerRecipientAddress(
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
applicationNo = applicationNo,
command = request.toCommand()
)
)
)
@PostMapping("/{applicationNo}/delivery-complete")
fun confirmDelivery(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable applicationNo: String
) = ApiResponse.ok(
GiftDeliveryConfirmationResponse.from(
commandService.confirmDelivery(
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
applicationNo = applicationNo
)
)
)
@PostMapping("/{applicationNo}/review")
fun writeReview(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable applicationNo: String,
@RequestBody request: GiftReviewRequest
) = ApiResponse.ok(
GiftReviewResponse.from(
commandService.writeReview(
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
applicationNo = applicationNo,
command = request.toCommand()
)
)
)
}
@@ -1,32 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.gift.dto
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
data class GiftApplicationRequest(
val recipientMemberId: Long,
val senderName: String,
val senderPhoneNumber: String,
val senderZipCode: String,
val senderAddress: String,
val senderAddressDetail: String?,
val sizeCode: GiftSize,
val categoryId: Long,
val senderTermsAgreed: Boolean,
val senderPrivacyAgreed: Boolean,
val damageWaiverAgreed: Boolean
) {
fun toCommand() = GiftApplicationCommand(
recipientMemberId = recipientMemberId,
senderName = senderName,
senderPhoneNumber = senderPhoneNumber,
senderZipCode = senderZipCode,
senderAddress = senderAddress,
senderAddressDetail = senderAddressDetail,
sizeCode = sizeCode,
categoryId = categoryId,
senderTermsAgreed = senderTermsAgreed,
senderPrivacyAgreed = senderPrivacyAgreed,
damageWaiverAgreed = damageWaiverAgreed
)
}
@@ -1,23 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.gift.dto
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand
data class GiftRecipientAddressRegistrationRequest(
val recipientName: String,
val recipientPhoneNumber: String,
val recipientZipCode: String,
val recipientAddress: String,
val recipientAddressDetail: String?,
val recipientTermsAgreed: Boolean,
val recipientPrivacyAgreed: Boolean
) {
fun toCommand() = GiftRecipientAddressRegistrationCommand(
name = recipientName,
phoneNumber = recipientPhoneNumber,
zipCode = recipientZipCode,
address = recipientAddress,
addressDetail = recipientAddressDetail,
recipientTermsAgreed = recipientTermsAgreed,
recipientPrivacyAgreed = recipientPrivacyAgreed
)
}
@@ -1,281 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.gift.dto
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftDetailResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftFormOptionsResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftListResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult
data class GiftFormOptionsResponse(
val sizes: List<GiftSizeOptionResponse>,
val categories: List<GiftCategoryOptionResponse>
) {
companion object {
fun from(result: GiftFormOptionsResult) = GiftFormOptionsResponse(
sizes = result.sizes.map {
GiftSizeOptionResponse(it.sizeCode.name, it.sizeName, it.basePriceCan, it.salePriceCan)
},
categories = result.categories.map {
GiftCategoryOptionResponse(it.categoryId, it.name, it.requiresDamageWaiver)
}
)
}
}
data class GiftSizeOptionResponse(
val sizeCode: String,
val name: String,
val basePriceCan: Int,
val salePriceCan: Int
)
data class GiftCategoryOptionResponse(
val categoryId: Long,
val name: String,
val requiresDamageWaiver: Boolean
)
data class GiftListResponse(
val totalCount: Long,
val items: List<GiftListItemResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
) {
companion object {
fun from(result: GiftListResult) = GiftListResponse(
totalCount = result.totalCount,
items = result.items.map {
GiftListItemResponse(
applicationNo = it.applicationNo,
direction = it.direction.name,
status = it.status.name,
statusName = it.statusName,
priceCan = it.priceCan,
categoryName = it.categoryName,
sizeName = it.sizeName,
counterpartMemberId = it.counterpartMemberId,
counterpartNickname = it.counterpartNickname,
counterpartProfileImageUrl = it.counterpartProfileImageUrl,
createdAt = it.createdAt?.toUtcIso()
)
},
page = result.page,
size = result.size,
hasNext = result.hasNext
)
}
}
data class GiftListItemResponse(
val applicationNo: String,
val direction: String,
val status: String,
val statusName: String,
val priceCan: Int,
val categoryName: String,
val sizeName: String,
val counterpartMemberId: Long,
val counterpartNickname: String,
val counterpartProfileImageUrl: String,
val createdAt: String?
)
data class GiftDetailResponse(
val applicationNo: String,
val direction: String,
val status: String,
val statusName: String,
val giftInfo: GiftDetailInfoResponse,
val senderInfo: GiftAddressResponse?,
val recipientAddress: GiftAddressResponse?,
val mailbox: GiftMailboxResponse?,
val trackingRequired: Boolean,
val recipientAddressRequired: Boolean,
val recipientAddressDeadlineAt: String?,
val delivery: GiftDeliveryInfoResponse,
val statusTimeline: List<GiftStatusTimelineResponse>
) {
companion object {
fun from(result: GiftDetailResult) = GiftDetailResponse(
applicationNo = result.applicationNo,
direction = result.direction.name,
status = result.status.name,
statusName = result.statusName,
giftInfo = GiftDetailInfoResponse(
recipientCreatorNickname = result.giftInfo.recipientCreatorNickname,
senderNickname = result.giftInfo.senderNickname,
sizeName = result.giftInfo.sizeName,
categoryName = result.giftInfo.categoryName,
applicationNo = result.giftInfo.applicationNo,
paidCan = result.giftInfo.paidCan,
tracking = result.giftInfo.tracking,
shippingRequestedAt = result.giftInfo.shippingRequestedAt?.toUtcIso()
),
senderInfo = result.senderInfo?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) },
recipientAddress = result.recipientAddress?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) },
mailbox = result.mailbox?.let { GiftMailboxResponse(it.name, it.address, it.phoneNumber) },
trackingRequired = result.trackingRequired,
recipientAddressRequired = result.recipientAddressRequired,
recipientAddressDeadlineAt = result.recipientAddressDeadlineAt?.toUtcIso(),
delivery = GiftDeliveryInfoResponse(
canceledAt = result.delivery.canceledAt?.toUtcIso(),
undeliverableAt = result.delivery.undeliverableAt?.toUtcIso(),
undeliverableReason = result.delivery.undeliverableReason
),
statusTimeline = result.statusTimeline.map {
GiftStatusTimelineResponse(it.status.name, it.statusName, it.occurredAt?.toUtcIso())
}
)
}
}
data class GiftDetailInfoResponse(
val recipientCreatorNickname: String?,
val senderNickname: String?,
val sizeName: String,
val categoryName: String,
val applicationNo: String?,
val paidCan: Int?,
val tracking: String?,
val shippingRequestedAt: String?
)
data class GiftAddressResponse(
val name: String,
val phoneNumber: String,
val address: String
)
data class GiftMailboxResponse(
val name: String,
val address: String,
val phoneNumber: String
)
data class GiftDeliveryInfoResponse(
val canceledAt: String?,
val undeliverableAt: String?,
val undeliverableReason: String?
)
data class GiftStatusTimelineResponse(
val status: String,
val statusName: String,
val occurredAt: String?
)
data class GiftApplicationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val priceCan: Int,
val trackingDeadlineAt: String
) {
companion object {
fun from(result: GiftApplicationResult) = GiftApplicationResponse(
applicationNo = result.applicationNo,
status = result.status.name,
statusName = result.statusName,
priceCan = result.priceCan,
trackingDeadlineAt = result.trackingDeadlineAt.toUtcIso()
)
}
}
data class GiftCancellationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val priceCan: Int,
val canceledAt: String
) {
companion object {
fun from(result: GiftCancellationResult) = GiftCancellationResponse(
applicationNo = result.applicationNo,
status = result.status.name,
statusName = result.statusName,
priceCan = result.priceCan,
canceledAt = result.canceledAt.toUtcIso()
)
}
}
data class GiftTrackingRegistrationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val courierCompanyName: String,
val trackingNumber: String,
val trackingRegisteredAt: String,
val recipientAddressDeadlineAt: String
) {
companion object {
fun from(result: GiftTrackingRegistrationResult) = GiftTrackingRegistrationResponse(
applicationNo = result.applicationNo,
status = result.status.name,
statusName = result.statusName,
courierCompanyName = result.courierCompanyName,
trackingNumber = result.trackingNumber,
trackingRegisteredAt = result.trackingRegisteredAt.toUtcIso(),
recipientAddressDeadlineAt = result.recipientAddressDeadlineAt.toUtcIso()
)
}
}
data class GiftRecipientAddressRegistrationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val recipientAddressRegisteredAt: String
) {
companion object {
fun from(result: GiftRecipientAddressRegistrationResult) = GiftRecipientAddressRegistrationResponse(
applicationNo = result.applicationNo,
status = result.status.name,
statusName = result.statusName,
recipientAddressRegisteredAt = result.recipientAddressRegisteredAt.toUtcIso()
)
}
}
data class GiftDeliveryConfirmationResponse(
val applicationNo: String,
val status: String,
val statusName: String,
val deliveredAt: String
) {
companion object {
fun from(result: GiftDeliveryConfirmationResult) = GiftDeliveryConfirmationResponse(
applicationNo = result.applicationNo,
status = result.status.name,
statusName = result.statusName,
deliveredAt = result.deliveredAt.toUtcIso()
)
}
}
data class GiftReviewResponse(
val reviewId: Long,
val applicationNo: String,
val rating: Int,
val keywords: List<String>,
val comment: String?,
val createdAt: String?
) {
companion object {
fun from(result: GiftReviewResult) = GiftReviewResponse(
reviewId = result.reviewId,
applicationNo = result.applicationNo,
rating = result.rating,
keywords = result.keywords,
comment = result.comment,
createdAt = result.createdAt?.toUtcIso()
)
}
}
@@ -1,15 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.gift.dto
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand
data class GiftReviewRequest(
val rating: Int,
val keywords: List<String>,
val comment: String?
) {
fun toCommand() = GiftReviewCommand(
rating = rating,
keywords = keywords,
comment = comment
)
}
@@ -1,13 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.gift.dto
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
data class GiftTrackingRegistrationRequest(
val courierCompanyName: String,
val trackingNumber: String
) {
fun toCommand() = GiftTrackingRegistrationCommand(
courierCompanyName = courierCompanyName,
trackingNumber = trackingNumber
)
}
@@ -1,75 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "gift",
uniqueConstraints = [UniqueConstraint(name = "uk_gift_application_no", columnNames = ["application_no"])]
)
class Gift(
@Column(name = "application_no", nullable = false, length = 20)
val applicationNo: String,
@Column(name = "sender_member_id", nullable = false)
val senderMemberId: Long,
@Column(name = "recipient_member_id", nullable = false)
val recipientMemberId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
var status: GiftStatus,
@Enumerated(EnumType.STRING)
@Column(name = "size_code", nullable = false, length = 20)
val sizeCode: GiftSize,
@Column(name = "category_id", nullable = false)
val categoryId: Long,
@Column(name = "category_name_snapshot", nullable = false, length = 50)
val categoryNameSnapshot: String,
@Column(name = "sale_price_can", nullable = false)
val salePriceCan: Int,
@Column(name = "can_usage_id", nullable = false)
val canUsageId: Long,
@Column(name = "sender_terms_agreed", nullable = false)
val senderTermsAgreed: Boolean,
@Column(name = "sender_privacy_agreed", nullable = false)
val senderPrivacyAgreed: Boolean,
@Column(name = "sender_terms_agreed_at", nullable = false)
val senderTermsAgreedAt: LocalDateTime,
@Column(name = "sender_privacy_agreed_at", nullable = false)
val senderPrivacyAgreedAt: LocalDateTime,
@Column(name = "recipient_terms_agreed", nullable = false)
var recipientTermsAgreed: Boolean = false,
@Column(name = "recipient_privacy_agreed", nullable = false)
var recipientPrivacyAgreed: Boolean = false,
@Column(name = "recipient_terms_agreed_at")
var recipientTermsAgreedAt: LocalDateTime? = null,
@Column(name = "recipient_privacy_agreed_at")
var recipientPrivacyAgreedAt: LocalDateTime? = null,
@Column(name = "damage_waiver_agreed", nullable = false)
val damageWaiverAgreed: Boolean = false
) : BaseEntity()
@@ -1,28 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "gift_application_no_sequence",
uniqueConstraints = [
UniqueConstraint(
name = "uk_gift_application_no_sequence_category_date",
columnNames = ["category_id", "sequence_date"]
)
]
)
class GiftApplicationNoSequence(
@Column(name = "category_id", nullable = false)
val categoryId: Long,
@Column(name = "sequence_date", nullable = false, length = 6)
val sequenceDate: String,
@Column(name = "last_sequence", nullable = false)
var lastSequence: Int
) : BaseEntity()
@@ -1,25 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import javax.persistence.LockModeType
interface GiftApplicationNoSequenceRepository : JpaRepository<GiftApplicationNoSequence, Long> {
fun findByCategoryIdAndSequenceDate(categoryId: Long, sequenceDate: String): GiftApplicationNoSequence?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"""
select s
from GiftApplicationNoSequence s
where s.categoryId = :categoryId
and s.sequenceDate = :sequenceDate
"""
)
fun findByCategoryIdAndSequenceDateForUpdate(
@Param("categoryId") categoryId: Long,
@Param("sequenceDate") sequenceDate: String
): GiftApplicationNoSequence?
}
@@ -1,39 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "gift_category",
uniqueConstraints = [
UniqueConstraint(name = "uk_gift_category_classification_number", columnNames = ["classification_number"]),
UniqueConstraint(name = "uk_gift_category_code", columnNames = ["category_code"]),
UniqueConstraint(name = "uk_gift_category_receipt_code", columnNames = ["receipt_code"])
]
)
class GiftCategory(
@Column(name = "classification_number", nullable = false, length = 3)
var classificationNumber: String,
@Column(name = "category_code", nullable = false, length = 50)
val categoryCode: String,
@Column(name = "name", nullable = false, length = 50)
var name: String,
@Column(name = "receipt_code", nullable = false, length = 9)
var receiptCode: String,
@Column(name = "representative_item", nullable = false, length = 100)
var representativeItem: String,
@Column(name = "requires_damage_waiver", nullable = false)
var requiresDamageWaiver: Boolean = false,
@Column(name = "is_active", nullable = false)
var isActive: Boolean = true
) : BaseEntity()
@@ -1,7 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface GiftCategoryRepository : JpaRepository<GiftCategory, Long> {
fun findByCategoryCode(categoryCode: String): GiftCategory?
}
@@ -1,94 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "gift_delivery",
uniqueConstraints = [UniqueConstraint(name = "uk_gift_delivery_gift_id", columnNames = ["gift_id"])]
)
class GiftDelivery(
@Column(name = "gift_id", nullable = false)
val giftId: Long,
@Column(name = "sender_name", nullable = false, length = 50)
val senderName: String,
@Column(name = "sender_phone_number", nullable = false, length = 30)
val senderPhoneNumber: String,
@Column(name = "sender_zip_code", nullable = false, length = 20)
val senderZipCode: String,
@Column(name = "sender_address", nullable = false, length = 255)
val senderAddress: String,
@Column(name = "sender_address_detail", length = 255)
val senderAddressDetail: String? = null,
@Column(name = "recipient_name", length = 50)
var recipientName: String? = null,
@Column(name = "recipient_phone_number", length = 30)
var recipientPhoneNumber: String? = null,
@Column(name = "recipient_zip_code", length = 20)
var recipientZipCode: String? = null,
@Column(name = "recipient_address", length = 255)
var recipientAddress: String? = null,
@Column(name = "recipient_address_detail", length = 255)
var recipientAddressDetail: String? = null,
@Column(name = "courier_company_name", length = 50)
var courierCompanyName: String? = null,
@Column(name = "tracking_number", length = 100)
var trackingNumber: String? = null,
@Column(name = "tracking_deadline_at", nullable = false)
val trackingDeadlineAt: LocalDateTime,
@Column(name = "recipient_address_deadline_at")
var recipientAddressDeadlineAt: LocalDateTime? = null,
@Column(name = "tracking_deadline_reminder_sent_at")
var trackingDeadlineReminderSentAt: LocalDateTime? = null,
@Column(name = "recipient_address_deadline_reminder_sent_at")
var recipientAddressDeadlineReminderSentAt: LocalDateTime? = null,
@Column(name = "tracking_registered_at")
var trackingRegisteredAt: LocalDateTime? = null,
@Column(name = "arrived_at_mailbox_at")
var arrivedAtMailboxAt: LocalDateTime? = null,
@Column(name = "inspection_completed_at")
var inspectionCompletedAt: LocalDateTime? = null,
@Column(name = "delivered_at")
var deliveredAt: LocalDateTime? = null,
@Column(name = "canceled_at")
var canceledAt: LocalDateTime? = null,
@Column(name = "undeliverable_at")
var undeliverableAt: LocalDateTime? = null,
@Column(name = "undeliverable_reason", length = 255)
var undeliverableReason: String? = null
) : BaseEntity() {
fun hasCompleteRecipientInformation() = listOf(
recipientName,
recipientPhoneNumber,
recipientZipCode,
recipientAddress
).none { it.isNullOrBlank() }
}
@@ -1,9 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface GiftDeliveryRepository : JpaRepository<GiftDelivery, Long> {
fun findByGiftId(giftId: Long): GiftDelivery?
fun findByGiftIdIn(giftIds: Collection<Long>): List<GiftDelivery>
}
@@ -1,33 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.Table
@Entity
@Table(name = "gift_mailbox")
class GiftMailbox(
@Id
@Column(name = "id", nullable = false)
val id: Long = SINGLETON_ID,
@Column(name = "name", nullable = false, length = 50)
var name: String,
@Column(name = "phone_number", nullable = false, length = 30)
var phoneNumber: String,
@Column(name = "zip_code", nullable = false, length = 20)
var zipCode: String,
@Column(name = "address", nullable = false, length = 255)
var address: String,
@Column(name = "address_detail", length = 255)
var addressDetail: String?
) {
companion object {
const val SINGLETON_ID = 1L
}
}
@@ -1,5 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface GiftMailboxRepository : JpaRepository<GiftMailbox, Long>
@@ -1,145 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.data.domain.Page
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 java.time.LocalDateTime
interface GiftRepository : JpaRepository<Gift, Long> {
fun findByApplicationNo(applicationNo: String): Gift?
fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page<Gift>
fun findByRecipientMemberIdAndStatusIn(
recipientMemberId: Long,
statuses: Collection<GiftStatus>,
pageable: Pageable
): Page<Gift>
fun findBySenderMemberIdOrRecipientMemberIdAndStatusIn(
senderMemberId: Long,
recipientMemberId: Long,
statuses: Collection<GiftStatus>,
pageable: Pageable
): Page<Gift>
@Query(
value = """
select g
from Gift g
join Member sender on sender.id = g.senderMemberId
join Member recipient on recipient.id = g.recipientMemberId
where (:status is null or g.status = :status)
and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%'))
and (
:nickname is null
or sender.nickname like concat('%', :nickname, '%')
or recipient.nickname like concat('%', :nickname, '%')
)
""",
countQuery = """
select count(g)
from Gift g
join Member sender on sender.id = g.senderMemberId
join Member recipient on recipient.id = g.recipientMemberId
where (:status is null or g.status = :status)
and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%'))
and (
:nickname is null
or sender.nickname like concat('%', :nickname, '%')
or recipient.nickname like concat('%', :nickname, '%')
)
"""
)
fun findAdminGifts(
@Param("status") status: GiftStatus?,
@Param("applicationNo") applicationNo: String?,
@Param("nickname") nickname: String?,
pageable: Pageable
): Page<Gift>
@Query(
"""
select g
from Gift g
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.RECEIVED
and exists (
select d.id
from GiftDelivery d
where d.giftId = g.id
and d.trackingDeadlineAt < :now
)
"""
)
fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List<Gift>
@Query(
"""
select g
from Gift g
where g.status in (
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED,
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.ARRIVED_AT_MAILBOX,
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.INSPECTION_COMPLETED
)
and exists (
select d.id
from GiftDelivery d
where d.giftId = g.id
and d.recipientAddressDeadlineAt < :now
and d.recipientAddress is null
)
"""
)
fun findGiftsWithRecipientAddressDeadlineBefore(
@Param("now") now: LocalDateTime
): List<Gift>
@Query(
"""
select g
from Gift g
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.RECEIVED
and exists (
select d.id
from GiftDelivery d
where d.giftId = g.id
and d.trackingDeadlineAt > :now
and d.trackingDeadlineAt <= :reminderUntil
and d.trackingDeadlineReminderSentAt is null
)
"""
)
fun findReceivedGiftsWithTrackingDeadlineReminderDue(
@Param("now") now: LocalDateTime,
@Param("reminderUntil") reminderUntil: LocalDateTime
): List<Gift>
@Query(
"""
select g
from Gift g
where g.status in (
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED,
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.ARRIVED_AT_MAILBOX,
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.INSPECTION_COMPLETED
)
and exists (
select d.id
from GiftDelivery d
where d.giftId = g.id
and d.recipientAddressDeadlineAt > :now
and d.recipientAddressDeadlineAt <= :reminderUntil
and d.recipientAddress is null
and d.recipientAddressDeadlineReminderSentAt is null
)
"""
)
fun findGiftsWithRecipientAddressDeadlineReminderDue(
@Param("now") now: LocalDateTime,
@Param("reminderUntil") reminderUntil: LocalDateTime
): List<Gift>
}
@@ -1,29 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "gift_review",
uniqueConstraints = [UniqueConstraint(name = "uk_gift_review_gift_id", columnNames = ["gift_id"])]
)
class GiftReview(
@Column(name = "gift_id", nullable = false)
val giftId: Long,
@Column(name = "sender_member_id", nullable = false)
val senderMemberId: Long,
@Column(name = "rating", nullable = false)
val rating: Int,
@Column(name = "keywords", columnDefinition = "text")
var keywords: String? = null,
@Column(name = "comment", length = 255)
var comment: String? = null
) : BaseEntity()
@@ -1,7 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface GiftReviewRepository : JpaRepository<GiftReview, Long> {
fun findByGiftId(giftId: Long): GiftReview?
}
@@ -1,30 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "gift_size_price",
uniqueConstraints = [UniqueConstraint(name = "uk_gift_size_price_size_code", columnNames = ["size_code"])]
)
class GiftSizePrice(
@Enumerated(EnumType.STRING)
@Column(name = "size_code", nullable = false, length = 20)
val sizeCode: GiftSize,
@Column(name = "base_price_can", nullable = false)
var basePriceCan: Int,
@Column(name = "sale_price_can", nullable = false)
var salePriceCan: Int,
@Column(name = "is_active", nullable = false)
var isActive: Boolean = true
) : BaseEntity()
@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import org.springframework.data.jpa.repository.JpaRepository
interface GiftSizePriceRepository : JpaRepository<GiftSizePrice, Long> {
fun findBySizeCode(sizeCode: GiftSize): GiftSizePrice?
}
@@ -1,101 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.redisson.api.RedissonClient
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.util.concurrent.TimeUnit
@Component
class GiftScheduler(
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val canPaymentService: CanPaymentService,
private val giftPushService: GiftPushService,
private val redissonClient: RedissonClient
) {
@Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul")
@Transactional
fun runExpiredTrackingRegistrationGiftCancellation() {
val lock = redissonClient.getLock(EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY)
try {
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
cancelExpiredTrackingRegistrationGifts(LocalDateTime.now())
}
} finally {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
@Transactional
fun cancelExpiredTrackingRegistrationGifts(now: LocalDateTime): Int {
val expiredGifts = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(now)
expiredGifts.forEach { gift ->
gift.status = GiftStatus.CANCELED
requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))).canceledAt = now
canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId)
giftPushService.sendAutoCanceledToSender(gift.applicationNo, gift.senderMemberId)
}
return expiredGifts.size
}
@Transactional
fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int {
val expiredGifts = giftRepository.findGiftsWithRecipientAddressDeadlineBefore(now)
expiredGifts.forEach { gift ->
gift.status = GiftStatus.UNDELIVERABLE
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
delivery.undeliverableAt = now
delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON
}
return expiredGifts.size
}
@Transactional
fun sendDeadlineReminderGifts(now: LocalDateTime): Int {
val reminderUntil = now.plusHours(DEADLINE_REMINDER_HOURS)
val trackingReminderCount = sendTrackingDeadlineReminders(now, reminderUntil)
val recipientAddressReminderCount = sendRecipientAddressDeadlineReminders(now, reminderUntil)
return trackingReminderCount + recipientAddressReminderCount
}
private fun sendTrackingDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil)
gifts.forEach { gift ->
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
delivery.trackingDeadlineReminderSentAt = now
giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId)
}
return gifts.size
}
private fun sendRecipientAddressDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
val gifts = giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil)
gifts.forEach { gift ->
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
delivery.recipientAddressDeadlineReminderSentAt = now
giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId)
}
return gifts.size
}
private companion object {
const val DEADLINE_REMINDER_HOURS = 24L
const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation"
const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과"
}
}
@@ -1,209 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
data class AdminGiftListResult(
val totalCount: Long,
val items: List<AdminGiftListItemResult>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class AdminGiftListItemResult(
val applicationNo: String,
val senderNickname: String,
val recipientNickname: String,
val sizeCode: String,
val sizeName: String,
val categoryName: String,
val classificationNumber: String,
val courierCompanyName: String?,
val trackingNumber: String?,
val status: GiftStatus,
val statusName: String,
val availableActions: List<String>
)
data class AdminGiftDetailResult(
val applicationNo: String,
val senderInfo: AdminGiftMemberInfoResult,
val recipientInfo: AdminGiftMemberInfoResult,
val productInfo: AdminGiftProductInfoResult,
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResult,
val status: GiftStatus,
val statusName: String,
val availableActions: List<String>
)
data class AdminGiftMemberInfoResult(
val nickname: String,
val name: String,
val phoneNumber: String,
val address: String
)
data class AdminGiftProductInfoResult(
val sizeCode: String,
val sizeName: String,
val categoryName: String,
val classificationNumber: String
)
data class AdminGiftInboundDeliveryInfoResult(
val courierCompanyName: String?,
val trackingNumber: String?
)
@Service
class GiftAdminQueryService(
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val categoryRepository: GiftCategoryRepository,
private val memberRepository: MemberRepository
) {
@Transactional(readOnly = true)
fun getGifts(
status: GiftStatus?,
applicationNo: String?,
nickname: String?,
page: Int,
size: Int
): AdminGiftListResult {
val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size
val gifts = giftRepository.findAdminGifts(
status = status,
applicationNo = applicationNo?.takeIf { it.isNotBlank() },
nickname = nickname?.takeIf { it.isNotBlank() },
pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id"))
)
val context = buildContext(gifts.content)
return AdminGiftListResult(
totalCount = gifts.totalElements,
items = gifts.content.map { gift -> gift.toListItem(context) },
page = normalizedPage,
size = normalizedSize,
hasNext = gifts.hasNext()
)
}
@Transactional(readOnly = true)
fun getGiftDetail(applicationNo: String): AdminGiftDetailResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val context = buildContext(listOf(gift))
val delivery = context.delivery(gift)
val sender = context.sender(gift)
val recipient = context.recipient(gift)
val category = context.category(gift)
return AdminGiftDetailResult(
applicationNo = gift.applicationNo,
senderInfo = AdminGiftMemberInfoResult(
nickname = sender.nickname,
name = delivery.senderName,
phoneNumber = delivery.senderPhoneNumber,
address = formatAddress(delivery.senderZipCode, delivery.senderAddress, delivery.senderAddressDetail)
),
recipientInfo = AdminGiftMemberInfoResult(
nickname = recipient.nickname,
name = delivery.recipientName.orEmpty(),
phoneNumber = delivery.recipientPhoneNumber.orEmpty(),
address = delivery.recipientAddressResult()
),
productInfo = AdminGiftProductInfoResult(
sizeCode = gift.sizeCode.name,
sizeName = gift.sizeCode.displayName,
categoryName = gift.categoryNameSnapshot,
classificationNumber = category.classificationNumber
),
inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResult(
courierCompanyName = delivery.courierCompanyName,
trackingNumber = delivery.trackingNumber
),
status = gift.status,
statusName = gift.status.displayName,
availableActions = gift.status.availableActions(delivery)
)
}
private fun Gift.toListItem(context: GiftAdminQueryContext) = AdminGiftListItemResult(
applicationNo = applicationNo,
senderNickname = context.sender(this).nickname,
recipientNickname = context.recipient(this).nickname,
sizeCode = sizeCode.name,
sizeName = sizeCode.displayName,
categoryName = categoryNameSnapshot,
classificationNumber = context.category(this).classificationNumber,
courierCompanyName = context.delivery(this).courierCompanyName,
trackingNumber = context.delivery(this).trackingNumber,
status = status,
statusName = status.displayName,
availableActions = status.availableActions(context.delivery(this))
)
private fun GiftStatus.availableActions(delivery: GiftDelivery) = when (this) {
GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE")
GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE")
GiftStatus.INSPECTION_COMPLETED -> if (delivery.hasCompleteRecipientInformation()) {
listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE")
} else {
listOf("MARK_UNDELIVERABLE")
}
GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList()
}
private fun GiftDelivery.recipientAddressResult(): String {
val zipCode = recipientZipCode ?: return ""
val address = recipientAddress ?: return ""
return formatAddress(zipCode, address, recipientAddressDetail)
}
private fun formatAddress(zipCode: String, address: String, detail: String?) =
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
private fun buildContext(gifts: List<Gift>): GiftAdminQueryContext {
val giftIds = gifts.mapNotNull { it.id }
val memberIds = gifts.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet()
val categoryIds = gifts.map { it.categoryId }.toSet()
return GiftAdminQueryContext(
deliveriesByGiftId = deliveryRepository.findByGiftIdIn(giftIds).associateBy { it.giftId },
membersById = memberRepository.findAllById(memberIds).associateBy { it.id ?: throw invalidRequest() },
categoriesById = categoryRepository.findAllById(categoryIds).associateBy { it.id ?: throw invalidRequest() }
)
}
private inner class GiftAdminQueryContext(
private val deliveriesByGiftId: Map<Long, GiftDelivery>,
private val membersById: Map<Long, Member>,
private val categoriesById: Map<Long, GiftCategory>
) {
fun delivery(gift: Gift) = deliveriesByGiftId[gift.id] ?: throw invalidRequest()
fun sender(gift: Gift) = membersById[gift.senderMemberId] ?: throw invalidRequest()
fun recipient(gift: Gift) = membersById[gift.recipientMemberId] ?: throw invalidRequest()
fun category(gift: Gift) = categoriesById[gift.categoryId] ?: throw invalidRequest()
}
private companion object {
const val DEFAULT_PAGE_SIZE = 20
}
}
@@ -1,290 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
data class GiftCategoryCommand(
val classificationNumber: String,
val categoryCode: String,
val name: String,
val receiptCode: String,
val representativeItem: String,
val requiresDamageWaiver: Boolean,
val isActive: Boolean
)
data class GiftCategoryResult(
val categoryId: Long,
val classificationNumber: String,
val categoryCode: String,
val name: String,
val receiptCode: String,
val representativeItem: String,
val requiresDamageWaiver: Boolean,
val isActive: Boolean
)
data class GiftSizePriceCommand(
val basePriceCan: Int,
val salePriceCan: Int,
val isActive: Boolean
)
data class GiftMailboxCommand(
val name: String,
val phoneNumber: String,
val zipCode: String,
val address: String,
val addressDetail: String?
)
data class GiftSizePriceResult(
val sizeCode: GiftSize,
val sizeName: String,
val basePriceCan: Int,
val salePriceCan: Int,
val isActive: Boolean
)
data class GiftOperationStatusResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val occurredAt: LocalDateTime
)
@Service
class GiftAdminService(
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val giftMailboxRepository: GiftMailboxRepository,
private val giftPushService: GiftPushService
) {
@Transactional(readOnly = true)
fun getMailbox(): GiftMailboxResult? = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.toResult()
@Transactional
fun updateMailbox(command: GiftMailboxCommand): GiftMailboxResult {
validateMailbox(command)
val mailbox = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)
?: GiftMailbox(
name = command.name,
phoneNumber = command.phoneNumber,
zipCode = command.zipCode,
address = command.address,
addressDetail = command.addressDetail?.takeIf { it.isNotBlank() }
)
mailbox.name = command.name
mailbox.phoneNumber = command.phoneNumber
mailbox.zipCode = command.zipCode
mailbox.address = command.address
mailbox.addressDetail = command.addressDetail?.takeIf { it.isNotBlank() }
return giftMailboxRepository.save(mailbox).toResult()
}
@Transactional(readOnly = true)
fun getCategories(): List<GiftCategoryResult> = categoryRepository.findAll().map { it.toResult() }
@Transactional
fun createCategory(command: GiftCategoryCommand): GiftCategoryResult {
validateCategory(command)
return categoryRepository.save(
GiftCategory(
classificationNumber = command.classificationNumber,
categoryCode = command.categoryCode,
name = command.name,
receiptCode = command.receiptCode,
representativeItem = command.representativeItem,
requiresDamageWaiver = command.requiresDamageWaiver,
isActive = command.isActive
)
).toResult()
}
@Transactional
fun updateCategory(categoryId: Long, command: GiftCategoryCommand): GiftCategoryResult {
validateCategory(command)
val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest()
category.classificationNumber = command.classificationNumber
category.name = command.name
category.receiptCode = command.receiptCode
category.representativeItem = command.representativeItem
category.requiresDamageWaiver = command.requiresDamageWaiver
category.isActive = command.isActive
return category.toResult()
}
@Transactional
fun deleteCategory(categoryId: Long): GiftCategoryResult {
val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest()
category.isActive = false
return category.toResult()
}
@Transactional(readOnly = true)
fun getSizePrices(): List<GiftSizePriceResult> = sizePriceRepository.findAll().map { it.toResult() }
@Transactional
fun updateSizePrice(sizeCode: GiftSize, command: GiftSizePriceCommand): GiftSizePriceResult {
validateSizePrice(command)
val sizePrice = sizePriceRepository.findBySizeCode(sizeCode)
?: GiftSizePrice(sizeCode = sizeCode, basePriceCan = command.basePriceCan, salePriceCan = command.salePriceCan)
sizePrice.basePriceCan = command.basePriceCan
sizePrice.salePriceCan = command.salePriceCan
sizePrice.isActive = command.isActive
return sizePriceRepository.save(sizePrice).toResult()
}
@Transactional
fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.TRACKING_REGISTERED) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
gift.status = GiftStatus.ARRIVED_AT_MAILBOX
delivery.arrivedAtMailboxAt = now
giftPushService.sendArrivedAtMailboxToSender(gift.applicationNo, gift.senderMemberId)
giftPushService.sendArrivedAtMailboxToRecipient(gift.applicationNo, gift.recipientMemberId)
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now)
}
@Transactional
fun completeInspection(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.ARRIVED_AT_MAILBOX) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
gift.status = GiftStatus.INSPECTION_COMPLETED
delivery.inspectionCompletedAt = now
giftPushService.sendInspectionCompleted(gift.applicationNo, gift.recipientMemberId)
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now)
}
@Transactional
fun markUndeliverable(
applicationNo: String,
reason: String,
now: LocalDateTime = LocalDateTime.now()
): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.status !in UNDELIVERABLE_ALLOWED_STATUSES || reason.isBlank() || reason.length > 255) {
throw invalidRequest()
}
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
gift.status = GiftStatus.UNDELIVERABLE
delivery.undeliverableAt = now
delivery.undeliverableReason = reason
giftPushService.sendUndeliverableToSender(gift.applicationNo, gift.senderMemberId)
giftPushService.sendUndeliverableToRecipient(gift.applicationNo, gift.recipientMemberId)
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now)
}
@Transactional
fun completeDelivery(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
if (!delivery.hasCompleteRecipientInformation()) throw invalidRequest()
gift.status = GiftStatus.DELIVERED
delivery.deliveredAt = now
giftPushService.sendDeliveredToSender(gift.applicationNo, gift.senderMemberId)
giftPushService.sendDeliveredToRecipient(gift.applicationNo, gift.recipientMemberId)
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now)
}
private fun validateCategory(command: GiftCategoryCommand) {
if (
command.categoryCode.isBlank() ||
command.categoryCode.length > 50 ||
command.name.isBlank() ||
command.name.length > 50 ||
!CLASSIFICATION_NUMBER_REGEX.matches(command.classificationNumber) ||
!RECEIPT_CODE_REGEX.matches(command.receiptCode) ||
command.representativeItem.isBlank() ||
command.representativeItem.length > 100
) {
throw invalidRequest()
}
}
private fun validateSizePrice(command: GiftSizePriceCommand) {
if (command.salePriceCan <= 0 || command.salePriceCan > command.basePriceCan) throw invalidRequest()
}
private fun validateMailbox(command: GiftMailboxCommand) {
if (
command.name.isBlank() ||
command.name.length > 50 ||
command.phoneNumber.isBlank() ||
command.phoneNumber.length > 30 ||
command.zipCode.isBlank() ||
command.zipCode.length > 20 ||
command.address.isBlank() ||
command.address.length > 255 ||
(command.addressDetail?.length ?: 0) > 255
) {
throw invalidRequest()
}
}
private fun GiftCategory.toResult() = GiftCategoryResult(
categoryId = id ?: throw invalidRequest(),
classificationNumber = classificationNumber,
categoryCode = categoryCode,
name = name,
receiptCode = receiptCode,
representativeItem = representativeItem,
requiresDamageWaiver = requiresDamageWaiver,
isActive = isActive
)
private fun GiftSizePrice.toResult() = GiftSizePriceResult(
sizeCode = sizeCode,
sizeName = sizeCode.displayName,
basePriceCan = basePriceCan,
salePriceCan = salePriceCan,
isActive = isActive
)
private fun GiftMailbox.toResult() = GiftMailboxResult(
name = name,
address = formatAddress(zipCode, address, addressDetail),
phoneNumber = phoneNumber
)
private fun formatAddress(zipCode: String, address: String, detail: String?) =
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
private companion object {
val UNDELIVERABLE_ALLOWED_STATUSES = setOf(
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
val CLASSIFICATION_NUMBER_REGEX = Regex("\\d{3}")
val RECEIPT_CODE_REGEX = Regex("[A-Z]{1,9}")
}
}
@@ -1,52 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class GiftApplicationNoGenerator(
private val repository: GiftApplicationNoSequenceRepository,
transactionManager: PlatformTransactionManager
) {
private val transactionTemplate = TransactionTemplate(transactionManager)
fun generate(category: GiftCategory, now: LocalDateTime = LocalDateTime.now(SEOUL_ZONE)): String {
val categoryId = category.id ?: throw SodaException(messageKey = "common.error.invalid_request")
val sequenceDate = now.format(DATE_FORMATTER)
for (attempt in 1..MAX_RETRY_COUNT) {
try {
return transactionTemplate.execute {
val sequence = repository.findByCategoryIdAndSequenceDateForUpdate(categoryId, sequenceDate)
?: repository.saveAndFlush(
GiftApplicationNoSequence(
categoryId = categoryId,
sequenceDate = sequenceDate,
lastSequence = 0
)
)
sequence.lastSequence += 1
"${category.receiptCode}-${category.classificationNumber}$sequenceDate" +
sequence.lastSequence.toString().padStart(4, '0')
} ?: throw SodaException(messageKey = "common.error.invalid_request")
} catch (_: DataIntegrityViolationException) {
if (attempt == MAX_RETRY_COUNT) throw SodaException(messageKey = "gift.application_no.generate_failed")
}
}
throw SodaException(messageKey = "gift.application_no.generate_failed")
}
companion object {
private val SEOUL_ZONE = ZoneId.of("Asia/Seoul")
private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyMMdd")
private const val MAX_RETRY_COUNT = 3
}
}
@@ -1,368 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
data class GiftApplicationCommand(
val recipientMemberId: Long,
val senderName: String,
val senderPhoneNumber: String,
val senderZipCode: String,
val senderAddress: String,
val senderAddressDetail: String?,
val sizeCode: GiftSize,
val categoryId: Long,
val senderTermsAgreed: Boolean,
val senderPrivacyAgreed: Boolean,
val damageWaiverAgreed: Boolean
)
data class GiftApplicationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val priceCan: Int,
val trackingDeadlineAt: LocalDateTime
)
data class GiftCancellationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val priceCan: Int,
val canceledAt: LocalDateTime
)
data class GiftTrackingRegistrationCommand(
val courierCompanyName: String,
val trackingNumber: String
)
data class GiftTrackingRegistrationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val courierCompanyName: String,
val trackingNumber: String,
val trackingRegisteredAt: LocalDateTime,
val recipientAddressDeadlineAt: LocalDateTime
)
data class GiftRecipientAddressRegistrationCommand(
val name: String,
val phoneNumber: String,
val zipCode: String,
val address: String,
val addressDetail: String?,
val recipientTermsAgreed: Boolean,
val recipientPrivacyAgreed: Boolean
)
data class GiftRecipientAddressRegistrationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val recipientAddressRegisteredAt: LocalDateTime
)
data class GiftDeliveryConfirmationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val deliveredAt: LocalDateTime
)
data class GiftReviewCommand(
val rating: Int,
val keywords: List<String>,
val comment: String?
)
data class GiftReviewResult(
val reviewId: Long,
val applicationNo: String,
val rating: Int,
val keywords: List<String>,
val comment: String?,
val createdAt: LocalDateTime?
)
@Service
class GiftCommandService(
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val reviewRepository: GiftReviewRepository,
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val memberRepository: MemberRepository,
private val applicationNoGenerator: GiftApplicationNoGenerator,
private val canPaymentService: CanPaymentService,
private val giftPushService: GiftPushService,
private val now: () -> LocalDateTime = { LocalDateTime.now() }
) {
@Transactional
fun registerGift(member: Member, command: GiftApplicationCommand): GiftApplicationResult {
validate(command)
val senderMemberId = member.id ?: throw invalidRequest()
memberRepository.findCreatorByIdOrNull(command.recipientMemberId) ?: throw invalidRequest()
val category = categoryRepository.findByIdOrNull(command.categoryId)?.takeIf { it.isActive } ?: throw invalidRequest()
if (category.requiresDamageWaiver && !command.damageWaiverAgreed) throw invalidRequest()
val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest()
val appliedAt = now()
val trackingDeadlineAt = appliedAt.plusDays(3)
val applicationNo = applicationNoGenerator.generate(category, appliedAt)
val canUsageId = canPaymentService.spendGiftCan(
senderMemberId,
sizePrice.salePriceCan,
member.container.ifBlank { "web" }
)
val gift = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = senderMemberId,
recipientMemberId = command.recipientMemberId,
status = GiftStatus.RECEIVED,
sizeCode = command.sizeCode,
categoryId = category.id ?: throw invalidRequest(),
categoryNameSnapshot = category.name,
salePriceCan = sizePrice.salePriceCan,
canUsageId = canUsageId,
senderTermsAgreed = command.senderTermsAgreed,
senderPrivacyAgreed = command.senderPrivacyAgreed,
senderTermsAgreedAt = appliedAt,
senderPrivacyAgreedAt = appliedAt,
damageWaiverAgreed = command.damageWaiverAgreed
)
)
deliveryRepository.save(
GiftDelivery(
giftId = gift.id ?: throw invalidRequest(),
senderName = command.senderName,
senderPhoneNumber = command.senderPhoneNumber,
senderZipCode = command.senderZipCode,
senderAddress = command.senderAddress,
senderAddressDetail = command.senderAddressDetail,
trackingDeadlineAt = trackingDeadlineAt
)
)
return GiftApplicationResult(
applicationNo = applicationNo,
status = GiftStatus.RECEIVED,
statusName = GiftStatus.RECEIVED.displayName,
priceCan = sizePrice.salePriceCan,
trackingDeadlineAt = trackingDeadlineAt
)
}
@Transactional
fun writeReview(member: Member, applicationNo: String, command: GiftReviewCommand): GiftReviewResult {
val senderMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val giftId = gift.id ?: throw invalidRequest()
if (
gift.senderMemberId != senderMemberId ||
gift.status != GiftStatus.DELIVERED ||
reviewRepository.findByGiftId(giftId) != null
) {
throw invalidRequest()
}
validate(command)
val review = reviewRepository.save(
GiftReview(
giftId = giftId,
senderMemberId = senderMemberId,
rating = command.rating,
keywords = command.keywords.joinToString(","),
comment = command.comment
)
)
return GiftReviewResult(
reviewId = review.id ?: throw invalidRequest(),
applicationNo = gift.applicationNo,
rating = review.rating,
keywords = review.keywords?.takeIf { it.isNotBlank() }?.split(",") ?: emptyList(),
comment = review.comment,
createdAt = review.createdAt
)
}
@Transactional
fun cancelGift(member: Member, applicationNo: String): GiftCancellationResult {
val senderMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
val canceledAt = now()
gift.status = GiftStatus.CANCELED
deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt
canPaymentService.refundGiftCan(senderMemberId, gift.canUsageId)
return GiftCancellationResult(
applicationNo = gift.applicationNo,
status = gift.status,
statusName = gift.status.displayName,
priceCan = gift.salePriceCan,
canceledAt = canceledAt
)
}
@Transactional
fun registerTracking(
member: Member,
applicationNo: String,
command: GiftTrackingRegistrationCommand
): GiftTrackingRegistrationResult {
val senderMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
validate(command)
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val trackingRegisteredAt = now()
val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7)
gift.status = GiftStatus.TRACKING_REGISTERED
delivery.courierCompanyName = command.courierCompanyName
delivery.trackingNumber = command.trackingNumber
delivery.trackingRegisteredAt = trackingRegisteredAt
delivery.recipientAddressDeadlineAt = recipientAddressDeadlineAt
giftPushService.sendTrackingRegisteredToRecipient(gift.applicationNo, gift.recipientMemberId)
return GiftTrackingRegistrationResult(
applicationNo = gift.applicationNo,
status = gift.status,
statusName = gift.status.displayName,
courierCompanyName = command.courierCompanyName,
trackingNumber = command.trackingNumber,
trackingRegisteredAt = trackingRegisteredAt,
recipientAddressDeadlineAt = recipientAddressDeadlineAt
)
}
@Transactional
fun registerRecipientAddress(
member: Member,
applicationNo: String,
command: GiftRecipientAddressRegistrationCommand
): GiftRecipientAddressRegistrationResult {
val recipientMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.recipientMemberId != recipientMemberId || gift.status !in RECIPIENT_ADDRESS_REGISTRABLE_STATUSES) {
throw invalidRequest()
}
validate(command)
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val registeredAt = now()
delivery.recipientName = command.name
delivery.recipientPhoneNumber = command.phoneNumber
delivery.recipientZipCode = command.zipCode
delivery.recipientAddress = command.address
delivery.recipientAddressDetail = command.addressDetail
gift.recipientTermsAgreed = command.recipientTermsAgreed
gift.recipientPrivacyAgreed = command.recipientPrivacyAgreed
gift.recipientTermsAgreedAt = registeredAt
gift.recipientPrivacyAgreedAt = registeredAt
return GiftRecipientAddressRegistrationResult(
applicationNo = gift.applicationNo,
status = gift.status,
statusName = gift.status.displayName,
recipientAddressRegisteredAt = registeredAt
)
}
@Transactional
fun confirmDelivery(member: Member, applicationNo: String): GiftDeliveryConfirmationResult {
val recipientMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.recipientMemberId != recipientMemberId || gift.status != GiftStatus.INSPECTION_COMPLETED) {
throw invalidRequest()
}
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val deliveredAt = now()
gift.status = GiftStatus.DELIVERED
delivery.deliveredAt = deliveredAt
return GiftDeliveryConfirmationResult(
applicationNo = gift.applicationNo,
status = gift.status,
statusName = gift.status.displayName,
deliveredAt = deliveredAt
)
}
private fun validate(command: GiftApplicationCommand) {
if (
command.senderName.isBlank() ||
command.senderPhoneNumber.isBlank() ||
command.senderZipCode.isBlank() ||
command.senderAddress.isBlank() ||
!command.senderTermsAgreed ||
!command.senderPrivacyAgreed
) {
throw invalidRequest()
}
}
private fun validate(command: GiftTrackingRegistrationCommand) {
if (
command.courierCompanyName.isBlank() ||
command.courierCompanyName.length > 50 ||
command.trackingNumber.isBlank()
) {
throw invalidRequest()
}
}
private fun validate(command: GiftRecipientAddressRegistrationCommand) {
if (
command.name.isBlank() ||
command.phoneNumber.isBlank() ||
command.zipCode.isBlank() ||
command.address.isBlank() ||
!command.recipientTermsAgreed ||
!command.recipientPrivacyAgreed
) {
throw invalidRequest()
}
}
private fun validate(command: GiftReviewCommand) {
if (
command.rating !in 1..5 ||
command.keywords.any { it.length > 255 } ||
(command.comment?.length ?: 0) > 255
) {
throw invalidRequest()
}
}
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
private companion object {
val RECIPIENT_ADDRESS_REGISTRABLE_STATUSES = setOf(
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
}
}
@@ -1,126 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
@Service
class GiftPushService(
private val applicationEventPublisher: ApplicationEventPublisher
) {
fun sendTrackingDeadlineReminder(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "운송장 번호를 입력해 주세요",
message = "24시간 이내에 운송장 번호를 등록해 주세요.\n기한 내 등록하지 않으면 신청이 자동 취소됩니다."
)
}
fun sendRecipientAddressDeadlineReminder(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "배송지 입력 기한이 1일 남았어요",
message = "24시간 안에 배송지를 입력해 주세요.\n입력하지 않으면 선물은 반송되지 않고 폐기돼요"
)
}
fun sendAutoCanceledToSender(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "선물 신청이 자동 취소됐어요",
message = "신청 후 3일 이내에 운송장 번호가 등록되지 않아 신청이 취소됐어요.\n사용한 캔은 모두 환불되었습니다."
)
}
fun sendTrackingRegisteredToRecipient(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "팬이 보낸 선물이 있어요!",
message = "7일 이내에 배송지를 입력해 주세요.\n기간이 지나면 선물을 받을 수 없어요"
)
}
fun sendArrivedAtMailboxToSender(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "선물이 사서함에 도착했어요",
message = "보내주신 선물이 소다라이브 사서함에 도착했어요.\n안전하게 전달할 수 있도록 선물을 확인하고 있어요."
)
}
fun sendArrivedAtMailboxToRecipient(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "선물이 사서함에 도착했어요",
message = "팬이 보낸 선물을 검수하고 있어요.\n검수가 끝나면 다시 알려드릴게요"
)
}
fun sendInspectionCompleted(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "선물 검수가 완료됐어요",
message = "확인된 선물을 빠른 시일 내에 입력하신 배송지로 보내드릴게요."
)
}
fun sendUndeliverableToSender(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "선물을 전달할 수 없어요",
message = "도착한 선물이 선물 접수 규격에 맞지 않아 크리에이터에게 전달할 수 없어요.\n자세한 사유는 신청 상세에서 확인해 주세요."
)
}
fun sendUndeliverableToRecipient(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "전달할 수 없는 선물이 있어요",
message = "검수 결과 선물 정책에 맞지 않아 해당 선물은 배송되지 않아요.\n자세한 사유를 확인해 주세요."
)
}
fun sendDeliveredToSender(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "선물이 전달됐어요",
message = "보내주신 선물이 크리에이터에게 안전하게 전달됐어요."
)
}
fun sendDeliveredToRecipient(applicationNo: String, memberId: Long) {
publishGiftPush(
memberId = memberId,
applicationNo = applicationNo,
title = "선물이 도착했어요!",
message = "팬이 보낸 선물이 배송지에 도착했어요.\n지금 확인해 보세요."
)
}
private fun publishGiftPush(memberId: Long, applicationNo: String, title: String, message: String) {
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.GIFT,
title = title,
message = message,
deepLinkValue = FcmDeepLinkValue.GIFT_DETAIL,
deepLinkId = applicationNo,
recipients = listOf(memberId)
)
)
}
}
@@ -1,308 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
data class GiftFormOptionsResult(
val sizes: List<GiftSizePriceResult>,
val categories: List<GiftCategoryResult>
)
data class GiftListResult(
val totalCount: Long,
val items: List<GiftListItemResult>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class GiftListItemResult(
val applicationNo: String,
val direction: GiftDirection,
val status: GiftStatus,
val statusName: String,
val priceCan: Int,
val categoryName: String,
val sizeName: String,
val counterpartMemberId: Long,
val counterpartNickname: String,
val counterpartProfileImageUrl: String,
val createdAt: LocalDateTime?
)
data class GiftDetailResult(
val applicationNo: String,
val direction: GiftDirection,
val status: GiftStatus,
val statusName: String,
val giftInfo: GiftDetailInfoResult,
val senderInfo: GiftAddressResult?,
val recipientAddress: GiftAddressResult?,
val mailbox: GiftMailboxResult?,
val trackingRequired: Boolean,
val recipientAddressRequired: Boolean,
val recipientAddressDeadlineAt: LocalDateTime?,
val delivery: GiftDeliveryInfoResult,
val statusTimeline: List<GiftStatusTimelineResult>
)
data class GiftDetailInfoResult(
val recipientCreatorNickname: String?,
val senderNickname: String?,
val sizeName: String,
val categoryName: String,
val applicationNo: String?,
val paidCan: Int?,
val tracking: String?,
val shippingRequestedAt: LocalDateTime?
)
data class GiftAddressResult(
val name: String,
val phoneNumber: String,
val address: String
)
data class GiftMailboxResult(
val name: String,
val address: String,
val phoneNumber: String
)
data class GiftDeliveryInfoResult(
val canceledAt: LocalDateTime?,
val undeliverableAt: LocalDateTime?,
val undeliverableReason: String?
)
data class GiftStatusTimelineResult(
val status: GiftStatus,
val statusName: String,
val occurredAt: LocalDateTime?
)
@Service
class GiftQueryService(
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@Transactional(readOnly = true)
fun getFormOptions() = GiftFormOptionsResult(
sizes = sizePriceRepository.findAll().filter { it.isActive }.map {
GiftSizePriceResult(it.sizeCode, it.sizeCode.displayName, it.basePriceCan, it.salePriceCan, it.isActive)
},
categories = categoryRepository.findAll().filter { it.isActive }.map {
GiftCategoryResult(
categoryId = it.id!!,
classificationNumber = it.classificationNumber,
categoryCode = it.categoryCode,
name = "${it.name}/${it.representativeItem}",
receiptCode = it.receiptCode,
representativeItem = it.representativeItem,
requiresDamageWaiver = it.requiresDamageWaiver,
isActive = it.isActive
)
}
)
@Transactional(readOnly = true)
fun getGifts(memberId: Long, type: GiftListType, page: Int, size: Int): GiftListResult {
val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size
val pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id"))
val gifts = when (type) {
GiftListType.SENT -> giftRepository.findBySenderMemberId(memberId, pageable)
GiftListType.RECEIVED -> giftRepository.findByRecipientMemberIdAndStatusIn(
memberId,
RECIPIENT_VISIBLE_STATUSES,
pageable
)
GiftListType.ALL -> giftRepository.findBySenderMemberIdOrRecipientMemberIdAndStatusIn(
memberId,
memberId,
RECIPIENT_VISIBLE_STATUSES,
pageable
)
}
val membersById = memberRepository.findAllById(
gifts.content.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet()
).associateBy { it.id ?: throw invalidRequest() }
return GiftListResult(
totalCount = gifts.totalElements,
items = gifts.content.map { it.toGiftListItem(memberId, membersById) },
page = normalizedPage,
size = normalizedSize,
hasNext = gifts.hasNext()
)
}
@Transactional(readOnly = true)
fun getGiftDetail(memberId: Long, applicationNo: String): GiftDetailResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val direction = gift.directionFor(memberId)
val sender = memberRepository.findByIdOrNull(gift.senderMemberId) ?: throw invalidRequest()
val recipient = memberRepository.findByIdOrNull(gift.recipientMemberId) ?: throw invalidRequest()
val recipientAddress = if (direction == GiftDirection.RECEIVED) delivery.toRecipientAddressResult() else null
return GiftDetailResult(
applicationNo = gift.applicationNo,
direction = direction,
status = gift.status,
statusName = gift.status.displayName,
giftInfo = GiftDetailInfoResult(
recipientCreatorNickname = recipient.nickname.takeIf { direction == GiftDirection.SENT },
senderNickname = sender.nickname.takeIf { direction == GiftDirection.RECEIVED },
sizeName = gift.sizeCode.displayName,
categoryName = gift.categoryNameSnapshot,
applicationNo = gift.applicationNo.takeIf { direction == GiftDirection.SENT },
paidCan = gift.salePriceCan.takeIf { direction == GiftDirection.SENT },
tracking = delivery.tracking().takeIf { direction == GiftDirection.SENT },
shippingRequestedAt = gift.createdAt.takeIf { direction == GiftDirection.RECEIVED }
),
senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT },
recipientAddress = recipientAddress,
mailbox = mailboxFor(direction, gift.status),
trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED,
recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null &&
gift.status !in TERMINAL_STATUSES,
recipientAddressDeadlineAt = delivery.recipientAddressDeadlineAt,
delivery = GiftDeliveryInfoResult(
canceledAt = delivery.canceledAt,
undeliverableAt = delivery.undeliverableAt,
undeliverableReason = delivery.undeliverableReason
),
statusTimeline = gift.toStatusTimeline(delivery)
)
}
private fun Gift.toGiftListItem(memberId: Long, membersById: Map<Long, Member>): GiftListItemResult {
val direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED
val counterpartId = if (direction == GiftDirection.SENT) recipientMemberId else senderMemberId
val counterpart = membersById[counterpartId] ?: throw invalidRequest()
return GiftListItemResult(
applicationNo = applicationNo,
direction = direction,
status = status,
statusName = status.displayName,
priceCan = salePriceCan,
categoryName = categoryNameSnapshot,
sizeName = sizeCode.displayName,
counterpartMemberId = counterpartId,
counterpartNickname = counterpart.nickname,
counterpartProfileImageUrl = counterpart.profileImageUrl(),
createdAt = createdAt
)
}
private fun Member.profileImageUrl() = if (profileImage.isNullOrBlank()) {
"$cloudFrontHost/profile/default-profile.png"
} else {
"$cloudFrontHost/$profileImage"
}
private fun Gift.directionFor(memberId: Long) = when {
senderMemberId == memberId -> GiftDirection.SENT
recipientMemberId == memberId && status in RECIPIENT_VISIBLE_STATUSES -> GiftDirection.RECEIVED
else -> throw invalidRequest()
}
private fun GiftDelivery.toSenderInfo() = GiftAddressResult(
name = senderName,
phoneNumber = senderPhoneNumber,
address = formatAddress(senderZipCode, senderAddress, senderAddressDetail)
)
private fun GiftDelivery.toRecipientAddressResult(): GiftAddressResult? {
val name = recipientName ?: return null
val phoneNumber = recipientPhoneNumber ?: return null
val zipCode = recipientZipCode ?: return null
val address = recipientAddress ?: return null
return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail))
}
private fun mailboxFor(direction: GiftDirection, status: GiftStatus): GiftMailboxResult? {
if (direction != GiftDirection.SENT || status in TERMINAL_STATUSES) return null
return giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.let {
GiftMailboxResult(
name = it.name,
address = formatAddress(it.zipCode, it.address, it.addressDetail),
phoneNumber = it.phoneNumber
)
}
}
private fun GiftDelivery.tracking() = if (courierCompanyName != null && trackingNumber != null) {
"${courierCompanyName}_$trackingNumber"
} else {
null
}
private fun Gift.toStatusTimeline(delivery: GiftDelivery) = NORMAL_PROGRESS_STATUSES.map { timelineStatus ->
GiftStatusTimelineResult(
status = timelineStatus,
statusName = timelineStatus.displayName,
occurredAt = when (timelineStatus) {
GiftStatus.RECEIVED -> createdAt
GiftStatus.TRACKING_REGISTERED -> delivery.trackingRegisteredAt
GiftStatus.ARRIVED_AT_MAILBOX -> delivery.arrivedAtMailboxAt
GiftStatus.INSPECTION_COMPLETED -> delivery.inspectionCompletedAt
GiftStatus.DELIVERED -> delivery.deliveredAt
GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> null
}
)
}
private fun formatAddress(zipCode: String, address: String, detail: String?) =
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
private companion object {
const val DEFAULT_PAGE_SIZE = 20
val RECIPIENT_VISIBLE_STATUSES = setOf(
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED,
GiftStatus.DELIVERED,
GiftStatus.UNDELIVERABLE
)
val NORMAL_PROGRESS_STATUSES = listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED,
GiftStatus.DELIVERED
)
val TERMINAL_STATUSES = setOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED)
}
}
@@ -1,12 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.domain
enum class GiftListType {
ALL,
SENT,
RECEIVED
}
enum class GiftDirection {
SENT,
RECEIVED
}
@@ -1,12 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.domain
enum class GiftSize(
val displayName: String,
val maxTotalLengthCm: Int,
val maxWeightKg: Int,
val defaultPriceCan: Int
) {
SMALL("소형", 100, 5, 100),
MEDIUM("중형", 120, 15, 150),
LARGE("대형", 160, 20, 200)
}
@@ -1,11 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.domain
enum class GiftStatus(val displayName: String) {
RECEIVED("접수 완료"),
TRACKING_REGISTERED("발송 확인"),
ARRIVED_AT_MAILBOX("사서함 도착"),
INSPECTION_COMPLETED("검수 완료"),
DELIVERED("전달 완료"),
UNDELIVERABLE("전달 불가"),
CANCELED("신청 취소")
}
@@ -1,78 +0,0 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import javax.persistence.EntityManager
@SpringBootTest(properties = ["spring.cache.type=none"])
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@Transactional
class CanControllerIntegrationTest @Autowired constructor(
private val controller: CanController,
private val countryContext: CountryContext,
private val entityManager: EntityManager
) {
@BeforeEach
fun setUp() {
listOf("KRW", "USD", "JPY").forEach { currency ->
entityManager.persist(
Can(
title = "test-$currency",
can = 100,
rewardCan = 0,
price = BigDecimal("100"),
currency = currency,
status = CanStatus.SALE
)
)
}
entityManager.flush()
entityManager.clear()
}
@ParameterizedTest
@CsvSource(
"2, KR, KRW",
"2, US, USD",
"2, JP, USD",
"2, , USD",
"4, KR, JPY",
"4, US, JPY",
"44144, KR, JPY",
"44144, US, JPY",
"3, KR, KRW",
"3, US, USD",
", KR, KRW",
", US, USD",
", , USD"
)
@DisplayName("회원 2는 국가별 통화를 사용하고 기존 JPY 예외와 일반·비로그인 조회는 유지한다")
fun shouldSelectCurrencyByCountryExceptRemainingForcedMembers(
memberId: Long?,
countryCode: String?,
expectedCurrency: String
) {
val member = memberId?.let { id ->
Member(nickname = "test-$id", password = "").apply { this.id = id }
}
countryContext.setCountryCode(countryCode)
val response = controller.getCans(member)
assertEquals(true, response.success)
assertEquals(listOf(expectedCurrency), response.data?.map { it.currency })
}
}
@@ -1,57 +0,0 @@
package kr.co.vividnext.sodalive.menu
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class MenuServiceTest {
private val repository = Mockito.mock(MenuRepository::class.java)
private val service = MenuService(repository)
@Test
@DisplayName("관리자 메뉴 마지막에 선물함 관리 메뉴를 추가한다")
fun shouldAppendGiftMenuForAdmin() {
Mockito.`when`(repository.getMenu(MemberRole.ADMIN)).thenReturn(
listOf(GetMenuResponse(title = "회원 관리", route = "/member/list"))
)
val menus = service.getMenus(member(MemberRole.ADMIN))
assertEquals("회원 관리", menus[0].title)
val giftMenu = menus.last()
assertEquals("선물함 관리", giftMenu.title)
assertNull(giftMenu.route)
assertEquals(
listOf("선물함 리스트", "선물 카테고리", "선물 사이즈"),
giftMenu.items?.map { it.title }
)
assertEquals(
listOf("/gift/list", "/gift/category", "/gift/size"),
giftMenu.items?.map { it.route }
)
}
@Test
@DisplayName("관리자가 아니면 선물함 관리 메뉴를 추가하지 않는다")
fun shouldNotAppendGiftMenuForNonAdmin() {
Mockito.`when`(repository.getMenu(MemberRole.CONTENT_MANAGER)).thenReturn(
listOf(GetMenuResponse(title = "콘텐츠 리스트", route = "/content/list"))
)
val menus = service.getMenus(member(MemberRole.CONTENT_MANAGER))
assertEquals(1, menus.size)
assertEquals("콘텐츠 리스트", menus[0].title)
}
private fun member(role: MemberRole) = Member(
email = "${role.name.lowercase()}@test.com",
password = "password",
nickname = role.name.lowercase(),
role = role
)
}
@@ -18,7 +18,9 @@ class EmbeddedRedisInitializer : ApplicationContextInitializer<ConfigurableAppli
}
private object EmbeddedRedisHolder {
private var redisServer: RedisServer? = null
private var port: Int? = null
private var shutdownHookRegistered = false
@Synchronized
fun start(): Int {
@@ -27,9 +29,8 @@ private object EmbeddedRedisHolder {
}
val selectedPort = PortProvider.newEphemeralPortProvider().get()
RedisServer.newRedisServer()
redisServer = RedisServer.newRedisServer()
.port(selectedPort)
.onShutdownForceStop(true)
.setting("bind 127.0.0.1")
.setting("daemonize no")
.setting("appendonly no")
@@ -37,6 +38,22 @@ private object EmbeddedRedisHolder {
.also { it.start() }
port = selectedPort
if (!shutdownHookRegistered) {
Runtime.getRuntime().addShutdownHook(
Thread {
stop()
}
)
shutdownHookRegistered = true
}
return selectedPort
}
@Synchronized
fun stop() {
redisServer?.stop()
redisServer = null
port = null
}
}
@@ -1,453 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.hamcrest.Matchers.endsWith
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.LocalDateTime
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.datasource.url=jdbc:h2:mem:gift-admin-controller;MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;" +
"DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@DisplayName("선물 관리자 API 테스트")
class AdminGiftControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository
) {
@BeforeEach
fun deleteGiftFixtures() {
giftDeliveryRepository.deleteAll()
giftRepository.deleteAll()
categoryRepository.deleteAll()
}
@Test
@DisplayName("관리자는 받을 주소를 조회하고 저장한다")
fun shouldManageGiftMailbox() {
mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").isEmpty)
mockMvc.perform(
put("/api/v2/admin/gift-mailbox")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"name":"소다라이브 선물 담당자",
"phoneNumber":"01012345678",
"zipCode":"06234",
"address":"서울시 강남구",
"addressDetail":"3층 선물 접수처"
}
""".trimIndent()
)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자"))
.andExpect(jsonPath("$.data.phoneNumber").value("01012345678"))
.andExpect(jsonPath("$.data.address").value("(06234) 서울시 강남구, 3층 선물 접수처"))
mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자"))
}
@Test
@DisplayName("관리자는 공백 받을 주소를 저장할 수 없다")
fun shouldRejectInvalidGiftMailbox() {
mockMvc.perform(
put("/api/v2/admin/gift-mailbox")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"name":" ","phoneNumber":"01012345678","zipCode":"06234","address":"서울시 강남구"}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다")
fun shouldListAdminGifts() {
val (gift, _) = saveAdminGift(
applicationNo = "A-1002609300001",
status = GiftStatus.TRACKING_REGISTERED,
senderNickname = "admin-fan-alpha",
recipientNickname = "admin-creator-alpha",
withTracking = true
)
mockMvc.perform(
get("/api/v2/admin/gifts")
.with(user("admin").roles("ADMIN"))
.param("status", "TRACKING_REGISTERED")
.param("applicationNo", "0001")
.param("nickname", "alpha")
.param("page", "0")
.param("size", "20")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].applicationNo").value(gift.applicationNo))
.andExpect(jsonPath("$.data.items[0].senderNickname").value("admin-fan-alpha"))
.andExpect(jsonPath("$.data.items[0].recipientNickname").value("admin-creator-alpha"))
.andExpect(jsonPath("$.data.items[0].sizeCode").value("SMALL"))
.andExpect(jsonPath("$.data.items[0].sizeName").value("소형"))
.andExpect(jsonPath("$.data.items[0].categoryName").value("인형"))
.andExpect(jsonPath("$.data.items[0].classificationNumber").value("100"))
.andExpect(jsonPath("$.data.items[0].courierCompanyName").value("CJ대한통운"))
.andExpect(jsonPath("$.data.items[0].trackingNumber").value("1234567890"))
.andExpect(jsonPath("$.data.items[0].status").value("TRACKING_REGISTERED"))
.andExpect(jsonPath("$.data.items[0].statusName").value("발송 확인"))
.andExpect(jsonPath("$.data.items[0].availableActions[0]").value("ARRIVE_MAILBOX"))
.andExpect(jsonPath("$.data.items[0].availableActions[1]").value("MARK_UNDELIVERABLE"))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("관리자는 선물 상세를 조회하고 미입력 수취인 정보는 빈 문자열로 받는다")
fun shouldGetAdminGiftDetail() {
saveAdminGift(
applicationNo = "A-1002609300002",
status = GiftStatus.ARRIVED_AT_MAILBOX,
senderNickname = "admin-fan-beta",
recipientNickname = "admin-creator-beta",
withTracking = true
)
mockMvc.perform(
get("/api/v2/admin/gifts/A-1002609300002")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value("A-1002609300002"))
.andExpect(jsonPath("$.data.senderInfo.nickname").value("admin-fan-beta"))
.andExpect(jsonPath("$.data.senderInfo.name").value("홍길동"))
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value("01012345678"))
.andExpect(jsonPath("$.data.senderInfo.address").value("(06234) 서울시 강남구, 101동 1001호"))
.andExpect(jsonPath("$.data.recipientInfo.nickname").value("admin-creator-beta"))
.andExpect(jsonPath("$.data.recipientInfo.name").value(""))
.andExpect(jsonPath("$.data.recipientInfo.phoneNumber").value(""))
.andExpect(jsonPath("$.data.recipientInfo.address").value(""))
.andExpect(jsonPath("$.data.productInfo.sizeCode").value("SMALL"))
.andExpect(jsonPath("$.data.productInfo.sizeName").value("소형"))
.andExpect(jsonPath("$.data.productInfo.categoryName").value("인형"))
.andExpect(jsonPath("$.data.productInfo.classificationNumber").value("100"))
.andExpect(jsonPath("$.data.inboundDeliveryInfo.courierCompanyName").value("CJ대한통운"))
.andExpect(jsonPath("$.data.inboundDeliveryInfo.trackingNumber").value("1234567890"))
.andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX"))
.andExpect(jsonPath("$.data.statusName").value("사서함 도착"))
.andExpect(jsonPath("$.data.availableActions[0]").value("COMPLETE_INSPECTION"))
.andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE"))
}
@Test
@DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다")
fun shouldManageGiftCategories() {
mockMvc.perform(
post("/api/v2/admin/gift-categories")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"classificationNumber":"100",
"categoryCode":"DOLL",
"name":"인형",
"receiptCode":"A",
"representativeItem":"피규어",
"requiresDamageWaiver":true,
"isActive":true
}
""".trimIndent()
)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.classificationNumber").value("100"))
.andExpect(jsonPath("$.data.categoryCode").value("DOLL"))
.andExpect(jsonPath("$.data.receiptCode").value("A"))
.andExpect(jsonPath("$.data.representativeItem").value("피규어"))
val categoryId = categoryRepository.findByCategoryCode("DOLL")!!.id!!
mockMvc.perform(
put("/api/v2/admin/gift-categories/$categoryId")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"classificationNumber":"101",
"categoryCode":"DOLL",
"name":"큰 인형",
"receiptCode":"B",
"representativeItem":"대형 피규어",
"requiresDamageWaiver":false,
"isActive":true
}
""".trimIndent()
)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.classificationNumber").value("101"))
.andExpect(jsonPath("$.data.name").value("큰 인형"))
.andExpect(jsonPath("$.data.receiptCode").value("B"))
.andExpect(jsonPath("$.data.representativeItem").value("대형 피규어"))
mockMvc.perform(get("/api/v2/admin/gift-categories").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data[0].name").value("큰 인형"))
mockMvc.perform(delete("/api/v2/admin/gift-categories/$categoryId").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.isActive").value(false))
}
@Test
@DisplayName("관리자는 사이즈 가격을 조회하고 수정하며 잘못된 가격을 거부한다")
fun shouldManageGiftSizePrices() {
sizePriceRepository.save(GiftSizePrice(sizeCode = GiftSize.SMALL, basePriceCan = 100, salePriceCan = 100))
mockMvc.perform(get("/api/v2/admin/gift-size-prices").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data[0].sizeCode").value("SMALL"))
mockMvc.perform(
put("/api/v2/admin/gift-size-prices/SMALL")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"basePriceCan":100,"salePriceCan":80,"isActive":true}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.salePriceCan").value(80))
mockMvc.perform(
put("/api/v2/admin/gift-size-prices/SMALL")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"basePriceCan":100,"salePriceCan":101,"isActive":true}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("관리자는 사서함 도착과 검수완료를 처리한다")
fun shouldUpdateGiftOperationStatuses() {
saveGift(GiftStatus.TRACKING_REGISTERED)
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/arrive-mailbox").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX"))
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-inspection").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("INSPECTION_COMPLETED"))
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
}
@Test
@DisplayName("관리자는 전달불가 사유를 등록하고 전달불가 처리한다")
fun shouldMarkGiftUndeliverable() {
saveGift(GiftStatus.INSPECTION_COMPLETED)
mockMvc.perform(
post("/api/v2/admin/gifts/$APPLICATION_NO/mark-undeliverable")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"reason":"선물 접수 규격에 맞지 않습니다."}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("UNDELIVERABLE"))
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
}
@Test
@DisplayName("관리자는 전달불가 사유 없이 전달불가 처리할 수 없다")
fun shouldRejectUndeliverableWithoutReason() {
saveGift(GiftStatus.INSPECTION_COMPLETED)
mockMvc.perform(
post("/api/v2/admin/gifts/$APPLICATION_NO/mark-undeliverable")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"reason":" "}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("관리자는 검수완료 선물을 전달완료 처리한다")
fun shouldCompleteGiftDelivery() {
saveGift(GiftStatus.INSPECTION_COMPLETED, withRecipientAddress = true)
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("DELIVERED"))
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
}
@Test
@DisplayName("관리자는 수취인 정보가 없는 검수완료 선물을 전달완료 처리할 수 없다")
fun shouldRejectGiftDeliveryWithoutRecipientAddress() {
saveGift(GiftStatus.INSPECTION_COMPLETED)
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
private fun saveGift(status: GiftStatus, withRecipientAddress: Boolean = false) {
val gift = giftRepository.save(
Gift(
applicationNo = APPLICATION_NO,
senderMemberId = SENDER_MEMBER_ID,
recipientMemberId = RECIPIENT_MEMBER_ID,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = 1L,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = 10L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW.minusDays(1),
senderPrivacyAgreedAt = NOW.minusDays(1),
damageWaiverAgreed = true
)
)
giftDeliveryRepository.save(
GiftDelivery(
giftId = requireNotNull(gift.id),
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
recipientName = "김소다".takeIf { withRecipientAddress },
recipientPhoneNumber = "01098765432".takeIf { withRecipientAddress },
recipientZipCode = "04524".takeIf { withRecipientAddress },
recipientAddress = "서울시 중구".takeIf { withRecipientAddress },
trackingDeadlineAt = NOW.minusDays(1),
trackingRegisteredAt = NOW.minusHours(1)
)
)
}
private fun saveAdminGift(
applicationNo: String,
status: GiftStatus,
senderNickname: String,
recipientNickname: String,
withTracking: Boolean
): Pair<Gift, GiftDelivery> {
val sender = memberRepository.save(Member(password = "password", nickname = senderNickname))
val recipient = memberRepository.save(Member(password = "password", nickname = recipientNickname))
val category = categoryRepository.save(
GiftCategory(
classificationNumber = "100",
categoryCode = "ADMIN_${applicationNo.takeLast(4)}",
name = "인형",
receiptCode = "A",
representativeItem = "피규어"
)
)
val gift = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = category.id!!,
categoryNameSnapshot = category.name,
salePriceCan = 80,
canUsageId = 10L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW.minusDays(1),
senderPrivacyAgreedAt = NOW.minusDays(1),
damageWaiverAgreed = true
)
)
val delivery = giftDeliveryRepository.save(
GiftDelivery(
giftId = requireNotNull(gift.id),
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
trackingDeadlineAt = NOW.minusDays(1)
).apply {
if (withTracking) {
courierCompanyName = "CJ대한통운"
trackingNumber = "1234567890"
trackingRegisteredAt = NOW.minusHours(1)
}
}
)
return gift to delivery
}
private companion object {
const val APPLICATION_NO = "G20260929000001"
const val SENDER_MEMBER_ID = 1L
const val RECIPIENT_MEMBER_ID = 2L
val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0)
}
}
@@ -1,538 +0,0 @@
package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.hamcrest.Matchers.endsWith
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.LocalDateTime
@SpringBootTest(
properties = [
"spring.datasource.url=" +
"jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"cloud.aws.cloud-front.host=https://cdn.test"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class GiftControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val categoryRepository: GiftCategoryRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository,
private val objectMapper: ObjectMapper
) {
@MockBean
private lateinit var commandService: GiftCommandService
@Test
fun `팬은 활성 폼 옵션을 조회하고 categoryCode를 받지 않는다`() {
categoryRepository.save(giftCategory("100", "DOLL", "A", "인형", "피규어", true, true))
categoryRepository.save(giftCategory("200", "FOOD", "B", "간식", "쿠키", false, false))
sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true))
mockMvc.perform(get("/api/v2/gifts/form-options").with(user("fan").roles("USER")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.sizes[0].sizeCode").value("SMALL"))
.andExpect(jsonPath("$.data.sizes[0].name").value("소형"))
.andExpect(jsonPath("$.data.sizes[0].basePriceCan").value(100))
.andExpect(jsonPath("$.data.sizes[0].salePriceCan").value(80))
.andExpect(jsonPath("$.data.categories[0].name").value("인형/피규어"))
.andExpect(jsonPath("$.data.categories[0].categoryCode").doesNotExist())
.andExpect(jsonPath("$.data.categories[0].classificationNumber").doesNotExist())
.andExpect(jsonPath("$.data.categories[0].receiptCode").doesNotExist())
.andExpect(jsonPath("$.data.categories[0].representativeItem").doesNotExist())
}
@Test
@DisplayName("회원은 선물함 목록에서 보낸 선물과 노출 가능한 받은 선물을 페이징 응답으로 조회한다")
fun shouldListGifts() {
val member = memberRepository.save(Member(password = "password", nickname = "member"))
val counterpart = memberRepository.save(
Member(password = "password", nickname = "counterpart", profileImage = "profile/counterpart.png")
)
saveGift(
"G-CONTROLLER-SENT",
senderMemberId = member.id!!,
recipientMemberId = counterpart.id!!,
status = GiftStatus.RECEIVED
)
saveGift(
"G-CONTROLLER-HIDDEN",
senderMemberId = counterpart.id!!,
recipientMemberId = member.id!!,
status = GiftStatus.RECEIVED
)
saveGift(
"G-CONTROLLER-VISIBLE",
senderMemberId = counterpart.id!!,
recipientMemberId = member.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
mockMvc.perform(
get("/api/v2/gifts")
.param("type", "ALL")
.param("page", "-1")
.param("size", "0")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(2))
.andExpect(jsonPath("$.data.items.length()").value(2))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
.andExpect(jsonPath("$.data.items[0].applicationNo").value("G-CONTROLLER-VISIBLE"))
.andExpect(jsonPath("$.data.items[0].direction").value("RECEIVED"))
.andExpect(jsonPath("$.data.items[0].status").value("TRACKING_REGISTERED"))
.andExpect(jsonPath("$.data.items[0].statusName").value("발송 확인"))
.andExpect(jsonPath("$.data.items[0].priceCan").value(80))
.andExpect(jsonPath("$.data.items[0].categoryName").value("인형"))
.andExpect(jsonPath("$.data.items[0].sizeName").value("소형"))
.andExpect(jsonPath("$.data.items[0].counterpartMemberId").value(counterpart.id!!))
.andExpect(jsonPath("$.data.items[0].counterpartNickname").value("counterpart"))
.andExpect(jsonPath("$.data.items[0].counterpartProfileImageUrl").value("https://cdn.test/profile/counterpart.png"))
.andExpect(jsonPath("$.data.items[0].createdAt").value(endsWith("Z")))
}
@Test
@DisplayName("발신자는 인증 정보로 선물 상세와 자신의 발신자 정보를 조회한다")
fun shouldGetSentGiftDetail() {
val sender = memberRepository.save(Member(password = "password", nickname = "fan"))
val recipient = memberRepository.save(Member(password = "password", nickname = "creator"))
val gift = saveGift(
applicationNo = "G-CONTROLLER-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
giftDeliveryRepository.save(
GiftDelivery(
giftId = gift.id!!,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
).apply {
courierCompanyName = "CJ대한통운"
trackingNumber = "1234567890"
trackingRegisteredAt = LocalDateTime.of(2026, 9, 30, 12, 0)
}
)
mockMvc.perform(
get("/api/v2/gifts/{applicationNo}", gift.applicationNo)
.with(user(MemberAdapter(sender)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value("G-CONTROLLER-DETAIL"))
.andExpect(jsonPath("$.data.direction").value("SENT"))
.andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED"))
.andExpect(jsonPath("$.data.giftInfo.recipientCreatorNickname").value("creator"))
.andExpect(jsonPath("$.data.giftInfo.applicationNo").value("G-CONTROLLER-DETAIL"))
.andExpect(jsonPath("$.data.giftInfo.paidCan").value(80))
.andExpect(jsonPath("$.data.giftInfo.tracking").value("CJ대한통운_1234567890"))
.andExpect(jsonPath("$.data.senderInfo.name").value("홍길동"))
.andExpect(jsonPath("$.data.senderInfo.address").value("(06234) 서울시 강남구, 101동 1001호"))
.andExpect(jsonPath("$.data.recipientAddress").isEmpty)
.andExpect(jsonPath("$.data.trackingRequired").value(false))
.andExpect(jsonPath("$.data.statusTimeline[0].status").value("RECEIVED"))
.andExpect(jsonPath("$.data.recipientAddressDeadlineAt").isEmpty)
.andExpect(jsonPath("$.data.giftInfo.shippingRequestedAt").isEmpty)
.andExpect(jsonPath("$.data.delivery.canceledAt").isEmpty)
.andExpect(jsonPath("$.data.delivery.undeliverableAt").isEmpty)
.andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z"))
}
@Test
@DisplayName("발신자는 전달 완료 전 정상 진행 선물 상세에서 받을 주소를 조회한다")
fun shouldGetMailboxForSentNormalProgressGiftDetailBeforeDelivered() {
val sender = memberRepository.save(Member(password = "password", nickname = "fan"))
val recipient = memberRepository.save(Member(password = "password", nickname = "creator"))
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val statuses = listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
statuses.forEachIndexed { index, giftStatus ->
val gift = saveGift(
applicationNo = "G-C-MBOX-$index",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = giftStatus
)
giftDeliveryRepository.save(
GiftDelivery(
giftId = gift.id!!,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
)
).applyStatusTimes(giftStatus)
mockMvc.perform(
get("/api/v2/gifts/{applicationNo}", gift.applicationNo)
.with(user(MemberAdapter(sender)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.mailbox.name").value("소다라이브 선물 담당자"))
.andExpect(jsonPath("$.data.mailbox.phoneNumber").value("01012345678"))
.andExpect(jsonPath("$.data.mailbox.address").value("(06234) 서울시 강남구, 3층 선물 접수처"))
}
}
@Test
@DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다")
fun shouldRegisterGift() {
// Given: 로그인한 팬과 선물 신청 요청을 준비한다.
val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID }
val request = GiftApplicationRequest(
recipientMemberId = RECIPIENT_ID,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
damageWaiverAgreed = true
)
val deadline = LocalDateTime.of(2026, 10, 2, 12, 0)
Mockito.`when`(
commandService.registerGift(member, request.toCommand())
).thenReturn(
GiftApplicationResult(
applicationNo = "G20260929000001",
status = GiftStatus.RECEIVED,
statusName = "접수 완료",
priceCan = 80,
trackingDeadlineAt = deadline
)
)
// When: 팬이 선물 신청 API를 호출한다.
mockMvc.perform(
post("/api/v2/gifts")
.with(user(MemberAdapter(member)))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
// Then: 신청번호, 접수 상태, 판매가와 운송장 등록 기한을 반환한다.
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value("G20260929000001"))
.andExpect(jsonPath("$.data.status").value("RECEIVED"))
.andExpect(jsonPath("$.data.statusName").value("접수 완료"))
.andExpect(jsonPath("$.data.priceCan").value(80))
.andExpect(jsonPath("$.data.trackingDeadlineAt").value("2026-10-02T12:00:00Z"))
}
@Test
@DisplayName("팬은 POST /api/v2/gifts/{applicationNo}/cancel로 접수된 선물을 취소하고 환불 결과를 받는다")
fun shouldCancelGift() {
// Given: 취소할 접수 선물과 로그인한 발신자를 준비한다.
val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID }
val canceledAt = LocalDateTime.of(2026, 9, 29, 12, 0)
Mockito.`when`(commandService.cancelGift(member, APPLICATION_NO)).thenReturn(
GiftCancellationResult(
applicationNo = APPLICATION_NO,
status = GiftStatus.CANCELED,
statusName = "신청 취소",
priceCan = 80,
canceledAt = canceledAt
)
)
// When: 발신자 인증으로 취소 API를 호출한다.
mockMvc.perform(
post("/api/v2/gifts/{applicationNo}/cancel", APPLICATION_NO)
.with(user(MemberAdapter(member)))
)
// Then: 취소 상태, 환불 금액과 취소 시각을 반환한다.
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("CANCELED"))
.andExpect(jsonPath("$.data.statusName").value("신청 취소"))
.andExpect(jsonPath("$.data.priceCan").value(80))
.andExpect(jsonPath("$.data.canceledAt").value("2026-09-29T12:00:00Z"))
}
@Test
@DisplayName("팬은 POST /api/v2/gifts/{applicationNo}/tracking으로 운송장을 등록하고 발송 확인 결과를 받는다")
fun shouldRegisterTracking() {
// Given: 운송장 등록 요청과 로그인한 발신자를 준비한다.
val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID }
val request = GiftTrackingRegistrationRequest(
courierCompanyName = "CJ대한통운",
trackingNumber = "1234567890"
)
val trackingRegisteredAt = LocalDateTime.of(2026, 9, 29, 12, 0)
val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7)
Mockito.`when`(commandService.registerTracking(member, APPLICATION_NO, request.toCommand())).thenReturn(
GiftTrackingRegistrationResult(
applicationNo = APPLICATION_NO,
status = GiftStatus.TRACKING_REGISTERED,
statusName = "발송 확인",
courierCompanyName = "CJ대한통운",
trackingNumber = "1234567890",
trackingRegisteredAt = trackingRegisteredAt,
recipientAddressDeadlineAt = recipientAddressDeadlineAt
)
)
// When: 발신자 인증으로 운송장 등록 API를 호출한다.
mockMvc.perform(
post("/api/v2/gifts/{applicationNo}/tracking", APPLICATION_NO)
.with(user(MemberAdapter(member)))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
// Then: 신청번호, 발송 확인 상태와 운송장 및 수신자 주소 입력 기한을 반환한다.
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED"))
.andExpect(jsonPath("$.data.statusName").value("발송 확인"))
.andExpect(jsonPath("$.data.courierCompanyName").value("CJ대한통운"))
.andExpect(jsonPath("$.data.trackingNumber").value("1234567890"))
.andExpect(jsonPath("$.data.trackingRegisteredAt").value("2026-09-29T12:00:00Z"))
.andExpect(jsonPath("$.data.recipientAddressDeadlineAt").value("2026-10-06T12:00:00Z"))
}
@Test
@DisplayName("받는 크리에이터는 POST recipient-address로 주소 등록 성공 envelope를 받는다")
fun shouldRegisterRecipientAddress() {
// Given: 로그인한 받는 크리에이터와 주소 등록 요청을 준비한다.
val member = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID }
val request = GiftRecipientAddressRegistrationRequest(
recipientName = "김소다",
recipientPhoneNumber = "01098765432",
recipientZipCode = "04524",
recipientAddress = "서울시 중구",
recipientAddressDetail = "202호",
recipientTermsAgreed = true,
recipientPrivacyAgreed = true
)
val registeredAt = LocalDateTime.of(2026, 9, 29, 12, 0)
Mockito.`when`(
commandService.registerRecipientAddress(member, APPLICATION_NO, request.toCommand())
).thenReturn(
GiftRecipientAddressRegistrationResult(
applicationNo = APPLICATION_NO,
status = GiftStatus.TRACKING_REGISTERED,
statusName = "발송 확인",
recipientAddressRegisteredAt = registeredAt
)
)
// When: 받는 크리에이터 인증으로 주소 등록 API를 호출한다.
mockMvc.perform(
post("/api/v2/gifts/{applicationNo}/recipient-address", APPLICATION_NO)
.with(user(MemberAdapter(member)))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
// Then: 성공 envelope에 신청번호, 상태와 주소 등록 시각을 반환한다.
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED"))
.andExpect(jsonPath("$.data.statusName").value("발송 확인"))
.andExpect(jsonPath("$.data.recipientAddressRegisteredAt").value("2026-09-29T12:00:00Z"))
}
@Test
@DisplayName("받는 크리에이터는 POST delivery-complete로 수령 확인 성공 envelope를 받는다")
fun shouldConfirmDelivery() {
val member = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID }
val deliveredAt = LocalDateTime.of(2026, 9, 29, 12, 0)
Mockito.`when`(commandService.confirmDelivery(member, APPLICATION_NO)).thenReturn(
GiftDeliveryConfirmationResult(
applicationNo = APPLICATION_NO,
status = GiftStatus.DELIVERED,
statusName = "전달완료",
deliveredAt = deliveredAt
)
)
mockMvc.perform(
post("/api/v2/gifts/{applicationNo}/delivery-complete", APPLICATION_NO)
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("DELIVERED"))
.andExpect(jsonPath("$.data.statusName").value("전달완료"))
.andExpect(jsonPath("$.data.deliveredAt").value("2026-09-29T12:00:00Z"))
Mockito.verify(commandService).confirmDelivery(member, APPLICATION_NO)
}
@Test
@DisplayName("팬은 POST review로 리뷰를 작성하고 keywords 배열 응답을 받는다")
fun shouldWriteReview() {
val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID }
val request = GiftReviewRequest(
rating = 5,
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
comment = "선물 전달 과정이 만족스러웠습니다."
)
val createdAt = LocalDateTime.of(2026, 10, 11, 12, 0)
Mockito.`when`(commandService.writeReview(member, APPLICATION_NO, request.toCommand())).thenReturn(
GiftReviewResult(
reviewId = 1L,
applicationNo = APPLICATION_NO,
rating = 5,
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
comment = "선물 전달 과정이 만족스러웠습니다.",
createdAt = createdAt
)
)
mockMvc.perform(
post("/api/v2/gifts/{applicationNo}/review", APPLICATION_NO)
.with(user(MemberAdapter(member)))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.reviewId").value(1L))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.rating").value(5))
.andExpect(jsonPath("$.data.keywords[0]").value("배송이 빨라요"))
.andExpect(jsonPath("$.data.keywords[1]").value("안내가 친절해요"))
.andExpect(jsonPath("$.data.keyword").doesNotExist())
.andExpect(jsonPath("$.data.comment").value("선물 전달 과정이 만족스러웠습니다."))
.andExpect(jsonPath("$.data.createdAt").value("2026-10-11T12:00:00Z"))
Mockito.verify(commandService).writeReview(member, APPLICATION_NO, request.toCommand())
}
private fun saveGift(
applicationNo: String,
senderMemberId: Long,
recipientMemberId: Long,
status: GiftStatus
) = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = senderMemberId,
recipientMemberId = recipientMemberId,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = 1L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0)
)
)
private fun giftCategory(
classificationNumber: String,
categoryCode: String,
receiptCode: String,
name: String,
representativeItem: String,
requiresDamageWaiver: Boolean,
isActive: Boolean
) = GiftCategory(
classificationNumber = classificationNumber,
categoryCode = categoryCode,
name = name,
receiptCode = receiptCode,
representativeItem = representativeItem,
requiresDamageWaiver = requiresDamageWaiver,
isActive = isActive
)
private fun GiftDelivery.applyStatusTimes(status: GiftStatus) {
if (status.ordinal >= GiftStatus.TRACKING_REGISTERED.ordinal) {
trackingRegisteredAt = LocalDateTime.of(2026, 9, 30, 12, 0)
}
if (status.ordinal >= GiftStatus.ARRIVED_AT_MAILBOX.ordinal) {
arrivedAtMailboxAt = LocalDateTime.of(2026, 10, 1, 12, 0)
}
if (status.ordinal >= GiftStatus.INSPECTION_COMPLETED.ordinal) {
inspectionCompletedAt = LocalDateTime.of(2026, 10, 2, 12, 0)
}
}
private companion object {
const val SENDER_ID = 1L
const val RECIPIENT_ID = 2L
const val CATEGORY_ID = 3L
const val APPLICATION_NO = "G20260929000001"
}
}
@@ -1,876 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
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.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.common.SodaException
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.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGenerator
import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.mockito.Mockito
import java.time.LocalDateTime
import java.util.Optional
class GiftCommandServiceTest {
@Test
@DisplayName("선물 캔 사용은 한 번만 환불할 수 있다")
fun shouldSpendAndRefundGiftCanOnlyOnce() {
// Given: 충전 캔을 보유한 선물 발신자와 결제 서비스를 준비한다.
val member = Member(password = "password", nickname = "sender")
member.id = MEMBER_ID
member.pgChargeCan = INITIAL_CAN
val charge = Charge(chargeCan = INITIAL_CAN, rewardCan = 0)
charge.id = CHARGE_ID
charge.member = member
charge.payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
val fixture = CanPaymentFixture(member, charge)
// When: 선물용 캔을 사용한 뒤 해당 사용 내역을 환불한다.
val useCanId = fixture.service.spendGiftCan(
memberId = MEMBER_ID,
needCan = GIFT_PRICE_CAN,
container = "web"
)
val afterSpendCan = member.getChargeCan("web") + member.getRewardCan("web")
fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId)
val afterRefundCan = member.getChargeCan("web") + member.getRewardCan("web")
// Then: 발신자와 GIFT 용도가 저장되고, 잔액이 복원되며 재환불은 거부된다.
assertEquals(CanUsage.GIFT, fixture.savedUseCan.canUsage)
assertEquals(member, fixture.savedUseCan.member)
assertEquals(INITIAL_CAN - GIFT_PRICE_CAN, afterSpendCan)
assertEquals(INITIAL_CAN, afterRefundCan)
assertEquals(true, fixture.savedUseCan.isRefund)
assertThrows(SodaException::class.java) {
fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId)
}
}
@Test
@DisplayName("선물 신청은 가격과 약관 동의, 배송 기한을 스냅샷으로 저장하고 캔을 차감한다")
fun shouldRegisterGiftWithPriceAgreementAndDeliverySnapshots() {
// Given: 활성 선물 카테고리와 가격, 발신자 및 수신 크리에이터를 준비한다.
val fixture = GiftApplicationFixture()
// When: 팬이 선물을 신청한다.
val result = fixture.service.registerGift(fixture.sender, validGiftApplicationCommand())
// Then: 신청 정보와 배송 기한을 저장하고 판매가만큼 캔을 차감한다.
assertEquals(APPLICATION_NO, result.applicationNo)
assertEquals(GiftStatus.RECEIVED, result.status)
assertEquals(GiftStatus.RECEIVED.displayName, result.statusName)
assertEquals(SALE_PRICE_CAN, result.priceCan)
assertEquals(NOW.plusDays(3), result.trackingDeadlineAt)
assertEquals(SENDER_ID, fixture.savedGift.senderMemberId)
assertEquals(RECIPIENT_ID, fixture.savedGift.recipientMemberId)
assertEquals(GiftStatus.RECEIVED, fixture.savedGift.status)
assertEquals(GiftSize.SMALL, fixture.savedGift.sizeCode)
assertEquals(CATEGORY_ID, fixture.savedGift.categoryId)
assertEquals("인형", fixture.savedGift.categoryNameSnapshot)
assertEquals(SALE_PRICE_CAN, fixture.savedGift.salePriceCan)
assertEquals(USE_CAN_ID, fixture.savedGift.canUsageId)
assertEquals(true, fixture.savedGift.senderTermsAgreed)
assertEquals(true, fixture.savedGift.senderPrivacyAgreed)
assertEquals(NOW, fixture.savedGift.senderTermsAgreedAt)
assertEquals(NOW, fixture.savedGift.senderPrivacyAgreedAt)
assertEquals(true, fixture.savedGift.damageWaiverAgreed)
assertEquals(GIFT_ID, fixture.savedDelivery.giftId)
assertEquals("홍길동", fixture.savedDelivery.senderName)
assertEquals("01012345678", fixture.savedDelivery.senderPhoneNumber)
assertEquals("06234", fixture.savedDelivery.senderZipCode)
assertEquals("서울시 강남구", fixture.savedDelivery.senderAddress)
assertEquals("101동 1001호", fixture.savedDelivery.senderAddressDetail)
assertEquals(NOW.plusDays(3), fixture.savedDelivery.trackingDeadlineAt)
Mockito.verify(fixture.canPaymentService).spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")
}
@Test
@DisplayName("보낸 팬은 운송장 등록 전 선물을 취소하고 전액 환불받는다")
fun shouldCancelReceivedGiftAndRefundOnce() {
// Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다.
val fixture = GiftApplicationFixture()
fixture.prepareGiftForCancellation()
// When: 보낸 팬이 선물을 취소한다.
val result = fixture.service.cancelGift(fixture.sender, APPLICATION_NO)
// Then: 선물과 배송 정보에 취소 시각을 저장하고 한 번만 환불한다.
assertEquals(
GiftCancellationResult(
applicationNo = APPLICATION_NO,
status = GiftStatus.CANCELED,
statusName = GiftStatus.CANCELED.displayName,
priceCan = SALE_PRICE_CAN,
canceledAt = NOW
),
result
)
assertEquals(GiftStatus.CANCELED, fixture.cancellationGift.status)
assertEquals(NOW, fixture.cancellationDelivery.canceledAt)
Mockito.verify(fixture.canPaymentService).refundGiftCan(SENDER_ID, USE_CAN_ID)
}
@Test
@DisplayName("보낸 팬은 접수된 선물의 운송장을 등록하고 크리에이터 배송지 입력 기한을 생성한다")
fun shouldRegisterTrackingForReceivedGift() {
// Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다.
val fixture = GiftApplicationFixture()
fixture.prepareGiftForTracking()
// When: 보낸 팬이 택배사와 운송장 번호를 등록한다.
val result = fixture.service.registerTracking(
fixture.sender,
APPLICATION_NO,
validGiftTrackingRegistrationCommand()
)
// Then: 발송 확인 상태와 운송장 정보, 수신자 주소 입력 기한을 저장한다.
assertEquals(
GiftTrackingRegistrationResult(
applicationNo = APPLICATION_NO,
status = GiftStatus.TRACKING_REGISTERED,
statusName = GiftStatus.TRACKING_REGISTERED.displayName,
courierCompanyName = "CJ대한통운",
trackingNumber = "1234567890",
trackingRegisteredAt = NOW,
recipientAddressDeadlineAt = NOW.plusDays(7)
),
result
)
assertEquals(GiftStatus.TRACKING_REGISTERED, fixture.trackingGift.status)
assertEquals("CJ대한통운", fixture.trackingDelivery.courierCompanyName)
assertEquals("1234567890", fixture.trackingDelivery.trackingNumber)
assertEquals(NOW, fixture.trackingDelivery.trackingRegisteredAt)
assertEquals(NOW.plusDays(7), fixture.trackingDelivery.recipientAddressDeadlineAt)
Mockito.verify(fixture.giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, RECIPIENT_ID)
}
@ParameterizedTest
@MethodSource("recipientAddressRegistrableStatuses")
@DisplayName("받는 크리에이터는 운송장 등록 이후 종료 전 상태에서 주소와 수령 동의를 등록한다")
fun shouldRegisterRecipientAddressAndAgreements(status: GiftStatus) {
// Given: 주소 입력이 허용된 선물과 받는 크리에이터를 준비한다.
val fixture = GiftApplicationFixture()
fixture.prepareGiftForRecipientAddress(status = status)
// When: 받는 크리에이터가 배송지와 수령 약관 동의를 등록한다.
val result = fixture.service.registerRecipientAddress(
fixture.creator,
APPLICATION_NO,
validGiftRecipientAddressRegistrationCommand()
)
// Then: 주소 스냅샷과 두 동의 시각을 저장하고 현재 상태를 반환한다.
assertEquals(
GiftRecipientAddressRegistrationResult(
applicationNo = APPLICATION_NO,
status = status,
statusName = status.displayName,
recipientAddressRegisteredAt = NOW
),
result
)
assertEquals("김소다", fixture.recipientAddressDelivery.recipientName)
assertEquals("01098765432", fixture.recipientAddressDelivery.recipientPhoneNumber)
assertEquals("04524", fixture.recipientAddressDelivery.recipientZipCode)
assertEquals("서울시 중구", fixture.recipientAddressDelivery.recipientAddress)
assertEquals("202호", fixture.recipientAddressDelivery.recipientAddressDetail)
assertEquals(true, fixture.recipientAddressGift.recipientTermsAgreed)
assertEquals(true, fixture.recipientAddressGift.recipientPrivacyAgreed)
assertEquals(NOW, fixture.recipientAddressGift.recipientTermsAgreedAt)
assertEquals(NOW, fixture.recipientAddressGift.recipientPrivacyAgreedAt)
}
@Test
@DisplayName("받는 크리에이터는 검수 완료 선물의 수령을 확인하고 전달 완료 시각을 저장한다")
fun shouldConfirmDeliveryForInspectionCompletedGift() {
val fixture = GiftApplicationFixture()
fixture.prepareGiftForDeliveryConfirmation()
val result = fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO)
assertEquals(
GiftDeliveryConfirmationResult(
applicationNo = APPLICATION_NO,
status = GiftStatus.DELIVERED,
statusName = GiftStatus.DELIVERED.displayName,
deliveredAt = NOW
),
result
)
assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status)
assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt)
Mockito.verifyNoInteractions(fixture.giftPushService)
}
@Test
@DisplayName("보낸 팬은 전달 완료 선물에 리뷰를 한 번 작성한다")
fun shouldWriteReviewForDeliveredGiftBySender() {
val fixture = GiftApplicationFixture()
fixture.prepareGiftForReview()
val result = fixture.service.writeReview(fixture.sender, APPLICATION_NO, validGiftReviewCommand())
assertEquals(
GiftReviewResult(
reviewId = REVIEW_ID,
applicationNo = APPLICATION_NO,
rating = 5,
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
comment = "선물 전달 과정이 만족스러웠습니다.",
createdAt = NOW
),
result
)
assertEquals(GIFT_ID, fixture.savedReview.giftId)
assertEquals(SENDER_ID, fixture.savedReview.senderMemberId)
assertEquals(5, fixture.savedReview.rating)
assertEquals("배송이 빨라요,안내가 친절해요", fixture.savedReview.keywords)
assertEquals("선물 전달 과정이 만족스러웠습니다.", fixture.savedReview.comment)
}
@ParameterizedTest
@MethodSource("invalidGiftReviewScenarios")
@DisplayName("리뷰 작성은 보낸 팬의 전달 완료 선물, 1회, 유효한 별점과 내용만 허용한다")
fun shouldRejectInvalidGiftReview(scenario: InvalidGiftReviewScenario) {
val fixture = GiftApplicationFixture()
fixture.prepareGiftForReview(
status = scenario.status,
senderMemberId = scenario.senderMemberId,
reviewExists = scenario.reviewExists
)
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
val exception = assertThrows(SodaException::class.java) {
fixture.service.writeReview(requester, APPLICATION_NO, scenario.command)
}
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(fixture.reviewRepository, Mockito.never()).save(Mockito.any(GiftReview::class.java))
}
@ParameterizedTest
@MethodSource("invalidGiftDeliveryConfirmationScenarios")
@DisplayName("수령 확인은 받는 크리에이터의 검수 완료 선물에만 가능하고 배송 정보가 필요하다")
fun shouldRejectInvalidGiftDeliveryConfirmation(scenario: InvalidGiftDeliveryConfirmationScenario) {
val fixture = GiftApplicationFixture()
fixture.prepareGiftForDeliveryConfirmation(
status = scenario.status,
recipientMemberId = scenario.recipientMemberId,
deliveryExists = scenario.deliveryExists
)
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
val exception = assertThrows(SodaException::class.java) {
fixture.service.confirmDelivery(requester, APPLICATION_NO)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(scenario.status, fixture.deliveryConfirmationGift.status)
assertEquals(null, fixture.deliveryConfirmationDelivery.deliveredAt)
}
@ParameterizedTest
@MethodSource("invalidGiftRecipientAddressRegistrationScenarios")
@DisplayName("수령자 주소 등록은 받는 크리에이터, 허용 상태, 필수 주소와 두 동의가 필요하다")
fun shouldRejectInvalidGiftRecipientAddressRegistration(scenario: InvalidGiftRecipientAddressRegistrationScenario) {
// Given: 권한, 상태 또는 입력값이 유효하지 않은 선물을 준비한다.
val fixture = GiftApplicationFixture()
fixture.prepareGiftForRecipientAddress(
status = scenario.status,
recipientMemberId = scenario.recipientMemberId,
deliveryExists = scenario.deliveryExists
)
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
// When: 배송지 등록을 요청한다.
val exception = assertThrows(SodaException::class.java) {
fixture.service.registerRecipientAddress(requester, APPLICATION_NO, scenario.command)
}
// Then: 공통 요청 오류를 반환하고 선물 상태와 수령 동의를 바꾸지 않는다.
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(scenario.status, fixture.recipientAddressGift.status)
assertEquals(false, fixture.recipientAddressGift.recipientTermsAgreed)
assertEquals(false, fixture.recipientAddressGift.recipientPrivacyAgreed)
}
@ParameterizedTest
@MethodSource("invalidGiftTrackingRegistrationScenarios")
@DisplayName("운송장 등록은 보낸 팬의 접수 상태에서 유효한 택배사와 운송장 번호가 있어야 한다")
fun shouldRejectInvalidGiftTrackingRegistration(scenario: InvalidGiftTrackingRegistrationScenario) {
// Given: 운송장 등록 권한, 상태, 입력값 또는 배송 정보가 유효하지 않은 선물을 준비한다.
val fixture = GiftApplicationFixture()
fixture.prepareGiftForTracking(scenario.status, scenario.senderMemberId, scenario.deliveryExists)
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
// When: 운송장 등록을 요청한다.
val exception = assertThrows(SodaException::class.java) {
fixture.service.registerTracking(requester, APPLICATION_NO, scenario.command)
}
// Then: 공통 요청 오류를 반환하고 선물 상태를 바꾸지 않는다.
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(scenario.status, fixture.trackingGift.status)
}
@ParameterizedTest
@MethodSource("invalidGiftCancellationScenarios")
@DisplayName("선물 취소는 보낸 팬의 접수 상태에서만 가능하다")
fun shouldRejectGiftCancellationForInvalidSenderOrStatus(scenario: InvalidGiftCancellationScenario) {
// Given: 취소 권한 또는 상태가 유효하지 않은 선물을 준비한다.
val fixture = GiftApplicationFixture()
fixture.prepareGiftForCancellation(scenario.status, scenario.senderMemberId)
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
// When: 취소를 요청한다.
val exception = assertThrows(SodaException::class.java) {
fixture.service.cancelGift(requester, APPLICATION_NO)
}
// Then: 환불하지 않고 공통 요청 오류를 반환한다.
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(fixture.canPaymentService, Mockito.never()).refundGiftCan(
Mockito.anyLong(),
Mockito.anyLong()
)
}
@ParameterizedTest
@MethodSource("invalidGiftApplicationScenarios")
@DisplayName("선물 신청은 필수 발신자 정보, 동의, 활성 옵션 및 수신 크리에이터를 검증한다")
fun shouldRejectInvalidGiftApplication(scenario: InvalidGiftApplicationScenario) {
// Given: 하나의 신청 조건만 유효하지 않은 선물 신청을 준비한다.
val fixture = GiftApplicationFixture()
fixture.category.isActive = scenario.categoryActive
fixture.category.requiresDamageWaiver = scenario.requiresDamageWaiver
fixture.sizePrice.isActive = scenario.sizePriceActive
fixture.setCreatorExists(scenario.creatorExists)
// When: 유효하지 않은 신청을 등록한다.
val exception = assertThrows(SodaException::class.java) {
fixture.service.registerGift(fixture.sender, scenario.command)
}
// Then: 캔을 차감하지 않고 공통 요청 오류를 반환한다.
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verifyNoInteractions(fixture.canPaymentService)
}
private class CanPaymentFixture(member: Member, charge: Charge) {
private val canRepository = Mockito.mock(CanRepository::class.java)
private val memberRepository = Mockito.mock(MemberRepository::class.java)
private val chargeRepository = Mockito.mock(ChargeRepository::class.java)
private val useCanRepository = Mockito.mock(UseCanRepository::class.java)
private val useCanCalculateRepository = Mockito.mock(UseCanCalculateRepository::class.java)
private val useCanCalculates = mutableListOf<UseCanCalculate>()
lateinit var savedUseCan: UseCan
private set
val service = CanPaymentService(
repository = canRepository,
memberRepository = memberRepository,
chargeRepository = chargeRepository,
useCanRepository = useCanRepository,
useCanCalculateRepository = useCanCalculateRepository,
messageSource = SodaMessageSource(),
langContext = LangContext(),
countryContext = CountryContext()
)
init {
Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member))
Mockito.`when`(
chargeRepository.getOldestChargeWhereRewardCanGreaterThan0(0L, MEMBER_ID, "web")
).thenReturn(null)
Mockito.`when`(
chargeRepository.getOldestChargeWhereChargeCanGreaterThan0(0L, MEMBER_ID, "web")
).thenReturn(charge)
Mockito.`when`(useCanRepository.save(Mockito.any(UseCan::class.java))).thenAnswer { invocation ->
invocation.getArgument<UseCan>(0).apply {
id = USE_CAN_ID
savedUseCan = this
}
}
Mockito.`when`(useCanRepository.findById(USE_CAN_ID)).thenAnswer { Optional.of(savedUseCan) }
Mockito.`when`(useCanCalculateRepository.save(Mockito.any(UseCanCalculate::class.java))).thenAnswer {
invocation ->
invocation.getArgument<UseCanCalculate>(0).also(useCanCalculates::add)
}
Mockito.`when`(useCanCalculateRepository.findByUseCanIdAndStatus(USE_CAN_ID)).thenReturn(useCanCalculates)
}
}
private class GiftApplicationFixture {
val giftRepository = Mockito.mock(GiftRepository::class.java)
val deliveryRepository = Mockito.mock(GiftDeliveryRepository::class.java)
val reviewRepository = Mockito.mock(GiftReviewRepository::class.java)
val categoryRepository = Mockito.mock(GiftCategoryRepository::class.java)
val sizePriceRepository = Mockito.mock(GiftSizePriceRepository::class.java)
val memberRepository = Mockito.mock(MemberRepository::class.java)
val applicationNoGenerator = Mockito.mock(GiftApplicationNoGenerator::class.java)
val canPaymentService = Mockito.mock(CanPaymentService::class.java)
val giftPushService = Mockito.mock(GiftPushService::class.java)
val sender = Member(password = "password", nickname = "sender", container = "aos").apply { id = SENDER_ID }
val creator = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID }
val category = GiftCategory(
classificationNumber = "100",
categoryCode = "DOLL",
name = "인형",
receiptCode = "A",
representativeItem = "피규어",
requiresDamageWaiver = false,
isActive = true
).apply { id = CATEGORY_ID }
val sizePrice = GiftSizePrice(GiftSize.SMALL, BASE_PRICE_CAN, SALE_PRICE_CAN, true)
lateinit var savedGift: Gift
lateinit var savedDelivery: GiftDelivery
lateinit var cancellationGift: Gift
lateinit var cancellationDelivery: GiftDelivery
lateinit var trackingGift: Gift
lateinit var trackingDelivery: GiftDelivery
lateinit var recipientAddressGift: Gift
lateinit var recipientAddressDelivery: GiftDelivery
lateinit var deliveryConfirmationGift: Gift
lateinit var deliveryConfirmationDelivery: GiftDelivery
lateinit var reviewGift: Gift
lateinit var savedReview: GiftReview
val service = GiftCommandService(
giftRepository = giftRepository,
deliveryRepository = deliveryRepository,
reviewRepository = reviewRepository,
categoryRepository = categoryRepository,
sizePriceRepository = sizePriceRepository,
memberRepository = memberRepository,
applicationNoGenerator = applicationNoGenerator,
canPaymentService = canPaymentService,
giftPushService = giftPushService,
now = { NOW }
)
init {
setCreatorExists(true)
Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category))
Mockito.`when`(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).thenReturn(sizePrice)
Mockito.`when`(applicationNoGenerator.generate(category, NOW)).thenReturn(APPLICATION_NO)
Mockito.`when`(canPaymentService.spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")).thenReturn(USE_CAN_ID)
Mockito.`when`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation ->
invocation.getArgument<Gift>(0).apply {
id = GIFT_ID
savedGift = this
}
}
Mockito.`when`(deliveryRepository.save(Mockito.any(GiftDelivery::class.java))).thenAnswer { invocation ->
invocation.getArgument<GiftDelivery>(0).also { savedDelivery = it }
}
Mockito.`when`(reviewRepository.save(Mockito.any(GiftReview::class.java))).thenAnswer { invocation ->
invocation.getArgument<GiftReview>(0).apply {
id = REVIEW_ID
createdAt = NOW
savedReview = this
}
}
}
fun setCreatorExists(exists: Boolean) {
Mockito.`when`(memberRepository.findCreatorByIdOrNull(RECIPIENT_ID)).thenReturn(creator.takeIf { exists })
}
fun prepareGiftForCancellation(
status: GiftStatus = GiftStatus.RECEIVED,
senderMemberId: Long = SENDER_ID
) {
cancellationGift = Gift(
applicationNo = APPLICATION_NO,
senderMemberId = senderMemberId,
recipientMemberId = RECIPIENT_ID,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
categoryNameSnapshot = "인형",
salePriceCan = SALE_PRICE_CAN,
canUsageId = USE_CAN_ID,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW,
senderPrivacyAgreedAt = NOW,
damageWaiverAgreed = true
).apply { id = GIFT_ID }
cancellationDelivery = GiftDelivery(
giftId = GIFT_ID,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(cancellationGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery)
}
fun prepareGiftForTracking(
status: GiftStatus = GiftStatus.RECEIVED,
senderMemberId: Long = SENDER_ID,
deliveryExists: Boolean = true
) {
trackingGift = Gift(
applicationNo = APPLICATION_NO,
senderMemberId = senderMemberId,
recipientMemberId = RECIPIENT_ID,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
categoryNameSnapshot = "인형",
salePriceCan = SALE_PRICE_CAN,
canUsageId = USE_CAN_ID,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW,
senderPrivacyAgreedAt = NOW,
damageWaiverAgreed = true
).apply { id = GIFT_ID }
trackingDelivery = GiftDelivery(
giftId = GIFT_ID,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(trackingGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
trackingDelivery.takeIf { deliveryExists }
)
}
fun prepareGiftForRecipientAddress(
status: GiftStatus = GiftStatus.TRACKING_REGISTERED,
recipientMemberId: Long = RECIPIENT_ID,
deliveryExists: Boolean = true
) {
recipientAddressGift = Gift(
applicationNo = APPLICATION_NO,
senderMemberId = SENDER_ID,
recipientMemberId = recipientMemberId,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
categoryNameSnapshot = "인형",
salePriceCan = SALE_PRICE_CAN,
canUsageId = USE_CAN_ID,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW,
senderPrivacyAgreedAt = NOW,
damageWaiverAgreed = true
).apply { id = GIFT_ID }
recipientAddressDelivery = GiftDelivery(
giftId = GIFT_ID,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(recipientAddressGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
recipientAddressDelivery.takeIf { deliveryExists }
)
}
fun prepareGiftForDeliveryConfirmation(
status: GiftStatus = GiftStatus.INSPECTION_COMPLETED,
recipientMemberId: Long = RECIPIENT_ID,
deliveryExists: Boolean = true
) {
deliveryConfirmationGift = Gift(
applicationNo = APPLICATION_NO,
senderMemberId = SENDER_ID,
recipientMemberId = recipientMemberId,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
categoryNameSnapshot = "인형",
salePriceCan = SALE_PRICE_CAN,
canUsageId = USE_CAN_ID,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW,
senderPrivacyAgreedAt = NOW,
damageWaiverAgreed = true
).apply { id = GIFT_ID }
deliveryConfirmationDelivery = GiftDelivery(
giftId = GIFT_ID,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(deliveryConfirmationGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
deliveryConfirmationDelivery.takeIf { deliveryExists }
)
}
fun prepareGiftForReview(
status: GiftStatus = GiftStatus.DELIVERED,
senderMemberId: Long = SENDER_ID,
reviewExists: Boolean = false
) {
reviewGift = Gift(
applicationNo = APPLICATION_NO,
senderMemberId = senderMemberId,
recipientMemberId = RECIPIENT_ID,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
categoryNameSnapshot = "인형",
salePriceCan = SALE_PRICE_CAN,
canUsageId = USE_CAN_ID,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW,
senderPrivacyAgreedAt = NOW,
damageWaiverAgreed = true
).apply { id = GIFT_ID }
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(reviewGift)
Mockito.`when`(reviewRepository.findByGiftId(GIFT_ID)).thenReturn(
GiftReview(GIFT_ID, SENDER_ID, 5).takeIf { reviewExists }
)
}
}
data class InvalidGiftApplicationScenario(
val command: GiftApplicationCommand,
val categoryActive: Boolean = true,
val requiresDamageWaiver: Boolean = false,
val sizePriceActive: Boolean = true,
val creatorExists: Boolean = true
)
data class InvalidGiftCancellationScenario(
val requesterMemberId: Long,
val senderMemberId: Long,
val status: GiftStatus
)
data class InvalidGiftTrackingRegistrationScenario(
val requesterMemberId: Long = SENDER_ID,
val senderMemberId: Long = SENDER_ID,
val status: GiftStatus = GiftStatus.RECEIVED,
val command: GiftTrackingRegistrationCommand = validGiftTrackingRegistrationCommand(),
val deliveryExists: Boolean = true
)
data class InvalidGiftRecipientAddressRegistrationScenario(
val requesterMemberId: Long = RECIPIENT_ID,
val recipientMemberId: Long = RECIPIENT_ID,
val status: GiftStatus = GiftStatus.TRACKING_REGISTERED,
val command: GiftRecipientAddressRegistrationCommand = validGiftRecipientAddressRegistrationCommand(),
val deliveryExists: Boolean = true
)
data class InvalidGiftDeliveryConfirmationScenario(
val requesterMemberId: Long = RECIPIENT_ID,
val recipientMemberId: Long = RECIPIENT_ID,
val status: GiftStatus = GiftStatus.INSPECTION_COMPLETED,
val deliveryExists: Boolean = true
)
data class InvalidGiftReviewScenario(
val requesterMemberId: Long = SENDER_ID,
val senderMemberId: Long = SENDER_ID,
val status: GiftStatus = GiftStatus.DELIVERED,
val command: GiftReviewCommand = validGiftReviewCommand(),
val reviewExists: Boolean = false
)
private companion object {
const val MEMBER_ID = 1L
const val CHARGE_ID = 10L
const val USE_CAN_ID = 100L
const val INITIAL_CAN = 100
const val GIFT_PRICE_CAN = 30
const val SENDER_ID = 2L
const val OTHER_MEMBER_ID = 4L
const val RECIPIENT_ID = 3L
const val CATEGORY_ID = 4L
const val GIFT_ID = 5L
const val REVIEW_ID = 6L
const val APPLICATION_NO = "G20260929000001"
const val BASE_PRICE_CAN = 100
const val SALE_PRICE_CAN = 80
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
@JvmStatic
fun invalidGiftApplicationScenarios() = listOf(
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderName = "")),
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPhoneNumber = "")),
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderZipCode = "")),
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderAddress = "")),
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderTermsAgreed = false)),
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPrivacyAgreed = false)),
InvalidGiftApplicationScenario(validGiftApplicationCommand(), categoryActive = false),
InvalidGiftApplicationScenario(
validGiftApplicationCommand().copy(damageWaiverAgreed = false),
requiresDamageWaiver = true
),
InvalidGiftApplicationScenario(validGiftApplicationCommand(), sizePriceActive = false),
InvalidGiftApplicationScenario(validGiftApplicationCommand(), creatorExists = false)
)
@JvmStatic
fun invalidGiftCancellationScenarios() = listOf(
InvalidGiftCancellationScenario(OTHER_MEMBER_ID, SENDER_ID, GiftStatus.RECEIVED),
InvalidGiftCancellationScenario(SENDER_ID, SENDER_ID, GiftStatus.TRACKING_REGISTERED)
)
@JvmStatic
fun invalidGiftTrackingRegistrationScenarios() = listOf(
InvalidGiftTrackingRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID),
InvalidGiftTrackingRegistrationScenario(status = GiftStatus.TRACKING_REGISTERED),
InvalidGiftTrackingRegistrationScenario(
command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "")
),
InvalidGiftTrackingRegistrationScenario(
command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "a".repeat(51))
),
InvalidGiftTrackingRegistrationScenario(
command = validGiftTrackingRegistrationCommand().copy(trackingNumber = "")
),
InvalidGiftTrackingRegistrationScenario(deliveryExists = false)
)
@JvmStatic
fun recipientAddressRegistrableStatuses() = listOf(
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
@JvmStatic
fun invalidGiftRecipientAddressRegistrationScenarios() = listOf(
InvalidGiftRecipientAddressRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID),
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.RECEIVED),
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.DELIVERED),
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.UNDELIVERABLE),
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.CANCELED),
InvalidGiftRecipientAddressRegistrationScenario(
command = validGiftRecipientAddressRegistrationCommand().copy(name = "")
),
InvalidGiftRecipientAddressRegistrationScenario(
command = validGiftRecipientAddressRegistrationCommand().copy(phoneNumber = "")
),
InvalidGiftRecipientAddressRegistrationScenario(
command = validGiftRecipientAddressRegistrationCommand().copy(zipCode = "")
),
InvalidGiftRecipientAddressRegistrationScenario(
command = validGiftRecipientAddressRegistrationCommand().copy(address = "")
),
InvalidGiftRecipientAddressRegistrationScenario(
command = validGiftRecipientAddressRegistrationCommand().copy(recipientTermsAgreed = false)
),
InvalidGiftRecipientAddressRegistrationScenario(
command = validGiftRecipientAddressRegistrationCommand().copy(recipientPrivacyAgreed = false)
),
InvalidGiftRecipientAddressRegistrationScenario(deliveryExists = false)
)
@JvmStatic
fun invalidGiftDeliveryConfirmationScenarios() = listOf(
InvalidGiftDeliveryConfirmationScenario(requesterMemberId = OTHER_MEMBER_ID),
InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.TRACKING_REGISTERED),
InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.DELIVERED),
InvalidGiftDeliveryConfirmationScenario(deliveryExists = false)
)
@JvmStatic
fun invalidGiftReviewScenarios() = listOf(
InvalidGiftReviewScenario(requesterMemberId = OTHER_MEMBER_ID),
InvalidGiftReviewScenario(status = GiftStatus.INSPECTION_COMPLETED),
InvalidGiftReviewScenario(reviewExists = true),
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 0)),
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 6)),
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(keywords = listOf("a".repeat(256)))),
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(comment = "a".repeat(256)))
)
fun validGiftApplicationCommand() = GiftApplicationCommand(
recipientMemberId = RECIPIENT_ID,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
damageWaiverAgreed = true
)
fun validGiftTrackingRegistrationCommand() = GiftTrackingRegistrationCommand(
courierCompanyName = "CJ대한통운",
trackingNumber = "1234567890"
)
fun validGiftRecipientAddressRegistrationCommand() = GiftRecipientAddressRegistrationCommand(
name = "김소다",
phoneNumber = "01098765432",
zipCode = "04524",
address = "서울시 중구",
addressDetail = "202호",
recipientTermsAgreed = true,
recipientPrivacyAgreed = true
)
fun validGiftReviewCommand() = GiftReviewCommand(
rating = 5,
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
comment = "선물 전달 과정이 만족스러웠습니다."
)
}
}
@@ -1,156 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
@DisplayName("선물 엔티티 매핑 테스트")
class GiftEntityMappingTest @Autowired constructor(
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val giftCategoryRepository: GiftCategoryRepository,
private val giftSizePriceRepository: GiftSizePriceRepository,
private val giftReviewRepository: GiftReviewRepository,
private val giftApplicationNoSequenceRepository: GiftApplicationNoSequenceRepository
) {
@Test
@DisplayName("선물 엔티티와 배송 정보를 저장하고 조회한다")
fun shouldSaveAndFindGiftWithDelivery() {
val category = giftCategoryRepository.save(
GiftCategory(
classificationNumber = "100",
categoryCode = "DOLL",
name = "인형",
receiptCode = "A",
representativeItem = "피규어",
requiresDamageWaiver = true
)
)
giftSizePriceRepository.save(
GiftSizePrice(
sizeCode = GiftSize.SMALL,
basePriceCan = 100,
salePriceCan = 80
)
)
val gift = giftRepository.save(
Gift(
applicationNo = "G20260929000001",
senderMemberId = 1L,
recipientMemberId = 2L,
status = GiftStatus.RECEIVED,
sizeCode = GiftSize.SMALL,
categoryId = category.id!!,
categoryNameSnapshot = category.name,
salePriceCan = 80,
canUsageId = 10L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
damageWaiverAgreed = true
)
)
giftDeliveryRepository.save(
GiftDelivery(
giftId = gift.id!!,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
)
)
val saved = giftRepository.findByApplicationNo("G20260929000001")
val delivery = giftDeliveryRepository.findByGiftId(gift.id!!)
assertNotNull(saved)
assertEquals(GiftStatus.RECEIVED, saved!!.status)
assertEquals("홍길동", delivery!!.senderName)
assertNotNull(saved.createdAt)
assertNotNull(saved.updatedAt)
}
@Test
@DisplayName("신청번호와 카테고리 unique 값과 리뷰 선물 ID는 중복 저장할 수 없다")
fun shouldRejectDuplicateUniqueValues() {
val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A"))
val gift = giftRepository.save(gift(category.id!!, "G20260929000001"))
giftRepository.saveAndFlush(gift(category.id!!, "G20260929000002"))
giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 5))
assertThrows(DataIntegrityViolationException::class.java) {
giftRepository.saveAndFlush(gift(category.id!!, "G20260929000001"))
}
assertThrows(DataIntegrityViolationException::class.java) {
giftCategoryRepository.saveAndFlush(giftCategory("101", "DOLL", "B"))
}
assertThrows(DataIntegrityViolationException::class.java) {
giftCategoryRepository.saveAndFlush(giftCategory("100", "TOY", "B"))
}
assertThrows(DataIntegrityViolationException::class.java) {
giftCategoryRepository.saveAndFlush(giftCategory("101", "TOY", "A"))
}
assertThrows(DataIntegrityViolationException::class.java) {
giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 4))
}
}
@Test
@DisplayName("카테고리와 날짜별 신청번호 채번 sequence를 저장한다")
fun shouldSaveApplicationNoSequence() {
val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A"))
val sequence = giftApplicationNoSequenceRepository.save(
GiftApplicationNoSequence(categoryId = category.id!!, sequenceDate = "260929", lastSequence = 1)
)
assertEquals(category.id, sequence.categoryId)
assertEquals("260929", sequence.sequenceDate)
assertEquals(1, sequence.lastSequence)
}
private fun gift(categoryId: Long, applicationNo: String) = Gift(
applicationNo = applicationNo,
senderMemberId = 1L,
recipientMemberId = 2L,
status = GiftStatus.RECEIVED,
sizeCode = GiftSize.SMALL,
categoryId = categoryId,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = 10L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
damageWaiverAgreed = true
)
private fun giftCategory(classificationNumber: String, categoryCode: String, receiptCode: String) = GiftCategory(
classificationNumber = classificationNumber,
categoryCode = categoryCode,
name = "인형",
receiptCode = receiptCode,
representativeItem = "피규어"
)
}
@@ -1,351 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.redisson.api.RedissonClient
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
@DisplayName("선물 자동처리 스케줄러 테스트")
class GiftSchedulerTest @Autowired constructor(
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository
) {
private val canPaymentService = Mockito.mock(CanPaymentService::class.java)
private val giftPushService = Mockito.mock(GiftPushService::class.java)
private val scheduler = GiftScheduler(
giftRepository,
giftDeliveryRepository,
canPaymentService,
giftPushService,
Mockito.mock(RedissonClient::class.java)
)
@Test
@DisplayName("운송장 등록 기한이 지난 접수 선물을 취소하고 한 번 환불한다")
fun shouldCancelOverdueReceivedGiftAndRefundOnce() {
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now.minusSeconds(1))
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
assertEquals(1, canceledCount)
assertEquals(GiftStatus.CANCELED, gift.status)
assertEquals(now, delivery.canceledAt)
Mockito.verify(canPaymentService).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID)
Mockito.verify(giftPushService).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID)
}
@Test
@DisplayName("운송장 등록 기한과 같은 접수 선물은 취소하지 않는다")
fun shouldIgnoreReceivedGiftWhoseTrackingDeadlineIsNotPast() {
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now)
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
assertEquals(0, canceledCount)
assertEquals(GiftStatus.RECEIVED, gift.status)
assertEquals(null, delivery.canceledAt)
Mockito.verifyNoInteractions(canPaymentService)
}
@Test
@DisplayName("운송장 등록 기한이 지나도 접수 상태가 아닌 선물은 취소하지 않는다")
fun shouldIgnoreOverdueGiftThatIsNotReceived() {
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED, now.minusSeconds(1))
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
assertEquals(0, canceledCount)
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
assertEquals(null, delivery.canceledAt)
Mockito.verifyNoInteractions(canPaymentService)
}
@Test
@DisplayName("자동취소를 다시 실행해도 이미 취소된 선물은 환불하지 않는다")
fun shouldNotRefundAlreadyCanceledGiftOnSecondRun() {
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
saveGift(GiftStatus.RECEIVED, now.minusSeconds(1))
val firstCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
val secondCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
assertEquals(1, firstCanceledCount)
assertEquals(0, secondCanceledCount)
Mockito.verify(canPaymentService, Mockito.times(1)).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID)
Mockito.verify(giftPushService, Mockito.times(1)).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID)
}
@Test
@DisplayName("배송지 입력 기한이 지난 발송 확인 선물을 전달 불가 처리한다")
fun shouldMarkOverdueTrackingRegisteredGiftWithoutRecipientAddressUndeliverable() {
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
val (gift, delivery) = saveGift(
status = GiftStatus.TRACKING_REGISTERED,
trackingDeadlineAt = now.minusDays(7),
recipientAddressDeadlineAt = now.minusSeconds(1)
)
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
assertEquals(1, updatedCount)
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
assertEquals(now, delivery.undeliverableAt)
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
}
@Test
@DisplayName("배송지 입력 기한이 지난 진행 중 선물은 현재 상태와 무관하게 전달 불가 처리한다")
fun shouldMarkOverdueInProgressGiftsWithoutRecipientAddressUndeliverable() {
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
val gifts = listOf(GiftStatus.ARRIVED_AT_MAILBOX, GiftStatus.INSPECTION_COMPLETED).mapIndexed { index, status ->
saveGift(
status = status,
trackingDeadlineAt = now.minusDays(7),
recipientAddressDeadlineAt = now.minusSeconds(1),
applicationNo = "$APPLICATION_NO-$index"
)
}
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
assertEquals(2, updatedCount)
gifts.forEach { (gift, delivery) ->
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
assertEquals(now, delivery.undeliverableAt)
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
}
}
@Test
@DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다")
fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() {
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
val (gift, delivery) = saveGift(
status = GiftStatus.TRACKING_REGISTERED,
trackingDeadlineAt = now.minusDays(7),
recipientAddressDeadlineAt = now
)
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
assertEquals(0, updatedCount)
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
assertEquals(null, delivery.undeliverableAt)
assertEquals(null, delivery.undeliverableReason)
}
@Test
@DisplayName("배송지가 입력된 선물은 배송지 입력 기한이 지나도 전달 불가 처리하지 않는다")
fun shouldIgnoreGiftWithRecipientAddress() {
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
val (gift, delivery) = saveGift(
status = GiftStatus.TRACKING_REGISTERED,
trackingDeadlineAt = now.minusDays(7),
recipientAddressDeadlineAt = now.minusSeconds(1),
recipientAddress = "서울시 송파구"
)
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
assertEquals(0, updatedCount)
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
assertEquals(null, delivery.undeliverableAt)
assertEquals(null, delivery.undeliverableReason)
}
@Test
@DisplayName("미래 또는 미설정 기한과 대상 외 상태는 배송지 미입력 전달 불가 처리하지 않는다")
fun shouldIgnoreUnexpiredAndOutOfScopeGiftsWithoutRecipientAddress() {
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
val unexpiredGifts = listOf(
saveGift(
GiftStatus.ARRIVED_AT_MAILBOX,
now.minusDays(7),
now.plusSeconds(1),
applicationNo = "A-1002609302001"
),
saveGift(
GiftStatus.INSPECTION_COMPLETED,
now.minusDays(7),
applicationNo = "A-1002609302002"
)
)
val terminalGifts = listOf(
GiftStatus.RECEIVED,
GiftStatus.DELIVERED,
GiftStatus.UNDELIVERABLE,
GiftStatus.CANCELED
).mapIndexed { index, status ->
saveGift(
status,
now.minusDays(7),
now.minusSeconds(1),
applicationNo = "A-100260930201$index"
)
}
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
assertEquals(0, updatedCount)
(unexpiredGifts + terminalGifts).forEach { (_, delivery) ->
assertEquals(null, delivery.undeliverableAt)
assertEquals(null, delivery.undeliverableReason)
}
}
@Test
@DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다")
fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() {
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
val (_, delivery) = saveGift(
status = GiftStatus.TRACKING_REGISTERED,
trackingDeadlineAt = now.minusDays(7),
recipientAddressDeadlineAt = now.minusSeconds(1)
)
val firstUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
val secondUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now.plusMinutes(1))
assertEquals(1, firstUpdatedCount)
assertEquals(0, secondUpdatedCount)
assertEquals(now, delivery.undeliverableAt)
}
@Test
@DisplayName("운송장 등록 기한 24시간 전 접수 선물에 한 번만 안내한다")
fun shouldSendTrackingDeadlineReminderOnce() {
val now = LocalDateTime.of(2026, 10, 1, 12, 0)
val (_, delivery) = saveGift(GiftStatus.RECEIVED, now.plusHours(24))
val firstSentCount = scheduler.sendDeadlineReminderGifts(now)
val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1))
assertEquals(1, firstSentCount)
assertEquals(0, secondSentCount)
assertEquals(now, delivery.trackingDeadlineReminderSentAt)
Mockito.verify(giftPushService, Mockito.times(1)).sendTrackingDeadlineReminder(APPLICATION_NO, SENDER_MEMBER_ID)
}
@Test
@DisplayName("운송장 등록 기한 24시간 초과 전 접수 선물은 안내하지 않는다")
fun shouldIgnoreTrackingDeadlineReminderBeforeWindow() {
val now = LocalDateTime.of(2026, 10, 1, 12, 0)
saveGift(GiftStatus.RECEIVED, now.plusHours(24).plusSeconds(1))
val sentCount = scheduler.sendDeadlineReminderGifts(now)
assertEquals(0, sentCount)
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@DisplayName("배송지 입력 기한 24시간 전 선물에 한 번만 안내한다")
fun shouldSendRecipientAddressDeadlineReminderOnce() {
val now = LocalDateTime.of(2026, 10, 8, 12, 0)
val (_, delivery) = saveGift(
status = GiftStatus.TRACKING_REGISTERED,
trackingDeadlineAt = now.minusDays(6),
recipientAddressDeadlineAt = now.plusHours(24)
)
val firstSentCount = scheduler.sendDeadlineReminderGifts(now)
val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1))
assertEquals(1, firstSentCount)
assertEquals(0, secondSentCount)
assertEquals(now, delivery.recipientAddressDeadlineReminderSentAt)
Mockito.verify(giftPushService, Mockito.times(1))
.sendRecipientAddressDeadlineReminder(APPLICATION_NO, RECIPIENT_MEMBER_ID)
}
@Test
@DisplayName("배송지가 입력된 선물은 배송지 입력 기한 안내를 보내지 않는다")
fun shouldIgnoreRecipientAddressDeadlineReminderWhenAddressExists() {
val now = LocalDateTime.of(2026, 10, 8, 12, 0)
saveGift(
status = GiftStatus.TRACKING_REGISTERED,
trackingDeadlineAt = now.minusDays(6),
recipientAddressDeadlineAt = now.plusHours(24),
recipientAddress = "서울시 송파구"
)
val sentCount = scheduler.sendDeadlineReminderGifts(now)
assertEquals(0, sentCount)
Mockito.verifyNoInteractions(giftPushService)
}
private fun saveGift(
status: GiftStatus,
trackingDeadlineAt: LocalDateTime,
recipientAddressDeadlineAt: LocalDateTime? = null,
recipientAddress: String? = null,
applicationNo: String = APPLICATION_NO
): Pair<Gift, GiftDelivery> {
val gift = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = SENDER_MEMBER_ID,
recipientMemberId = RECIPIENT_MEMBER_ID,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = CAN_USAGE_ID,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = APPLIED_AT,
senderPrivacyAgreedAt = APPLIED_AT,
damageWaiverAgreed = true
)
)
val delivery = giftDeliveryRepository.save(
GiftDelivery(
giftId = requireNotNull(gift.id),
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
recipientAddress = recipientAddress,
trackingDeadlineAt = trackingDeadlineAt,
recipientAddressDeadlineAt = recipientAddressDeadlineAt
)
)
return gift to delivery
}
private companion object {
const val APPLICATION_NO = "G20260929000001"
const val SENDER_MEMBER_ID = 1L
const val RECIPIENT_MEMBER_ID = 2L
const val CATEGORY_ID = 3L
const val CAN_USAGE_ID = 4L
val APPLIED_AT: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
}
}
@@ -1,259 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:gift-admin-query;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
@DisplayName("관리자 선물함 조회 서비스 테스트")
class GiftAdminQueryServiceTest @Autowired constructor(
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val categoryRepository: GiftCategoryRepository,
private val memberRepository: MemberRepository
) {
@Test
@DisplayName("관리자는 선물함 목록을 필터링하고 가능한 액션을 조회한다")
fun shouldListAdminGiftsWithFiltersAndAvailableActions() {
val sender = saveMember("fan-alpha")
val recipient = saveMember("creator-alpha")
val otherSender = saveMember("fan-beta")
val otherRecipient = saveMember("creator-beta")
val category = saveCategory()
saveGift(
applicationNo = "A-1002609300001",
sender = sender,
recipient = recipient,
category = category,
status = GiftStatus.TRACKING_REGISTERED,
withTracking = true
)
saveGift(
applicationNo = "A-1002609300002",
sender = otherSender,
recipient = otherRecipient,
category = category,
status = GiftStatus.RECEIVED
)
val result = service().getGifts(
status = GiftStatus.TRACKING_REGISTERED,
applicationNo = "0001",
nickname = "alpha",
page = 0,
size = 20
)
assertEquals(1L, result.totalCount)
assertEquals(1, result.items.size)
assertEquals(0, result.page)
assertEquals(20, result.size)
assertEquals(false, result.hasNext)
val item = result.items.single()
assertEquals("A-1002609300001", item.applicationNo)
assertEquals("fan-alpha", item.senderNickname)
assertEquals("creator-alpha", item.recipientNickname)
assertEquals("SMALL", item.sizeCode)
assertEquals("소형", item.sizeName)
assertEquals("인형", item.categoryName)
assertEquals("100", item.classificationNumber)
assertEquals("CJ대한통운", item.courierCompanyName)
assertEquals("1234567890", item.trackingNumber)
assertEquals(GiftStatus.TRACKING_REGISTERED, item.status)
assertEquals("발송 확인", item.statusName)
assertEquals(listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"), item.availableActions)
}
@Test
@DisplayName("관리자는 선물 상세에서 개인정보와 상품 정보를 조회하고 미입력 수취인 정보는 빈 문자열로 받는다")
fun shouldGetAdminGiftDetailWithPersonalInformation() {
val sender = saveMember("fan-alpha")
val recipient = saveMember("creator-alpha")
val category = saveCategory()
saveGift(
applicationNo = "A-1002609300001",
sender = sender,
recipient = recipient,
category = category,
status = GiftStatus.ARRIVED_AT_MAILBOX,
withTracking = true
)
val result = service().getGiftDetail("A-1002609300001")
assertEquals("A-1002609300001", result.applicationNo)
assertEquals("fan-alpha", result.senderInfo.nickname)
assertEquals("홍길동", result.senderInfo.name)
assertEquals("01012345678", result.senderInfo.phoneNumber)
assertEquals("(06234) 서울시 강남구, 101동 1001호", result.senderInfo.address)
assertEquals("creator-alpha", result.recipientInfo.nickname)
assertEquals("", result.recipientInfo.name)
assertEquals("", result.recipientInfo.phoneNumber)
assertEquals("", result.recipientInfo.address)
assertEquals("SMALL", result.productInfo.sizeCode)
assertEquals("소형", result.productInfo.sizeName)
assertEquals("인형", result.productInfo.categoryName)
assertEquals("100", result.productInfo.classificationNumber)
assertEquals("CJ대한통운", result.inboundDeliveryInfo.courierCompanyName)
assertEquals("1234567890", result.inboundDeliveryInfo.trackingNumber)
assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status)
assertEquals("사서함 도착", result.statusName)
assertEquals(listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), result.availableActions)
}
@Test
@DisplayName("관리자 선물 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다")
fun shouldReturnAvailableActionsByStatusAndRejectUnknownApplicationNo() {
val sender = saveMember("fan-alpha")
val recipient = saveMember("creator-alpha")
val category = saveCategory()
GiftStatus.values().forEachIndexed { index, status ->
saveGift("A-10026093010$index", sender, recipient, category, status)
}
val service = service()
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930100").availableActions)
assertEquals(
listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"),
service.getGiftDetail("A-100260930101").availableActions
)
assertEquals(
listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"),
service.getGiftDetail("A-100260930102").availableActions
)
assertEquals(
listOf("MARK_UNDELIVERABLE"),
service.getGiftDetail("A-100260930103").availableActions
)
assertEquals(
listOf("MARK_UNDELIVERABLE"),
service.getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single().availableActions
)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930104").availableActions)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930105").availableActions)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930106").availableActions)
val exception = assertThrows(SodaException::class.java) {
service.getGiftDetail("UNKNOWN")
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("수취인 정보가 입력된 검수 완료 선물만 전달 완료 액션을 반환한다")
fun shouldReturnCompleteDeliveryActionOnlyWithRecipientAddress() {
val sender = saveMember("fan-alpha")
val recipient = saveMember("creator-alpha")
val category = saveCategory()
val gift = saveGift(
applicationNo = "A-1002609300001",
sender = sender,
recipient = recipient,
category = category,
status = GiftStatus.INSPECTION_COMPLETED
)
requireNotNull(deliveryRepository.findByGiftId(requireNotNull(gift.id))).apply {
recipientName = "김소다"
recipientPhoneNumber = "01098765432"
recipientZipCode = "04524"
recipientAddress = "서울시 중구"
}
val detail = service().getGiftDetail(gift.applicationNo)
val listItem = service().getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single()
assertEquals(listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), detail.availableActions)
assertEquals(listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), listItem.availableActions)
}
private fun service() = GiftAdminQueryService(
giftRepository = giftRepository,
deliveryRepository = deliveryRepository,
categoryRepository = categoryRepository,
memberRepository = memberRepository
)
private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname))
private fun saveCategory() = categoryRepository.save(
GiftCategory(
classificationNumber = "100",
categoryCode = "DOLL",
name = "인형",
receiptCode = "A",
representativeItem = "피규어"
)
)
private fun saveGift(
applicationNo: String,
sender: Member,
recipient: Member,
category: GiftCategory,
status: GiftStatus,
withTracking: Boolean = false
): Gift {
val gift = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = category.id!!,
categoryNameSnapshot = category.name,
salePriceCan = 80,
canUsageId = 10L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW,
senderPrivacyAgreedAt = NOW
)
)
deliveryRepository.save(
GiftDelivery(
giftId = gift.id!!,
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
trackingDeadlineAt = NOW.plusDays(3)
).apply {
if (withTracking) {
courierCompanyName = "CJ대한통운"
trackingNumber = "1234567890"
trackingRegisteredAt = NOW.plusHours(1)
}
}
)
return gift
}
private companion object {
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 30, 12, 0)
}
}
@@ -1,379 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
@DisplayName("선물 관리자 서비스 테스트")
class GiftAdminServiceTest @Autowired constructor(
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val giftMailboxRepository: GiftMailboxRepository
) {
private val giftPushService = Mockito.mock(GiftPushService::class.java)
private val service = GiftAdminService(
categoryRepository,
sizePriceRepository,
giftRepository,
giftDeliveryRepository,
giftMailboxRepository,
giftPushService
)
@Test
@DisplayName("받을 주소는 미등록이면 null이고 저장 후 단일 설정으로 갱신된다")
fun shouldUpsertGiftMailbox() {
assertEquals(null, service.getMailbox())
val created = service.updateMailbox(
GiftMailboxCommand(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val updated = service.updateMailbox(
GiftMailboxCommand(
name = "소다라이브 운영팀",
phoneNumber = "01087654321",
zipCode = "04524",
address = "서울시 중구",
addressDetail = null
)
)
assertEquals("소다라이브 선물 담당자", created.name)
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", created.address)
assertEquals("01012345678", created.phoneNumber)
assertEquals("소다라이브 운영팀", updated.name)
assertEquals("(04524) 서울시 중구", updated.address)
assertEquals(1, giftMailboxRepository.count())
}
@Test
@DisplayName("받을 주소 필수값이 공백이면 저장할 수 없다")
fun shouldRejectInvalidGiftMailbox() {
val exception = assertThrows(SodaException::class.java) {
service.updateMailbox(
GiftMailboxCommand(
name = " ",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = null
)
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(0, giftMailboxRepository.count())
}
@Test
@DisplayName("카테고리를 등록, 수정, 논리삭제한다")
fun shouldCreateUpdateAndDeactivateCategory() {
val created = service.createCategory(
GiftCategoryCommand(
classificationNumber = "100",
categoryCode = "DOLL",
name = "인형",
receiptCode = "A",
representativeItem = "피규어",
requiresDamageWaiver = true,
isActive = true
)
)
val updated = service.updateCategory(
created.categoryId,
GiftCategoryCommand(
classificationNumber = "101",
categoryCode = "DOLL",
name = "큰 인형",
receiptCode = "B",
representativeItem = "대형 피규어",
requiresDamageWaiver = false,
isActive = true
)
)
service.deleteCategory(created.categoryId)
val saved = categoryRepository.findById(created.categoryId).orElseThrow()
assertEquals("101", updated.classificationNumber)
assertEquals("DOLL", updated.categoryCode)
assertEquals("큰 인형", updated.name)
assertEquals("B", updated.receiptCode)
assertEquals("대형 피규어", updated.representativeItem)
assertFalse(updated.requiresDamageWaiver)
assertFalse(saved.isActive)
}
@Test
@DisplayName("카테고리 분류번호와 접수코드 형식을 검증한다")
fun shouldValidateCategoryClassificationNumberAndReceiptCode() {
val invalidClassificationNumber = assertThrows(SodaException::class.java) {
service.createCategory(categoryCommand(classificationNumber = "10A"))
}
val invalidReceiptCode = assertThrows(SodaException::class.java) {
service.createCategory(categoryCommand(receiptCode = "A1"))
}
val blankRepresentativeItem = assertThrows(SodaException::class.java) {
service.createCategory(categoryCommand(representativeItem = " "))
}
assertEquals("common.error.invalid_request", invalidClassificationNumber.messageKey)
assertEquals("common.error.invalid_request", invalidReceiptCode.messageKey)
assertEquals("common.error.invalid_request", blankRepresentativeItem.messageKey)
}
@Test
@DisplayName("사이즈 가격을 수정하고 salePriceCan이 basePriceCan보다 크면 거부한다")
fun shouldUpdateSizePriceAndRejectInvalidSalePrice() {
val updated = service.updateSizePrice(
GiftSize.SMALL,
GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 80, isActive = true)
)
val exception = assertThrows(SodaException::class.java) {
service.updateSizePrice(
GiftSize.SMALL,
GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 101, isActive = true)
)
}
assertEquals(GiftSize.SMALL, updated.sizeCode)
assertEquals(80, updated.salePriceCan)
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("운영자는 발송 확인 선물을 사서함 도착 처리한다")
fun shouldMarkTrackingRegisteredGiftArrivedAtMailbox() {
val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED)
val result = service.arriveMailbox(APPLICATION_NO, NOW)
assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status)
assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, gift.status)
assertEquals(NOW, delivery.arrivedAtMailboxAt)
Mockito.verify(giftPushService).sendArrivedAtMailboxToSender(APPLICATION_NO, SENDER_MEMBER_ID)
Mockito.verify(giftPushService).sendArrivedAtMailboxToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
}
@Test
@DisplayName("사서함 도착 상태가 아니면 검수 완료 처리할 수 없다")
fun shouldRejectInspectionCompletionWhenGiftIsNotArrivedAtMailbox() {
saveGift(GiftStatus.TRACKING_REGISTERED)
val exception = assertThrows(SodaException::class.java) {
service.completeInspection(APPLICATION_NO, NOW)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("운영자는 사서함 도착 선물을 검수 완료 처리한다")
fun shouldCompleteInspectionForArrivedAtMailboxGift() {
val (gift, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
val result = service.completeInspection(APPLICATION_NO, NOW)
assertEquals(GiftStatus.INSPECTION_COMPLETED, result.status)
assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status)
assertEquals(NOW, delivery.inspectionCompletedAt)
Mockito.verify(giftPushService).sendInspectionCompleted(APPLICATION_NO, RECIPIENT_MEMBER_ID)
}
@Test
@DisplayName("운영자는 진행 중 선물을 전달 불가 처리한다")
fun shouldMarkActiveGiftUndeliverable() {
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
val result = service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW)
assertEquals(GiftStatus.UNDELIVERABLE, result.status)
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
assertEquals(NOW, delivery.undeliverableAt)
assertEquals(UNDELIVERABLE_REASON, delivery.undeliverableReason)
Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID)
Mockito.verify(giftPushService).sendUndeliverableToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
}
@Test
@DisplayName("전달 불가 사유가 비어 있거나 종료 상태면 전달 불가 처리할 수 없다")
fun shouldRejectUndeliverableWhenReasonBlankOrGiftTerminated() {
saveGift(GiftStatus.DELIVERED)
val blankReasonException = assertThrows(SodaException::class.java) {
service.markUndeliverable(APPLICATION_NO, " ", NOW)
}
val terminatedStatusException = assertThrows(SodaException::class.java) {
service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW)
}
assertEquals("common.error.invalid_request", blankReasonException.messageKey)
assertEquals("common.error.invalid_request", terminatedStatusException.messageKey)
}
@Test
@DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다")
fun shouldCompleteDeliveryForInspectionCompletedGift() {
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
populateRecipientInformation(delivery)
val result = service.completeDelivery(APPLICATION_NO, NOW)
assertEquals(GiftStatus.DELIVERED, result.status)
assertEquals(GiftStatus.DELIVERED, gift.status)
assertEquals(NOW, delivery.deliveredAt)
Mockito.verify(giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_MEMBER_ID)
Mockito.verify(giftPushService).sendDeliveredToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
Mockito.verifyNoMoreInteractions(giftPushService)
}
@Test
@DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다")
fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() {
val (_, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
populateRecipientInformation(delivery)
val exception = assertThrows(SodaException::class.java) {
service.completeDelivery(APPLICATION_NO, NOW)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("수취인 정보가 없으면 운영 전달 완료 처리할 수 없다")
fun shouldRejectDeliveryCompletionWithoutRecipientAddress() {
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
val exception = assertThrows(SodaException::class.java) {
service.completeDelivery(APPLICATION_NO, NOW)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status)
assertEquals(null, delivery.deliveredAt)
Mockito.verifyNoInteractions(giftPushService)
}
@ParameterizedTest
@ValueSource(strings = ["name", "phoneNumber", "zipCode", "address"])
@DisplayName("수취인 필수 정보에 공백이 있으면 운영 전달 완료 처리할 수 없다")
fun shouldRejectDeliveryCompletionWithBlankRecipientInformation(blankField: String) {
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
populateRecipientInformation(delivery)
when (blankField) {
"name" -> delivery.recipientName = " "
"phoneNumber" -> delivery.recipientPhoneNumber = " "
"zipCode" -> delivery.recipientZipCode = " "
"address" -> delivery.recipientAddress = " "
}
assertThrows(SodaException::class.java) {
service.completeDelivery(APPLICATION_NO, NOW)
}
assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status)
assertEquals(null, delivery.deliveredAt)
Mockito.verifyNoInteractions(giftPushService)
}
private fun populateRecipientInformation(delivery: GiftDelivery) {
delivery.recipientName = "김소다"
delivery.recipientPhoneNumber = "01098765432"
delivery.recipientZipCode = "04524"
delivery.recipientAddress = "서울시 중구"
}
private fun saveGift(status: GiftStatus): Pair<Gift, GiftDelivery> {
val gift = giftRepository.save(
Gift(
applicationNo = APPLICATION_NO,
senderMemberId = SENDER_MEMBER_ID,
recipientMemberId = RECIPIENT_MEMBER_ID,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = 1L,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = 10L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW.minusDays(1),
senderPrivacyAgreedAt = NOW.minusDays(1),
damageWaiverAgreed = true
)
)
val delivery = giftDeliveryRepository.save(
GiftDelivery(
giftId = requireNotNull(gift.id),
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.minusDays(1),
trackingRegisteredAt = NOW.minusHours(1)
)
)
return gift to delivery
}
private fun categoryCommand(
classificationNumber: String = "100",
categoryCode: String = "DOLL",
name: String = "인형",
receiptCode: String = "A",
representativeItem: String = "피규어"
) = GiftCategoryCommand(
classificationNumber = classificationNumber,
categoryCode = categoryCode,
name = name,
receiptCode = receiptCode,
representativeItem = representativeItem,
requiresDamageWaiver = true,
isActive = true
)
private companion object {
const val APPLICATION_NO = "G20260929000001"
const val SENDER_MEMBER_ID = 1L
const val RECIPIENT_MEMBER_ID = 2L
const val UNDELIVERABLE_REASON = "선물 접수 규격에 맞지 않습니다."
val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0)
}
}
@@ -1,115 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.transaction.PlatformTransactionManager
import java.time.LocalDateTime
import java.util.concurrent.Callable
import java.util.concurrent.Executors
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
@DisplayName("선물 신청번호 채번 테스트")
class GiftApplicationNoGeneratorTest @Autowired constructor(
private val repository: GiftApplicationNoSequenceRepository,
private val categoryRepository: GiftCategoryRepository,
private val transactionManager: PlatformTransactionManager
) {
@BeforeEach
fun setUp() {
repository.deleteAll()
categoryRepository.deleteAll()
}
@Test
@DisplayName("같은 카테고리와 날짜 신청번호를 순서대로 발급한다")
fun shouldGenerateSequentialApplicationNoByCategoryAndDate() {
val generator = GiftApplicationNoGenerator(repository, transactionManager)
val now = LocalDateTime.of(2026, 9, 29, 12, 0)
val category = saveCategory("100", "DOLL", "A")
val first = generator.generate(category, now)
val second = generator.generate(category, now)
assertEquals("A-1002609290001", first)
assertEquals("A-1002609290002", second)
}
@Test
@DisplayName("카테고리와 날짜가 다르면 sequence를 따로 발급한다")
fun shouldGenerateApplicationNoByCategoryAndDateIndependently() {
val generator = GiftApplicationNoGenerator(repository, transactionManager)
val doll = saveCategory("100", "DOLL", "A")
val food = saveCategory("200", "FOOD", "C")
val dollFirst = generator.generate(doll, LocalDateTime.of(2026, 9, 29, 12, 0))
val foodFirst = generator.generate(food, LocalDateTime.of(2026, 9, 29, 12, 0))
val dollNextDay = generator.generate(doll, LocalDateTime.of(2026, 9, 30, 12, 0))
assertEquals("A-1002609290001", dollFirst)
assertEquals("C-2002609290001", foodFirst)
assertEquals("A-1002609300001", dollNextDay)
}
@Test
@DisplayName("sequence가 9999를 초과하면 자리수를 늘린다")
fun shouldExpandSequenceDigitsAfter9999() {
val generator = GiftApplicationNoGenerator(repository, transactionManager)
val category = saveCategory("100", "DOLL", "A")
val now = LocalDateTime.of(2026, 9, 30, 12, 0)
repository.save(GiftApplicationNoSequence(categoryId = category.id!!, sequenceDate = "260930", lastSequence = 9999))
val applicationNo = generator.generate(category, now)
assertEquals("A-10026093010000", applicationNo)
}
@Test
@DisplayName("병렬 발급 결과는 모두 중복되지 않는다")
fun shouldGenerateUniqueApplicationNoConcurrently() {
val generator = GiftApplicationNoGenerator(repository, transactionManager)
val now = LocalDateTime.of(2026, 9, 29, 12, 0)
val category = saveCategory("100", "DOLL", "A")
val executor = Executors.newFixedThreadPool(4)
val applicationNos = try {
executor.invokeAll(
(1..10).map {
Callable { generator.generate(category, now) }
}
).map { it.get() }
} finally {
executor.shutdownNow()
}
assertEquals(10, applicationNos.toSet().size)
assertEquals("A-1002609290001", applicationNos.minOrNull())
assertEquals("A-1002609290010", applicationNos.maxOrNull())
}
private fun saveCategory(classificationNumber: String, categoryCode: String, receiptCode: String) =
categoryRepository.save(
GiftCategory(
classificationNumber = classificationNumber,
categoryCode = categoryCode,
name = "인형",
receiptCode = receiptCode,
representativeItem = "피규어"
)
)
}
@@ -1,145 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
class GiftPushServiceTest {
private val eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
private val service = GiftPushService(eventPublisher)
@Test
fun shouldPublishTrackingDeadlineReminderToSender() {
service.sendTrackingDeadlineReminder(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("운송장 번호를 입력해 주세요", event.title)
assertEquals("24시간 이내에 운송장 번호를 등록해 주세요.\n기한 내 등록하지 않으면 신청이 자동 취소됩니다.", event.message)
}
@Test
fun shouldPublishRecipientAddressDeadlineReminderToRecipient() {
service.sendRecipientAddressDeadlineReminder(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("배송지 입력 기한이 1일 남았어요", event.title)
assertEquals("24시간 안에 배송지를 입력해 주세요.\n입력하지 않으면 선물은 반송되지 않고 폐기돼요", event.message)
}
@Test
fun shouldPublishAutoCanceledToSender() {
service.sendAutoCanceledToSender(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("선물 신청이 자동 취소됐어요", event.title)
assertEquals("신청 후 3일 이내에 운송장 번호가 등록되지 않아 신청이 취소됐어요.\n사용한 캔은 모두 환불되었습니다.", event.message)
}
@Test
fun shouldPublishTrackingRegisteredToRecipient() {
service.sendTrackingRegisteredToRecipient(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("팬이 보낸 선물이 있어요!", event.title)
assertEquals("7일 이내에 배송지를 입력해 주세요.\n기간이 지나면 선물을 받을 수 없어요", event.message)
}
@Test
fun shouldPublishArrivedAtMailboxToSender() {
service.sendArrivedAtMailboxToSender(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("선물이 사서함에 도착했어요", event.title)
assertEquals("보내주신 선물이 소다라이브 사서함에 도착했어요.\n안전하게 전달할 수 있도록 선물을 확인하고 있어요.", event.message)
}
@Test
fun shouldPublishArrivedAtMailboxToRecipient() {
service.sendArrivedAtMailboxToRecipient(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("선물이 사서함에 도착했어요", event.title)
assertEquals("팬이 보낸 선물을 검수하고 있어요.\n검수가 끝나면 다시 알려드릴게요", event.message)
}
@Test
fun shouldPublishInspectionCompletedToRecipient() {
service.sendInspectionCompleted(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("선물 검수가 완료됐어요", event.title)
assertEquals("확인된 선물을 빠른 시일 내에 입력하신 배송지로 보내드릴게요.", event.message)
}
@Test
fun shouldPublishUndeliverableToSender() {
service.sendUndeliverableToSender(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("선물을 전달할 수 없어요", event.title)
assertEquals("도착한 선물이 선물 접수 규격에 맞지 않아 크리에이터에게 전달할 수 없어요.\n자세한 사유는 신청 상세에서 확인해 주세요.", event.message)
}
@Test
fun shouldPublishUndeliverableToRecipient() {
service.sendUndeliverableToRecipient(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("전달할 수 없는 선물이 있어요", event.title)
assertEquals("검수 결과 선물 정책에 맞지 않아 해당 선물은 배송되지 않아요.\n자세한 사유를 확인해 주세요.", event.message)
}
@Test
fun shouldPublishDeliveredToSender() {
service.sendDeliveredToSender(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("선물이 전달됐어요", event.title)
assertEquals("보내주신 선물이 크리에이터에게 안전하게 전달됐어요.", event.message)
}
@Test
fun shouldPublishDeliveredToRecipient() {
service.sendDeliveredToRecipient(APPLICATION_NO, MEMBER_ID)
val event = capturePublishedEvent()
assertGiftEvent(event)
assertEquals("선물이 도착했어요!", event.title)
assertEquals("팬이 보낸 선물이 배송지에 도착했어요.\n지금 확인해 보세요.", event.message)
}
private fun assertGiftEvent(event: FcmEvent) {
assertEquals(FcmEventType.INDIVIDUAL, event.type)
assertEquals(PushNotificationCategory.GIFT, event.category)
assertEquals(FcmDeepLinkValue.GIFT_DETAIL, event.deepLinkValue)
assertEquals(APPLICATION_NO, event.deepLinkId)
assertEquals(listOf(MEMBER_ID), event.recipients)
}
private fun capturePublishedEvent(): FcmEvent {
val captor = ArgumentCaptor.forClass(FcmEvent::class.java)
Mockito.verify(eventPublisher).publishEvent(captor.capture())
return captor.value
}
private companion object {
const val APPLICATION_NO = "G20260929000001"
const val MEMBER_ID = 1L
}
}
@@ -1,577 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:gift-query;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class GiftQueryServiceTest @Autowired constructor(
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository
) {
@Test
fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() {
val activeCategory = categoryRepository.save(giftCategory("100", "DOLL", "A", "인형", "피규어", true, true))
categoryRepository.save(giftCategory("200", "FOOD", "B", "간식", "쿠키", false, false))
sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true))
sizePriceRepository.save(GiftSizePrice(GiftSize.MEDIUM, 150, 120, false))
val result = queryService().getFormOptions()
assertEquals(listOf(GiftSize.SMALL), result.sizes.map { it.sizeCode })
assertEquals(listOf(100), result.sizes.map { it.basePriceCan })
assertEquals(listOf(80), result.sizes.map { it.salePriceCan })
assertEquals(listOf(activeCategory.id), result.categories.map { it.categoryId })
assertEquals("인형/피규어", result.categories.single().name)
assertEquals(true, result.categories.single().requiresDamageWaiver)
}
@Test
@DisplayName("보낸 선물은 접수 직후 선물함에 노출된다")
fun shouldListReceivedGiftForSender() {
val sender = saveMember("fan")
val recipient = saveMember("creator", "profile/creator.png")
saveGift(
"G-SENT-RECEIVED",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
val result = queryService().getGifts(
memberId = sender.id!!,
type = GiftListType.SENT,
page = 0,
size = 20
)
assertEquals(1L, result.totalCount)
assertEquals(1, result.items.size)
assertEquals("G-SENT-RECEIVED", result.items.single().applicationNo)
assertEquals(GiftDirection.SENT, result.items.single().direction)
assertEquals(GiftStatus.RECEIVED, result.items.single().status)
assertEquals(GiftStatus.RECEIVED.displayName, result.items.single().statusName)
assertEquals(80, result.items.single().priceCan)
assertEquals("인형", result.items.single().categoryName)
assertEquals("소형", result.items.single().sizeName)
assertEquals(recipient.id, result.items.single().counterpartMemberId)
assertEquals("creator", result.items.single().counterpartNickname)
assertEquals("https://cdn.test/profile/creator.png", result.items.single().counterpartProfileImageUrl)
assertNotNull(result.items.single().createdAt)
}
@Test
@DisplayName("받은 선물은 운송장 등록 이후 상태만 선물함에 노출된다")
fun shouldExcludeReceivedOnlyGiftAndListVisibleGiftForRecipient() {
val recipient = saveMember("creator")
val hiddenSender = saveMember("hidden-fan")
val visibleSender = saveMember("visible-fan")
saveGift(
"G-RECEIVED-HIDDEN",
senderMemberId = hiddenSender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
saveGift(
"G-RECEIVED-VISIBLE",
senderMemberId = visibleSender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
val result = queryService().getGifts(
memberId = recipient.id!!,
type = GiftListType.RECEIVED,
page = 0,
size = 20
)
assertEquals(1L, result.totalCount)
assertEquals(listOf("G-RECEIVED-VISIBLE"), result.items.map { it.applicationNo })
assertEquals(GiftDirection.RECEIVED, result.items.single().direction)
assertEquals(visibleSender.id, result.items.single().counterpartMemberId)
assertEquals("visible-fan", result.items.single().counterpartNickname)
assertEquals("https://cdn.test/profile/default-profile.png", result.items.single().counterpartProfileImageUrl)
}
@Test
@DisplayName("전체 선물함은 보낸 선물과 노출 가능한 받은 선물을 방향과 함께 반환한다")
fun shouldListSentAndVisibleReceivedGiftsWithDirectionsForAll() {
val member = saveMember("member")
val creator = saveMember("creator")
val fan = saveMember("fan")
val hiddenFan = saveMember("hidden-fan")
saveGift("G-ALL-SENT", senderMemberId = member.id!!, recipientMemberId = creator.id!!, status = GiftStatus.CANCELED)
saveGift(
"G-ALL-RECEIVED",
senderMemberId = fan.id!!,
recipientMemberId = member.id!!,
status = GiftStatus.ARRIVED_AT_MAILBOX
)
saveGift("G-ALL-HIDDEN", senderMemberId = hiddenFan.id!!, recipientMemberId = member.id!!, status = GiftStatus.RECEIVED)
val result = queryService().getGifts(
memberId = member.id!!,
type = GiftListType.ALL,
page = 0,
size = 20
)
assertEquals(2L, result.totalCount)
assertEquals(
mapOf("G-ALL-SENT" to GiftDirection.SENT, "G-ALL-RECEIVED" to GiftDirection.RECEIVED),
result.items.associate { it.applicationNo to it.direction }
)
assertEquals(
mapOf("G-ALL-SENT" to creator.id, "G-ALL-RECEIVED" to fan.id),
result.items.associate { it.applicationNo to it.counterpartMemberId }
)
}
@Test
@DisplayName("선물함 page와 size는 음수 또는 0이면 기본값으로 보정된다")
fun shouldNormalizeGiftListPageAndSize() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
saveGift("G-PAGING", senderMemberId = sender.id!!, recipientMemberId = recipient.id!!, status = GiftStatus.RECEIVED)
val result = queryService().getGifts(
memberId = sender.id!!,
type = GiftListType.SENT,
page = -1,
size = 0
)
assertEquals(0, result.page)
assertEquals(20, result.size)
assertEquals(false, result.hasNext)
assertEquals(listOf("G-PAGING"), result.items.map { it.applicationNo })
}
@Test
@DisplayName("발신자는 자신의 선물 상세와 발신자 정보만 조회한다")
fun shouldReturnSentGiftDetailWithoutRecipientAddress() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val gift = saveGift(
applicationNo = "G-SENT-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
val delivery = saveDelivery(gift)
delivery.recipientName = "수신자"
delivery.recipientPhoneNumber = "01098765432"
delivery.recipientZipCode = "04524"
delivery.recipientAddress = "서울시 중구"
delivery.recipientAddressDetail = "202호"
val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo)
assertEquals(GiftDirection.SENT, result.direction)
assertEquals(recipient.nickname, result.giftInfo.recipientCreatorNickname)
assertNull(result.giftInfo.senderNickname)
assertEquals("소형", result.giftInfo.sizeName)
assertEquals("인형", result.giftInfo.categoryName)
assertEquals(gift.applicationNo, result.giftInfo.applicationNo)
assertEquals(80, result.giftInfo.paidCan)
assertNull(result.giftInfo.tracking)
assertNull(result.giftInfo.shippingRequestedAt)
assertEquals("발신자", result.senderInfo?.name)
assertEquals("01012345678", result.senderInfo?.phoneNumber)
assertEquals("(06234) 서울시 강남구, 101동 1001호", result.senderInfo?.address)
assertNull(result.recipientAddress)
assertNull(result.mailbox)
assertEquals(true, result.trackingRequired)
assertEquals(false, result.recipientAddressRequired)
assertEquals(
listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED,
GiftStatus.DELIVERED
),
result.statusTimeline.map { it.status }
)
assertNotNull(result.statusTimeline[0].occurredAt)
assertNull(result.statusTimeline[1].occurredAt)
}
@Test
@DisplayName("보낸 선물의 전달 완료 전 정상 진행 상세는 받을 주소를 반환한다")
fun shouldReturnMailboxForSentNormalProgressGiftDetailBeforeDelivered() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val service = queryService()
val allowedStatuses = listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
allowedStatuses.forEachIndexed { index, status ->
val gift = saveGift(
applicationNo = "G-MAILBOX-ALLOWED-$index",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = status
)
saveDelivery(gift).applyStatusTimes(status)
val result = service.getGiftDetail(sender.id!!, gift.applicationNo)
assertEquals("소다라이브 선물 담당자", result.mailbox?.name)
assertEquals("01012345678", result.mailbox?.phoneNumber)
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", result.mailbox?.address)
assertEquals(status == GiftStatus.RECEIVED, result.trackingRequired)
}
}
@Test
@DisplayName("보낸 선물의 종료 상태와 받은 선물 상세는 받을 주소를 반환하지 않는다")
fun shouldReturnNullMailboxForTerminalOrReceivedGiftDetail() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val service = queryService()
val terminalStatuses = listOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED)
terminalStatuses.forEachIndexed { index, status ->
val gift = saveGift(
applicationNo = "G-MAILBOX-TERMINAL-$index",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = status
)
saveDelivery(gift).applyStatusTimes(status)
val result = service.getGiftDetail(sender.id!!, gift.applicationNo)
assertNull(result.mailbox)
}
val receivedGift = saveGift(
applicationNo = "G-MAILBOX-RECIPIENT",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(receivedGift).applyStatusTimes(GiftStatus.TRACKING_REGISTERED)
val recipientResult = service.getGiftDetail(recipient.id!!, receivedGift.applicationNo)
assertNull(recipientResult.mailbox)
}
@Test
@DisplayName("받을 주소가 없으면 전달 완료 전 정상 진행 상세도 mailbox가 null이다")
fun shouldReturnNullMailboxWhenMailboxNotConfigured() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val gift = saveGift(
applicationNo = "G-MAILBOX-EMPTY",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.INSPECTION_COMPLETED
)
saveDelivery(gift).applyStatusTimes(GiftStatus.INSPECTION_COMPLETED)
val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo)
assertNull(result.mailbox)
}
@Test
@DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다")
fun shouldReturnReceivedGiftDetailWithoutSenderInformation() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val gift = saveGift(
applicationNo = "G-RECEIVED-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(gift).apply {
trackingRegisteredAt = NOW.plusHours(1)
recipientName = "수신자"
recipientPhoneNumber = "01098765432"
recipientZipCode = "04524"
recipientAddress = "서울시 중구"
recipientAddressDetail = "202호"
}
val result = queryService().getGiftDetail(recipient.id!!, gift.applicationNo)
assertEquals(GiftDirection.RECEIVED, result.direction)
assertNull(result.giftInfo.recipientCreatorNickname)
assertEquals(sender.nickname, result.giftInfo.senderNickname)
assertEquals("소형", result.giftInfo.sizeName)
assertEquals("인형", result.giftInfo.categoryName)
assertNull(result.giftInfo.applicationNo)
assertNull(result.giftInfo.paidCan)
assertNull(result.giftInfo.tracking)
assertNotNull(result.giftInfo.shippingRequestedAt)
assertNull(result.senderInfo)
assertEquals("수신자", result.recipientAddress?.name)
assertEquals("01098765432", result.recipientAddress?.phoneNumber)
assertEquals("(04524) 서울시 중구, 202호", result.recipientAddress?.address)
assertNull(result.mailbox)
assertEquals(false, result.trackingRequired)
assertEquals(false, result.recipientAddressRequired)
assertNotNull(result.statusTimeline[0].occurredAt)
assertEquals(NOW.plusHours(1), result.statusTimeline[1].occurredAt)
assertNull(result.statusTimeline[2].occurredAt)
}
@Test
@DisplayName("상세 조회는 수신자 노출 조건과 관계없는 회원 및 배송 정보 누락을 거부한다")
fun shouldRejectHiddenOrUnrelatedGiftDetailAndMissingDelivery() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val other = saveMember("other")
val hiddenGift = saveGift(
applicationNo = "G-HIDDEN-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
saveDelivery(hiddenGift)
val visibleGift = saveGift(
applicationNo = "G-VISIBLE-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(visibleGift)
val missingDeliveryGift = saveGift(
applicationNo = "G-MISSING-DELIVERY",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
val service = queryService()
val hiddenException = assertThrows(SodaException::class.java) {
service.getGiftDetail(recipient.id!!, hiddenGift.applicationNo)
}
val unrelatedException = assertThrows(SodaException::class.java) {
service.getGiftDetail(other.id!!, visibleGift.applicationNo)
}
val missingDeliveryException = assertThrows(SodaException::class.java) {
service.getGiftDetail(sender.id!!, missingDeliveryGift.applicationNo)
}
assertEquals("common.error.invalid_request", hiddenException.messageKey)
assertEquals("common.error.invalid_request", unrelatedException.messageKey)
assertEquals("common.error.invalid_request", missingDeliveryException.messageKey)
}
@Test
@DisplayName("수신자 배송지 입력 필요 여부는 미입력인 진행 상태에서만 참이다")
fun shouldRequireRecipientAddressOnlyForNonTerminalReceivedGift() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val pendingGift = saveGift(
applicationNo = "G-ADDRESS-PENDING",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(pendingGift).trackingRegisteredAt = NOW
val terminalGift = saveGift(
applicationNo = "G-ADDRESS-TERMINAL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.UNDELIVERABLE
)
saveDelivery(terminalGift).apply {
trackingRegisteredAt = NOW
undeliverableAt = NOW.plusHours(1)
undeliverableReason = "배송지 미입력 기한 초과"
}
val service = queryService()
val pendingResult = service.getGiftDetail(recipient.id!!, pendingGift.applicationNo)
val terminalResult = service.getGiftDetail(recipient.id!!, terminalGift.applicationNo)
assertEquals(true, pendingResult.recipientAddressRequired)
assertNull(pendingResult.recipientAddress)
assertEquals(false, terminalResult.recipientAddressRequired)
}
@Test
@DisplayName("취소와 전달 불가 상세는 정상 진행 타임라인과 종료 정보를 함께 반환한다")
fun shouldReturnTerminalInformationWithNormalProgressTimeline() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val canceledGift = saveGift(
applicationNo = "G-CANCELED-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.CANCELED
)
saveDelivery(canceledGift).canceledAt = NOW.plusHours(1)
val undeliverableGift = saveGift(
applicationNo = "G-UNDELIVERABLE-X",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.UNDELIVERABLE
)
saveDelivery(undeliverableGift).apply {
trackingRegisteredAt = NOW.plusHours(1)
arrivedAtMailboxAt = NOW.plusHours(2)
undeliverableAt = NOW.plusHours(3)
undeliverableReason = "검수 실패"
}
val service = queryService()
val canceledResult = service.getGiftDetail(sender.id!!, canceledGift.applicationNo)
val undeliverableResult = service.getGiftDetail(sender.id!!, undeliverableGift.applicationNo)
assertEquals(NOW.plusHours(1), canceledResult.delivery.canceledAt)
assertNull(canceledResult.statusTimeline[1].occurredAt)
assertEquals(NOW.plusHours(1), undeliverableResult.statusTimeline[1].occurredAt)
assertEquals(NOW.plusHours(2), undeliverableResult.statusTimeline[2].occurredAt)
assertNull(undeliverableResult.statusTimeline[3].occurredAt)
assertEquals(NOW.plusHours(3), undeliverableResult.delivery.undeliverableAt)
assertEquals("검수 실패", undeliverableResult.delivery.undeliverableReason)
}
private fun saveGift(
applicationNo: String,
senderMemberId: Long,
recipientMemberId: Long,
status: GiftStatus
) = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = senderMemberId,
recipientMemberId = recipientMemberId,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = 1L,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = 1L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW,
senderPrivacyAgreedAt = NOW
)
)
private fun saveMember(nickname: String, profileImage: String? = null) = memberRepository.save(
Member(password = "password", nickname = nickname, profileImage = profileImage)
)
private fun giftCategory(
classificationNumber: String,
categoryCode: String,
receiptCode: String,
name: String,
representativeItem: String,
requiresDamageWaiver: Boolean,
isActive: Boolean
) = GiftCategory(
classificationNumber = classificationNumber,
categoryCode = categoryCode,
name = name,
receiptCode = receiptCode,
representativeItem = representativeItem,
requiresDamageWaiver = requiresDamageWaiver,
isActive = isActive
)
private fun saveDelivery(gift: Gift) = deliveryRepository.save(
GiftDelivery(
giftId = gift.id!!,
senderName = "발신자",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
trackingDeadlineAt = NOW.plusDays(3)
)
)
private fun GiftDelivery.applyStatusTimes(status: GiftStatus) {
if (status.ordinal >= GiftStatus.TRACKING_REGISTERED.ordinal) trackingRegisteredAt = NOW.plusHours(1)
if (status.ordinal >= GiftStatus.ARRIVED_AT_MAILBOX.ordinal) arrivedAtMailboxAt = NOW.plusHours(2)
if (status.ordinal >= GiftStatus.INSPECTION_COMPLETED.ordinal) inspectionCompletedAt = NOW.plusHours(3)
if (status == GiftStatus.DELIVERED) deliveredAt = NOW.plusHours(4)
if (status == GiftStatus.UNDELIVERABLE) {
undeliverableAt = NOW.plusHours(4)
undeliverableReason = "검수 실패"
}
if (status == GiftStatus.CANCELED) canceledAt = NOW.plusHours(1)
}
private fun queryService() = GiftQueryService(
categoryRepository,
sizePriceRepository,
giftRepository,
deliveryRepository,
memberRepository,
giftMailboxRepository,
"https://cdn.test"
)
private companion object {
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
}
}
@@ -1,19 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class GiftStatusTest {
@Test
@DisplayName("선물 상태 표시명은 한글 띄어쓰기를 통일한다")
fun shouldUseConsistentKoreanSpacingForDisplayNames() {
assertEquals("접수 완료", GiftStatus.RECEIVED.displayName)
assertEquals("발송 확인", GiftStatus.TRACKING_REGISTERED.displayName)
assertEquals("사서함 도착", GiftStatus.ARRIVED_AT_MAILBOX.displayName)
assertEquals("검수 완료", GiftStatus.INSPECTION_COMPLETED.displayName)
assertEquals("전달 완료", GiftStatus.DELIVERED.displayName)
assertEquals("전달 불가", GiftStatus.UNDELIVERABLE.displayName)
assertEquals("신청 취소", GiftStatus.CANCELED.displayName)
}
}