From 19d3544c72a4424983f30efd7403ccac7f5488ac Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 26 Feb 2026 18:57:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(channel-donation-calculate):=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=ED=9B=84=EC=9B=90=20=EC=A0=95=EC=82=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._channel_donation_settlement_index_ddl.sql | 49 +++++ ...ํฌ๋ฆฌ์—์ดํ„ฐ๊ด€๋ฆฌ์ž์ฑ„๋„ํ›„์›์ •์‚ฐํŽ˜์ด์ง€api์ƒ์„ฑ.md | 191 ++++++++++++++++++ ...AdminChannelDonationCalculateController.kt | 30 +++ ...ChannelDonationCalculateQueryRepository.kt | 90 +++++++++ .../AdminChannelDonationCalculateService.kt | 28 +++ .../GetAdminChannelDonationSettlementItem.kt | 15 ++ ...AdminChannelDonationSettlementQueryData.kt | 27 +++ ...tAdminChannelDonationSettlementResponse.kt | 6 + .../ChannelDonationSettlementCalculator.kt | 50 +++++ ...AdminChannelDonationCalculateController.kt | 40 ++++ ...ChannelDonationCalculateQueryRepository.kt | 80 ++++++++ ...torAdminChannelDonationCalculateService.kt | 30 +++ ...GetCreatorChannelDonationSettlementItem.kt | 15 ++ ...eatorChannelDonationSettlementQueryData.kt | 26 +++ ...reatorChannelDonationSettlementResponse.kt | 6 + ...nChannelDonationCalculateControllerTest.kt | 68 +++++++ ...nelDonationCalculateQueryRepositoryTest.kt | 126 ++++++++++++ ...dminChannelDonationCalculateServiceTest.kt | 72 +++++++ ...ChannelDonationSettlementCalculatorTest.kt | 43 ++++ ...nChannelDonationCalculateControllerTest.kt | 104 ++++++++++ ...nelDonationCalculateQueryRepositoryTest.kt | 125 ++++++++++++ ...dminChannelDonationCalculateServiceTest.kt | 75 +++++++ .../support/H2MySqlFunctionDialect.kt | 14 ++ .../sodalive/support/H2MysqlDateFunctions.kt | 36 ++++ 24 files changed, 1346 insertions(+) create mode 100644 docs/20260226_channel_donation_settlement_index_ddl.sql create mode 100644 docs/20260226_๊ด€๋ฆฌ์žํฌ๋ฆฌ์—์ดํ„ฐ๊ด€๋ฆฌ์ž์ฑ„๋„ํ›„์›์ •์‚ฐํŽ˜์ด์ง€api์ƒ์„ฑ.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementItem.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementQueryData.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculator.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementItem.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementQueryData.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculatorTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateServiceTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/support/H2MySqlFunctionDialect.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt diff --git a/docs/20260226_channel_donation_settlement_index_ddl.sql b/docs/20260226_channel_donation_settlement_index_ddl.sql new file mode 100644 index 00000000..4407736b --- /dev/null +++ b/docs/20260226_channel_donation_settlement_index_ddl.sql @@ -0,0 +1,49 @@ +SET @schema_name := DATABASE(); + +SET @use_can_index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'use_can' + AND index_name = 'idx_use_can_channel_donation_filter' +); +SET @use_can_index_sql := IF( + @use_can_index_exists = 0, + 'ALTER TABLE use_can ADD INDEX idx_use_can_channel_donation_filter (can_usage, is_refund, created_at, id)', + 'SELECT "idx_use_can_channel_donation_filter already exists"' +); +PREPARE use_can_index_stmt FROM @use_can_index_sql; +EXECUTE use_can_index_stmt; +DEALLOCATE PREPARE use_can_index_stmt; + +SET @use_can_calculate_join_index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'use_can_calculate' + AND index_name = 'idx_use_can_calculate_settlement_join' +); +SET @use_can_calculate_join_index_sql := IF( + @use_can_calculate_join_index_exists = 0, + 'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_settlement_join (use_can_id, status, recipient_creator_id)', + 'SELECT "idx_use_can_calculate_settlement_join already exists"' +); +PREPARE use_can_calculate_join_index_stmt FROM @use_can_calculate_join_index_sql; +EXECUTE use_can_calculate_join_index_stmt; +DEALLOCATE PREPARE use_can_calculate_join_index_stmt; + +SET @use_can_calculate_creator_index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'use_can_calculate' + AND index_name = 'idx_use_can_calculate_creator_settlement' +); +SET @use_can_calculate_creator_index_sql := IF( + @use_can_calculate_creator_index_exists = 0, + 'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_creator_settlement (recipient_creator_id, status, use_can_id)', + 'SELECT "idx_use_can_calculate_creator_settlement already exists"' +); +PREPARE use_can_calculate_creator_index_stmt FROM @use_can_calculate_creator_index_sql; +EXECUTE use_can_calculate_creator_index_stmt; +DEALLOCATE PREPARE use_can_calculate_creator_index_stmt; diff --git a/docs/20260226_๊ด€๋ฆฌ์žํฌ๋ฆฌ์—์ดํ„ฐ๊ด€๋ฆฌ์ž์ฑ„๋„ํ›„์›์ •์‚ฐํŽ˜์ด์ง€api์ƒ์„ฑ.md b/docs/20260226_๊ด€๋ฆฌ์žํฌ๋ฆฌ์—์ดํ„ฐ๊ด€๋ฆฌ์ž์ฑ„๋„ํ›„์›์ •์‚ฐํŽ˜์ด์ง€api์ƒ์„ฑ.md new file mode 100644 index 00000000..4fa2135a --- /dev/null +++ b/docs/20260226_๊ด€๋ฆฌ์žํฌ๋ฆฌ์—์ดํ„ฐ๊ด€๋ฆฌ์ž์ฑ„๋„ํ›„์›์ •์‚ฐํŽ˜์ด์ง€api์ƒ์„ฑ.md @@ -0,0 +1,191 @@ +# ๊ด€๋ฆฌ์ž/ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์ฑ„๋„ ํ›„์› ์ •์‚ฐ ํŽ˜์ด์ง€ API ์ž‘์—… ๊ณ„ํš + +- [x] ๊ธฐ์กด ์ •์‚ฐ API ํŒจํ„ด(`admin.calculate`, `creator.admin.calculate`)๊ณผ ์ฑ„๋„ ํ›„์› ๋ฐ์ดํ„ฐ ์†Œ์Šค(`ChannelDonationMessage`, `CanUsage.CHANNEL_DONATION`)๋ฅผ ํ™•์ธํ•œ๋‹ค. +- [x] ๊ธฐ์กด ํŒจํ‚ค์ง€์— ์ง์ ‘ ๋ˆ„์ ํ•˜์ง€ ์•Š๋„๋ก ์‹ ๊ทœ ํ•˜์œ„ ํŒจํ‚ค์ง€๋ฅผ ์„ค๊ณ„ํ•œ๋‹ค. + - ๊ด€๋ฆฌ์ž: `kr.co.vividnext.sodalive.admin.calculate.channelDonation` + - ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž: `kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation` +- [x] ๊ด€๋ฆฌ์ž ์ฑ„๋„ ํ›„์› ์ •์‚ฐ ์กฐํšŒ API๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ๋‚ ์งœ ๋ฒ”์œ„(`startDateStr`, `endDateStr`)๋กœ ์ „์ฒด ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•œ ๋’ค ์‘๋‹ต์„ ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„๋กœ ๊ทธ๋ฃนํ™”ํ•ด ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์„ค๊ณ„ํ•œ๋‹ค. +- [x] ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์ฑ„๋„ ํ›„์› ์ •์‚ฐ ์กฐํšŒ API๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ๋‚ ์งœ ๋ฒ”์œ„(`startDateStr`, `endDateStr`)๋งŒ ์ž…๋ ฅ๋ฐ›์•„ ์ธ์ฆ ์‚ฌ์šฉ์ž ๋ณธ์ธ ๋ฐ์ดํ„ฐ๋งŒ ์กฐํšŒํ•œ๋‹ค. +- [x] ์„œ๋น„์Šค ๊ณ„์ธต์—์„œ ๋‚ ์งœ ๋ฌธ์ž์—ด์„ `convertLocalDateTime()`์œผ๋กœ ๋ณ€ํ™˜ํ•˜๊ณ  ์ข…๋ฃŒ์ผ์€ `23:59:59`๋กœ ๋ณด์ •ํ•ด ์กฐํšŒ ๊ตฌ๊ฐ„์„ ํ†ต์ผํ•œ๋‹ค. +- [x] ์ €์žฅ์†Œ(QueryRepository) ๊ณ„์ธต์— ๋‚ ์งœ ๋ฒ”์œ„ ์กฐ๊ฑด(`createdAt >= startDate`, `createdAt <= endDate`)๊ณผ ํฌ๋ฆฌ์—์ดํ„ฐ ๊ธฐ์ค€ ๊ทธ๋ฃนํ™”(`groupBy(member.id)` ๋“ฑ)๋ฅผ ๋ฐ˜์˜ํ•œ ์ง‘๊ณ„ ์กฐํšŒ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. +- [x] API URL์„ ๊ธฐ์กด ์ •์‚ฐ URL ๊ทœ์น™์— ๋งž์ถฐ ํ™•์ •ํ•˜๊ณ  ๋ฌธ์„œํ™”ํ•œ๋‹ค. + - ๊ด€๋ฆฌ์ž: `GET /admin/calculate/channel-donation-by-creator` + - ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž: `GET /creator-admin/calculate/channel-donation` +- [x] ์ •์‚ฐ ๊ณ„์‚ฐ ๊ณต์‹์„ ๊ณตํ†ต ๋กœ์ง์œผ๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ์‚ฌ๋žŒ์ด ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์šด ํ•œ๊ธ€ ์ฃผ์„์„ ์ถ”๊ฐ€ํ•œ๋‹ค. + - ์›ํ™” = ์บ” * 100 + - ์ˆ˜์ˆ˜๋ฃŒ = ์›ํ™” * 6.6% + - ์ •์‚ฐ๊ธˆ์•ก = (์›ํ™” - ์ˆ˜์ˆ˜๋ฃŒ) * 85% + - ์›์ฒœ์„ธ = ์ •์‚ฐ๊ธˆ์•ก * 3.3% + - ์ž…๊ธˆ์•ก = ์ •์‚ฐ๊ธˆ์•ก - ์›์ฒœ์„ธ +- [x] ๊ณ„์‚ฐ ์ •๋ฐ€๋„ ์ •์ฑ…์„ ์ •์˜ํ•œ๋‹ค(`BigDecimal`, `RoundingMode.HALF_UP`, ๋ฐ˜์˜ฌ๋ฆผ ์‹œ์  ๊ณ ์ •). +- [x] ์„ฑ๋Šฅ/ํšจ์œจ ๊ฐœ์„  ํ•ญ๋ชฉ์„ ๋ฐ˜์˜ํ•œ๋‹ค(์ง‘๊ณ„ ์ฟผ๋ฆฌ ์ค‘์‹ฌ ์ฒ˜๋ฆฌ, ๋ถˆํ•„์š”ํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ํ›„์ฒ˜๋ฆฌ ์ตœ์†Œํ™”, count ์กฐํšŒ ์ตœ์ ํ™” ๊ฒ€ํ† ). +- [x] ์‘๋‹ต DTO ์ŠคํŽ™์„ ์•„๋ž˜ ํ•„๋“œ๋กœ ๊ณ ์ •ํ•˜๊ณ  ๊ถŒํ•œ ์ •์ฑ…(๊ด€๋ฆฌ์ž=์ „์ฒด, ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž=๋ณธ์ธ)์„ ํ•จ๊ป˜ ๊ฒ€์ฆํ•œ๋‹ค. + - ๋‚ ์งœ(`yyyy-MM-dd`) + - ํฌ๋ฆฌ์—์ดํ„ฐ + - ๊ฑด์ˆ˜(`count`) + - ์ด ๋ฐ›์€ ์บ” ์ˆ˜(`totalCan`) + - ์›ํ™” + - ์ˆ˜์ˆ˜๋ฃŒ + - ์ •์‚ฐ๊ธˆ์•ก + - ์›์ฒœ์„ธ + - ์ž…๊ธˆ์•ก +- [x] ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค(๊ด€๋ฆฌ์ž ๋‚ ์งœ ํ•„ํ„ฐ + ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„ ๊ทธ๋ฃนํ™” ์‘๋‹ต, ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ๋‚ ์งœ ํ•„ํ„ฐ/๋ณธ์ธ ๋ฒ”์œ„, ๊ณ„์‚ฐ์‹ ์ •ํ™•์„ฑ, ๊ฒฝ๊ณ„๊ฐ’). +- [x] ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค(`./gradlew test`, `./gradlew build`, ํ•„์š” ์‹œ `./gradlew ktlintCheck`). + +## API URL ์„ ์ • ๊ทผ๊ฑฐ + +- ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋Š” ๊ถŒํ•œ ๋ฒ”์œ„๋ณ„ ์ •์‚ฐ ์ปจํŠธ๋กค๋Ÿฌ ๊ด€๋ก€๋ฅผ ๋”ฐ๋ฅธ๋‹ค. + - ๊ด€๋ฆฌ์ž: `@RequestMapping("/admin/calculate")` + - ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž: `@RequestMapping("/creator-admin/calculate")` +- ํ•˜์œ„ ๊ฒฝ๋กœ๋Š” ๊ธฐ์กด ์ •์‚ฐ API์™€ ๋™์ผํ•˜๊ฒŒ ์†Œ๋ฌธ์ž ํ•˜์ดํ”ˆ(`kebab-case`) ๋ช…์‚ฌ ์กฐํ•ฉ์„ ์‚ฌ์šฉํ•œ๋‹ค. + - ์˜ˆ: `content-donation-list`, `cumulative-sales-by-content`, `community-by-creator` +- `channel-donation` ํ† ํฐ์€ ๊ธฐ์กด ์ฑ„๋„ ํ›„์› API ๊ฒฝ๋กœ(`@RequestMapping("/explorer/profile/channel-donation")`)์™€ ์šฉ์–ด๋ฅผ ๋งž์ถฐ ๋„๋ฉ”์ธ ํ‘œํ˜„์„ ํ†ต์ผํ•œ๋‹ค. +- ๊ด€๋ฆฌ์ž ์ •์‚ฐ์€ ์กฐํšŒ ๊ฒฐ๊ณผ๊ฐ€ ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„ ๊ทธ๋ฃนํ™” ์‘๋‹ต์ด๋ฏ€๋กœ ๊ธฐ์กด `*-by-creator` ํŒจํ„ด์„ ์ ์šฉํ•ด `channel-donation-by-creator`๋กœ ์ •ํ•œ๋‹ค. +- ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์ •์‚ฐ์€ ์ธ์ฆ ์‚ฌ์šฉ์ž ๋ณธ์ธ ๋ฒ”์œ„๋กœ ๊ณ ์ •๋˜๋ฏ€๋กœ `-by-creator` ์ ‘๋ฏธ์‚ฌ๋ฅผ ์ œ์™ธํ•˜๊ณ  `channel-donation`์œผ๋กœ ์ •ํ•œ๋‹ค. + +## ๊ฒ€์ฆ ๊ธฐ๋ก + +### ๊ณ„ํš ์ˆ˜๋ฆฝ +- ๋ฌด์—‡์„: ๊ด€๋ฆฌ์ž/ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์ฑ„๋„ ํ›„์› ์ •์‚ฐ ํŽ˜์ด์ง€ API ๊ตฌํ˜„์„ ์œ„ํ•œ ์ž‘์—… ๊ณ„ํš ๋ฌธ์„œ๋ฅผ ์ž‘์„ฑํ–ˆ๋‹ค. +- ์™œ: ๊ตฌํ˜„ ์ „์— ํŒจํ‚ค์ง€ ๊ตฌ์กฐ, ๋‚ ์งœ ๋ฒ”์œ„ ์กฐํšŒ, ์ •์‚ฐ ๊ณ„์‚ฐ์‹, ์„ฑ๋Šฅ ๊ฒ€์ฆ ๊ธฐ์ค€์„ ๋ช…ํ™•ํžˆ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - `docs`์˜ ๊ธฐ์กด ์ž‘์—… ๊ณ„ํš ๋ฌธ์„œ ํ˜•์‹(์ฒดํฌ๋ฐ•์Šค + ๊ฒ€์ฆ ๊ธฐ๋ก)์„ ๊ธฐ์ค€์œผ๋กœ ํ…œํ”Œ๋ฆฟ์„ ๋งž์ท„๋‹ค. + - `admin.calculate`, `creator.admin.calculate`, `explorer.profile.channelDonation` ๊ฒฝ๋กœ๋ฅผ ํƒ์ƒ‰ํ•ด ๋ฐ˜์˜ํ–ˆ๋‹ค. + - ์‚ฌ์šฉ์ž ์š”์ฒญ์— ๋”ฐ๋ผ ์‹ค์ œ ์ฝ”๋“œ ๊ตฌํ˜„/ํ…Œ์ŠคํŠธ๋Š” ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š๊ณ  ๊ณ„ํš ๋ฌธ์„œ๋งŒ ์ž‘์„ฑํ–ˆ๋‹ค. + +### 2์ฐจ ๊ณ„ํš ์ˆ˜์ • +- ๋ฌด์—‡์„: ์กฐํšŒ ์กฐ๊ฑด์„ `๊ด€๋ฆฌ์ž=๋‚ ์งœ+ํฌ๋ฆฌ์—์ดํ„ฐ ๊ตฌ๋ถ„`, `ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž=๋‚ ์งœ๋งŒ`์œผ๋กœ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌํ–ˆ๊ณ , ์‘๋‹ต ํ•„๋“œ๋ฅผ `๋‚ ์งœ(yyyy-MM-dd), ํฌ๋ฆฌ์—์ดํ„ฐ, ์›ํ™”, ์ˆ˜์ˆ˜๋ฃŒ, ์ •์‚ฐ๊ธˆ์•ก, ์›์ฒœ์„ธ, ์ž…๊ธˆ์•ก`์œผ๋กœ ๊ณ ์ •ํ–ˆ๋‹ค. +- ์™œ: ์ถ”๊ฐ€ ์š”๊ตฌ์‚ฌํ•ญ(์กฐํšŒ ์กฐ๊ฑด ๋ถ„๋ฆฌ, Response ํ•„๋“œ ๊ณ ์ •)์„ ๊ณ„ํš ๋‹จ๊ณ„์—์„œ ๋ˆ„๋ฝ ์—†์ด ๋ฐ˜์˜ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - `admin.calculate`/`creator.admin.calculate`์˜ ๊ธฐ์กด ๋‚ ์งœ ํŒŒ๋ผ๋ฏธํ„ฐ ๋ฐ ์ธ์ฆ ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง ํŒจํ„ด์„ ์žฌํƒ์ƒ‰ํ•ด ๊ณ„ํš ํ•ญ๋ชฉ์„ ์ˆ˜์ •ํ–ˆ๋‹ค. + - `lsp_diagnostics`(๋Œ€์ƒ: ๋ณธ ๋ฌธ์„œ) ๊ฒฐ๊ณผ `No diagnostics found`๋ฅผ ํ™•์ธํ–ˆ๋‹ค. + - `./gradlew tasks --all` ์‹คํ–‰ ๊ฒฐ๊ณผ `BUILD SUCCESSFUL`์„ ํ™•์ธํ–ˆ๋‹ค. + - ๋ฌธ์„œ๋งŒ ์ž‘์„ฑํ•ด์•ผ ํ•˜๋Š” ์š”์ฒญ ๋ฒ”์œ„๋ฅผ ์œ ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ์ฝ”๋“œ ๊ตฌํ˜„/ํ…Œ์ŠคํŠธ ๋ณ€๊ฒฝ์€ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š์•˜๋‹ค. + +### 3์ฐจ ๊ณ„ํš ์ˆ˜์ • +- ๋ฌด์—‡์„: ๊ด€๋ฆฌ์ž ์กฐํšŒ ์š”๊ตฌ์‚ฌํ•ญ์„ `ํฌ๋ฆฌ์—์ดํ„ฐ ์‹๋ณ„๊ฐ’์œผ๋กœ ํ•„ํ„ฐ`๊ฐ€ ์•„๋‹Œ `์กฐํšŒ ๊ฒฐ๊ณผ๋ฅผ ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„๋กœ ๊ทธ๋ฃนํ™”ํ•˜์—ฌ ๋ฐ˜ํ™˜`์œผ๋กœ ์ •์ •ํ–ˆ๋‹ค. +- ์™œ: ์‚ฌ์šฉ์ž ์˜๋„๊ฐ€ โ€œ์กฐํšŒ ์กฐ๊ฑด ์ถ”๊ฐ€โ€๊ฐ€ ์•„๋‹ˆ๋ผ โ€œ์‘๋‹ต ๊ฒฐ๊ณผ ๊ตฌ์„ฑ ๋ฐฉ์‹(ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„ ๊ทธ๋ฃนํ™”)โ€์ด์—ˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - `AdminCalculateController`์˜ `*-by-creator` ์—”๋“œํฌ์ธํŠธ๊ฐ€ ๋‚ ์งœ/ํŽ˜์ด์ง€ ํŒŒ๋ผ๋ฏธํ„ฐ๋งŒ ๋ฐ›๊ณ (`creatorId/memberId` ๋ฏธ์ž…๋ ฅ), ์„œ๋น„์Šค/๋ฆฌํฌ์ง€ํ† ๋ฆฌ์—์„œ `GetCalculateByCreatorResponse`์™€ `groupBy(member.id)` ๊ธฐ๋ฐ˜์œผ๋กœ ๊ฒฐ๊ณผ๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” ํŒจํ„ด์„ ํ™•์ธํ–ˆ๋‹ค. + - ์œ„ ๊ทผ๊ฑฐ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋ฅผ `๊ด€๋ฆฌ์ž=๋‚ ์งœ ํ•„ํ„ฐ + ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„ ๊ทธ๋ฃนํ™” ์‘๋‹ต` ๊ธฐ์ค€์œผ๋กœ ์ˆ˜์ •ํ–ˆ๋‹ค. + - `lsp_diagnostics`(๋Œ€์ƒ: ๋ณธ ๋ฌธ์„œ) ๊ฒฐ๊ณผ `No diagnostics found`๋ฅผ ํ™•์ธํ–ˆ๋‹ค. + - `./gradlew tasks --all` ์‹คํ–‰ ๊ฒฐ๊ณผ `BUILD SUCCESSFUL`์„ ํ™•์ธํ–ˆ๋‹ค. + +### 4์ฐจ ๊ณ„ํš ์ˆ˜์ • +- ๋ฌด์—‡์„: ์ž‘์—… ๊ณ„ํš ๋ฌธ์„œ์— API URL์„ ์–ด๋–ค ๊ธฐ์ค€์œผ๋กœ ์ •ํ–ˆ๋Š”์ง€(๊ฒฝ๋กœ ๊ทœ์น™, ์šฉ์–ด ์„ ํƒ, ์ตœ์ข… URL) ๊ทผ๊ฑฐ๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค. +- ์™œ: ๊ตฌํ˜„ ์ „์— URL ๋ช…๋ช… ๊ธฐ์ค€์„ ๋ช…ํ™•ํžˆ ๋‚จ๊ฒจ, ์ดํ›„ ๊ฐœ๋ฐœ ์‹œ ๊ฒฝ๋กœ ํ•ด์„ ์ฐจ์ด์™€ ์žฌ์ž‘์—…์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - `AdminCalculateController`, `CreatorAdminCalculateController`, `ChannelDonationController`์˜ `@RequestMapping`/`@GetMapping` ํŒจํ„ด์„ ๋น„๊ตํ•ด ๊ธฐ์ค€ ๊ฒฝ๋กœ์™€ ํ•˜์œ„ ๊ฒฝ๋กœ ๊ทœ์น™์„ ๋„์ถœํ–ˆ๋‹ค. + - ๊ด€๋ฆฌ์ž URL์€ `*-by-creator` ๊ด€๋ก€๋ฅผ ์ ์šฉํ•ด `/admin/calculate/channel-donation-by-creator`, ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž URL์€ ๋ณธ์ธ ๋ฒ”์œ„ ๊ณ ์ • ํŠน์„ฑ์— ๋งž์ถฐ `/creator-admin/calculate/channel-donation`์œผ๋กœ ๋ฌธ์„œํ™”ํ–ˆ๋‹ค. + - `lsp_diagnostics`(๋Œ€์ƒ: ๋ณธ ๋ฌธ์„œ) ๊ฒฐ๊ณผ `No diagnostics found`๋ฅผ ํ™•์ธํ–ˆ๋‹ค. + - `./gradlew tasks --all` ์‹คํ–‰ ๊ฒฐ๊ณผ `BUILD SUCCESSFUL`์„ ํ™•์ธํ–ˆ๋‹ค. + +### 5์ฐจ ๊ตฌํ˜„ +- ๋ฌด์—‡์„: ๊ด€๋ฆฌ์ž/ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์ฑ„๋„ ํ›„์› ์ •์‚ฐ API๋ฅผ ์‹ ๊ทœ ํ•˜์œ„ ํŒจํ‚ค์ง€๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋‚ ์งœ ๋ฒ”์œ„ ์กฐํšŒ/ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„ ๊ทธ๋ฃนํ™”/์ •์‚ฐ ๊ณต์‹ ๊ณตํ†ต ๊ณ„์‚ฐ ๋กœ์ง์„ ์ ์šฉํ–ˆ๋‹ค. +- ์™œ: ๊ธฐ์กด ์ •์‚ฐ ์ฝ”๋“œ์— ์–ฝํžˆ์ง€ ์•Š๊ณ  ์œ ์ง€๋ณด์ˆ˜ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ์š”๊ตฌ์‚ฌํ•ญ(๊ด€๋ฆฌ์ž=ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„ ๊ทธ๋ฃน ์‘๋‹ต, ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž=๋ณธ์ธ ๋ฒ”์œ„ ์กฐํšŒ)์„ ์ •ํ™•ํžˆ ๋ฐ˜์˜ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - ์‹ ๊ทœ ํŒจํ‚ค์ง€ ์ƒ์„ฑ: `admin.calculate.channelDonation`, `creator.admin.calculate.channelDonation`, ๊ณตํ†ต ๊ณ„์‚ฐ๊ธฐ `calculate.channelDonation`. + - API ๊ตฌํ˜„: `GET /admin/calculate/channel-donation-by-creator`, `GET /creator-admin/calculate/channel-donation`. + - QueryDSL ์ง‘๊ณ„: `UseCan` + `UseCanCalculate`๋ฅผ ์‚ฌ์šฉํ•ด `CanUsage.CHANNEL_DONATION`, ๋‚ ์งœ ๋ฒ”์œ„, ํ™˜๋ถˆ ์ œ์™ธ ์กฐ๊ฑด์„ ์ ์šฉํ•˜๊ณ  ๊ด€๋ฆฌ์ž ์‘๋‹ต์€ ๋‚ ์งœ+ํฌ๋ฆฌ์—์ดํ„ฐ ๊ธฐ์ค€ ๊ทธ๋ฃนํ™”, ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์‘๋‹ต์€ ๋‚ ์งœ ๊ธฐ์ค€ ๊ทธ๋ฃนํ™”๋กœ ๊ตฌํ˜„. + - ์ •์‚ฐ ๊ณ„์‚ฐ์‹ ๊ณตํ†ตํ™”: `ChannelDonationSettlementCalculator`์—์„œ `BigDecimal("0.066")`, `BigDecimal("0.85")`, `BigDecimal("0.033")`, `RoundingMode.HALF_UP` ์ •์ฑ…์œผ๋กœ ๊ณ„์‚ฐํ•˜๊ณ  ๊ณต์‹ ์„ค๋ช… ํ•œ๊ธ€ ์ฃผ์„์„ ์ถ”๊ฐ€. + - ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€: ๊ณ„์‚ฐ์‹/๋ฐ˜์˜ฌ๋ฆผ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๋ฐ ๊ด€๋ฆฌ์žยทํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์ปจํŠธ๋กค๋Ÿฌ/์„œ๋น„์Šค ๊ฒฝ๋กœ ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€. + - ๊ฒ€์ฆ ์‹คํ–‰: + - `./gradlew test --tests "kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculatorTest"` โ†’ ์„ฑ๊ณต + - `./gradlew test --tests "*channelDonation*"` โ†’ ์„ฑ๊ณต + - `./gradlew test` โ†’ ์„ฑ๊ณต + - `./gradlew build` โ†’ ์„ฑ๊ณต + - ์ฐธ๊ณ : Kotlin LSP ์„œ๋ฒ„ ๋ฏธ์„ค์ • ํ™˜๊ฒฝ์ด๋ผ `.kt` ํŒŒ์ผ์— ๋Œ€ํ•œ `lsp_diagnostics`๋Š” ์‹คํ–‰ ์‹œ ์„œ๋ฒ„ ๋ฏธ์„ค์ • ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ–ˆ๋‹ค. + +### 6์ฐจ ์ˆ˜์ • +- ๋ฌด์—‡์„: ์ •์‚ฐ ๊ณ„์‚ฐ์‹์„ ๋‹จ๊ณ„๋ณ„ ๋ฐ˜์˜ฌ๋ฆผ ํ›„ ๋‹ค์Œ ๋‹จ๊ณ„ ๊ณ„์‚ฐํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ˆ˜์ •ํ•˜๊ณ , ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์กฐํšŒ ์ฟผ๋ฆฌ/์นด์šดํŠธ์—์„œ ๋ถˆํ•„์š”ํ•œ `member` ์กฐ์ธ์„ ์ œ๊ฑฐํ–ˆ๋‹ค. +- ์™œ: ์ •์‚ฐ ํ•ญ๋ชฉ ๊ฐ„ ๊ด€๊ณ„(`์ž…๊ธˆ์•ก = ์ •์‚ฐ๊ธˆ์•ก - ์›์ฒœ์„ธ`)๋ฅผ ์ •์ˆ˜ ๊ธฐ์ค€์œผ๋กœ ์ผ๊ด€๋˜๊ฒŒ ๋งž์ถ”๊ณ , ์กฐํšŒ ์„ฑ๋Šฅ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•ด ๋ถˆํ•„์š” ์กฐ์ธ์„ ์ค„์ด๊ธฐ ์œ„ํ•ด์„œ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - `ChannelDonationSettlementCalculator`๋ฅผ ๋‹จ๊ณ„๋ณ„ ๋ฐ˜์˜ฌ๋ฆผ ํŒŒ์ดํ”„๋ผ์ธ์œผ๋กœ ๋ณ€๊ฒฝํ–ˆ๋‹ค. + - `์ˆ˜์ˆ˜๋ฃŒ = round(์›ํ™” * 6.6%)` + - `์ •์‚ฐ๊ธˆ์•ก = round((์›ํ™” - ์ˆ˜์ˆ˜๋ฃŒ) * 85%)` + - `์›์ฒœ์„ธ = round(์ •์‚ฐ๊ธˆ์•ก * 3.3%)` + - `์ž…๊ธˆ์•ก = ์ •์‚ฐ๊ธˆ์•ก - ์›์ฒœ์„ธ` + - ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ๊ฒฝ๋กœ๋Š” ์ธ์ฆ ์‚ฌ์šฉ์ž ๋‹‰๋„ค์ž„์„ ์„œ๋น„์Šค ์ธ์ž๋กœ ์ „๋‹ฌํ•ด ์‘๋‹ต `creator`๋ฅผ ๊ตฌ์„ฑํ•˜๊ณ , QueryRepository์˜ `member` ์กฐ์ธ/๋‹‰๋„ค์ž„ select๋ฅผ ์ œ๊ฑฐํ–ˆ๋‹ค. + - ๊ด€๋ฆฌ์ž totalCount๋Š” `member` ์กฐ์ธ ์—†์ด `recipientCreatorId` ๊ธฐ๋ฐ˜ distinct ํ‚ค๋กœ ๊ณ„์‚ฐํ•˜๋„๋ก ๋ณ€๊ฒฝํ–ˆ๋‹ค. +- ๊ฒ€์ฆ ์‹คํ–‰: + - `./gradlew test --tests "*channelDonation*"` โ†’ ์„ฑ๊ณต + - `./gradlew test` โ†’ ์„ฑ๊ณต + - `./gradlew build` โ†’ ์„ฑ๊ณต + +### 7์ฐจ ์ˆ˜์ • +- ๋ฌด์—‡์„: ์š”์ฒญํ•œ 2๋ฒˆ/3๋ฒˆ ์ตœ์ ํ™”๋ฅผ ๋ฐ˜์˜ํ•ด QueryDSL `@QueryProjection` ๊ธฐ๋ฐ˜ ๋งคํ•‘์œผ๋กœ ์ „ํ™˜ํ•˜๊ณ , ๋‚ ์งœ ๊ทธ๋ฃน ์กฐํšŒ ๊ฒฝ๋กœ ์ธ๋ฑ์Šค ์ „๋žต DDL์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. ๋˜ํ•œ ํ…Œ์ŠคํŠธ ๊ฐ€๋…์„ฑ์„ ์œ„ํ•ด `@DisplayName`์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. +- ์™œ: `Projections.constructor` ๋Œ€๋น„ ํƒ€์ž… ์•ˆ์ „์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ๋†’์ด๊ณ , ์ฑ„๋„ ํ›„์› ์ •์‚ฐ ์กฐํšŒ์˜ ๋‚ ์งœ ๋ฒ”์œ„/์กฐ์ธ ํ•„ํ„ฐ ์„ฑ๋Šฅ ๊ฐœ์„  ๊ทผ๊ฑฐ๋ฅผ DDL๋กœ ๋ช…ํ™•ํžˆ ๋‚จ๊ธฐ๊ธฐ ์œ„ํ•ด์„œ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - Query DTO ์ „ํ™˜: + - `GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`์— `@QueryProjection`์„ ์ ์šฉํ–ˆ๋‹ค. + - ๊ฐ QueryRepository์˜ `Projections.constructor`๋ฅผ `QGet*QueryData(...)` ํ˜ธ์ถœ๋กœ ๊ต์ฒดํ–ˆ๋‹ค. + - ์ธ๋ฑ์Šค ์ „๋žต ๋ฐ˜์˜: + - `docs/20260226_channel_donation_settlement_index_ddl.sql` ํŒŒ์ผ์„ ์ถ”๊ฐ€ํ•ด `use_can`, `use_can_calculate` ์ธ๋ฑ์Šค DDL์„ ์ •์˜ํ–ˆ๋‹ค. + - ํ…Œ์ŠคํŠธ ๊ฐ€๋…์„ฑ ๊ฐœ์„ : + - ์ฑ„๋„ ํ›„์› ์ •์‚ฐ ๊ด€๋ จ ์‹ ๊ทœ ํ…Œ์ŠคํŠธ์— `@DisplayName`(ํ•œ๊ธ€)์„ ์ถ”๊ฐ€ํ•ด ํ…Œ์ŠคํŠธ ์˜๋„๋ฅผ ๋ช…ํ™•ํžˆ ํ–ˆ๋‹ค. + - ๊ฒ€์ฆ ์‹คํ–‰: + - `./gradlew test --tests "*channelDonation*"` โ†’ ์„ฑ๊ณต + - `./gradlew test` โ†’ ์„ฑ๊ณต + - `./gradlew build` โ†’ ์„ฑ๊ณต + - ์ฐธ๊ณ : `./gradlew test`์™€ `./gradlew build`๋ฅผ ๋ณ‘๋ ฌ ์‹คํ–‰ํ•˜๋ฉด ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ XML ํŒŒ์ผ ์“ฐ๊ธฐ ์ถฉ๋Œ์ด ์žฌ๋ฐœํ•  ์ˆ˜ ์žˆ์–ด, ์ˆœ์ฐจ ์‹คํ–‰ ๊ธฐ์ค€์œผ๋กœ ์ตœ์ข… ๊ฒ€์ฆํ–ˆ๋‹ค. + +### 8์ฐจ ์ˆ˜์ • +- ๋ฌด์—‡์„: ์ฑ„๋„ ํ›„์› ์ •์‚ฐ Item ์‘๋‹ต(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)์— `count` ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , QueryData/Repository/Test๋ฅผ ํ•จ๊ป˜ ๊ฐฑ์‹ ํ–ˆ๋‹ค. +- ์™œ: ์‚ฌ์šฉ์ž ์š”์ฒญ์— ๋”ฐ๋ผ ์ •์‚ฐ ์‘๋‹ต์—์„œ ๊ทธ๋ฃน๋ณ„ ๊ฑด์ˆ˜๋ฅผ ์ง์ ‘ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - Item DTO์— `@JsonProperty("count") val count: Int`๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค. + - QueryDTO(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)์— `count: Long`์„ ์ถ”๊ฐ€ํ•˜๊ณ  `toResponseItem()`์—์„œ `count.toInt()`๋กœ ๋งคํ•‘ํ–ˆ๋‹ค. + - Repository projection์— `useCan.id.count()`๋ฅผ ์ถ”๊ฐ€ํ•ด count ๊ฐ’์„ ์กฐํšŒํ•˜๋„๋ก ๋ฐ˜์˜ํ–ˆ๋‹ค. + - ์ปจํŠธ๋กค๋Ÿฌ/์„œ๋น„์Šค ํ…Œ์ŠคํŠธ fixture ๋ฐ assertion์— `count` ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. + - ๊ฒ€์ฆ ์‹คํ–‰: + - `./gradlew test --tests "*channelDonation*"` โ†’ ์„ฑ๊ณต + - `./gradlew test` โ†’ ์„ฑ๊ณต + - `./gradlew build` โ†’ ์„ฑ๊ณต + +### 9์ฐจ ์ˆ˜์ • +- ๋ฌด์—‡์„: ์ฑ„๋„ ํ›„์› ์ •์‚ฐ `count`๊ฐ€ ๋ถ„ํ•  ์ •์‚ฐ ๋ ˆ์ฝ”๋“œ ์ˆ˜๋กœ ๊ณผ์ง‘๊ณ„๋˜๋˜ ๋ฌธ์ œ๋ฅผ ์ˆ˜์ •ํ•˜๊ณ , ๋™์ผ ํ›„์›(`UseCan` 1๊ฑด) + ๋ถ„ํ•  ์ •์‚ฐ(`UseCanCalculate` 2๊ฑด) ํšŒ๊ท€ ํ…Œ์ŠคํŠธ๋ฅผ ๊ด€๋ฆฌ์ž/ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ๊ฒฝ๋กœ์— ์ถ”๊ฐ€ํ–ˆ๋‹ค. +- ์™œ: ๊ฒฐ์ œ ๊ฒŒ์ดํŠธ์›จ์ด๋ณ„ ๋ถ„ํ•  ์ •์‚ฐ์ด ๋ฐœ์ƒํ•˜๋ฉด ๊ธฐ์กด `useCan.id.count()`๊ฐ€ ์‹ค์ œ ํ›„์› ๊ฑด์ˆ˜๋ณด๋‹ค ํฌ๊ฒŒ ์ง‘๊ณ„๋˜์–ด ์ •์‚ฐ ํ™”๋ฉด `count`๊ฐ€ ์ž˜๋ชป ํ‘œ์‹œ๋˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - `AdminChannelDonationCalculateQueryRepository`, `CreatorAdminChannelDonationCalculateQueryRepository`์˜ ์ง‘๊ณ„ `count`๋ฅผ `useCan.id.countDistinct()`๋กœ ๋ณ€๊ฒฝํ–ˆ๋‹ค. + - QueryRepository ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ(`AdminChannelDonationCalculateQueryRepositoryTest`, `CreatorAdminChannelDonationCalculateQueryRepositoryTest`)๋ฅผ ์ถ”๊ฐ€ํ•ด ๋ถ„ํ•  ์ •์‚ฐ ์‹œ `count=1`, `totalCan` ํ•ฉ์‚ฐ(50) ๋™์ž‘์„ ๊ฒ€์ฆํ–ˆ๋‹ค. + - H2 ํ™˜๊ฒฝ์—์„œ MySQL ํ•จ์ˆ˜(`DATE_FORMAT`, `CONVERT_TZ`)๋ฅผ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•ด `H2MySqlFunctionDialect`, `H2MysqlDateFunctions` ํ…Œ์ŠคํŠธ ์ง€์› ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ  ๊ฐ ํ…Œ์ŠคํŠธ์—์„œ alias๋ฅผ ๋“ฑ๋กํ–ˆ๋‹ค. + - ๊ฒ€์ฆ ์‹คํ–‰: + - `./gradlew test --tests "*channelDonation*"` โ†’ ์„ฑ๊ณต + - `./gradlew build` โ†’ ์„ฑ๊ณต + - ์ฐธ๊ณ : Kotlin LSP ๋ฏธ์„ค์ • ํ™˜๊ฒฝ์ด๋ผ `.kt` ๋Œ€์ƒ `lsp_diagnostics`๋Š” ์‹คํ–‰ ์‹œ ์„œ๋ฒ„ ๋ฏธ์„ค์ • ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค. + +### 10์ฐจ ์ˆ˜์ • +- ๋ฌด์—‡์„: ๊ด€๋ฆฌ์ž ์ฑ„๋„ ํ›„์› ์ •์‚ฐ์˜ `totalCount` ์ฟผ๋ฆฌ์— `member` `innerJoin`์„ ์ถ”๊ฐ€ํ•ด ๋ชฉ๋ก ์กฐํšŒ์™€ ๋™์ผํ•œ ์กฐ์ธ ์กฐ๊ฑด์œผ๋กœ ์ง‘๊ณ„ํ•˜๋„๋ก ์ •๋ ฌํ–ˆ๋‹ค. +- ์™œ: ๊ธฐ์กด์—๋Š” `totalCount`๋Š” `member` ์กฐ์ธ ์—†์ด ๊ณ„์‚ฐํ•˜๊ณ  ๋ชฉ๋ก์€ `member` `innerJoin`์„ ์‚ฌ์šฉํ•ด, ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ ์ด์Šˆ(๊ณ ์•„ `recipientCreatorId`)๊ฐ€ ์žˆ์„ ๋•Œ `totalCount`์™€ `items`๊ฐ€ ๋ถˆ์ผ์น˜ํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotalCount(...)`์— `member` ์กฐ์ธ(`member.id = useCanCalculate.recipientCreatorId`)์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. + - distinct ๊ทธ๋ฃน ํ‚ค๋ฅผ `recipientCreatorId` ๋ฌธ์ž์—ด ๋Œ€์‹  `member.id` ๋ฌธ์ž์—ด ๊ธฐ์ค€์œผ๋กœ ๋ณ€๊ฒฝํ•ด ๋ชฉ๋ก ์ฟผ๋ฆฌ์˜ ๊ทธ๋ฃน ์ถ•(๋‚ ์งœ+๋ฉค๋ฒ„)๊ณผ ๋งž์ท„๋‹ค. + - ๊ฒ€์ฆ ์‹คํ–‰: + - `./gradlew test --tests "*channelDonation*"` โ†’ ์„ฑ๊ณต + - `./gradlew build` โ†’ ์„ฑ๊ณต + - ์ฐธ๊ณ : `./gradlew test`์™€ `./gradlew build`๋ฅผ ๋ณ‘๋ ฌ ์‹คํ–‰ํ–ˆ์„ ๋•Œ test result XML ์“ฐ๊ธฐ ์ถฉ๋Œ์ด 1ํšŒ ๋ฐœ์ƒํ•ด, ์ดํ›„ ์ˆœ์ฐจ ์‹คํ–‰์œผ๋กœ ์žฌ๊ฒ€์ฆํ–ˆ๋‹ค. + +### 10์ฐจ ์ˆ˜์ • +- ๋ฌด์—‡์„: ์ •์‚ฐ ํŽ˜์ด์ง€ Item ์‘๋‹ต(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)์— `totalCan` ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค. +- ์™œ: ์‚ฌ์šฉ์ž ์š”์ฒญ๋Œ€๋กœ ํ™”๋ฉด์—์„œ ๊ฑด์ˆ˜ ๋‹ค์Œ์— ์ด ๋ฐ›์€ ์บ” ์ˆ˜๋ฅผ ํ•จ๊ป˜ ๋…ธ์ถœํ•˜๊ธฐ ์œ„ํ•ด์„œ๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - Item DTO์— `@JsonProperty("totalCan") val totalCan: Int`๋ฅผ `count` ๋‹ค์Œ ์œ„์น˜๋กœ ์ถ”๊ฐ€ํ–ˆ๋‹ค. + - QueryData(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)์˜ `toResponseItem()`์—์„œ `totalCan ?: 0`์„ ์‘๋‹ต Item์˜ `totalCan`์œผ๋กœ ๋งคํ•‘ํ–ˆ๋‹ค. + - ์ปจํŠธ๋กค๋Ÿฌ/์„œ๋น„์Šค ํ…Œ์ŠคํŠธ fixture์™€ assertion์— `totalCan` ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. +- ๊ฒ€์ฆ ์‹คํ–‰: + - `./gradlew test --tests "*channelDonation*"` โ†’ ์„ฑ๊ณต + - `./gradlew test` โ†’ ์„ฑ๊ณต + - `./gradlew build` โ†’ ์„ฑ๊ณต + +### 11์ฐจ ์ˆ˜์ • +- ๋ฌด์—‡์„: ์ฑ„๋„ ํ›„์› ์ •์‚ฐ ์ธ๋ฑ์Šค DDL(`docs/20260226_channel_donation_settlement_index_ddl.sql`)์„ ์žฌ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ๋ฉฑ๋“ฑ ์Šคํฌ๋ฆฝํŠธ๋กœ ์ˆ˜์ •ํ–ˆ๋‹ค. +- ์™œ: ๋™์ผ DB์— DDL์„ ์žฌ์ ์šฉํ•  ๋•Œ ๊ธฐ์กด `ADD INDEX`๊ฐ€ `Duplicate key name`์œผ๋กœ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์–ด, ์šด์˜ ์žฌ์ ์šฉ/๋กค๋ฐฑ ํ›„ ์žฌ์ ์šฉ ์‹œ ์•ˆ์ •์„ฑ์„ ํ™•๋ณดํ•ด์•ผ ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. +- ์–ด๋–ป๊ฒŒ: + - `information_schema.statistics`์—์„œ `table_schema = DATABASE()` ๊ธฐ์ค€์œผ๋กœ ์ธ๋ฑ์Šค ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ์กฐํšŒํ•˜๋„๋ก ๋ณ€๊ฒฝํ–ˆ๋‹ค. + - ์ธ๋ฑ์Šค๊ฐ€ ์—†์„ ๋•Œ๋งŒ `ALTER TABLE ... ADD INDEX`๋ฅผ ์‹คํ–‰ํ•˜๊ณ , ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ์•ˆ๋‚ด `SELECT`๋ฅผ ์‹คํ–‰ํ•˜๋Š” ๋™์  SQL(`PREPARE`/`EXECUTE`) ํŒจํ„ด์„ ์ ์šฉํ–ˆ๋‹ค. + - ๋Œ€์ƒ ์ธ๋ฑ์Šค 3๊ฐœ(`idx_use_can_channel_donation_filter`, `idx_use_can_calculate_settlement_join`, `idx_use_can_calculate_creator_settlement`) ๋ชจ๋‘ ๋™์ผ ๊ทœ์น™์œผ๋กœ ๋ฐ˜์˜ํ–ˆ๋‹ค. +- ๊ฒ€์ฆ ์‹คํ–‰: + - `lsp_diagnostics`(๋Œ€์ƒ: `docs/20260226_channel_donation_settlement_index_ddl.sql`) โ†’ `.sql` LSP ์„œ๋ฒ„ ๋ฏธ์„ค์ •์œผ๋กœ ์ง„๋‹จ ๋ถˆ๊ฐ€(ํ™˜๊ฒฝ ์ œ์•ฝ) + - `lsp_diagnostics`(๋Œ€์ƒ: ๋ณธ ๋ฌธ์„œ) โ†’ `No diagnostics found` + - `./gradlew test --tests "*channelDonation*"` โ†’ ์„ฑ๊ณต + - `./gradlew build` โ†’ ์„ฑ๊ณต diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt new file mode 100644 index 00000000..df1b74a4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("/admin/calculate") +class AdminChannelDonationCalculateController( + private val service: AdminChannelDonationCalculateService +) { + @GetMapping("/channel-donation-by-creator") + fun getChannelDonationByCreator( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getChannelDonationByCreator( + startDateStr = startDateStr, + endDateStr = endDateStr, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt new file mode 100644 index 00000000..5ffe9a5f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.core.types.dsl.StringTemplate +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class AdminChannelDonationCalculateQueryRepository( + private val queryFactory: JPAQueryFactory +) { + fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { + val formattedDate = getFormattedDate(useCan.createdAt) + val distinctGroupKey = Expressions.stringTemplate( + "CONCAT({0}, '-', {1})", + formattedDate, + member.id.stringValue() + ) + + return queryFactory + .select(distinctGroupKey.countDistinct()) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .where(baseWhereCondition(startDate, endDate)) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getChannelDonationByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + offset: Long, + limit: Long + ): List { + val formattedDate = getFormattedDate(useCan.createdAt) + + return queryFactory + .select( + QGetAdminChannelDonationSettlementQueryData( + formattedDate, + member.nickname, + useCan.id.countDistinct(), + useCanCalculate.can.sum() + ) + ) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .where(baseWhereCondition(startDate, endDate)) + .groupBy(formattedDate, member.id) + .orderBy(formattedDate.desc(), member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + private fun baseWhereCondition( + startDate: LocalDateTime, + endDate: LocalDateTime + ) = useCan.canUsage.eq(CanUsage.CHANNEL_DONATION) + .and(useCan.isRefund.isFalse) + .and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + + private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + return Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + dateTimePath, + "UTC", + "Asia/Seoul" + ), + "%Y-%m-%d" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt new file mode 100644 index 00000000..0292ab63 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminChannelDonationCalculateService( + private val repository: AdminChannelDonationCalculateQueryRepository +) { + @Transactional(readOnly = true) + fun getChannelDonationByCreator( + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetAdminChannelDonationSettlementResponse { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + + val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) + val items = repository + .getChannelDonationByCreator(startDate, endDate, offset, limit) + .map { it.toResponseItem() } + + return GetAdminChannelDonationSettlementResponse(totalCount, items) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementItem.kt new file mode 100644 index 00000000..ad1a7dca --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementItem.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetAdminChannelDonationSettlementItem( + @JsonProperty("date") val date: String, + @JsonProperty("creator") val creator: String, + @JsonProperty("count") val count: Int, + @JsonProperty("totalCan") val totalCan: Int, + @JsonProperty("krw") val krw: Int, + @JsonProperty("fee") val fee: Int, + @JsonProperty("settlementAmount") val settlementAmount: Int, + @JsonProperty("withholdingTax") val withholdingTax: Int, + @JsonProperty("depositAmount") val depositAmount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementQueryData.kt new file mode 100644 index 00000000..4574b6c0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementQueryData.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator + +data class GetAdminChannelDonationSettlementQueryData @QueryProjection constructor( + val date: String, + val creator: String, + val count: Long, + val totalCan: Int? +) { + fun toResponseItem(): GetAdminChannelDonationSettlementItem { + val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0) + + return GetAdminChannelDonationSettlementItem( + date = date, + creator = creator, + count = count.toInt(), + totalCan = totalCan ?: 0, + krw = settlement.krw, + fee = settlement.fee, + settlementAmount = settlement.settlementAmount, + withholdingTax = settlement.withholdingTax, + depositAmount = settlement.depositAmount + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementResponse.kt new file mode 100644 index 00000000..16fc5da4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +data class GetAdminChannelDonationSettlementResponse( + val totalCount: Int, + val items: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculator.kt new file mode 100644 index 00000000..c5d9f43f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculator.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.calculate.channelDonation + +import java.math.BigDecimal +import java.math.RoundingMode + +data class ChannelDonationSettlementAmount( + val krw: Int, + val fee: Int, + val settlementAmount: Int, + val withholdingTax: Int, + val depositAmount: Int +) + +object ChannelDonationSettlementCalculator { + private val KRW_PER_CAN = BigDecimal("100") + private val FEE_RATE = BigDecimal("0.066") + private val SETTLEMENT_RATE = BigDecimal("0.85") + private val WITHHOLDING_TAX_RATE = BigDecimal("0.033") + + fun calculate(totalCan: Int): ChannelDonationSettlementAmount { + // ์›ํ™” = ์บ” * 100 + val krw = BigDecimal(totalCan).multiply(KRW_PER_CAN).setScale(0, RoundingMode.HALF_UP).toInt() + + // ์ˆ˜์ˆ˜๋ฃŒ = ์›ํ™” * 6.6% + val fee = BigDecimal(krw).multiply(FEE_RATE).setScale(0, RoundingMode.HALF_UP).toInt() + + // ์ •์‚ฐ๊ธˆ์•ก = (์›ํ™” - ์ˆ˜์ˆ˜๋ฃŒ) * 85% + val settlementAmount = BigDecimal(krw - fee) + .multiply(SETTLEMENT_RATE) + .setScale(0, RoundingMode.HALF_UP) + .toInt() + + // ์›์ฒœ์„ธ = ์ •์‚ฐ๊ธˆ์•ก * 3.3% + val withholdingTax = BigDecimal(settlementAmount) + .multiply(WITHHOLDING_TAX_RATE) + .setScale(0, RoundingMode.HALF_UP) + .toInt() + + // ์ž…๊ธˆ์•ก = ์ •์‚ฐ๊ธˆ์•ก - ์›์ฒœ์„ธ + val depositAmount = settlementAmount - withholdingTax + + return ChannelDonationSettlementAmount( + krw = krw, + fee = fee, + settlementAmount = settlementAmount, + withholdingTax = withholdingTax, + depositAmount = depositAmount + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateController.kt new file mode 100644 index 00000000..ef29c528 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateController.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('CREATOR')") +@RequestMapping("/creator-admin/calculate") +class CreatorAdminChannelDonationCalculateController( + private val service: CreatorAdminChannelDonationCalculateService +) { + @GetMapping("/channel-donation") + fun getChannelDonation( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + + ApiResponse.ok( + service.getChannelDonation( + startDateStr = startDateStr, + endDateStr = endDateStr, + memberId = member.id!!, + creatorNickname = member.nickname, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt new file mode 100644 index 00000000..1a3a1d8c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt @@ -0,0 +1,80 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.core.types.dsl.StringTemplate +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class CreatorAdminChannelDonationCalculateQueryRepository( + private val queryFactory: JPAQueryFactory +) { + fun getChannelDonationTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, memberId: Long): Int { + val formattedDate = getFormattedDate(useCan.createdAt) + + return queryFactory + .select(formattedDate.countDistinct()) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .where(baseWhereCondition(startDate, endDate, memberId)) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getChannelDonation( + startDate: LocalDateTime, + endDate: LocalDateTime, + memberId: Long, + offset: Long, + limit: Long + ): List { + val formattedDate = getFormattedDate(useCan.createdAt) + + return queryFactory + .select( + QGetCreatorChannelDonationSettlementQueryData( + formattedDate, + useCan.id.countDistinct(), + useCanCalculate.can.sum() + ) + ) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .where(baseWhereCondition(startDate, endDate, memberId)) + .groupBy(formattedDate) + .orderBy(formattedDate.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + private fun baseWhereCondition(startDate: LocalDateTime, endDate: LocalDateTime, memberId: Long) = useCan.canUsage.eq( + CanUsage.CHANNEL_DONATION + ) + .and(useCan.isRefund.isFalse) + .and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED)) + .and(useCanCalculate.recipientCreatorId.eq(memberId)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + + private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + return Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + dateTimePath, + "UTC", + "Asia/Seoul" + ), + "%Y-%m-%d" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateService.kt new file mode 100644 index 00000000..28055160 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateService.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CreatorAdminChannelDonationCalculateService( + private val repository: CreatorAdminChannelDonationCalculateQueryRepository +) { + @Transactional(readOnly = true) + fun getChannelDonation( + startDateStr: String, + endDateStr: String, + memberId: Long, + creatorNickname: String, + offset: Long, + limit: Long + ): GetCreatorChannelDonationSettlementResponse { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + + val totalCount = repository.getChannelDonationTotalCount(startDate, endDate, memberId) + val items = repository + .getChannelDonation(startDate, endDate, memberId, offset, limit) + .map { it.toResponseItem(creatorNickname) } + + return GetCreatorChannelDonationSettlementResponse(totalCount, items) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementItem.kt new file mode 100644 index 00000000..cb58643e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementItem.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetCreatorChannelDonationSettlementItem( + @JsonProperty("date") val date: String, + @JsonProperty("creator") val creator: String, + @JsonProperty("count") val count: Int, + @JsonProperty("totalCan") val totalCan: Int, + @JsonProperty("krw") val krw: Int, + @JsonProperty("fee") val fee: Int, + @JsonProperty("settlementAmount") val settlementAmount: Int, + @JsonProperty("withholdingTax") val withholdingTax: Int, + @JsonProperty("depositAmount") val depositAmount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementQueryData.kt new file mode 100644 index 00000000..99d381b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementQueryData.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator + +data class GetCreatorChannelDonationSettlementQueryData @QueryProjection constructor( + val date: String, + val count: Long, + val totalCan: Int? +) { + fun toResponseItem(creatorNickname: String): GetCreatorChannelDonationSettlementItem { + val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0) + + return GetCreatorChannelDonationSettlementItem( + date = date, + creator = creatorNickname, + count = count.toInt(), + totalCan = totalCan ?: 0, + krw = settlement.krw, + fee = settlement.fee, + settlementAmount = settlement.settlementAmount, + withholdingTax = settlement.withholdingTax, + depositAmount = settlement.depositAmount + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementResponse.kt new file mode 100644 index 00000000..81204c96 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +data class GetCreatorChannelDonationSettlementResponse( + val totalCount: Int, + val items: List +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt new file mode 100644 index 00000000..e1e34c6e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +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.mockito.Mockito +import org.springframework.data.domain.PageRequest + +class AdminChannelDonationCalculateControllerTest { + private lateinit var service: AdminChannelDonationCalculateService + private lateinit var controller: AdminChannelDonationCalculateController + + @BeforeEach + fun setup() { + service = Mockito.mock(AdminChannelDonationCalculateService::class.java) + controller = AdminChannelDonationCalculateController(service) + } + + @Test + @DisplayName("๊ด€๋ฆฌ์ž ์ปจํŠธ๋กค๋Ÿฌ๋Š” ๋‚ ์งœ/ํŽ˜์ด์ง€ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์„œ๋น„์Šค๋กœ ์ „๋‹ฌํ•œ๋‹ค") + fun shouldForwardDateRangeAndPageableToService() { + val response = GetAdminChannelDonationSettlementResponse( + totalCount = 1, + items = listOf( + GetAdminChannelDonationSettlementItem( + date = "2026-02-26", + creator = "creator-a", + count = 2, + totalCan = 20, + krw = 2000, + fee = 132, + settlementAmount = 1588, + withholdingTax = 52, + depositAmount = 1536 + ) + ) + ) + + Mockito.`when`( + service.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 15L, + limit = 15L + ) + ).thenReturn(response) + + val apiResponse = controller.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + pageable = PageRequest.of(1, 15) + ) + + assertEquals(true, apiResponse.success) + assertEquals(1, apiResponse.data!!.totalCount) + assertEquals("creator-a", apiResponse.data!!.items[0].creator) + assertEquals(2, apiResponse.data!!.items[0].count) + assertEquals(20, apiResponse.data!!.items[0].totalCan) + + Mockito.verify(service).getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 15L, + limit = 15L + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt new file mode 100644 index 00000000..728e0a7c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt @@ -0,0 +1,126 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +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.UseCanCalculateStatus +import kr.co.vividnext.sodalive.can.use.UseCanRepository +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.member.MemberRole +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 java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) +@Import(QueryDslConfig::class) +class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val useCanRepository: UseCanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val entityManager: EntityManager +) { + private lateinit var repository: AdminChannelDonationCalculateQueryRepository + + @BeforeEach + fun setup() { + registerMysqlDateFunctions() + repository = AdminChannelDonationCalculateQueryRepository(queryFactory) + } + + @Test + @DisplayName("๋™์ผ ํ›„์›์˜ ๋ถ„ํ•  ์ •์‚ฐ ๋ ˆ์ฝ”๋“œ๋Š” ๊ฑด์ˆ˜๋ฅผ ์ค‘๋ณต ์ง‘๊ณ„ํ•˜์ง€ ์•Š๋Š”๋‹ค") + fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculations() { + val creator = saveMember(nickname = "creator-admin", role = MemberRole.CREATOR) + val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER) + val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0) + + saveUseCanCalculate( + useCan = useCan, + recipientCreatorId = creator.id!!, + can = 20, + paymentGateway = PaymentGateway.PG + ) + saveUseCanCalculate( + useCan = useCan, + recipientCreatorId = creator.id!!, + can = 30, + paymentGateway = PaymentGateway.GOOGLE_IAP + ) + updateUseCanCreatedAt(useCan.id!!, LocalDateTime.of(2026, 2, 20, 12, 0, 0)) + entityManager.flush() + entityManager.clear() + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) + val items = repository.getChannelDonationByCreator(startDate, endDate, offset = 0, limit = 20) + + assertEquals(1, totalCount) + assertEquals(1, items.size) + assertEquals("2026-02-20", items[0].date) + assertEquals("creator-admin", items[0].creator) + assertEquals(1L, items[0].count) + assertEquals(50, items[0].totalCan) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveUseCan(member: Member, can: Int, rewardCan: Int): UseCan { + val useCan = UseCan( + canUsage = CanUsage.CHANNEL_DONATION, + can = can, + rewardCan = rewardCan + ) + useCan.member = member + return useCanRepository.saveAndFlush(useCan) + } + + private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) { + val useCanCalculate = UseCanCalculate( + can = can, + paymentGateway = paymentGateway, + status = UseCanCalculateStatus.RECEIVED + ) + useCanCalculate.useCan = useCan + useCanCalculate.recipientCreatorId = recipientCreatorId + useCanCalculateRepository.saveAndFlush(useCanCalculate) + } + + private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", useCanId) + .executeUpdate() + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt new file mode 100644 index 00000000..6dc4e2b8 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt @@ -0,0 +1,72 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +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.mockito.Mockito + +class AdminChannelDonationCalculateServiceTest { + private lateinit var repository: AdminChannelDonationCalculateQueryRepository + private lateinit var service: AdminChannelDonationCalculateService + + @BeforeEach + fun setup() { + repository = Mockito.mock(AdminChannelDonationCalculateQueryRepository::class.java) + service = AdminChannelDonationCalculateService(repository) + } + + @Test + @DisplayName("๊ด€๋ฆฌ์ž ์ •์‚ฐ ์กฐํšŒ๋Š” ๋‚ ์งœ ๋ฒ”์œ„๋ฅผ ๋ณ€ํ™˜ํ•˜๊ณ  ํฌ๋ฆฌ์—์ดํ„ฐ ๊ทธ๋ฃน ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + fun shouldConvertDateRangeAndReturnCreatorGroupedItems() { + val queryData = GetAdminChannelDonationSettlementQueryData( + date = "2026-02-26", + creator = "creator-a", + count = 3L, + totalCan = 100 + ) + + Mockito.`when`( + repository.getChannelDonationByCreatorTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + ).thenReturn(1) + Mockito.`when`( + repository.getChannelDonationByCreator( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 0L, + 20L + ) + ).thenReturn(listOf(queryData)) + + val result = service.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 0, + limit = 20 + ) + + assertEquals(1, result.totalCount) + assertEquals(1, result.items.size) + assertEquals("2026-02-26", result.items[0].date) + assertEquals("creator-a", result.items[0].creator) + assertEquals(3, result.items[0].count) + assertEquals(100, result.items[0].totalCan) + assertEquals(10_000, result.items[0].krw) + assertEquals(660, result.items[0].fee) + + Mockito.verify(repository).getChannelDonationByCreatorTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + Mockito.verify(repository).getChannelDonationByCreator( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 0L, + 20L + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculatorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculatorTest.kt new file mode 100644 index 00000000..8ffaa24c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculatorTest.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.calculate.channelDonation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class ChannelDonationSettlementCalculatorTest { + @Test + @DisplayName("์ •์‚ฐ ๊ณต์‹์— ๋”ฐ๋ผ ๊ธˆ์•ก์„ ๊ณ„์‚ฐํ•œ๋‹ค") + fun shouldCalculateSettlementAmountsWithExpectedFormula() { + val result = ChannelDonationSettlementCalculator.calculate(totalCan = 100) + + assertEquals(10_000, result.krw) + assertEquals(660, result.fee) + assertEquals(7_939, result.settlementAmount) + assertEquals(262, result.withholdingTax) + assertEquals(7_677, result.depositAmount) + } + + @Test + @DisplayName("์†Œ์ˆ˜ ๊ณ„์‚ฐ์€ ๋‹จ๊ณ„๋ณ„ ๋ฐ˜์˜ฌ๋ฆผ ๊ทœ์น™์„ ๋”ฐ๋ฅธ๋‹ค") + fun shouldRoundHalfUpOnDecimalResults() { + val result = ChannelDonationSettlementCalculator.calculate(totalCan = 1) + + assertEquals(100, result.krw) + assertEquals(7, result.fee) + assertEquals(79, result.settlementAmount) + assertEquals(3, result.withholdingTax) + assertEquals(76, result.depositAmount) + } + + @Test + @DisplayName("์ด ์บ” ์ˆ˜๊ฐ€ 0์ด๋ฉด ๋ชจ๋“  ๊ธˆ์•ก์€ 0์ด๋‹ค") + fun shouldReturnZeroWhenTotalCanIsZero() { + val result = ChannelDonationSettlementCalculator.calculate(totalCan = 0) + + assertEquals(0, result.krw) + assertEquals(0, result.fee) + assertEquals(0, result.settlementAmount) + assertEquals(0, result.withholdingTax) + assertEquals(0, result.depositAmount) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateControllerTest.kt new file mode 100644 index 00000000..31022790 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateControllerTest.kt @@ -0,0 +1,104 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.common.SodaException +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.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest + +class CreatorAdminChannelDonationCalculateControllerTest { + private lateinit var service: CreatorAdminChannelDonationCalculateService + private lateinit var controller: CreatorAdminChannelDonationCalculateController + + @BeforeEach + fun setup() { + service = Mockito.mock(CreatorAdminChannelDonationCalculateService::class.java) + controller = CreatorAdminChannelDonationCalculateController(service) + } + + @Test + @DisplayName("์ธ์ฆ ์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ์—†์œผ๋ฉด ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + fun shouldThrowWhenMemberIsNull() { + val exception = assertThrows(SodaException::class.java) { + controller.getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = null, + pageable = PageRequest.of(0, 10) + ) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + @Test + @DisplayName("ํฌ๋ฆฌ์—์ดํ„ฐ ์ปจํŠธ๋กค๋Ÿฌ๋Š” ๋ณธ์ธ ID/๋‹‰๋„ค์ž„๊ณผ ํŽ˜์ด์ง€ ์ •๋ณด๋ฅผ ์„œ๋น„์Šค๋กœ ์ „๋‹ฌํ•œ๋‹ค") + fun shouldForwardMemberAndPageableToService() { + val member = createMember(7L) + val response = GetCreatorChannelDonationSettlementResponse( + totalCount = 1, + items = listOf( + GetCreatorChannelDonationSettlementItem( + date = "2026-02-26", + creator = "creator-self", + count = 4, + totalCan = 10, + krw = 1000, + fee = 66, + settlementAmount = 794, + withholdingTax = 26, + depositAmount = 768 + ) + ) + ) + + Mockito.`when`( + service.getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + memberId = 7L, + creatorNickname = "creator", + offset = 10L, + limit = 5L + ) + ).thenReturn(response) + + val apiResponse = controller.getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = member, + pageable = PageRequest.of(2, 5) + ) + + assertEquals(true, apiResponse.success) + assertEquals(1, apiResponse.data!!.totalCount) + assertEquals("creator-self", apiResponse.data!!.items[0].creator) + assertEquals(4, apiResponse.data!!.items[0].count) + assertEquals(10, apiResponse.data!!.items[0].totalCan) + + Mockito.verify(service).getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + memberId = 7L, + creatorNickname = "creator", + offset = 10L, + limit = 5L + ) + } + + private fun createMember(id: Long): Member { + val member = Member( + email = "creator@test.com", + password = "password", + nickname = "creator", + role = MemberRole.CREATOR + ) + member.id = id + return member + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepositoryTest.kt new file mode 100644 index 00000000..5d06cec8 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepositoryTest.kt @@ -0,0 +1,125 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +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.UseCanCalculateStatus +import kr.co.vividnext.sodalive.can.use.UseCanRepository +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.member.MemberRole +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 java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) +@Import(QueryDslConfig::class) +class CreatorAdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val useCanRepository: UseCanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val entityManager: EntityManager +) { + private lateinit var repository: CreatorAdminChannelDonationCalculateQueryRepository + + @BeforeEach + fun setup() { + registerMysqlDateFunctions() + repository = CreatorAdminChannelDonationCalculateQueryRepository(queryFactory) + } + + @Test + @DisplayName("๋ถ„ํ•  ์ •์‚ฐ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ์–ด๋„ ํฌ๋ฆฌ์—์ดํ„ฐ ์ •์‚ฐ ๊ฑด์ˆ˜๋Š” ํ›„์› ๋‹จ์œ„๋กœ ์ง‘๊ณ„ํ•œ๋‹ค") + fun shouldCountDistinctUseCanForCreatorWhenDonationIsSplitAcrossCalculations() { + val creator = saveMember(nickname = "creator-self", role = MemberRole.CREATOR) + val sender = saveMember(nickname = "sender-self", role = MemberRole.USER) + val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0) + + saveUseCanCalculate( + useCan = useCan, + recipientCreatorId = creator.id!!, + can = 20, + paymentGateway = PaymentGateway.PG + ) + saveUseCanCalculate( + useCan = useCan, + recipientCreatorId = creator.id!!, + can = 30, + paymentGateway = PaymentGateway.GOOGLE_IAP + ) + updateUseCanCreatedAt(useCan.id!!, LocalDateTime.of(2026, 2, 20, 12, 0, 0)) + entityManager.flush() + entityManager.clear() + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getChannelDonationTotalCount(startDate, endDate, creator.id!!) + val items = repository.getChannelDonation(startDate, endDate, creator.id!!, offset = 0, limit = 20) + + assertEquals(1, totalCount) + assertEquals(1, items.size) + assertEquals("2026-02-20", items[0].date) + assertEquals(1L, items[0].count) + assertEquals(50, items[0].totalCan) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveUseCan(member: Member, can: Int, rewardCan: Int): UseCan { + val useCan = UseCan( + canUsage = CanUsage.CHANNEL_DONATION, + can = can, + rewardCan = rewardCan + ) + useCan.member = member + return useCanRepository.saveAndFlush(useCan) + } + + private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) { + val useCanCalculate = UseCanCalculate( + can = can, + paymentGateway = paymentGateway, + status = UseCanCalculateStatus.RECEIVED + ) + useCanCalculate.useCan = useCan + useCanCalculate.recipientCreatorId = recipientCreatorId + useCanCalculateRepository.saveAndFlush(useCanCalculate) + } + + private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", useCanId) + .executeUpdate() + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateServiceTest.kt new file mode 100644 index 00000000..05343e82 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateServiceTest.kt @@ -0,0 +1,75 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +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.mockito.Mockito + +class CreatorAdminChannelDonationCalculateServiceTest { + private lateinit var repository: CreatorAdminChannelDonationCalculateQueryRepository + private lateinit var service: CreatorAdminChannelDonationCalculateService + + @BeforeEach + fun setup() { + repository = Mockito.mock(CreatorAdminChannelDonationCalculateQueryRepository::class.java) + service = CreatorAdminChannelDonationCalculateService(repository) + } + + @Test + @DisplayName("ํฌ๋ฆฌ์—์ดํ„ฐ ๊ด€๋ฆฌ์ž ์ •์‚ฐ ์กฐํšŒ๋Š” ๋ณธ์ธ ๋ฒ”์œ„์™€ ๋‚ ์งœ ๋ฒ”์œ„๋ฅผ ์ ์šฉํ•œ๋‹ค") + fun shouldApplyMemberScopeAndDateRange() { + val queryData = GetCreatorChannelDonationSettlementQueryData( + date = "2026-02-26", + count = 2L, + totalCan = 50 + ) + + Mockito.`when`( + repository.getChannelDonationTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getChannelDonation( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 7L, + 0L, + 20L + ) + ).thenReturn(listOf(queryData)) + + val result = service.getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + memberId = 7L, + creatorNickname = "creator-self", + offset = 0, + limit = 20 + ) + + assertEquals(1, result.totalCount) + assertEquals(1, result.items.size) + assertEquals("creator-self", result.items[0].creator) + assertEquals(2, result.items[0].count) + assertEquals(50, result.items[0].totalCan) + assertEquals(5_000, result.items[0].krw) + + Mockito.verify(repository).getChannelDonationTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 7L + ) + Mockito.verify(repository).getChannelDonation( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 7L, + 0L, + 20L + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MySqlFunctionDialect.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MySqlFunctionDialect.kt new file mode 100644 index 00000000..502511e9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MySqlFunctionDialect.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.support + +import org.hibernate.dialect.H2Dialect +import org.hibernate.dialect.function.StandardSQLFunction +import org.hibernate.type.StandardBasicTypes + +class H2MySqlFunctionDialect : H2Dialect() { + init { + registerFunction("date_format", StandardSQLFunction("DATE_FORMAT", StandardBasicTypes.STRING)) + registerFunction("DATE_FORMAT", StandardSQLFunction("DATE_FORMAT", StandardBasicTypes.STRING)) + registerFunction("convert_tz", StandardSQLFunction("CONVERT_TZ", StandardBasicTypes.TIMESTAMP)) + registerFunction("CONVERT_TZ", StandardSQLFunction("CONVERT_TZ", StandardBasicTypes.TIMESTAMP)) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt new file mode 100644 index 00000000..eb0a446d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.support + +import java.sql.Timestamp +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +class H2MysqlDateFunctions { + companion object { + @JvmStatic + fun convertTz(value: Timestamp?, fromTz: String?, toTz: String?): Timestamp? { + if (value == null || fromTz == null || toTz == null) { + return value + } + + val fromZoneId = ZoneId.of(fromTz) + val toZoneId = ZoneId.of(toTz) + val converted = value.toLocalDateTime().atZone(fromZoneId).withZoneSameInstant(toZoneId).toLocalDateTime() + + return Timestamp.valueOf(converted) + } + + @JvmStatic + fun dateFormat(value: Timestamp?, pattern: String?): String? { + if (value == null || pattern == null) { + return null + } + + val javaPattern = pattern + .replace("%Y", "yyyy") + .replace("%m", "MM") + .replace("%d", "dd") + + return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern)) + } + } +}