Compare commits

..
451 Commits
Author SHA1 Message Date
klaus 41314ef797 Merge pull request 'Test' (#452) from test into main
Reviewed-on: #452
2026-09-18 05:56:04 +00:00
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
54 changed files with 9 additions and 9043 deletions
@@ -1,364 +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
)
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,118 +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 (
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='선물 신청번호 날짜별 채번';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,96 +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.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-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,40 +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.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 AdminGiftMarkUndeliverableRequest(
val reason: String
)
@@ -1,189 +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.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 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,275 +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,
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 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,87 +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()
@@ -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,141 +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 = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED
and exists (
select d.id
from GiftDelivery d
where d.giftId = g.id
and d.recipientAddressDeadlineAt < :now
and d.recipientAddress is null
)
"""
)
fun findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(
@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.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(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,205 +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()
)
}
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()
)
private fun GiftStatus.availableActions() = when (this) {
GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE")
GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE")
GiftStatus.INSPECTION_COMPLETED -> listOf("COMPLETE_DELIVERY", "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,231 +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.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 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 giftPushService: GiftPushService
) {
@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()
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 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 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,268 +0,0 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
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.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.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 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
) {
@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
)
}
return GiftListResult(
totalCount = gifts.totalElements,
items = gifts.content.map { it.toGiftListItem(memberId) },
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 = null,
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) = GiftListItemResult(
applicationNo = applicationNo,
direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED,
status = status,
statusName = status.displayName,
priceCan = salePriceCan,
categoryName = categoryNameSnapshot,
sizeName = sizeCode.displayName,
createdAt = createdAt
)
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 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,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
)
}
@@ -1,391 +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 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)
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")))
}
private fun saveGift(status: GiftStatus) {
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 = "서울시 강남구",
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,454 +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.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"
]
)
@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 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 = Member(password = "password", nickname = "member").apply { id = 10L }
saveGift("G-CONTROLLER-SENT", senderMemberId = 10L, recipientMemberId = 20L, status = GiftStatus.RECEIVED)
saveGift("G-CONTROLLER-HIDDEN", senderMemberId = 20L, recipientMemberId = 10L, status = GiftStatus.RECEIVED)
saveGift(
"G-CONTROLLER-VISIBLE",
senderMemberId = 20L,
recipientMemberId = 10L,
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].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("팬은 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 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,287 +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 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 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
): 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 = 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,228 +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("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"),
service.getGiftDetail("A-100260930103").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)
}
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,277 +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.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.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 giftPushService = Mockito.mock(GiftPushService::class.java)
private val service = GiftAdminService(
categoryRepository,
sizePriceRepository,
giftRepository,
giftDeliveryRepository,
giftPushService
)
@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)
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() {
saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
val exception = assertThrows(SodaException::class.java) {
service.completeDelivery(APPLICATION_NO, NOW)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
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,426 +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.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
) {
@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() {
saveGift("G-SENT-RECEIVED", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED)
val result = queryService().getGifts(
memberId = MEMBER_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)
assertNotNull(result.items.single().createdAt)
}
@Test
@DisplayName("받은 선물은 운송장 등록 이후 상태만 선물함에 노출된다")
fun shouldExcludeReceivedOnlyGiftAndListVisibleGiftForRecipient() {
saveGift("G-RECEIVED-HIDDEN", senderMemberId = 2L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED)
saveGift(
"G-RECEIVED-VISIBLE",
senderMemberId = 3L,
recipientMemberId = MEMBER_ID,
status = GiftStatus.TRACKING_REGISTERED
)
val result = queryService().getGifts(
memberId = MEMBER_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)
}
@Test
@DisplayName("전체 선물함은 보낸 선물과 노출 가능한 받은 선물을 방향과 함께 반환한다")
fun shouldListSentAndVisibleReceivedGiftsWithDirectionsForAll() {
saveGift("G-ALL-SENT", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.CANCELED)
saveGift(
"G-ALL-RECEIVED",
senderMemberId = 3L,
recipientMemberId = MEMBER_ID,
status = GiftStatus.ARRIVED_AT_MAILBOX
)
saveGift("G-ALL-HIDDEN", senderMemberId = 4L, 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 }
)
}
@Test
@DisplayName("선물함 page와 size는 음수 또는 0이면 기본값으로 보정된다")
fun shouldNormalizeGiftListPageAndSize() {
saveGift("G-PAGING", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED)
val result = queryService().getGifts(
memberId = MEMBER_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 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) = memberRepository.save(Member(password = "password", nickname = nickname))
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 queryService() = GiftQueryService(
categoryRepository,
sizePriceRepository,
giftRepository,
deliveryRepository,
memberRepository
)
private companion object {
const val MEMBER_ID = 1L
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)
}
}