Compare commits
43 Commits
test
...
5970cf67a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 5970cf67a7 | |||
| 1682c3ef8a | |||
| 23cbdb4782 | |||
| 83159713de | |||
| 8ce98012ad | |||
| c4213e93b6 | |||
| 57235e5a39 | |||
| 61d79709c9 | |||
| 96ce390e6f | |||
| 3a32d1a37e | |||
| ed865a192f | |||
| a7e06f278f | |||
| 9fccf51e4a | |||
| c270e3c3af | |||
| a646283cef | |||
| a20f4d37ab | |||
| c452f0070d | |||
| 431073ee14 | |||
| 13389cd558 | |||
| 472130b16b | |||
| e3b131268b | |||
| 931d6a5fef | |||
| e248a674c0 | |||
| e1f0347ca5 | |||
| 80f299eb1a | |||
| 31aadd9e83 | |||
| f464f02567 | |||
| e545fd493e | |||
| d195fea311 | |||
| 6aada583c6 | |||
| dfaacd068f | |||
| 7c85ec93fc | |||
| 8b5872ce86 | |||
| 6499bacc8d | |||
| f8dd068fb1 | |||
| ef1580cbb8 | |||
| ec10bc1e58 | |||
| f416d0169d | |||
| 8ba29a3719 | |||
| 9a8172ab8b | |||
| eecff03d4b | |||
| 327471f4fc | |||
| 35b939b951 |
285
.opencode/package-lock.json
generated
285
.opencode/package-lock.json
generated
@@ -5,40 +5,129 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.4.0"
|
||||
"@opencode-ai/plugin": "1.15.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.0.tgz",
|
||||
"integrity": "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg==",
|
||||
"version": "1.15.12",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.12.tgz",
|
||||
"integrity": "sha512-BBteGXEwJt+1ehHqQ+yKXmoWltrW+2xO++B1Fm/dnMGYWT9luEKA5RlUuVYA2qDF6uwlE7kmHZvQZAM79zWHEA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.4.0",
|
||||
"@opencode-ai/sdk": "1.15.12",
|
||||
"effect": "4.0.0-beta.66",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/keymap": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.0.tgz",
|
||||
"integrity": "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw==",
|
||||
"version": "1.15.12",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.12.tgz",
|
||||
"integrity": "sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -53,12 +142,135 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.66",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.66.tgz",
|
||||
"integrity": "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz",
|
||||
"integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.12",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
|
||||
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -68,6 +280,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -89,6 +317,28 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -104,6 +354,21 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
|
||||
60
docs/20260529_메인_홈_추천_API/alter-existing-tables.sql
Normal file
60
docs/20260529_메인_홈_추천_API/alter-existing-tables.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Phase 5: 추천 크리에이터 동시 팔로우 중복 방지 운영 DB 반영 SQL
|
||||
-- 목적: creator_following 테이블의 동일 회원/크리에이터 중복 row를 정리하고 유니크 제약을 추가한다.
|
||||
-- 주의: 운영 반영 전 아래 중복 조회 결과를 검토하고, 삭제 대상 row가 운영 정책상 보존 대상인지 확인한다.
|
||||
|
||||
-- 1. 중복 데이터 사전 점검
|
||||
select
|
||||
member_id,
|
||||
creator_id,
|
||||
count(*) as duplicate_count,
|
||||
group_concat(id order by id asc) as duplicate_ids
|
||||
from creator_following
|
||||
group by member_id, creator_id
|
||||
having count(*) > 1;
|
||||
|
||||
-- 2. 중복 row 정리
|
||||
-- 동일 member_id/creator_id 조합에서 가장 작은 id 1개만 유지한다.
|
||||
-- 유지 row는 중복 row 중 하나라도 활성 상태였으면 활성 상태로 보정한다.
|
||||
update creator_following keep_cf
|
||||
join (
|
||||
select
|
||||
member_id,
|
||||
creator_id,
|
||||
min(id) as keep_id,
|
||||
max(case when is_active = true then 1 else 0 end) as any_active,
|
||||
max(case when is_notify = true then 1 else 0 end) as any_notify
|
||||
from creator_following
|
||||
group by member_id, creator_id
|
||||
having count(*) > 1
|
||||
) duplicate_cf on keep_cf.id = duplicate_cf.keep_id
|
||||
set
|
||||
keep_cf.is_active = duplicate_cf.any_active = 1,
|
||||
keep_cf.is_notify = duplicate_cf.any_notify = 1;
|
||||
|
||||
delete duplicate_cf
|
||||
from creator_following duplicate_cf
|
||||
join (
|
||||
select
|
||||
member_id,
|
||||
creator_id,
|
||||
min(id) as keep_id
|
||||
from creator_following
|
||||
group by member_id, creator_id
|
||||
having count(*) > 1
|
||||
) keep_cf on duplicate_cf.member_id = keep_cf.member_id
|
||||
and duplicate_cf.creator_id = keep_cf.creator_id
|
||||
and duplicate_cf.id <> keep_cf.keep_id;
|
||||
|
||||
-- 3. 중복 정리 결과 재확인: 결과가 없어야 한다.
|
||||
select
|
||||
member_id,
|
||||
creator_id,
|
||||
count(*) as duplicate_count,
|
||||
group_concat(id order by id asc) as duplicate_ids
|
||||
from creator_following
|
||||
group by member_id, creator_id
|
||||
having count(*) > 1;
|
||||
|
||||
-- 4. 유니크 제약 추가
|
||||
alter table creator_following
|
||||
add constraint uk_creator_following_member_creator unique (member_id, creator_id);
|
||||
27
docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql
Normal file
27
docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
create table recommendation_snapshot (
|
||||
id bigint not null auto_increment comment 'ID',
|
||||
section_type varchar(50) not null comment '섹션 타입',
|
||||
target_id bigint not null comment '대상 ID',
|
||||
score double not null comment '점수',
|
||||
snapshot_at TIMESTAMP not null comment '스냅샷 시각',
|
||||
random_tie_breaker double 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),
|
||||
index idx_recommendation_snapshot_latest (section_type, snapshot_at, score, random_tie_breaker),
|
||||
index idx_recommendation_snapshot_target (section_type, target_id)
|
||||
) comment '추천 스냅샷';
|
||||
|
||||
create table creator_content_view_history (
|
||||
id bigint not null auto_increment comment 'ID',
|
||||
member_id bigint not null comment '회원 ID',
|
||||
content_id bigint not null comment '콘텐츠 ID',
|
||||
genre_id bigint not null comment '장르 ID',
|
||||
viewed_at TIMESTAMP 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),
|
||||
index idx_creator_content_view_history_member_viewed (member_id, viewed_at),
|
||||
index idx_creator_content_view_history_content (content_id),
|
||||
index idx_creator_content_view_history_genre (genre_id)
|
||||
) comment '크리에이터 콘텐츠 시청 이력';
|
||||
591
docs/20260529_메인_홈_추천_API/plan-task.md
Normal file
591
docs/20260529_메인_홈_추천_API/plan-task.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# 메인 홈 추천 API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||
|
||||
**Goal:** `/api/v2/home/recommendations` 하위에 메인 홈 추천 통합 조회, 섹션별 전체보기, 콘텐츠 조회 이력 기록, 추천 크리에이터 동시 팔로우 API를 제공한다.
|
||||
|
||||
**Architecture:** 공개 API 조립은 `kr.co.vividnext.sodalive.v2.api.home`에 두고, 추천 정책/점수/스냅샷/조회 이력/팔로우 기능은 `kr.co.vividnext.sodalive.v2.recommend`에 둔다. `v2.api.home`은 `v2.recommend`의 application use case만 호출하며, `v2.recommend`는 API DTO에 의존하지 않는다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL, native SQL, JUnit 5, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
## 0. 구현 전 확정 사항
|
||||
|
||||
- 통합 조회: `GET /api/v2/home/recommendations`
|
||||
- 전체보기 조회:
|
||||
- `GET /api/v2/home/recommendations/lives`
|
||||
- `GET /api/v2/home/recommendations/debut-creators`
|
||||
- `GET /api/v2/home/recommendations/first-audio-contents`
|
||||
- `GET /api/v2/home/recommendations/ai-characters`
|
||||
- 추천 크리에이터 동시 팔로우: `POST /api/v2/home/recommendations/creators/follow`
|
||||
- 요청에는 `creatorIds`만 포함한다.
|
||||
- 장르의 크리에이터와 최근 응원이 많은 크리에이터는 동일한 id 리스트 검증/팔로우 저장 로직을 사용한다.
|
||||
- 페이징 방식: 기존 Spring `Pageable`을 우선 사용하고 응답에는 `items`, `page`, `size`, `hasNext`를 포함한다.
|
||||
- 시간 응답: `LocalDateTime` 저장값을 기존 관례처럼 KST 기준 저장값으로 보고 UTC ISO 문자열(`...Z`)로 변환한다.
|
||||
- 스냅샷 일 배치는 KST 매일 06:00:00에 실행하고, 스냅샷 기준 시각은 전날 23:59:59 KST 의미를 코드에서 명확히 계산한다. 스케줄러는 `@Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul")`로 등록한다.
|
||||
- 저장소에는 DB migration 디렉터리가 없으므로 신규 스냅샷/조회 이력 엔티티 추가 시 운영 DB DDL 반영은 배포 절차에서 별도 수행한다. 코드 구현 task에는 JPA 엔티티/리포지토리와 통합 테스트를 포함하고, Phase 7 완료 후 신규 엔티티 테이블 생성 SQL을 문서 산출물로 작성한다.
|
||||
- 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. 단순 조회/상세 조립/대상 활성 조건은 JPA 또는 QueryDSL로 표현하고, CTE/window function/`union all`/DB-side exact scoring처럼 SQL 고급 기능이 필요한 추천 산정에만 native SQL을 사용한다. native SQL 사용 시에는 H2 MySQL mode와 Kotlin 정책 산식 parity를 포함한 repository 통합 테스트를 반드시 둔다.
|
||||
- 이번 범위에서는 기존 홈/콘텐츠 홈/라이브/AI 캐릭터 API의 공개 스키마를 변경하지 않고, 앱 다국어 문구 번역, ML 개인화, A/B 테스트 플랫폼, 관리자 화면, 추천 결과 수동 편집 기능은 구현하지 않는다. 응답 enum은 앱 다국어 처리를 위해 안정적인 영문 code로 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
|
||||
### 신규 API 조립 계층
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponse.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationPageResponse.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/FollowRecommendedCreatorsRequest.kt`
|
||||
|
||||
### 신규 추천 기능 계층
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicy.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/CreatorDebutPolicy.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendedActivityType.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendedSectionType.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/CreatorContentViewHistoryPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/RecommendationSnapshotPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/CreatorContentViewHistory.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/CreatorContentViewHistoryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshot.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
|
||||
|
||||
### 기존 코드 연결
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- 콘텐츠 상세 조회 성공 시 `CreatorContentViewHistoryService.recordView(...)`를 호출한다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt`
|
||||
- 기존 공개 스키마는 유지하고 인증 회원 정보를 서비스로 전달하는 기존 흐름만 활용한다.
|
||||
|
||||
### 테스트
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicyTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/CreatorDebutPolicyTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryServiceTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: 도메인 정책과 공통 모델
|
||||
|
||||
- [x] **Task 1.1: 추천 점수/신규 부스트 정책 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicy.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicyTest.kt`
|
||||
- RED: `shouldApplyCreatorNewBoostByDebutDays`, `shouldApplyAiCharacterNewBoostByCreatedDays`, `shouldCalculateDebutCreatorScore`, `shouldCalculateAiChatScore`, `shouldCalculateCheerScore`, `shouldCalculateCommunityScore`, `shouldCalculateFirstAudioRecencyScore` 테스트를 먼저 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScorePolicyTest`
|
||||
- GREEN: PRD 산식과 부스트 값을 그대로 구현한다. AI 캐릭터 신규 부스트는 캐릭터 생성일 기준 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다. 첫 오디오 최신성 점수는 `release_date` 기준 3일 이내 100, 7일 이내 80, 14일 이내 60, 21일 이내 40, 30일 이내 20을 적용한다.
|
||||
- REFACTOR: 산식별 public 함수명과 파라미터가 PRD 용어를 반영하는지 정리한다.
|
||||
- 기대 결과: 모든 산식/부스트/최신성 점수 테스트가 PASS이고 소수 계산 오차는 `assertEquals(expected, actual, 0.0001)` 범위 안에 들어간다.
|
||||
|
||||
- [x] **Task 1.2: 데뷔일/신규 크리에이터 판정 정책 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/CreatorDebutPolicy.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/CreatorDebutPolicyTest.kt`
|
||||
- RED: 첫 공개 콘텐츠 일시와 첫 라이브 일시 중 빠른 값을 데뷔일로 선택하는 테스트, 데뷔 후 30일 이내만 true인 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.domain.CreatorDebutPolicyTest`
|
||||
- GREEN: `resolveDebutAt(firstContentPublishedAt, firstLiveAt)`와 `isNewCreator(debutAt, now)`를 구현한다.
|
||||
- REFACTOR: 기존 `ExplorerService.getCreatorDetail`의 `debutDateTime` 계산과 비교해 의미가 어긋나지 않는지 확인한다.
|
||||
- 기대 결과: 콘텐츠만 있는 경우, 라이브만 있는 경우, 둘 다 있는 경우, 둘 다 없는 경우가 모두 명확히 검증된다.
|
||||
|
||||
- [x] **Task 1.3: 섹션/활동 enum과 내부 응답 모델 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendedActivityType.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendedSectionType.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- RED: `LIVE_REPLAY` 테마 콘텐츠가 `AUDIO`가 아니라 `LIVE_REPLAY`로 분류되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest`
|
||||
- GREEN: 내부 모델에 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` enum을 추가하고 활동 분류 함수를 구현한다.
|
||||
- REFACTOR: enum 값은 앱 다국어 처리를 위해 영문 code와 동일하게 유지한다.
|
||||
- 기대 결과: 활동 타입 응답 문자열이 PRD의 enum 후보와 일치한다.
|
||||
|
||||
### Phase 2: 스냅샷 엔티티와 일 1회 집계 작업
|
||||
|
||||
- [x] **Task 2.1: 추천 스냅샷 엔티티/리포지토리 추가**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/RecommendationSnapshotPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshot.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: 섹션 타입, 대상 id, 점수, 기준 시각, 랜덤 tie-breaker를 저장하고 기준 시각별 최신 스냅샷만 읽는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: `RecommendationSnapshot` JPA 엔티티와 `findTop...`, `deleteBySectionTypeAndSnapshotAt` 계열 리포지토리 메서드를 구현하고, application service가 의존할 `RecommendationSnapshotPort`를 둔다.
|
||||
- REFACTOR: 스냅샷 조회가 없으면 빈 배열을 반환하도록 service 경계에서 처리한다.
|
||||
- 기대 결과: 스냅샷 없음이 예외가 아니라 빈 결과로 검증된다.
|
||||
|
||||
- [x] **Task 2.2: 스냅샷 갱신 서비스 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: AI 캐릭터, 최근 응원, 인기 커뮤니티 점수를 전날 23:59:59 기준으로 생성하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: 최근 7일 집계와 신규 부스트를 적용해 `AI_CHARACTER`, `CHEER_CREATOR`, `POPULAR_COMMUNITY` 스냅샷을 저장한다. AI 캐릭터의 `followIncrease`는 팔로우 대상/관계 정의가 확정되지 않아 이번 스프린트에서 제외하고 0으로 집계한다.
|
||||
- REFACTOR: 무거운 QueryDSL 집계는 repository에 두고 점수 산식은 `RecommendationScorePolicy`만 사용한다.
|
||||
- 기대 결과: 동일 점수 항목은 `randomTieBreaker`가 저장되어 조회 시 랜덤 tie-breaker 정렬에 사용할 수 있다.
|
||||
|
||||
- [x] **Task 2.3: 매일 06:00 KST 스케줄러 추가**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: KST 매일 06:00:00 cron과 `Asia/Seoul` zone이 선언되는지 reflection 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: 스케줄러가 KST 06:00:00 cron으로 `RecommendationSnapshotRefreshService.refreshDailySnapshots()`를 호출하도록 구현한다.
|
||||
- REFACTOR: 스케줄러에는 집계 로직을 두지 않고 호출만 남긴다.
|
||||
- 기대 결과: KST 매일 06:00에 전날 23:59:59 KST 기준 집계가 실행되는 계약이 테스트로 고정된다.
|
||||
|
||||
- [x] **Task 2.4: Phase 2 스냅샷 집계 통합 검증과 경계 보강**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/RecommendationSnapshotPort.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: QueryDSL 집계 통합 테스트를 추가해 AI 캐릭터 최근 채팅 수/활성 사용자 수, 최근 응원 `CHANNEL_DONATION` 후원 금액/후원 수와 팬 Talk 수, 인기 커뮤니티 좋아요/댓글/팔로워 수가 Phase 2 요구와 일치하는지 검증한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: 스케줄러 cron을 KST 06:00:00 `Asia/Seoul` zone으로 수정하고, 최근 응원 후원 금액/후원 수는 `CanUsage.CHANNEL_DONATION`만 집계한다.
|
||||
- REFACTOR: `RecommendationSnapshotPort`가 persistence entity를 직접 노출하지 않도록 application/domain 경계 DTO 또는 모델을 도입해 `port.out` 의존 경계를 정리한다.
|
||||
- 기대 결과: Phase 2 집계 의미가 DB 기반 테스트로 고정되고, 스케줄러 timezone 계약과 `port.out` 경계 정리가 문서/테스트/구현에 함께 반영된다.
|
||||
|
||||
- [x] **Task 2.5: 크리에이터 신규 부스트 실제 데뷔일 적용**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: 최근 응원/인기 커뮤니티 신규 부스트가 단순 `Member.createdAt`이 아니라 실제 데뷔일을 사용하도록 실패 테스트를 추가한다. 실제 데뷔일은 첫 공개 콘텐츠 일시와 첫 라이브 일시 중 빠른 값이며, 둘 다 없는 경우는 스냅샷 후보에서 제외되는 실패 테스트를 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: 최근 응원/인기 커뮤니티 후보 DTO가 실제 데뷔일을 담도록 QueryDSL 집계를 수정하고, service는 신규 부스트 계산 시 해당 데뷔일만 사용한다.
|
||||
- REFACTOR: 데뷔일 의미는 `CreatorDebutPolicy.resolveDebutAt(...)`과 일치하도록 중복 계산을 최소화한다.
|
||||
- 기대 결과: 최근 응원/인기 커뮤니티 신규 부스트가 `Member.createdAt`이 아니라 실제 데뷔일 기준으로 계산된다.
|
||||
|
||||
- [x] **Task 2.6: AI 캐릭터 최근 채팅 수를 AI 발화 수로 고정**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- RED: AI 캐릭터 최근 채팅 수가 최근 7일 안에 해당 AI 캐릭터가 발화한 채팅 메시지 수만 세도록 실패 테스트를 추가한다. 사용자 메시지, 다른 캐릭터 메시지, 비활성 메시지, 기간 밖 메시지는 제외되는지 fixture로 검증한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||
- GREEN: QueryDSL where/join 조건을 보강해 `recentChatCount`가 AI 발화 메시지 수만 반환하도록 구현한다.
|
||||
- REFACTOR: 테스트 이름과 후보 DTO 필드 설명이 PRD의 "AI가 발화한 채팅 수" 의미를 드러내도록 정리한다.
|
||||
- 기대 결과: AI 캐릭터 추천 점수의 `최근 발생한 AI 채팅 수` 입력값이 AI 발화 수로 고정된다.
|
||||
|
||||
- [x] **Task 2.7: AI 캐릭터 채팅 활성 사용자 수를 중복 없는 채팅 사용자 수로 고정**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 최근 활성 사용자 수가 최근 7일 안에 해당 AI 캐릭터와 1회 이상 채팅한 중복 없는 사용자 수로 계산되도록 실패 테스트를 추가한다. 같은 사용자의 다중 메시지는 1명으로 세고, 다른 캐릭터와만 채팅한 사용자는 제외한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||
- GREEN: QueryDSL 집계가 캐릭터별 distinct 사용자 수를 반환하도록 구현한다.
|
||||
- REFACTOR: 활성 사용자 수 집계는 Task 2.6의 AI 발화 수 집계와 의미가 섞이지 않도록 별도 테스트 케이스로 유지한다.
|
||||
- 기대 결과: AI 캐릭터 추천 점수의 `최근 활성 사용자 수` 입력값이 중복 없는 채팅 사용자 수로 고정된다.
|
||||
|
||||
- [x] **Task 2.8: 스냅샷 최종 저장 수를 점수순으로 제한**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 스냅샷 저장 결과가 최종 점수 내림차순과 `randomTieBreaker` 기준으로 AI 캐릭터 최대 20개, 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개만 저장되는 실패 테스트를 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||
- GREEN: repository 조회에서 최종 점수와 `randomTieBreaker`를 계산하고, 점수 정렬 이후 동점자 랜덤 노출 여지를 위한 섹션별 최종 저장 수를 적용한다. service는 기준 시각 계산과 snapshot replace만 담당한다.
|
||||
- REFACTOR: `GENRE_CREATOR`는 Phase 2 스냅샷 갱신 대상이 아니라 Task 4.2의 조회 이력 기반 추천임을 문서/테스트 경계로 유지한다.
|
||||
- 기대 결과: application/service가 전체 후보를 메모리로 불러와 점수를 계산하지 않고, DB에서 정확한 최종 top 후보를 동점자 랜덤 정렬까지 반영해 반환하고 저장한다.
|
||||
|
||||
- [x] **Task 2.9: DB-side exact scoring으로 스냅샷 후보 산정 전환**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScoreSpec.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicy.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicyTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: `RecommendationScoreSpec` 공유 산식과 DB-scored snapshot 조회 계약이 없으면 컴파일/테스트가 실패하도록 테스트를 작성한다. native SQL을 사용하는 쿼리는 Kotlin `RecommendationScorePolicy` 기대값과 DB score를 비교하고, 부스트 경계일, null aggregate, 비활성/제외 row, `score desc, randomTieBreaker asc` 정렬, 최종 점수 계산 이후 limit 적용, H2 MySQL mode parameter binding 호환성을 함께 검증한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: DB 조회에서 모든 적격 후보의 최종 score와 `randomTieBreaker`를 계산한 뒤 `score desc, randomTieBreaker asc` 정렬과 섹션별 최종 limit을 적용한다. service는 기준 시각 계산과 snapshot replace만 담당하고 Kotlin-side score 재계산과 service-side limit을 제거한다.
|
||||
- REFACTOR: DB score expression과 Kotlin `RecommendationScorePolicy`가 `RecommendationScoreSpec`의 가중치/부스트 구간 상수를 공유하도록 정리하고, 최근 응원/인기 커뮤니티 집계는 aggregate CTE 기반으로 중복 계산을 줄인다.
|
||||
- 기대 결과: candidate pre-limit 없이 DB에서 정확한 최종 top 후보를 산정하고, 20/16/20 저장 상한은 최종 점수 계산과 동점 랜덤 정렬 이후 적용되는 저장 limit으로만 유지된다.
|
||||
|
||||
### Phase 3: 추천 조회 repository와 application service
|
||||
|
||||
- [x] **Task 3.1: 라이브/배너/활동 크리에이터 조회 구현**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 라이브 최신순 20개, 활성 배너 orders 정렬 최대 20개, 동일 `orders` 배너의 랜덤 tie-breaker 정렬, 크리에이터당 최신 활동 1개만 반환하는 테스트를 작성한다. 라이브 노출 정보는 크리에이터 닉네임/프로필 이미지/라이브 번호를 포함하고, 활동 크리에이터 노출 정보는 크리에이터 프로필 이미지/닉네임/활동 타입/UTC 활동 시간/이동 대상 id를 포함하며 라이브 활동의 이동 대상 id는 nullable임을 검증한다. 배너는 비활성 이벤트 대상 `EVENT`, 비활성 크리에이터 대상 `CREATOR`, 비활성 시리즈 대상 `SERIES`, 비활성 시리즈 소유 회원 대상 `SERIES`가 제외되고, `LINK` 배너는 별도 대상 엔티티 검증 없이 배너 자체 활성 상태만으로 노출되는 repository 테스트를 함께 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||
- GREEN: `LiveRoom`, `AudioContentBanner`, `AudioContent`, `CreatorCommunity` 기반 QueryDSL 조회를 구현한다. application service는 `HomeRecommendationQueryPort`에만 의존하고 persistence 구현체가 port를 구현한다. 배너는 기존 콘텐츠 홈 배너의 앱 이동 필드를 유지하고, 동일 `orders` 값은 후보군 축소 후 랜덤화하거나 랜덤 tie-breaker를 적용한다. 배너 대상 활성 조건은 service 후처리가 아니라 repository 조회 조건으로 고정한다. 활동 타입 enum 값은 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` 영문 code 그대로 유지한다.
|
||||
- REFACTOR: 차단 관계, 비활성 회원, 비활성 콘텐츠/배너 제외 조건을 공통 private 조건 함수로 정리한다. 단순 조회와 대상 활성 조건은 QueryDSL/JPA 우선으로 표현하고, native SQL은 SQL 고급 기능이 필요한 쿼리에만 남긴다.
|
||||
- 기대 결과: 특정 섹션 데이터가 부족해도 service가 가능한 개수만 반환한다.
|
||||
|
||||
- [x] **Task 3.2: 최근 데뷔/첫 오디오 콘텐츠 조회 구현**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- RED: 데뷔 후 30일 이내 추천 점수순, 최근 데뷔 크리에이터 노출 정보의 프로필 이미지/닉네임, 첫 오디오 콘텐츠 3번째 이내 활성 콘텐츠만 인정, 최신성 점수 구간별 정렬, 예약 공개 콘텐츠 제외 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest`
|
||||
- GREEN: 데뷔일 계산, 최근 7일/30일 집계, `release_date` 기준 최신성 점수, 동점 랜덤 정렬을 구현한다.
|
||||
- REFACTOR: 데뷔일 계산은 `CreatorDebutPolicy`, 산식은 `RecommendationScorePolicy`만 호출하도록 중복 제거한다.
|
||||
- 기대 결과: 앞선 비활성 콘텐츠가 3개 이상이면 이후 활성 콘텐츠가 제외된다.
|
||||
|
||||
- [x] **Task 3.3: AI 캐릭터/응원/인기 커뮤니티 스냅샷 조회 구현**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- RED: 스냅샷 기준 AI 캐릭터 10개, AI 캐릭터 응답의 캐릭터 이름/소개/전체 채팅 수/오리지널 작품명 조건, 최근 응원 8명과 크리에이터 프로필 이미지/닉네임, 인기 커뮤니티 10개와 크리에이터 프로필 이미지/닉네임/UTC 시간/좋아요 수/댓글 수/커뮤니티 내용, 스냅샷 없음 빈 배열 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest`
|
||||
- GREEN: `RecommendationSnapshotRepository`에서 최신 스냅샷을 읽고 대상 엔티티 상세 정보를 조립한다. AI 캐릭터 작품명은 오리지널 작품 캐릭터인 경우에만 채우고, 인기 커뮤니티는 스냅샷에 저장된 점수/랜덤 tie-breaker 순서를 유지한다.
|
||||
- REFACTOR: 비활성/노출 제한 캐릭터, 커뮤니티 비공개/유료/핀/성인 조건을 repository 조건으로 고정한다.
|
||||
- 기대 결과: AI 캐릭터 노출 필드가 PRD와 일치하고, 인기 커뮤니티는 크리에이터당 1개만 반환하며 동일 점수는 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출된다.
|
||||
|
||||
### Phase 4: 콘텐츠 조회 이력 기록
|
||||
|
||||
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/CreatorContentViewHistoryPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/CreatorContentViewHistory.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/CreatorContentViewHistoryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryServiceTest.kt`
|
||||
- RED: 인증 회원의 콘텐츠 상세 진입 시 memberId/contentId/genreId/viewedAt이 저장되는 테스트와 비회원은 저장하지 않는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.CreatorContentViewHistoryServiceTest`
|
||||
- GREEN: 이력 저장 service와 repository를 구현한다. application service는 `CreatorContentViewHistoryPort`에만 의존하고 persistence 구현체가 port를 구현한다.
|
||||
- REFACTOR: 동일 회원/콘텐츠의 연속 중복 저장 허용 여부는 추천 이력으로 보존하며, 집계 시 distinct 장르 기준으로 처리한다.
|
||||
- 기대 결과: 조회 이력 저장 실패가 콘텐츠 상세 조회 자체를 실패시키지 않도록 호출부에서 예외 전파 범위를 제한한다.
|
||||
|
||||
- [x] **Task 4.2: 장르 기반 크리에이터 추천 조회 구현**
|
||||
- 선행 조건: Task 4.1의 `CreatorContentViewHistory` 엔티티/리포지토리/저장 service가 준비되어 있어야 한다.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- RED: 조회 이력 콘텐츠의 `content_theme` 기준 랜덤 5개, 부족분 랜덤 보충, 테마별 8명, 한 응답의 5개 테마 안에서 크리에이터 중복 제거, 서로 다른 조회 시점에서는 같은 크리에이터 재노출 허용, 팔로우 크리에이터 제외, 활성 크리에이터/활성 콘텐츠가 없어 빈 그룹이 되는 테마 제외, 크리에이터 프로필 이미지/닉네임/id 노출 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest`
|
||||
- GREEN: `CreatorContentViewHistory.contentId`와 `content.theme_id` 매핑을 기반으로 후보 테마/크리에이터를 조회한다. 기존 응답 필드명은 공개 스키마 호환을 위해 `genreId`, `genreName`을 유지하되 값은 `content_theme.id`, `content_theme.theme`을 담는다.
|
||||
- REFACTOR: 성인 콘텐츠 테마는 `MemberContentPreference.isAdultContentVisible == true` 회원에게만 포함되도록 조건을 공통화한다.
|
||||
- 기대 결과: 비회원 또는 조회 이력 없는 회원도 조회 가능한 테마 중 랜덤 5개를 받고, 활성 크리에이터/활성 콘텐츠가 없는 빈 그룹은 제외한 뒤 다른 테마로 보충된다.
|
||||
|
||||
- [x] **Task 4.3: 기존 콘텐츠 상세 조회 흐름에 이력 기록 연결**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- RED: `getDetail` 성공 시 `CreatorContentViewHistoryService.recordView(...)`가 호출되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest`
|
||||
- GREEN: `AudioContentService` 생성자에 optional하지 않은 신규 service 의존성을 추가하고 상세 조회 성공 지점에서 기록한다.
|
||||
- REFACTOR: 기존 `GetAudioContentDetailResponse` 스키마와 Controller URL/응답은 변경하지 않는다.
|
||||
- 기대 결과: 기존 상세 조회 테스트가 모두 통과하고 응답 JSON 필드가 바뀌지 않는다.
|
||||
|
||||
### Phase 5: 추천 크리에이터 동시 팔로우
|
||||
|
||||
- [x] **Task 5.1: 팔로우 use case 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt`
|
||||
- RED: mock 없이 실제 Spring/JPA 흐름으로 신규 팔로우 id와 이미 팔로우/본인 id 등 제외 id를 구분하는 테스트, 존재하지 않는 id/크리에이터가 아닌 id 포함 시 전체 실패 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowServiceTest`
|
||||
- GREEN: `MemberRepository`, `CreatorFollowingRepository`를 사용해 전체 입력을 검증하고, 이미 팔로우 중인 id와 본인 id는 서버 내부에서 제외하며 신규 팔로우만 저장한다. 과거 언팔로우로 비활성화된 팔로우 이력은 신규 row를 만들지 않고 다시 활성화한다.
|
||||
- REFACTOR: 섹션별 분기 없이 팔로우 처리 로직은 `followCreators(member, creatorIds)` 하나로 유지한다.
|
||||
- 기대 결과: 존재하지 않는 id 또는 크리에이터가 아닌 id가 하나라도 있으면 신규 저장이 발생하지 않고, 이미 팔로우 중인 id와 본인 id는 실패가 아니라 서버 내부 제외 대상으로 처리된다. 동일 회원과 동일 크리에이터의 팔로우 row는 중복 저장되지 않는다.
|
||||
|
||||
- [x] **Task 5.2: 팔로우 API DTO/Controller 연결**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/FollowRecommendedCreatorsRequest.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- RED: mock 없이 `@SpringBootTest`와 실제 repository를 사용해 비로그인 요청은 Spring Security에서 거부되고, 로그인 요청은 `creatorIds`를 service에 전달해 신규 팔로우만 저장하며 결과를 `ApiResponse.ok`로 반환하는 controller 테스트를 작성한다. `creatorIds` null/empty/50개 초과 요청은 실패하고 신규 저장하지 않는 테스트를 포함한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: `POST /api/v2/home/recommendations/creators/follow`를 구현한다.
|
||||
- REFACTOR: request id 리스트가 null/empty이거나 50개를 초과하면 `SodaException`으로 거부한다.
|
||||
- 기대 결과: 클라이언트 응답은 성공/실패 여부만 제공하고, 신규 팔로우 id와 제외 id 목록은 공개 응답에 포함하지 않는다.
|
||||
|
||||
- [x] **Task 5.3: 기존 팔로우 테이블 유니크 제약 운영 반영 문서화**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt`
|
||||
- Create: `docs/20260529_메인_홈_추천_API/alter-existing-tables.sql`
|
||||
- TDD 예외 사유: 운영 DB 반영 SQL 문서 산출물 작성 task라 제품 코드 테스트를 새로 작성하지 않는다.
|
||||
- 대체 검증 방법:
|
||||
- `rg -n "uk_creator_following_member_creator|creator_following|duplicate_count|ALTER TABLE|alter table" docs/20260529_메인_홈_추천_API/alter-existing-tables.sql src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt`
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: 동일 회원과 동일 크리에이터의 팔로우 row를 중복 저장하지 않도록 `creator_following(member_id, creator_id)` 유니크 제약을 JPA entity에 명시하고, 운영 DB 반영 전 중복 데이터 점검/정리 및 `ALTER TABLE` 절차를 문서화한다.
|
||||
- 기대 결과: 테스트 H2 schema와 운영 DB 반영 절차가 같은 유니크 제약명 `uk_creator_following_member_creator`를 사용하며, 기존 중복 row가 있어도 배포 전 정리 절차를 검토할 수 있다.
|
||||
|
||||
### Phase 6: 홈 통합/전체보기 API
|
||||
|
||||
- [x] **Task 6.1: 홈 통합 응답 DTO와 facade 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponse.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- RED: 통합 조회가 섹션별 기본 limit(20/20/10/10/10/10/5x8/8/10)을 service에 전달하고, 인증 회원의 팔로우 제외/콘텐츠 조회 이력/본인인증 여부를 service 조건으로 전달하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: facade가 `HomeRecommendationQueryService` 결과를 API DTO로 변환한다. 인증 회원이면 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부 조건을 조회 context에 포함하고 비회원이면 회원 의존 조건을 제외한다.
|
||||
- REFACTOR: API DTO에는 앱 이동 대상 id가 없는 라이브 활동의 target id를 nullable로 둔다.
|
||||
- 기대 결과: 특정 섹션이 빈 배열이어도 통합 조회는 성공 응답이다.
|
||||
|
||||
- [x] **Task 6.2: 홈 통합 Controller 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- RED: `GET /api/v2/home/recommendations`가 인증 회원/비회원 모두 호출 가능하고 `ApiResponse.ok`를 반환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴으로 구현한다.
|
||||
- REFACTOR: controller에는 인증 null 허용과 request parameter 전달 외 로직을 두지 않는다.
|
||||
- 기대 결과: 비회원은 회원 의존 조건 없이 기본 추천을 받는다.
|
||||
|
||||
- [x] **Task 6.3: 커뮤니티를 제외한 섹션별 전체보기 API 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationPageResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- RED: 라이브/최근 데뷔/첫 오디오/AI 캐릭터 전체보기 endpoint가 `page`, `size`를 전달하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: 확정 URL 4개를 controller에 추가하고 `HomeRecommendationPageResponse`로 반환한다.
|
||||
- REFACTOR: size 기본값은 홈 기본 노출 수와 분리해 `20`으로 두고 최대값은 `50`으로 제한한다.
|
||||
- 기대 결과: 커뮤니티를 제외한 전체보기 API가 같은 페이징 응답 형식을 사용한다.
|
||||
|
||||
- [x] **Task 6.4: Phase 6 리뷰 보완과 인증/성인 노출 경계 수정**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- RED: 홈 통합 조회는 비회원 호출을 유지하지만 라이브/최근 데뷔/첫 오디오/AI 캐릭터 전체보기는 비회원 요청을 거부하는 테스트, 음수 `page`가 런타임 예외를 만들지 않는 테스트를 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: Security `permitAll`은 통합 조회 GET만 유지하고 전체보기 GET은 인증 대상이 되도록 정리한다. controller는 전체보기 요청에서 `member == null`이면 `SodaException(common.error.bad_credentials)`로 거부하고, `page < 0`은 0으로 보정한다. 성인 노출 여부는 단순 `member.auth != null` 대신 `MemberContentPreferenceService.initializeDefaultPreference(member).isAdultContentVisible`와 기존 `isAdultVisibleByPolicy(...)`를 사용한다.
|
||||
- REFACTOR: 공개 응답 스키마와 기존 follow API 동작은 변경하지 않는다.
|
||||
- 기대 결과: 홈 통합 API는 비회원 조회 가능, 세부 전체보기 API는 회원만 조회 가능하며 성인 노출 정책과 page 경계가 기존 프로젝트 관례와 일치한다.
|
||||
|
||||
- [x] **Task 6.5: 섹션 전체보기 성인 노출 정책 전파 보완**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- RED: 남은 섹션 전체보기 요청에서 controller가 인증 회원을 facade에 전달하고, 홈 통합 조회 응답도 같은 회원 성인 노출 정책을 사용하는 실패 테스트를 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: 라이브/최근 데뷔/첫 오디오/AI 캐릭터 전체보기 controller가 인증 회원을 facade에 전달하고, facade는 홈 통합 조회와 전체보기에서 같은 `MemberContentPreferenceService.initializeDefaultPreference(member).isAdultContentVisible`와 `isAdultVisibleByPolicy(...)` 기준으로 회원별 성인 노출 여부를 계산한다.
|
||||
- REFACTOR: 성인 노출 계산이 홈 통합 조회와 전체보기에서 서로 다른 의미로 분기되지 않도록 facade 내부 private 함수로만 정리한다.
|
||||
- 기대 결과: 홈 통합 조회와 남은 섹션 전체보기 모두 동일한 회원 성인 노출 정책을 사용하며, 커뮤니티 전체보기 구현 없이 회원 설정 기반 노출 여부가 일관되게 적용된다.
|
||||
|
||||
- [x] **Task 6.6: 전체보기 DB 레벨 페이징과 실제 데이터 페이징 테스트 보강**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- RED: facade 메모리 `drop/take` 방식으로는 실제 DB 데이터에서 `page`, `size`, `hasNext`가 정확히 보장되지 않는 실패 테스트를 추가하고, 라이브/최근 데뷔/첫 오디오/AI 캐릭터 전체보기의 실제 데이터 페이징 테스트를 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: 전체보기 조회 port/repository/service가 Spring `Pageable`과 동일한 의미의 `page`, `size`, `offset`, `limit + 1` 조회를 DB 레벨에서 적용하도록 변경하고, facade는 repository 결과를 재페이징하지 않고 `items`, `page`, `size`, `hasNext` 응답 조립만 담당한다.
|
||||
- REFACTOR: 홈 통합 조회의 고정 노출 수 조회와 전체보기 페이징 조회를 분리해, 전체보기 때문에 홈 통합 조회 쿼리 의미가 바뀌지 않도록 유지한다.
|
||||
- 기대 결과: 전체보기 API는 facade 메모리 페이징이 아니라 DB 레벨 페이징을 사용하고, 실제 데이터 기반 테스트로 각 섹션의 `items`, `page`, `size`, `hasNext` 계산이 검증된다.
|
||||
|
||||
- [x] **Task 6.7: 커뮤니티 전체보기 endpoint와 연결 로직 제거**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- RED: `/api/v2/home/recommendations/communities` 전체보기 endpoint와 `HomeRecommendationController.getCommunities` 연결이 더 이상 존재하지 않아야 하는 실패 테스트를 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||
- GREEN: 커뮤니티 전체보기 controller method, facade section full-view 연결, Security matcher를 제거하고 홈 통합 조회의 인기 커뮤니티 기본 노출은 유지한다.
|
||||
- REFACTOR: 커뮤니티 전체보기가 필요하다는 전제의 테스트명/fixture만 제거하고, 홈 통합 조회의 인기 커뮤니티 응답 검증은 유지한다.
|
||||
- 기대 결과: 커뮤니티 전체보기 API는 Phase 6 공개 endpoint에서 제외되고, 연결 로직 제거 후에도 홈 통합 조회의 인기 커뮤니티 섹션은 기존처럼 동작한다.
|
||||
|
||||
- [x] **Task 6.8: Phase 6 보완 task 경계와 상태 확인**
|
||||
- Files:
|
||||
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||
- RED: Task 6.5~6.8이 Phase 6 보완 범위이고 Phase 6 보완 범위 밖 구현 항목이 섞이지 않았는지 문서 diff로 확인한다.
|
||||
- 실패 확인: `rg -n "Task 6\.[5-8]" docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||
- GREEN: Task 6.5~6.8을 성인 노출 정책 전파, DB 레벨 페이징, 커뮤니티 전체보기 제거, Phase 6 보완 task 상태 확인 범위로만 유지한다.
|
||||
- REFACTOR: Phase 6 보완 task 제목과 기대 결과가 서로 겹치거나 구현 범위를 넓히지 않도록 문구만 정리한다.
|
||||
- 기대 결과: Task 6.5~6.8이 모두 완료 상태로 유지되고, Phase 6에서 처리한 후속 작업 범위와 상태가 명확하다.
|
||||
|
||||
### Phase 7: 통합 검증과 문서 갱신
|
||||
|
||||
- [x] **Task 7.1: repository 조건 회귀 테스트 보강**
|
||||
- Files:
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 차단 관계 양방향 제외, 커뮤니티 성인 노출 조건, 본인인증 여부 조건이 필요한 추천 필터, 팔로우 크리에이터 제외, 비활성 회원 제외 테스트를 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest`
|
||||
- GREEN: 누락 조건을 QueryDSL where 조건 또는 service 필터에 추가한다.
|
||||
- REFACTOR: 같은 차단/성인 조건이 여러 쿼리에 반복되면 repository private 함수로만 정리한다.
|
||||
- 기대 결과: PRD Edge Case와 회원 조건이 테스트 이름으로 추적된다.
|
||||
|
||||
- [x] **Task 7.2: 운영 지표 기록 지점 확인**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: 메인 홈 API 성공/실패와 응답 시간, 섹션별 빈 응답 여부, 전체보기 조회 수, 추천 섹션별 클릭률 기록 지점, 동시 팔로우 요청/성공 수, 콘텐츠 조회 이력 기록 성공/실패, 콘텐츠 상세 조회 흐름에서 `CreatorContentViewHistoryService.recordView(...)` 실패가 `runCatching`으로 삼켜지더라도 구조화 로그 또는 metric으로 관측되는지, 일 배치 집계 성공/실패와 소요 시간을 관측할 수 있는 로그 또는 metric 호출 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.recommend.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: 프로젝트에 이미 사용하는 metric 클라이언트가 있으면 해당 클라이언트를 사용하고, 없으면 구조화 로그로 PRD Metrics 항목을 관측 가능하게 남긴다. 콘텐츠 조회 이력 저장 실패는 상세 조회 응답 실패로 전파하지 않되, 실패 원인과 `memberId`, `contentId`를 추적 가능한 형태로 남긴다.
|
||||
- REFACTOR: 지표 기록 때문에 공개 응답 스키마나 비즈니스 분기가 바뀌지 않도록 application 경계에서만 정리한다.
|
||||
- 기대 결과: PRD Metrics 항목이 구현 코드의 로그/metric 지점으로 추적되고 테스트에서 호출 여부를 확인할 수 있다. 특히 콘텐츠 상세 조회의 이력 저장 실패가 사용자 응답을 깨지 않으면서도 운영자가 감지 가능한 신호로 남는다.
|
||||
|
||||
- [x] **Task 7.3: 전체 테스트/린트 검증**
|
||||
- Files:
|
||||
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||
- TDD 예외 사유: 검증 명령 실행과 문서 기록 task라 제품 코드 테스트를 새로 작성하지 않는다.
|
||||
- 대체 검증 방법:
|
||||
- `./gradlew test`
|
||||
- `./gradlew ktlintCheck`
|
||||
- `./gradlew tasks --all`
|
||||
- 기대 결과: 세 명령이 모두 성공하고, 이 문서 하단 검증 기록에 실행 일시/명령/결과를 누적한다.
|
||||
|
||||
- [x] **Task 7.4: 신규 엔티티 테이블 생성 SQL 문서화**
|
||||
- Files:
|
||||
- Create: `docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql`
|
||||
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||
- TDD 예외 사유: 운영 DB 반영용 SQL 문서 산출물 작성 task라 제품 코드 테스트를 새로 작성하지 않는다.
|
||||
- 대체 검증 방법:
|
||||
- `rg -n "CREATE TABLE|recommendation_snapshot|creator_content_view_history" docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql`
|
||||
- `./gradlew tasks --all`
|
||||
- 작성 기준: Phase 7까지 완료된 최종 JPA 엔티티 필드/인덱스/nullable 조건을 기준으로 `RecommendationSnapshot`, `CreatorContentViewHistory` 등 이번 작업에서 신규 생성된 엔티티의 운영 DB 테이블 생성 SQL을 작성한다.
|
||||
- REFACTOR: SQL 문서에는 이번 작업에서 새로 추가된 테이블만 포함한다. 기존 테이블 변경이나 데이터 마이그레이션은 별도 배포 절차 항목으로 분리하며, Phase 5의 `creator_following` 유니크 제약은 `docs/20260529_메인_홈_추천_API/alter-existing-tables.sql`에 기록한다.
|
||||
- 기대 결과: Phase 7 완료 시점의 최종 엔티티 구조와 일치하는 신규 테이블 생성 SQL이 문서로 남아 운영 DB 반영 범위를 검토할 수 있다.
|
||||
|
||||
- [x] **Task 7.5: 공통 차단 필터 전체 추천 섹션 적용 보완**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- RED: 라이브, 최근 활동, 최근 데뷔, 첫 오디오, 최근 응원 상세, 인기 커뮤니티 상세가 회원과 크리에이터의 양방향 활성 차단 관계를 제외하는 테스트를 추가한다. 커뮤니티 성인 노출, 본인인증 기반 성인 노출 조건, 팔로우 제외, 비활성 회원 제외 회귀 테스트 이름을 명시적으로 유지한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||
- GREEN: facade/service/port/repository에 `memberId` 조회 컨텍스트를 전파하고, QueryDSL/native SQL 조회에 양방향 `block_member` 제외 조건을 적용한다.
|
||||
- 기대 결과: 장르 추천뿐 아니라 요청된 모든 홈 추천 섹션에서 내가 차단했거나 나를 차단한 크리에이터의 데이터가 제외된다.
|
||||
|
||||
- [x] **Task 7.6: 운영 성공 로그 after-commit 기록 보완**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: 조회 이력 저장, 추천 크리에이터 동시 팔로우, 일 스냅샷 갱신 성공 로그가 트랜잭션 커밋 전에는 기록되지 않고 커밋 후 기록되는 테스트를 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: 성공 로그는 `TransactionSynchronizationManager`의 `afterCommit`으로 등록하고, 트랜잭션 동기화가 없는 단위 실행에서는 기존처럼 즉시 기록한다. 실패 로그와 skip 로그는 기존 동작을 유지한다.
|
||||
- 기대 결과: 트랜잭션이 커밋되기 전 성공 로그가 먼저 남아 운영 지표를 오염시키지 않는다.
|
||||
|
||||
- [x] **Task 7.7: 홈 배너 차단 필터 누락 보완**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- RED: 홈 배너 `CREATOR` 대상 크리에이터와 `SERIES` 대상 시리즈 소유자가 회원과 양방향 활성 차단 관계인 경우 제외되는 테스트를 추가한다. `EVENT`와 `LINK` 배너는 기존 활성 조건 기준으로 유지한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners`
|
||||
- GREEN: 홈 통합 조회에서 배너 조회에도 `memberId`를 전달하고, 배너 조회 포트/서비스/repository가 `CREATOR`의 `bannerCreator.id`, `SERIES`의 `seriesOwner.id` 기준으로 양방향 `block_member` 제외 조건을 적용한다.
|
||||
- 기대 결과: 홈 배너 섹션에서도 차단 관계 크리에이터 또는 시리즈 소유자의 추천 데이터가 노출되지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## PRD Coverage Check
|
||||
|
||||
- Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다.
|
||||
- Feature B: Task 3.1, Task 6.3에서 라이브 최신순/전체보기/비활성 회원 제외와 크리에이터 닉네임/프로필 이미지/라이브 번호 노출 필드를 검증한다.
|
||||
- Feature C: Task 3.1과 Task 7.7에서 기존 콘텐츠 홈 배너 재활용, orders 정렬, 동일 orders 랜덤 정렬, 활성 배너/콘텐츠 조건, `EVENT`/`CREATOR`/`SERIES` 대상 비활성 제외, `CREATOR`/`SERIES` 대상 양방향 차단 제외, `LINK` 배너의 자체 활성 상태 기준 노출, 앱 이동 필드 유지를 검증한다.
|
||||
- Feature D: Task 1.3, Task 3.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable을 검증한다.
|
||||
- Feature E: Task 1.1, Task 1.2, Task 3.2, Task 6.3에서 데뷔일/점수/동점 랜덤 정렬/프로필 이미지와 닉네임 노출/전체보기를 검증한다.
|
||||
- Feature F: Task 1.1, Task 3.2, Task 6.3에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외를 검증한다.
|
||||
- Feature G: Task 1.1, Task 2.2, Task 2.6, Task 2.7, Task 2.8, Task 2.9, Task 3.3, Task 6.3에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기를 검증한다.
|
||||
- Feature H: Task 4.1, Task 4.2, Task 4.3에서 장르 조회 이력, 조회 이력 없을 때 랜덤 장르, 부족분 랜덤 보충, 한 응답 내 크리에이터 중복 제거, 조회 시점별 재노출 허용, 팔로우 제외, 성인 장르 조건, 크리에이터 프로필 이미지/닉네임/id 노출을 검증한다.
|
||||
- Feature I: Task 5.1, Task 5.2에서 장르의 크리에이터와 최근 응원이 많은 크리에이터가 공통 동시 팔로우 use case를 재사용하고, 이미 팔로우 중인 id와 본인 id는 서버 내부에서 제외하며, 비활성 팔로우 이력은 재활성화하고, 존재하지 않는 id/크리에이터가 아닌 id는 전체 실패로 처리하는지 검증한다.
|
||||
- Feature J: Task 1.1, Task 2.2, Task 2.4, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 5.1, Task 5.2에서 최근 응원 점수/스냅샷 조회, 8명 limit, 크리에이터 프로필 이미지/닉네임 노출, `CHANNEL_DONATION` 기준 후원 금액/후원 수, 팬 Talk 수, 최근 7일 집계, 데뷔일 기준 신규 부스트, DB-side exact scoring, 해당 섹션의 동시 팔로우를 검증한다.
|
||||
- Feature K: Task 1.1, Task 2.2, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 7.1에서 인기 커뮤니티 점수/조건/홈 통합 응답 노출 필드(크리에이터 프로필 이미지, 닉네임, UTC 시간, 좋아요 수, 댓글 수, 내용)/댓글 불가 게시글 댓글 수 0점 계산, 데뷔일 기준 신규 부스트, 최근 7일 집계, DB-side exact scoring을 검증한다.
|
||||
- Metrics: Task 7.2에서 메인 홈 API 성공률/응답 시간, 섹션별 빈 응답 비율, 전체보기 API 조회 수, 추천 섹션별 클릭률, 동시 팔로우 요청/성공 수, 콘텐츠 조회 이력 기록 성공률, 일 배치 집계 성공/실패 수와 스냅샷 생성 소요 시간의 로그 또는 metric 기록 지점을 검증한다.
|
||||
- Technical Constraints/Non-Goals: Phase 1~7에서 `v2.api.home`/`v2.recommend` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, 기존 공개 스키마 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서 검증한다.
|
||||
|
||||
---
|
||||
|
||||
## Verification Log
|
||||
|
||||
- 2026-05-30: plan-task 문서 작성 전 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`, `docs/20260529_메인_홈_추천_API/prd.md`를 확인했다.
|
||||
- 2026-05-30: 기존 v2 패키지 구조, 테스트 스타일, QueryDSL/스케줄러 사용 패턴, 관련 엔티티/리포지토리 후보를 `find`, `rg`, `sed`로 확인해 계획의 파일 경로와 검증 명령에 반영했다.
|
||||
- 2026-05-30: `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 `/Users/klaus/.gradle/.../gradle-8.1.1-bin.zip.lck` 생성 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 13s`를 확인했다.
|
||||
- 2026-05-30: 사용자 피드백에 따라 PRD의 Feature I가 특정 섹션 한정이 아니라 공통 "여러 크리에이터 동시 팔로우" 요구사항임을 확인했다. 장르의 크리에이터와 최근 응원이 많은 크리에이터가 동일한 팔로우 로직을 쓰도록 endpoint를 `POST /api/v2/home/recommendations/creators/follow`로 일반화했다.
|
||||
- 2026-05-30: 동시 팔로우 범위 수정 후 `rg`로 장르 전용 명칭(`GenreCreator`, `genre-creators`, `FollowGenre`)과 placeholder 문구가 남지 않았음을 확인했다. `./gradlew tasks --all`은 sandbox 기본 권한에서 동일한 `.gradle` lock 파일 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 752ms`를 확인했다.
|
||||
- 2026-05-30: `sourceSection`은 PRD 필수 요구사항이 아니므로 제거했다. 동시 팔로우 요청은 `creatorIds`만 받도록 단순화하고, 장르의 크리에이터/최근 응원이 많은 크리에이터 화면은 같은 API를 호출하는 것으로 정리했다.
|
||||
- 2026-05-30: `sourceSection` 제거 후 `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 동일한 `.gradle` lock 파일 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 718ms`를 확인했다.
|
||||
- 2026-05-30: PRD와 plan-task를 대조해 본인인증 조건, 동일 orders 배너 랜덤 정렬, AI 캐릭터 응답 필드/캐릭터 생성일 기준 부스트, 첫 오디오 최신성 점수 구간, 댓글 불가 커뮤니티 점수 계산, Metrics 관측 지점, `port.out` 의존 경계 보강이 필요함을 확인하고 관련 task와 Coverage Check에 반영했다.
|
||||
- 2026-05-30: 문서 보강 후 `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 동일한 `.gradle` lock 파일 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 789ms`를 확인했다.
|
||||
- 2026-05-30: Phase 1 Task 1.1 RED/GREEN을 진행했다. `RecommendationScorePolicyTest`는 구현 전 `Unresolved reference: RecommendationScorePolicy`로 실패했고, 구현 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScorePolicyTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-30: Phase 1 Task 1.2 RED/GREEN을 진행했다. `CreatorDebutPolicyTest`는 구현 전 `Unresolved reference: CreatorDebutPolicy`로 실패했고, 구현 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.domain.CreatorDebutPolicyTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-30: Phase 1 Task 1.3 RED/GREEN을 진행했다. `HomeRecommendationQueryServiceTest`는 구현 전 `RecommendedActivityType`, `RecommendedSectionType`, `HomeRecommendationQueryService` 미구현으로 실패했고, 구현 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-30: Phase 1 최종 검증으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. Kotlin LSP는 이 환경에 `kotlin-lsp`가 설치되어 있지 않아 실행하지 못했고, Gradle 컴파일/테스트/ktlint로 대체 확인했다.
|
||||
- 2026-05-30: Phase 2 Task 2.1~2.3 RED/GREEN을 진행했다. `RecommendationSnapshotRefreshServiceTest`는 구현 전 `RecommendationSnapshot`, `RecommendationSnapshotPort`, `HomeRecommendationQueryPort`, `RecommendationSnapshotRefreshService`, `RecommendationSnapshotScheduler` 미구현으로 실패했고, 구현 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-30: Phase 2 검증으로 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. 병렬 Gradle 실행 중에는 KAPT 임시 stub 파일 경합이 발생해 이후 검증은 순차 실행으로 고정했다. Kotlin LSP는 이 환경에 `kotlin-lsp`가 설치되어 있지 않아 실행하지 못했고, Gradle 컴파일/테스트/ktlint로 대체 확인했다.
|
||||
- 2026-05-30: 기본 구현체 명명 규칙을 접미사 `Impl` 대신 접두사 `Default`로 변경했다. `HomeRecommendationQueryRepositoryImpl`은 `DefaultHomeRecommendationQueryRepository`로 바꿨고, PRD와 구현 계획에 AI 캐릭터 `followIncrease`는 팔로우 대상/관계 정의 확정 전까지 이번 스프린트 산식과 집계에서 제외한다고 기록했다.
|
||||
- 2026-05-30: 구현 전 문서 보강으로 기본 구현체 명명 규칙을 `docs/agent-guides/코드스타일.md`에 반영하고, 당시 스냅샷 일 배치 기준을 PRD/Task 2.3~2.4에 기록했다. 이후 Phase 2 권고 보강에서 스케줄은 KST 06:00 `Asia/Seoul` zone으로 변경했다. QueryDSL 집계 통합 테스트, `RecommendationSnapshotPort` 경계 정리, 최근 응원 `CHANNEL_DONATION` 기준 후원 금액/후원 수 검증은 Task 2.4로 추가했다.
|
||||
- 2026-05-30: Phase 2 Task 2.4 RED/GREEN을 진행했다. RED에서 `RecommendationSnapshotRefreshServiceTest`는 기존 KST cron/JVM timezone 기준 계산으로 실패했고, `DefaultHomeRecommendationQueryRepositoryTest`는 최근 응원 후원 금액이 `CHANNEL_DONATION` 외 사용처까지 포함되어 `expected: <150> but was: <3150>`으로 실패했다. GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-30: Phase 2 재점검을 진행했다. `RecommendationSnapshotRefreshServiceTest`와 `DefaultHomeRecommendationQueryRepositoryTest`는 각각 재실행 시 `BUILD SUCCESSFUL`로 통과했지만, 최근 응원/인기 커뮤니티 신규 부스트가 실제 데뷔일이 아니라 `Member.createdAt`에 의존하는 점, AI 캐릭터 최근 채팅 수의 participant 범위가 명확히 고정되지 않은 점, 스냅샷 후보 전체 저장은 과도한 데이터 저장으로 이어질 수 있다는 점을 확인했다. 해당 보완사항은 Task 2.5~2.8과 Coverage Check에 나누어 반영했고, 실제 데뷔일이 없는 크리에이터는 Task 2.5에서 스냅샷 후보 제외로 확정하고 테스트로 검증했다.
|
||||
- 2026-05-30: Phase 2 Task 2.5~2.8 보강을 진행했다. `DefaultHomeRecommendationQueryRepositoryTest`와 `RecommendationSnapshotRefreshServiceTest`에 실제 데뷔일 기준 후보, AI 발화 수, 중복 없는 활성 사용자 수, 섹션별 스냅샷 저장 상한(20/16/20) 검증을 추가했다. 첫 실행은 `AudioContent.theme` fixture 누락과 QueryDSL alias 문제로 실패했고, 보정 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-30: Phase 2 Task 2.5~2.8 최종 검증으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. Kotlin LSP는 이 환경에 `kotlin-lsp`가 설치되어 있지 않아 실행하지 못했고, Gradle 컴파일/테스트/ktlint로 대체 확인했다.
|
||||
- 2026-05-30: Phase 2 권고 보강으로 스냅샷 스케줄을 KST 06:00 `Asia/Seoul` zone으로 변경했다. 최종 점수 계산 전 후보 사전 제한은 정확한 top 후보를 누락할 수 있어 적용하지 않는다. AI 20개, 최근 응원 16개, 인기 커뮤니티 20개 저장 상한은 최종 점수와 동점 랜덤 정렬 이후 repository에서 적용하는 최종 limit으로 유지한다.
|
||||
- 2026-05-30: 사용자 피드백에 따라 service가 전체 후보를 모두 불러와 점수를 계산하는 구조를 DB-side exact scoring으로 전환하기로 확정했다. PRD와 Task 2.9에 `RecommendationScoreSpec` 공유 산식, DB 최종 점수 계산 후 정렬/limit, candidate pre-limit 금지, service scoring 제거 요구사항을 반영했다. 기존 20/16/20 저장 상한은 동점자 랜덤 노출 여지를 위한 최종 저장 limit으로 유지하되, 최종 점수 계산 전 후보 제한 의미로는 사용하지 않도록 명확히 했다.
|
||||
- 2026-05-31: Phase 2 Task 2.9 RED/GREEN을 진행했다. RED에서 `RecommendationScoreSpec`과 DB-scored snapshot 조회 계약 미구현으로 `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest` 컴파일이 실패했다. GREEN에서 `RecommendationScoreSpec`을 추가하고, AI/최근 응원/인기 커뮤니티 스냅샷 조회가 DB에서 최종 score와 `randomTieBreaker`를 계산한 뒤 `score desc, randomTieBreaker asc` 정렬과 최종 limit을 적용하도록 변경했다. `RecommendationSnapshotRefreshService`에서는 Kotlin-side score 재계산과 service-side limit을 제거했다.
|
||||
- 2026-05-31: Phase 2 Task 2.9 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-31: Phase 2 Task 2.9 리뷰 후속으로 `RecommendationScoreSpec`에 신규 부스트 일수 상수를 추가하고, `RecommendationScorePolicy`와 native SQL boost window가 같은 상수를 쓰도록 정리했다. 최근 응원/인기 커뮤니티 native SQL은 후보 행마다 donation/comment/follower/debut 집계를 반복하지 않도록 aggregate CTE 기반으로 변경했고, 데뷔일은 콘텐츠 공개일과 라이브 시작일을 `union all`한 이벤트 집계에서 `min(debut_at)`으로 계산해 DB-side exact scoring 의미를 유지했다. PRD/plan-task의 동일 점수 정렬 문구는 스냅샷 저장 `randomTieBreaker` 기준으로 맞췄다.
|
||||
- 2026-05-31: 리뷰 후속 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. 중간에 H2 native SQL 타입 추론 문제와 ktlint line length 오류가 있었고, 데뷔일 CTE를 `union all` 이벤트 집계로 단순화하고 긴 `setParameter` 호출을 줄바꿈해 해결했다.
|
||||
- 2026-05-31: Phase 3 Task 3.1 RED/GREEN을 진행했다. RED에서 `HomeRecommendationQueryServiceTest`와 `DefaultHomeRecommendationQueryRepositoryTest`는 라이브/배너/최근 활동 크리에이터 조회 record, port 메서드, service 메서드, repository 쿼리 미구현으로 컴파일 실패했다. GREEN에서 라이브 최신순 20개, 활성 배너 orders 정렬/동일 orders 랜덤 tie-breaker/최대 20개, 크리에이터당 최신 활동 1개와 `LIVE`/`AUDIO`/`COMMUNITY`/`LIVE_REPLAY` 분류를 구현했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-31: Phase 3 Task 3.1 추가 검증으로 `./gradlew ktlintCheck`와 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`를 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. 중간에 테스트 코드 line length ktlint 오류가 있었고, 긴 fixture 호출을 줄바꿈해 해결했다.
|
||||
- 2026-05-31: Phase 3 Task 3.2 RED/GREEN을 진행했다. RED에서 `HomeRecommendationQueryServiceTest`와 `DefaultHomeRecommendationQueryRepositoryTest`는 최근 데뷔 크리에이터/첫 오디오 콘텐츠 record, port 메서드, service 메서드, repository 쿼리 미구현으로 컴파일 실패했다. GREEN에서 실제 데뷔일 30일 이내 최근 데뷔 크리에이터 점수/랜덤 tie-breaker 정렬, 첫 3개 업로드 이내 활성 공개 오디오 콘텐츠 판정, 비활성 선행 콘텐츠 경계, 예약 공개 제외, `release_date` 최신성 점수 정렬을 구현했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-31: Phase 3 Task 3.3 RED/GREEN을 진행했다. RED에서 `HomeRecommendationQueryServiceTest`와 `DefaultHomeRecommendationQueryRepositoryTest`는 AI 캐릭터/최근 응원/인기 커뮤니티 상세 record, port 메서드, service 메서드, repository 상세 조회 미구현으로 `compileTestKotlin`이 실패했다. GREEN에서 `RecommendationSnapshotPort.findLatestSnapshots(sectionType)` 기반 최신 스냅샷 조회, AI 캐릭터 10개/최근 응원 8명/인기 커뮤니티 10개 limit, 스냅샷 순서 보존, 누락 상세 필터링, 인기 커뮤니티 크리에이터 중복 제거, 커뮤니티 비노출 조건 필터링, AI 캐릭터 원작명 조건부 응답과 전체 AI 발화 수 조립을 구현했다. `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`와 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-31: Phase 3에는 `CreatorContentViewHistory` 엔티티/리포지토리/저장 서비스가 아직 구현되어 있지 않아 장르 기반 크리에이터 추천 조회를 포함하지 않았다. 해당 산출물은 Phase 4 Task 4.1 범위이므로, 장르 기반 크리에이터 추천 조회는 조회 이력 저장 모델이 준비된 뒤 Phase 4 Task 4.2에서 별도 RED/GREEN으로 진행한다.
|
||||
- 2026-05-31: Phase 3 리뷰 후속으로 인기 커뮤니티 추천이 상위 10개 스냅샷을 먼저 자른 뒤 크리에이터 중복을 제거해 10개 미만으로 내려갈 수 있는 문제를 보완했다. RED에서 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채우는 테스트가 실패했고, GREEN에서 인기 커뮤니티만 최신 스냅샷 후보 20개를 읽은 뒤 상세 조립/크리에이터 중복 제거 후 최종 10개를 반환하도록 수정했다. 후속 검증으로 `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-31: 사용자 피드백에 따라 신규 엔티티 테이블 생성 SQL은 Phase 7 완료 후 최종 엔티티 구조 기준으로 작성하도록 계획을 보강했다. `RecommendationSnapshot`, `CreatorContentViewHistory` 등 이번 작업에서 새로 생성된 엔티티의 운영 DB DDL을 `docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql` 산출물로 남기는 Task 7.4를 추가했다.
|
||||
- 2026-05-31: PRD와 plan-task를 재대조해 큰 기능 흐름은 반영되어 있었으나 일부 PRD 세부 항목의 task 추적성이 약한 점을 확인했다. 라이브/활동/최근 데뷔/최근 응원/인기 커뮤니티/장르 크리에이터 노출 필드, `LINK` 배너 자체 활성 상태 기준, 활동 enum 영문 code 안정성, 한 응답 내 장르 크리에이터 중복 제거와 조회 시점별 재노출 허용, 추천 섹션별 클릭률 metric, Non-Goals 범위를 관련 task와 Coverage Check에 보강했다.
|
||||
- 2026-05-31: Phase 2/3 재점검 후속으로 배너 대상 활성 조건과 스냅샷 데뷔일 계산의 빈 `channel_name` 라이브 제외 누락을 확인했다. RED에서 `DefaultHomeRecommendationQueryRepositoryTest`에 회귀 테스트를 추가했고 `shouldExcludeHomeBannersWithInactiveTargetsExceptLink`, `shouldExcludeBlankChannelNameLiveFromSnapshotDebutAt`가 실패했다. GREEN에서 `findHomeBanners`에 `EVENT`/`CREATOR`/`SERIES` 대상 활성 조건을 추가하고, 최근 응원/인기 커뮤니티 데뷔일 CTE에 `lr.channel_name <> ''` 조건을 추가했다. 리뷰 중 PRD의 인기 커뮤니티 성인 노출 조건은 항상 제외가 아니라 `MemberContentPreference.isAdultContentVisible == true` 회원에게 노출 허용임을 재확인해, 스냅샷 산정은 성인 게시글도 후보로 유지하고 상세 조회에서 `includeAdultCommunities`로 필터링하도록 수정했다. 추가 코드 리뷰에서 기존 홈 배너가 `tabId = 1`일 때 `tab is null`만 조회하는 계약을 확인해, `findHomeBanners`에 `cb.tab_id is null` 조건과 탭 전용 배너 제외 회귀 테스트를 보강했다. 후속 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommend.*'`, `./gradlew ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 통과했다. 병렬 Gradle test 실행 중 XML 결과 파일 쓰기 충돌로 한 번 실패했으나, 동일 명령 단독 재실행 시 성공해 테스트 assertion 실패가 아님을 확인했다.
|
||||
- 2026-05-31: 사용자 피드백에 따라 여러 크리에이터 동시 팔로우에서 본인 크리에이터 id는 전체 실패 조건에서 제외하고, 이미 팔로우 중인 id와 동일하게 처리 제외 대상으로 보도록 PRD와 plan-task를 수정했다.
|
||||
- 2026-06-01: 사용자 피드백에 따라 동시 팔로우 공개 응답은 성공/실패 여부만 제공하도록 단순화했다. 이미 팔로우 중인 id와 본인 id는 실패 사유로 보지 않고 서버 내부에서 제외하며, 테스트는 mock 없이 실제 Spring/JPA 흐름으로 검증하도록 조정한다.
|
||||
- 2026-05-31: Phase 4 구현 중 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.CreatorContentViewHistoryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-06-01: Phase 6 Task 6.1~6.3을 진행했다. `HomeRecommendationResponse`/`HomeRecommendationPageResponse` API DTO와 `HomeRecommendationFacade`(섹션별 기본 limit 20/20/10/10/10/10/5x8/8/10 전달, 회원의 성인 노출 여부=`member.auth != null`와 `memberId`를 장르/커뮤니티 조회 조건으로 전달, KST→UTC ISO 변환, cloud-front host 이미지 URL 조립)를 추가했다. `HomeRecommendationController`에 통합 조회 `GET /api/v2/home/recommendations`와 전체보기 5개(`/lives`, `/debut-creators`, `/first-audio-contents`, `/ai-characters`, `/communities`)를 추가했고 size 기본값 20/최대 50으로 정규화했다. `SecurityConfig`에 해당 GET endpoint 6개 `permitAll`을 추가해 비회원 접근을 허용했다. `HomeRecommendationControllerTest`에 통합 조회(비회원/회원), 페이징 응답 형식, size 상한 테스트를 추가했고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`가 12/12 통과했다. ktlint는 이 환경 셸 PATH에 Java가 없어 직접 실행하지 못했고 IDE 인스펙션으로 신규 파일 무경고를 확인했다(컨트롤러의 `@AuthenticationPrincipal` SpEL 문자열 경고는 기존 팔로우 endpoint와 동일한 false positive).
|
||||
- 2026-06-01: Phase 6 Task 6.4 리뷰 보완을 진행했다. RED에서 `HomeRecommendationControllerTest`에 세부 전체보기 비회원 거부와 음수 `page` 보정 테스트를 추가했고 기존 구현은 `shouldRejectAnonymousSectionPages`, `shouldNormalizeNegativePageToZero` 2건 실패로 확인했다. GREEN에서 `SecurityConfig`는 통합 조회 `GET /api/v2/home/recommendations`만 `permitAll`로 유지하고 전체보기 5개는 회원 인증 대상으로 변경했다. `HomeRecommendationController`는 전체보기 요청에서 인증 회원을 요구하고 `page < 0`을 0으로 보정하며, `HomeRecommendationFacade`는 성인 노출 여부를 `MemberContentPreferenceService.initializeDefaultPreference(member).isAdultContentVisible`와 기존 `isAdultVisibleByPolicy(...)`로 계산하도록 수정했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-06-01: Phase 7 Task 7.1 RED/GREEN을 진행했다. RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromGenreCreatorRecommendations`가 양방향 차단 크리에이터를 제외하지 못해 실패했고, GREEN에서 장르 추천 테마 후보/크리에이터 조회 native SQL에 `block_member` 양방향 활성 차단 제외 조건을 추가했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromGenreCreatorRecommendations`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-06-01: Phase 7 Task 7.2 RED/GREEN을 진행했다. RED에서 홈 통합/전체보기 성공, 콘텐츠 조회 이력 저장/스킵, 추천 크리에이터 동시 팔로우 성공/실패, 일 스냅샷 갱신 성공, 콘텐츠 상세 조회 이력 기록 실패 관측 로그 테스트 9건이 로그 이벤트 키 미존재로 실패했다. GREEN에서 기존 프로젝트 관례대로 신규 metric dependency 없이 `LoggerFactory` 구조화 로그를 추가했고, 콘텐츠 상세 조회의 `CreatorContentViewHistoryService.recordView(...)` 실패는 응답 실패로 전파하지 않고 `memberId`, `contentId`, 원인을 로그로 남기도록 했다. 검증으로 Phase 7 대상 테스트 묶음 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.recommend.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest`가 `BUILD SUCCESSFUL`로 통과했다. 추천 섹션별 클릭률은 별도 클릭 ingress가 없어 이번 범위에서는 응답/impression 관측 로그만 추가했다.
|
||||
- 2026-06-01: Phase 7 리뷰 지적에 따라 홈 통합 조회와 라이브 전체보기 조회 실패 로그 테스트를 추가하고, `HomeRecommendationFacade`에서 실패 시 `home_recommendations_query_failure`, `home_recommendations_page_query_failure` 로그를 남긴 뒤 예외를 재전파하도록 보강했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldLogHomeRecommendationFailure --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldLogHomeRecommendationPageFailure`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-06-01: Phase 7 재리뷰 지적에 따라 최근 데뷔/첫 오디오/AI 캐릭터 전체보기 실패도 `home_recommendations_page_query_failure` 로그를 남긴 뒤 예외를 재전파하도록 보강했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldLogOtherHomeRecommendationPageFailures`가 `BUILD SUCCESSFUL`로 통과했고, 이후 `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 16s`, `./gradlew test`는 `BUILD SUCCESSFUL in 54s`로 통과했다.
|
||||
- 2026-06-01: Phase 7 Task 7.4로 신규 엔티티 테이블 생성 SQL `docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql`을 작성했다. 최종 JPA 엔티티 기준으로 `recommendation_snapshot`, `creator_content_view_history` 두 신규 테이블만 포함했고, 기존 테이블 변경은 `alter-existing-tables.sql` 범위로 유지했다. 검증으로 `rg -n "CREATE TABLE|create table|recommendation_snapshot|creator_content_view_history" docs/20260529_메인_홈_추천_API/create-new-entity-tables.sql`와 `./gradlew tasks --all`이 모두 성공했다.
|
||||
- 2026-06-01: Phase 7 Task 7.3 전체 검증을 순차 실행했다. `./gradlew ktlintCheck`는 처음에 신규 로그 호출의 긴 라인과 리뷰 보완 후 테스트 import 순서로 실패했고 줄바꿈/import 정리 후 통과했다. 최종 재리뷰 보완 후 `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 16s`, `./gradlew test`는 `BUILD SUCCESSFUL in 54s`로 통과했고, `./gradlew tasks --all`은 앞선 Task 7.4 검증에서 `BUILD SUCCESSFUL in 1s`로 통과했다.
|
||||
- 2026-06-01: Phase 7 Task 7.5~7.6 보완을 진행했다. 라이브/최근 활동/최근 데뷔/첫 오디오/최근 응원/인기 커뮤니티에 회원 `memberId` 조회 컨텍스트를 전달하고 양방향 활성 차단 관계를 제외하도록 수정했다. 커뮤니티 성인 노출, 본인인증 기반 성인 노출 조건, 팔로우 제외, 비활성 회원 제외 회귀 테스트 이름을 명시적으로 유지하고, 조회 이력/동시 팔로우/스냅샷 성공 로그는 트랜잭션 커밋 후 기록되도록 보완했다. 검증으로 Phase 7 대상 테스트 묶음, `./gradlew ktlintCheck`, `./gradlew test`가 모두 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-06-01: 리뷰 게이트 Context Mining에서 홈 배너 `CREATOR`/`SERIES` 대상의 차단 필터 누락을 발견해 Phase 7 Task 7.7로 보완했다. 홈 통합 조회의 배너 조회에도 `memberId`를 전달하고, 배너 repository 조회에서 `CREATOR`는 대상 크리에이터, `SERIES`는 시리즈 소유자 기준 양방향 활성 `block_member` 제외 조건을 적용했다. 회귀 테스트 `DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners`와 `HomeRecommendationQueryServiceTest`를 추가/보강했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners --tests kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryServiceTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
298
docs/20260529_메인_홈_추천_API/prd.md
Normal file
298
docs/20260529_메인_홈_추천_API/prd.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# PRD: 메인 홈 추천 API
|
||||
|
||||
## 1. Overview
|
||||
메인 홈에서 여러 추천 섹션을 한 번에 조회하고, 각 섹션의 전체보기/페이징 조회와 일부 사용자 액션을 지원하는 v2 API를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 기존 홈/콘텐츠/라이브/AI 캐릭터/커뮤니티/후원 도메인의 데이터가 여러 화면과 API에 흩어져 있어 신규 메인 홈 구성을 한 API 계약으로 제공하기 어렵다.
|
||||
- 추천 섹션별 정렬 기준, 점수 산식, 갱신 주기, 노출 필드가 서로 달라 구현 전 명확한 요구사항 문서가 필요하다.
|
||||
- 일부 섹션은 일 단위 집계 스냅샷이 필요하고, 일부 섹션은 실시간성 또는 랜덤성이 필요하므로 조회 API와 집계 작업의 책임을 분리해야 한다.
|
||||
- `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용해야 하므로 신규 API/서비스/조회 로직의 패키지 경계를 사전에 확정해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 메인 홈 추천 섹션을 v2 패키지 하위 신규 코드로 제공한다.
|
||||
- 홈 첫 화면에서 필요한 섹션별 기본 개수를 조회할 수 있다.
|
||||
- 전체보기 요구가 있는 섹션은 별도 리스트 API로 페이징 조회할 수 있다.
|
||||
- 홈 배너는 기존 콘텐츠 홈 배너 데이터를 재활용한다.
|
||||
- 점수 기반 섹션은 요구된 산식, 기간, 신규 부스트를 반영한다.
|
||||
- 일 1회 갱신 섹션은 KST 매일 06:00에 전날 23:59:59 KST 기준 데이터로 계산된 결과를 사용한다. 서버 스케줄러 cron은 `Asia/Seoul` zone의 KST 06:00으로 등록한다.
|
||||
- 시간 응답은 UTC 기준으로 내려주고 앱에서 표시 포맷과 다국어를 처리한다.
|
||||
- 장르 기반 크리에이터 추천을 위해 콘텐츠 조회 이력 기록 방식을 도입한다.
|
||||
- 여러 크리에이터를 동시에 팔로우하는 API를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 기존 `kr.co.vividnext.sodalive.v2` 외부의 Controller, Service, Repository 구현 코드를 직접 재사용하지 않는다.
|
||||
- 기존 홈 API, 콘텐츠 홈 API, 라이브 API, AI 캐릭터 API의 공개 스키마를 변경하지 않는다.
|
||||
- 앱 다국어 문구를 서버에서 번역해 내려주지 않는다.
|
||||
- 추천 산식의 머신러닝 모델화, 개인화 가중치 학습, A/B 테스트 플랫폼은 이번 범위에 포함하지 않는다.
|
||||
- 관리자 화면 신규 개발은 포함하지 않는다.
|
||||
- 추천 결과 수동 편집 기능은 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원: 메인 홈에서 라이브, 신규 크리에이터, 콘텐츠, AI 캐릭터, 커뮤니티, 후원 기반 추천을 탐색하는 사용자
|
||||
- 비회원: 인증 없이 조회 가능한 추천 섹션을 탐색하는 사용자
|
||||
- 앱 클라이언트: 섹션별 노출 정보와 이동 대상 id를 받아 홈 UI와 전체보기 화면을 구성하는 클라이언트
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 메인 홈 진입 시 라이브 중인 방송 20개를 최신순으로 보고 싶다.
|
||||
- 사용자는 홈 배너를 최대 20개까지 정해진 노출 순서대로 보고 싶다.
|
||||
- 사용자는 방금 활동한 크리에이터와 활동 영역을 확인하고 해당 콘텐츠/커뮤니티로 이동하고 싶다.
|
||||
- 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다.
|
||||
- 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다.
|
||||
- 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다.
|
||||
- 사용자는 내가 봤던 콘텐츠 장르 또는 랜덤 장르 기준으로 팔로우하지 않은 크리에이터를 추천받고 싶다.
|
||||
- 사용자는 장르 추천에서 여러 크리에이터를 한 번에 팔로우하고 싶다.
|
||||
- 사용자는 최근 응원이 많은 크리에이터를 순위로 보고 싶다.
|
||||
- 사용자는 인기 커뮤니티 게시글을 크리에이터별로 중복 없이 보고 싶다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 메인 홈 통합 조회
|
||||
|
||||
#### Requirements
|
||||
- 신규 홈 API는 `kr.co.vividnext.sodalive.v2` 하위 패키지에 작성한다.
|
||||
- 메인 홈 통합 API URL prefix는 `/api/v2/home/recommendations`를 사용한다.
|
||||
- 홈 첫 화면 응답은 섹션별 기본 limit만 포함한다.
|
||||
- 섹션별 기본 노출 수는 다음과 같다.
|
||||
- 라이브 중인 방송: 20개
|
||||
- 홈 배너: 최대 20개
|
||||
- 방금 활동한 크리에이터: 10개
|
||||
- 최근 데뷔한 크리에이터: 10개
|
||||
- 처음부터 함께 성장: 10개
|
||||
- 크리에이터와 이야기를 나눠요: 10개
|
||||
- 장르의 크리에이터: 장르 최대 5개, 장르별 크리에이터 8명
|
||||
- 최근 응원이 많은 크리에이터: 8명
|
||||
- 인기 커뮤니티: 10개
|
||||
- 인증 회원이면 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부 등 사용자 조건을 반영한다.
|
||||
- 인증 회원이 차단했거나 인증 회원을 차단한 크리에이터의 라이브, 콘텐츠, 커뮤니티, 크리에이터 추천 데이터는 노출하지 않는다.
|
||||
- 비회원이면 회원 의존 조건을 제외한 기본 추천만 제공한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 섹션별 데이터가 부족하면 부족한 개수만 내려주고 전체 API는 성공 처리한다.
|
||||
- 특정 섹션 집계 스냅샷이 없으면 해당 섹션은 빈 배열로 내려주고 장애가 전체 홈 조회를 막지 않도록 한다.
|
||||
- 앱 이동에 필요한 id가 없는 섹션은 이동 대상 필드를 nullable로 둔다.
|
||||
|
||||
### Feature B. 라이브 중인 방송
|
||||
|
||||
#### Requirements
|
||||
- 라이브 중인 방송을 최신순으로 조회한다.
|
||||
- 홈 첫 화면은 20개를 내려준다.
|
||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||
- 노출 정보는 크리에이터 닉네임, 프로필 이미지, 라이브 번호를 포함한다.
|
||||
- 기존 `LiveRoom`, `Member` 등 엔티티는 재활용할 수 있다.
|
||||
|
||||
#### Edge Cases
|
||||
- 방송자가 비활성 회원이면 노출하지 않는다.
|
||||
|
||||
### Feature C. 홈 배너
|
||||
|
||||
#### Requirements
|
||||
- 기존 콘텐츠 홈 배너를 재활용한다.
|
||||
- `orders` 기준으로 최대 20개를 조회한다.
|
||||
- 활성 배너만 노출한다.
|
||||
- 동일 `orders` 값이 있으면 랜덤으로 정렬한다.
|
||||
- 배너 대상 엔티티가 비활성 처리되었으면 노출하지 않는다. `EVENT`는 연결 이벤트가 활성인 경우만, `CREATOR`는 연결 크리에이터 회원이 활성인 경우만, `SERIES`는 연결 시리즈와 시리즈 소유 회원이 모두 활성인 경우만 노출한다. `LINK`는 별도 대상 엔티티가 없으므로 배너 자체 활성 상태만 적용한다.
|
||||
- 기존 배너 응답에서 앱 이동에 필요한 필드는 유지한다.
|
||||
|
||||
### Feature D. 방금 활동한 크리에이터
|
||||
|
||||
#### Requirements
|
||||
- 최신순 10개를 조회한다.
|
||||
- 활동 타입은 enum으로 내려주며 앱에서 다국어 처리한다.
|
||||
- 활동 타입 후보는 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`로 한다.
|
||||
- 오디오는 콘텐츠를 업로드한 경우를 의미한다.
|
||||
- 커뮤니티는 커뮤니티 게시글을 등록한 경우를 의미한다.
|
||||
- 라이브는 라이브 진행 후 종료한 경우를 의미한다.
|
||||
- 라이브 다시듣기는 콘텐츠 업로드 시 `다시듣기` 테마로 올린 경우를 의미한다.
|
||||
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다.
|
||||
- 라이브 활동은 별도 이동 대상 id가 필요하지 않다.
|
||||
- 라이브 외 활동은 콘텐츠 id 또는 커뮤니티 게시글 id를 내려준다.
|
||||
- 크리에이터당 최신 활동 1개만 노출한다.
|
||||
|
||||
#### Edge Cases
|
||||
- `다시듣기` 콘텐츠는 `AUDIO`가 아니라 `LIVE_REPLAY`로 분류한다.
|
||||
|
||||
### Feature E. 최근 데뷔한 크리에이터
|
||||
|
||||
#### Requirements
|
||||
- 홈 첫 화면은 추천순 10개를 조회한다.
|
||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||
- 데뷔일은 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
|
||||
- 데뷔일 계산 로직은 기존 `ExplorerService.getCreatorDetail`의 `debutDateTime` 계산 방식과 동일하게 맞춘다.
|
||||
- 데뷔 후 30일 이내 크리에이터만 대상으로 한다.
|
||||
- 추천 점수는 `((팔로우 증가량 * 0.35) + (콘텐츠 활동 점수 * 0.3) + (소통 점수 * 0.2)) * 신규 부스트`로 계산한다.
|
||||
- 팔로우 증가량은 최근 7일간 신규 팔로우한 유저 수로 계산한다.
|
||||
- 콘텐츠 활동 점수는 최근 30일간 업로드 콘텐츠 수와 라이브 횟수로 계산한다.
|
||||
- 소통 점수는 최근 7일간 커뮤니티 게시글 수, 커뮤니티 게시글 댓글 수, 커뮤니티 게시글 좋아요 수, 콘텐츠 댓글 수, 콘텐츠 좋아요 수로 계산한다.
|
||||
- 신규 부스트는 데뷔 후 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2를 적용한다.
|
||||
- 추천 점수가 동일하면 랜덤으로 정렬한다.
|
||||
- 노출 정보는 크리에이터 프로필 이미지, 닉네임을 포함한다.
|
||||
|
||||
### Feature F. 처음부터 함께 성장
|
||||
|
||||
#### Requirements
|
||||
- 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 조회한다.
|
||||
- 신규 크리에이터는 데뷔일로부터 30일 이내인 크리에이터다.
|
||||
- 홈 첫 화면은 최대 10개를 조회한다.
|
||||
- 전체보기 API는 신규 크리에이터의 첫 번째 콘텐츠를 페이징 조회할 수 있어야 한다.
|
||||
- 첫 번째 콘텐츠 판정은 해당 크리에이터의 오디오 콘텐츠를 `created_at`, `release_date` 기준으로 정렬해 3번째 이내에 업로드된 활성 콘텐츠인 경우로 한다.
|
||||
- 앞선 비활성 콘텐츠가 2개 있고 3번째 콘텐츠가 활성이라면 첫 번째 콘텐츠로 인정한다.
|
||||
- 앞선 비활성 콘텐츠가 3개 이상이면 이후 활성 콘텐츠는 첫 번째 콘텐츠로 인정하지 않는다.
|
||||
- 최신성 점수 기준일은 `release_date`로 본다.
|
||||
- 최신성 점수는 3일 이내 100, 7일 이내 80, 14일 이내 60, 21일 이내 40, 30일 이내 20으로 계산한다.
|
||||
- 정렬은 최신성 점수 내림차순, 동점이면 랜덤으로 한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 예약 공개 콘텐츠는 공개 전에는 노출하지 않는다.
|
||||
|
||||
### Feature G. 크리에이터와 이야기를 나눠요
|
||||
|
||||
#### Requirements
|
||||
- AI 캐릭터 리스트를 조회한다.
|
||||
- 홈 첫 화면은 10개를 조회한다.
|
||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||
- 노출 정보는 캐릭터 이름, 캐릭터 소개, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
||||
- 작품명은 오리지널 작품 캐릭터인 경우에만 내려준다.
|
||||
- 1차 정렬은 AI 채팅 추천 점수 내림차순이다.
|
||||
- 2차 정렬은 동일 점수인 경우 랜덤이다.
|
||||
- AI 채팅 추천 점수는 이번 스프린트에서 `((0.45 * 최근 발생한 AI 채팅 수) + (0.35 * 최근 활성 사용자 수)) * 신규 부스트`로 계산한다.
|
||||
- 최근 발생한 AI 채팅 수와 최근 활성 사용자 수는 최근 7일 데이터 기반으로 계산한다.
|
||||
- 최근 발생한 AI 채팅 수는 AI 캐릭터가 발화한 채팅 메시지 수를 의미한다.
|
||||
- 최근 활성 사용자 수는 최근 7일 안에 해당 AI 캐릭터와 1회 이상 채팅한 중복 없는 사용자 수를 의미한다.
|
||||
- AI 캐릭터의 팔로우 증가량은 팔로우 대상/관계의 정확한 정의가 확정되지 않아 이번 스프린트 산식과 집계에서 제외한다. 추후 AI 캐릭터 팔로우 정의가 확정되면 별도 요구사항으로 재도입한다.
|
||||
- 신규 부스트는 캐릭터 생성일 기준 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다.
|
||||
- 점수는 KST 매일 06:00에 전날 23:59:59 KST 기준 데이터로 1회 갱신한다. 스케줄러는 `Asia/Seoul` zone의 KST 06:00 기준으로 실행한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 비활성 또는 노출 제한 캐릭터는 제외한다.
|
||||
|
||||
### Feature H. 장르의 크리에이터
|
||||
|
||||
#### Requirements
|
||||
- 사용자가 조회한 장르가 없으면 조회 가능한 장르 중 랜덤 5개를 선별한다.
|
||||
- 사용자가 조회한 콘텐츠가 있으면 조회한 콘텐츠들의 장르 중 랜덤 5개를 선별한다.
|
||||
- 조회 이력 기반 장르가 5개 미만이면 나머지 조회 가능한 장르 중 랜덤으로 채운다.
|
||||
- 각 장르별로 해당 장르의 콘텐츠를 업로드한 크리에이터를 랜덤 8명씩 노출한다.
|
||||
- 같은 크리에이터가 서로 다른 조회 시점의 여러 장르 섹션에 노출될 수는 있다.
|
||||
- 한 번에 조회되는 5개 장르 안에서는 같은 크리에이터가 중복 노출되지 않아야 한다.
|
||||
- 사용자가 팔로우한 크리에이터는 제외한다.
|
||||
- 성인 콘텐츠 장르는 `MemberContentPreference.isAdultContentVisible == true`인 회원에게만 노출한다.
|
||||
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, id를 포함한다.
|
||||
- 콘텐츠 조회 데이터는 콘텐츠 상세 진입 시점에 기록한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 장르별 추천 가능한 크리에이터가 8명 미만이면 가능한 만큼만 내려준다.
|
||||
|
||||
### Feature I. 여러 크리에이터 동시 팔로우
|
||||
|
||||
#### Requirements
|
||||
- 크리에이터 id 리스트를 받아 해당 id의 크리에이터 중 팔로우되어 있지 않은 크리에이터를 모두 팔로우한다.
|
||||
- 요청의 `creatorIds`는 1개 이상 50개 이하만 허용한다.
|
||||
- 이미 팔로우한 크리에이터와 본인 크리에이터 id는 실패 사유로 보지 않고 중복 없이 유지한다.
|
||||
- 과거 언팔로우로 비활성화된 팔로우 이력이 있으면 신규 이력을 만들지 않고 기존 이력을 다시 활성화한다.
|
||||
- 클라이언트 응답은 성공/실패 여부만 제공하고, 신규 팔로우/처리 제외 id 목록은 공개 응답에 포함하지 않는다.
|
||||
- 요청 id 중 일부라도 존재하지 않는 id, 크리에이터가 아닌 회원 id 등 유효하지 않은 값이 포함되면 전체 실패로 처리한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 이미 팔로우 중인 크리에이터 id와 본인 크리에이터 id는 유효한 id로 보며 실패 사유로 처리하지 않고 서버 내부에서 제외한다.
|
||||
- 동일 회원과 동일 크리에이터의 팔로우 이력은 중복 저장하지 않는다.
|
||||
|
||||
### Feature J. 최근 응원이 많은 크리에이터
|
||||
|
||||
#### Requirements
|
||||
- 응원 점수가 높은 크리에이터 8명을 조회한다.
|
||||
- 노출 정보는 크리에이터 프로필 이미지, 크리에이터 닉네임을 포함한다.
|
||||
- 응원 점수는 `((0.6 * 후원 금액) + (0.3 * 팬 Talk 수) + (0.1 * 후원 수)) * 신규 부스트`로 계산한다.
|
||||
- 후원 금액과 후원 수는 `CanUsage.CHANNEL_DONATION` 데이터만 대상으로 계산한다.
|
||||
- 팬톡은 기존 `CreatorCheers`를 의미한다.
|
||||
- 점수는 최근 7일 데이터를 기반으로 계산한다.
|
||||
- 신규 부스트는 데뷔 후 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다.
|
||||
- 신규 부스트의 데뷔일은 `Member.createdAt`이 아니라 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
|
||||
- 점수는 KST 매일 06:00에 전날 23:59:59 KST 기준 데이터로 1회 갱신한다. 스케줄러는 `Asia/Seoul` zone의 KST 06:00 기준으로 실행한다.
|
||||
|
||||
### Feature K. 인기 커뮤니티
|
||||
|
||||
#### Requirements
|
||||
- 홈 첫 화면은 10개를 조회한다.
|
||||
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, UTC 시간, 좋아요 수, 댓글 수, 커뮤니티 내용을 포함한다.
|
||||
- 크리에이터당 1개의 커뮤니티 게시글만 노출한다.
|
||||
- 비공개 커뮤니티 게시글은 제외한다.
|
||||
- 유료 커뮤니티 게시글은 제외한다.
|
||||
- 핀으로 고정한 커뮤니티 게시글은 제외한다.
|
||||
- 성인 속성을 가진 커뮤니티 게시글은 기존 노출 조건과 동일하게 `MemberContentPreference.isAdultContentVisible == true`인 회원에게만 노출한다.
|
||||
- 동일 점수의 경우 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출한다.
|
||||
- 커뮤니티 인기 점수는 `((0.5 * 좋아요 수) + (0.5 * 댓글 수) + (0.1 * 팔로우 수)) * 신규 부스트`로 계산한다.
|
||||
- 점수는 최근 7일 데이터를 기반으로 계산한다.
|
||||
- 신규 부스트는 데뷔 후 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다.
|
||||
- 신규 부스트의 데뷔일은 `Member.createdAt`이 아니라 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
|
||||
- 점수는 KST 매일 06:00에 전날 23:59:59 KST 기준 데이터로 1회 갱신한다. 스케줄러는 `Asia/Seoul` zone의 KST 06:00 기준으로 실행한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 댓글 불가 게시글도 댓글 수 0으로 점수 계산 대상에 포함한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. Technical Constraints
|
||||
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
|
||||
- 신규 구현 코드는 `kr.co.vividnext.sodalive.v2` 하위에 둔다.
|
||||
- 신규 코드는 클라이언트 공개 API 조립 계층과 재사용 가능한 추천 기능 계층을 분리한다.
|
||||
- 클라이언트에 공개되는 메인 홈 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home` 하위에 둔다.
|
||||
- 홈 API 외부에서도 재사용 가능한 추천, 점수 계산, 노출 정책, 스냅샷, 캐시, 콘텐츠 조회 이력 기능은 `kr.co.vividnext.sodalive.v2.recommend` 하위에 둔다.
|
||||
- 의존 방향은 `v2.api.home`에서 `v2.recommend`를 호출하는 방향으로만 둔다. `v2.recommend`는 `v2.api.home`의 DTO나 application service에 의존하지 않는다.
|
||||
- `v2.api.home`과 `v2.recommend` 모두 필요한 범위에서 경량 헥사고날 아키텍처를 적용하고, 기본 하위 패키지는 `application`, `domain`, `port`, `adapter`, `dto`를 사용한다.
|
||||
- Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다.
|
||||
- `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다.
|
||||
- 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다.
|
||||
- `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다.
|
||||
- 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다.
|
||||
- 조회 구현은 복잡도에 맞춰 선택한다. 단순 id 조회, 단건 조회, 명확한 조건 조회는 Spring Data JPA 기본 메서드 또는 `@Query`를 사용할 수 있고, 동적 조건/집계/서브쿼리/복합 정렬이 필요한 경우 QueryDSL을 우선 사용한다.
|
||||
- native SQL은 CTE, window function, `union all`, DB-side exact scoring, DB별 랜덤 tie-breaker처럼 QueryDSL/JPA 표현이 부자연스럽거나 정확도/성능을 해칠 수 있는 경우에만 사용한다. native SQL을 사용할 때는 RED 단계에서 제외 조건, null aggregate, boundary window, 정렬/limit 순서, Kotlin 정책 산식과의 parity를 촘촘히 검증한다.
|
||||
- 홈 추천 조회에는 공통 차단 필터를 적용해 내가 차단했거나 나를 차단한 크리에이터의 데이터를 제외한다.
|
||||
- 커뮤니티 게시글 조회에는 비공개 제외, 유료 글 제외, 핀 고정 글 제외, 성인 노출 조건(`MemberContentPreference.isAdultContentVisible`)을 공통 적용한다.
|
||||
- 일 1회 갱신 섹션은 조회 시점마다 무거운 집계를 하지 않도록 집계 테이블 또는 스냅샷 엔티티를 신규로 둔다.
|
||||
- 랜덤 정렬이 필요한 섹션은 성능을 고려해 후보군 축소 후 랜덤화하거나 스냅샷 생성 시 랜덤 tie-breaker 값을 저장한다. 단, 일 1회 점수 기반 스냅샷은 아래 candidate pre-limit 금지 규칙을 따른다.
|
||||
- 일 1회 갱신 스냅샷은 후보를 application/service 메모리로 모두 불러와 점수를 계산하지 않는다. DB 조회에서 모든 적격 후보의 최종 점수와 랜덤 tie-breaker를 계산한 뒤 `score desc, randomTieBreaker asc` 기준으로 정렬하고, 그 이후에만 최종 저장 개수 limit을 적용한다.
|
||||
- 최종 점수 계산 전 candidate pre-limit, 랜덤 후보 컷오프, 임의 2배수 선제 제한은 정확한 top 후보를 누락할 수 있으므로 금지한다.
|
||||
- DB score expression과 Kotlin `RecommendationScorePolicy`는 동일한 `RecommendationScoreSpec`의 가중치/부스트 구간 상수를 공유해 산식 drift를 방지한다.
|
||||
- 스냅샷 최종 저장 개수는 동점자 랜덤 노출 여지를 확보하기 위해 홈 첫 화면 노출 수의 2배인 AI 캐릭터 최대 20개, 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개로 한다. 단, 이 숫자는 최종 점수 계산과 동점 랜덤 정렬 이후 적용하는 저장 limit이며 candidate pre-limit가 아니다.
|
||||
- 공개 시간은 UTC 기준 응답을 원칙으로 한다.
|
||||
- 응답 DTO의 enum 값은 앱 다국어 처리를 위해 안정적인 영문 code로 내려준다.
|
||||
- 기존 API 스키마는 변경하지 않고 신규 v2 endpoint로 분리한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Metrics
|
||||
- 메인 홈 API 성공률과 응답 시간
|
||||
- 섹션별 빈 응답 비율
|
||||
- 전체보기 API 조회 수
|
||||
- 추천 섹션별 클릭률
|
||||
- 장르 추천 크리에이터 동시 팔로우 요청 수와 성공 수
|
||||
- 콘텐츠 조회 이력 기록 성공률
|
||||
- 일 배치 집계 성공/실패 수
|
||||
- 집계 스냅샷 생성 소요 시간
|
||||
|
||||
---
|
||||
|
||||
## 10. Decisions
|
||||
- 실제 데뷔일을 계산할 첫 공개 콘텐츠와 첫 라이브가 모두 없는 크리에이터는 Phase 2 스냅샷 후보에서 제외한다.
|
||||
- Phase 2 점수 기반 스냅샷은 DB-side exact scoring으로 계산한다. service는 기준 시각 계산과 snapshot replace만 담당하고, 최종 점수 산식/정렬/limit은 repository query에서 처리한다.
|
||||
- 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. native SQL은 SQL 고급 기능이 필요한 추천/랭킹/스냅샷 산정에 한정하고, 단순 상세 조회와 대상 활성 조건은 가능하면 QueryDSL/JPA 조건으로 표현한다.
|
||||
|
||||
---
|
||||
|
||||
## 11. Related Documents
|
||||
- `docs/prd/sample-prd.md`
|
||||
- `docs/agent-guides/작업절차.md`
|
||||
- `docs/agent-guides/문서유지보수.md`
|
||||
@@ -13,6 +13,7 @@
|
||||
- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 PRD 또는 구현 계획/TASK 문서를 만들지 말고 기존 문서를 갱신한다.
|
||||
- 변경 중: 신규 기능, 버그 수정, 리팩터링, 동작 변경은 테스트 작성이 불가능한 작업이 아닌 한 실패하는 테스트를 먼저 작성하고 실패를 확인한 뒤 구현한다.
|
||||
- 변경 중: 범위가 변경되면 구현 전에 계획 문서 체크리스트를 먼저 업데이트한다.
|
||||
- 변경 중: Todo를 사용할 때는 사용자에게 보이는 Todo 내용을 한국어로 작성한다. 경로, 클래스명, 명령어, 코드 식별자는 원문을 유지한다.
|
||||
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
||||
- 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
|
||||
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
|
||||
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
|
||||
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
|
||||
- 인터페이스의 기본 구현체는 접미사 `Impl`을 사용하지 않고 접두사 `Default`를 사용한다. 예: `HomeRecommendationQueryRepository`의 기본 구현체는 `DefaultHomeRecommendationQueryRepository`로 명명한다.
|
||||
|
||||
### 4) 패키지/코드 배치 규칙
|
||||
- 기존 로직을 수정하는 경우에는 기존 패키지 구조를 따른다.
|
||||
|
||||
@@ -13,3 +13,8 @@
|
||||
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
|
||||
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
|
||||
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.
|
||||
|
||||
## Redis 테스트 격리 규칙
|
||||
- embedded Redis는 모든 테스트에 전역 등록하지 않는다. `src/test/resources/META-INF/spring.factories`로 `EmbeddedRedisInitializer`를 등록하면 Redis가 필요 없는 `@DataJpaTest`까지 Redis를 시작하므로 금지한다.
|
||||
- Redis가 필요한 통합 테스트만 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 명시적으로 opt-in 한다.
|
||||
- Redis가 필요 없는 JPA/QueryDSL 슬라이스 테스트는 기존 관례처럼 `@DataJpaTest(properties = ["spring.cache.type=none"])`로 캐시를 끈다.
|
||||
|
||||
@@ -101,6 +101,9 @@ class SecurityConfig(
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
||||
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
|
||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.build()
|
||||
|
||||
@@ -39,6 +39,8 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import kr.co.vividnext.sodalive.v2.recommend.application.CreatorContentViewHistoryService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.cache.annotation.Cacheable
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
@@ -63,6 +65,7 @@ class AudioContentService(
|
||||
private val limitedEditionOrderRepository: LimitedEditionOrderRepository,
|
||||
private val themeQueryRepository: AudioContentThemeQueryRepository,
|
||||
private val playbackTrackingRepository: PlaybackTrackingRepository,
|
||||
private val creatorContentViewHistoryService: CreatorContentViewHistoryService,
|
||||
private val commentRepository: AudioContentCommentRepository,
|
||||
private val audioContentLikeRepository: AudioContentLikeRepository,
|
||||
private val pinContentRepository: PinContentRepository,
|
||||
@@ -89,6 +92,8 @@ class AudioContentService(
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val coverImageHost: String
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Transactional
|
||||
fun audioContentLike(request: PutAudioContentLikeRequest, member: Member): PutAudioContentLikeResponse {
|
||||
var audioContentLike = audioContentLikeRepository.findByMemberIdAndContentId(
|
||||
@@ -813,6 +818,21 @@ class AudioContentService(
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
creatorContentViewHistoryService.recordView(
|
||||
memberId = member.id!!,
|
||||
contentId = audioContent.id!!
|
||||
)
|
||||
}.onFailure { ex ->
|
||||
log.warn(
|
||||
"event=creator_content_view_history_record_failure memberId={} contentId={} error={}",
|
||||
member.id,
|
||||
audioContent.id,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
}
|
||||
|
||||
return GetAudioContentDetailResponse(
|
||||
contentId = audioContent.id!!,
|
||||
title = audioContent.title,
|
||||
|
||||
@@ -6,9 +6,19 @@ import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
data class CreatorFollowing(
|
||||
@Table(
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_creator_following_member_creator",
|
||||
columnNames = ["member_id", "creator_id"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class CreatorFollowing(
|
||||
var isNotify: Boolean = true,
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home.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.home.application.HomeRecommendationFacade
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.FollowRecommendedCreatorsRequest
|
||||
import kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowService
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
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/home/recommendations")
|
||||
class HomeRecommendationController(
|
||||
private val homeRecommendationFacade: HomeRecommendationFacade,
|
||||
private val recommendedCreatorFollowService: RecommendedCreatorFollowService
|
||||
) {
|
||||
@GetMapping
|
||||
fun getHomeRecommendations(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(homeRecommendationFacade.getHomeRecommendations(member))
|
||||
}
|
||||
|
||||
@GetMapping("/lives")
|
||||
fun getLives(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
homeRecommendationFacade.getLives(
|
||||
requireMember(member),
|
||||
normalizePage(page),
|
||||
normalizeSize(size)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/debut-creators")
|
||||
fun getDebutCreators(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
homeRecommendationFacade.getRecentDebutCreators(
|
||||
requireMember(member),
|
||||
normalizePage(page),
|
||||
normalizeSize(size)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/first-audio-contents")
|
||||
fun getFirstAudioContents(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
homeRecommendationFacade.getFirstAudioContents(
|
||||
requireMember(member),
|
||||
normalizePage(page),
|
||||
normalizeSize(size)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/ai-characters")
|
||||
fun getAiCharacters(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
homeRecommendationFacade.getAiCharacters(
|
||||
requireMember(member),
|
||||
normalizePage(page),
|
||||
normalizeSize(size)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/creators/follow")
|
||||
fun followRecommendedCreators(
|
||||
@RequestBody request: FollowRecommendedCreatorsRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
val authenticatedMember = requireMember(member)
|
||||
val creatorIds = request.creatorIds
|
||||
if (creatorIds.isNullOrEmpty() || creatorIds.size > MAX_CREATOR_IDS) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
|
||||
recommendedCreatorFollowService.followCreators(
|
||||
member = authenticatedMember,
|
||||
creatorIds = creatorIds
|
||||
)
|
||||
ApiResponse.ok<Unit>()
|
||||
}
|
||||
|
||||
private fun requireMember(member: Member?): Member {
|
||||
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
}
|
||||
|
||||
private fun normalizePage(page: Int): Int = page.coerceIn(0, MAX_PAGE)
|
||||
|
||||
private fun normalizeSize(size: Int): Int {
|
||||
if (size < 1) return DEFAULT_PAGE_SIZE
|
||||
return minOf(size, MAX_PAGE_SIZE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_CREATOR_IDS = 50
|
||||
private const val DEFAULT_PAGE_SIZE = 20
|
||||
private const val MAX_PAGE_SIZE = 50
|
||||
private const val MAX_PAGE = 10_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeActiveCreatorItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeAiCharacterItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeBannerItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeCreatorItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeFirstAudioContentItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeGenreCreatorGroupItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeLiveItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomePopularCommunityItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeRecommendationPageResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeRecommendationResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.imageUrl
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.toUtcIso
|
||||
import kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryService
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationGroup
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Component
|
||||
class HomeRecommendationFacade(
|
||||
private val queryService: HomeRecommendationQueryService,
|
||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
fun getHomeRecommendations(member: Member?): HomeRecommendationResponse {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
return runCatching {
|
||||
val now = LocalDateTime.now()
|
||||
val includeAdult = resolveAdultVisibility(member)
|
||||
|
||||
HomeRecommendationResponse(
|
||||
lives = queryService.findLiveRecommendations(
|
||||
limit = HOME_LIVE_LIMIT,
|
||||
memberId = member?.id,
|
||||
includeAdultLives = includeAdult
|
||||
).map { it.toItem() },
|
||||
banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id).map { it.toItem() },
|
||||
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
|
||||
HOME_ACTIVE_CREATOR_LIMIT,
|
||||
member?.id,
|
||||
includeAdult
|
||||
)
|
||||
.map { it.toItem() },
|
||||
recentDebutCreators = queryService.findRecentDebutCreators(
|
||||
now,
|
||||
limit = HOME_RECENT_DEBUT_CREATOR_LIMIT,
|
||||
memberId = member?.id,
|
||||
includeAdultContents = includeAdult
|
||||
)
|
||||
.map { it.toItem() },
|
||||
firstAudioContents = queryService.findFirstAudioContents(
|
||||
now,
|
||||
limit = HOME_FIRST_AUDIO_CONTENT_LIMIT,
|
||||
memberId = member?.id,
|
||||
includeAdultContents = includeAdult
|
||||
)
|
||||
.map { it.toItem() },
|
||||
aiCharacters = queryService.findAiCharacterRecommendations(limit = HOME_AI_CHARACTER_LIMIT).map { it.toItem() },
|
||||
genreCreators = queryService.findGenreCreatorRecommendations(
|
||||
memberId = member?.id,
|
||||
includeAdultGenres = includeAdult,
|
||||
genreLimit = HOME_GENRE_CREATOR_GENRE_LIMIT,
|
||||
creatorLimit = HOME_GENRE_CREATOR_CREATOR_LIMIT
|
||||
).map { it.toItem() },
|
||||
cheerCreators = queryService.findCheerCreatorRecommendations(HOME_CHEER_CREATOR_LIMIT, member?.id)
|
||||
.map { it.toCreatorItem() },
|
||||
popularCommunities = queryService.findPopularCommunityRecommendations(
|
||||
limit = HOME_POPULAR_COMMUNITY_LIMIT,
|
||||
memberId = member?.id,
|
||||
includeAdultCommunities = includeAdult
|
||||
).map { it.toItem() }
|
||||
)
|
||||
}.onSuccess { response ->
|
||||
log.info(
|
||||
"event=home_recommendations_query_success memberId={} elapsedMs={} emptySections={}",
|
||||
member?.id,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
response.emptySections()
|
||||
)
|
||||
}.onFailure { ex ->
|
||||
log.warn(
|
||||
"event=home_recommendations_query_failure memberId={} elapsedMs={} error={}",
|
||||
member?.id,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
fun getLives(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeLiveItem> {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
return runCatching {
|
||||
val fetched = queryService.findLiveRecommendations(
|
||||
offset = page.toOffset(size),
|
||||
limit = size + 1,
|
||||
memberId = member.id,
|
||||
includeAdultLives = resolveAdultVisibility(member)
|
||||
)
|
||||
fetched.toPage(page, size) { it.toItem() }
|
||||
}.onSuccess {
|
||||
logPageSuccess("LIVE", member, page, size, it.items.size, System.currentTimeMillis() - startedAt)
|
||||
}.onFailure { ex ->
|
||||
logPageFailure("LIVE", member, page, size, startedAt, ex)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
fun getRecentDebutCreators(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeCreatorItem> {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
return runCatching {
|
||||
val fetched = queryService.findRecentDebutCreators(
|
||||
now = LocalDateTime.now(),
|
||||
offset = page.toOffset(size),
|
||||
limit = size + 1,
|
||||
memberId = member.id,
|
||||
includeAdultContents = resolveAdultVisibility(member)
|
||||
)
|
||||
fetched.toPage(page, size) { it.toItem() }
|
||||
}.onSuccess {
|
||||
logPageSuccess("DEBUT_CREATOR", member, page, size, it.items.size, System.currentTimeMillis() - startedAt)
|
||||
}.onFailure { ex ->
|
||||
logPageFailure("DEBUT_CREATOR", member, page, size, startedAt, ex)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
fun getFirstAudioContents(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeFirstAudioContentItem> {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
return runCatching {
|
||||
val fetched = queryService.findFirstAudioContents(
|
||||
now = LocalDateTime.now(),
|
||||
offset = page.toOffset(size),
|
||||
limit = size + 1,
|
||||
memberId = member.id,
|
||||
includeAdultContents = resolveAdultVisibility(member)
|
||||
)
|
||||
fetched.toPage(page, size) { it.toItem() }
|
||||
}.onSuccess {
|
||||
logPageSuccess("FIRST_AUDIO_CONTENT", member, page, size, it.items.size, System.currentTimeMillis() - startedAt)
|
||||
}.onFailure { ex ->
|
||||
logPageFailure("FIRST_AUDIO_CONTENT", member, page, size, startedAt, ex)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
fun getAiCharacters(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeAiCharacterItem> {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
return runCatching {
|
||||
val fetched = queryService.findAiCharacterRecommendations(offset = page.toOffset(size), limit = size + 1)
|
||||
fetched.toPage(page, size) { it.toItem() }
|
||||
}.onSuccess {
|
||||
logPageSuccess("AI_CHARACTER", member, page, size, it.items.size, System.currentTimeMillis() - startedAt)
|
||||
}.onFailure { ex ->
|
||||
logPageFailure("AI_CHARACTER", member, page, size, startedAt, ex)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private fun logPageSuccess(section: String, member: Member, page: Int, size: Int, itemCount: Int, elapsedMs: Long) {
|
||||
log.info(
|
||||
"event=home_recommendations_page_query_success section={} memberId={} page={} size={} itemCount={} elapsedMs={}",
|
||||
section,
|
||||
member.id,
|
||||
page,
|
||||
size,
|
||||
itemCount,
|
||||
elapsedMs
|
||||
)
|
||||
}
|
||||
|
||||
private fun logPageFailure(section: String, member: Member, page: Int, size: Int, startedAt: Long, ex: Throwable) {
|
||||
log.warn(
|
||||
"event=home_recommendations_page_query_failure section={} memberId={} page={} size={} elapsedMs={} error={}",
|
||||
section,
|
||||
member.id,
|
||||
page,
|
||||
size,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
}
|
||||
|
||||
private fun HomeRecommendationResponse.emptySections(): List<String> {
|
||||
return buildList {
|
||||
if (lives.isEmpty()) add("lives")
|
||||
if (banners.isEmpty()) add("banners")
|
||||
if (recentlyActiveCreators.isEmpty()) add("recentlyActiveCreators")
|
||||
if (recentDebutCreators.isEmpty()) add("recentDebutCreators")
|
||||
if (firstAudioContents.isEmpty()) add("firstAudioContents")
|
||||
if (aiCharacters.isEmpty()) add("aiCharacters")
|
||||
if (genreCreators.isEmpty()) add("genreCreators")
|
||||
if (cheerCreators.isEmpty()) add("cheerCreators")
|
||||
if (popularCommunities.isEmpty()) add("popularCommunities")
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAdultVisibility(member: Member?): Boolean {
|
||||
if (member == null) return false
|
||||
val preference = memberContentPreferenceService.initializeDefaultPreference(member)
|
||||
return isAdultVisibleByPolicy(member, preference.isAdultContentVisible)
|
||||
}
|
||||
|
||||
private fun Int.toOffset(size: Int): Int = this * size
|
||||
|
||||
private fun <S, T> List<S>.toPage(
|
||||
page: Int,
|
||||
size: Int,
|
||||
transform: (S) -> T
|
||||
): HomeRecommendationPageResponse<T> {
|
||||
val items = this.take(size).map(transform)
|
||||
val hasNext = this.size > size
|
||||
return HomeRecommendationPageResponse(items = items, page = page, size = size, hasNext = hasNext)
|
||||
}
|
||||
|
||||
private fun HomeLiveRecommendationRecord.toItem() = HomeLiveItem(
|
||||
liveRoomId = liveRoomId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = creatorNickname,
|
||||
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage),
|
||||
title = title,
|
||||
coverImage = imageUrl(cloudFrontHost, coverImage),
|
||||
beginDateTime = beginDateTime.toUtcIso(),
|
||||
channelName = channelName
|
||||
)
|
||||
|
||||
private fun HomeBannerRecommendationRecord.toItem() = HomeBannerItem(
|
||||
bannerId = bannerId,
|
||||
type = type,
|
||||
thumbnailImage = imageUrl(cloudFrontHost, thumbnailImage),
|
||||
eventId = eventId,
|
||||
creatorId = creatorId,
|
||||
seriesId = seriesId,
|
||||
link = link
|
||||
)
|
||||
|
||||
private fun RecentlyActiveCreatorRecord.toItem() = HomeActiveCreatorItem(
|
||||
creatorId = creatorId,
|
||||
creatorNickname = creatorNickname,
|
||||
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage),
|
||||
activityType = activityType.name,
|
||||
activityAt = activityAt.toUtcIso(),
|
||||
targetId = targetId
|
||||
)
|
||||
|
||||
private fun RecentDebutCreatorRecord.toItem() = HomeCreatorItem(
|
||||
creatorId = creatorId,
|
||||
creatorNickname = creatorNickname,
|
||||
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage)
|
||||
)
|
||||
|
||||
private fun HomeFirstAudioContentRecord.toItem() = HomeFirstAudioContentItem(
|
||||
contentId = contentId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = creatorNickname,
|
||||
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage),
|
||||
title = title,
|
||||
coverImage = imageUrl(cloudFrontHost, coverImage),
|
||||
releaseDate = releaseDate.toUtcIso()
|
||||
)
|
||||
|
||||
private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem(
|
||||
characterId = characterId,
|
||||
name = name,
|
||||
description = description,
|
||||
totalChatCount = totalChatCount,
|
||||
originalWorkTitle = originalWorkTitle
|
||||
)
|
||||
|
||||
private fun HomeGenreCreatorRecommendationGroup.toItem() = HomeGenreCreatorGroupItem(
|
||||
genreId = genreId,
|
||||
genreName = genreName,
|
||||
creators = creators.map {
|
||||
HomeCreatorItem(
|
||||
creatorId = it.creatorId,
|
||||
creatorNickname = it.creatorNickname,
|
||||
creatorProfileImage = imageUrl(cloudFrontHost, it.creatorProfileImage)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
private fun HomeCheerCreatorRecommendationRecord.toCreatorItem() = HomeCreatorItem(
|
||||
creatorId = creatorId,
|
||||
creatorNickname = creatorNickname,
|
||||
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage)
|
||||
)
|
||||
|
||||
private fun HomePopularCommunityRecommendationRecord.toItem() = HomePopularCommunityItem(
|
||||
communityId = communityId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = creatorNickname,
|
||||
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage),
|
||||
content = content,
|
||||
createdAt = createdAt.toUtcIso(),
|
||||
likeCount = likeCount,
|
||||
commentCount = commentCount
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val HOME_LIVE_LIMIT = 20
|
||||
private const val HOME_BANNER_LIMIT = 20
|
||||
private const val HOME_ACTIVE_CREATOR_LIMIT = 10
|
||||
private const val HOME_RECENT_DEBUT_CREATOR_LIMIT = 10
|
||||
private const val HOME_FIRST_AUDIO_CONTENT_LIMIT = 10
|
||||
private const val HOME_AI_CHARACTER_LIMIT = 10
|
||||
private const val HOME_GENRE_CREATOR_GENRE_LIMIT = 5
|
||||
private const val HOME_GENRE_CREATOR_CREATOR_LIMIT = 8
|
||||
private const val HOME_CHEER_CREATOR_LIMIT = 8
|
||||
private const val HOME_POPULAR_COMMUNITY_LIMIT = 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home.dto
|
||||
|
||||
data class FollowRecommendedCreatorsRequest(
|
||||
val creatorIds: List<Long>?
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home.dto
|
||||
|
||||
data class HomeRecommendationPageResponse<T>(
|
||||
val items: List<T>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home.dto
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
||||
|
||||
internal fun LocalDateTime.toUtcIso(): String {
|
||||
val instant = this.atZone(KST_ZONE).withZoneSameInstant(ZoneOffset.UTC).toInstant()
|
||||
return DateTimeFormatter.ISO_INSTANT.format(instant)
|
||||
}
|
||||
|
||||
internal fun imageUrl(cloudFrontHost: String, path: String?): String? {
|
||||
return if (path.isNullOrBlank()) null else "$cloudFrontHost/$path"
|
||||
}
|
||||
|
||||
data class HomeRecommendationResponse(
|
||||
val lives: List<HomeLiveItem>,
|
||||
val banners: List<HomeBannerItem>,
|
||||
val recentlyActiveCreators: List<HomeActiveCreatorItem>,
|
||||
val recentDebutCreators: List<HomeCreatorItem>,
|
||||
val firstAudioContents: List<HomeFirstAudioContentItem>,
|
||||
val aiCharacters: List<HomeAiCharacterItem>,
|
||||
val genreCreators: List<HomeGenreCreatorGroupItem>,
|
||||
val cheerCreators: List<HomeCreatorItem>,
|
||||
val popularCommunities: List<HomePopularCommunityItem>
|
||||
)
|
||||
|
||||
data class HomeLiveItem(
|
||||
val liveRoomId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val title: String,
|
||||
val coverImage: String?,
|
||||
val beginDateTime: String,
|
||||
val channelName: String
|
||||
)
|
||||
|
||||
data class HomeBannerItem(
|
||||
val bannerId: Long,
|
||||
val type: String,
|
||||
val thumbnailImage: String?,
|
||||
val eventId: Long?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?
|
||||
)
|
||||
|
||||
data class HomeActiveCreatorItem(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val activityType: String,
|
||||
val activityAt: String,
|
||||
val targetId: Long?
|
||||
)
|
||||
|
||||
data class HomeCreatorItem(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?
|
||||
)
|
||||
|
||||
data class HomeFirstAudioContentItem(
|
||||
val contentId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val title: String,
|
||||
val coverImage: String?,
|
||||
val releaseDate: String
|
||||
)
|
||||
|
||||
data class HomeAiCharacterItem(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val totalChatCount: Long,
|
||||
val originalWorkTitle: String?
|
||||
)
|
||||
|
||||
data class HomeGenreCreatorGroupItem(
|
||||
val genreId: Long,
|
||||
val genreName: String,
|
||||
val creators: List<HomeCreatorItem>
|
||||
)
|
||||
|
||||
data class HomePopularCommunityItem(
|
||||
val communityId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val content: String,
|
||||
val createdAt: String,
|
||||
val likeCount: Long,
|
||||
val commentCount: Long
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.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
|
||||
|
||||
@Entity
|
||||
@Table(name = "creator_content_view_history")
|
||||
class CreatorContentViewHistory(
|
||||
@Column(name = "member_id", nullable = false, updatable = false)
|
||||
val memberId: Long,
|
||||
|
||||
@Column(name = "content_id", nullable = false, updatable = false)
|
||||
val contentId: Long,
|
||||
|
||||
@Column(name = "genre_id", nullable = false, updatable = false)
|
||||
val genreId: Long,
|
||||
|
||||
@Column(name = "viewed_at", nullable = false, updatable = false)
|
||||
val viewedAt: LocalDateTime
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,38 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.CreatorContentViewHistoryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.CreatorContentViewHistoryRecord
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
class CreatorContentViewHistoryPersistenceAdapter(
|
||||
private val repository: CreatorContentViewHistoryRepository,
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : CreatorContentViewHistoryPort {
|
||||
override fun findGenreIdByContentId(contentId: Long): Long? {
|
||||
return queryFactory
|
||||
.select(audioContentTheme.id)
|
||||
.from(audioContent)
|
||||
.innerJoin(audioContent.theme, audioContentTheme)
|
||||
.where(
|
||||
audioContent.id.eq(contentId),
|
||||
audioContent.isActive.isTrue,
|
||||
audioContentTheme.isActive.isTrue
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun save(record: CreatorContentViewHistoryRecord) {
|
||||
repository.save(
|
||||
CreatorContentViewHistory(
|
||||
memberId = record.memberId,
|
||||
contentId = record.contentId,
|
||||
genreId = record.genreId,
|
||||
viewedAt = record.viewedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CreatorContentViewHistoryRepository : JpaRepository<CreatorContentViewHistory, Long>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||
import org.springframework.data.repository.NoRepositoryBean
|
||||
|
||||
@NoRepositoryBean
|
||||
interface HomeRecommendationQueryRepository : HomeRecommendationQueryPort
|
||||
@@ -0,0 +1,30 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.Table
|
||||
|
||||
@Entity
|
||||
@Table(name = "recommendation_snapshot")
|
||||
class RecommendationSnapshot(
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "section_type", nullable = false, updatable = false, length = 50)
|
||||
val sectionType: RecommendedSectionType,
|
||||
|
||||
@Column(name = "target_id", nullable = false, updatable = false)
|
||||
val targetId: Long,
|
||||
|
||||
@Column(name = "score", nullable = false, updatable = false)
|
||||
val score: Double,
|
||||
|
||||
@Column(name = "snapshot_at", nullable = false, updatable = false)
|
||||
val snapshotAt: LocalDateTime,
|
||||
|
||||
@Column(name = "random_tie_breaker", nullable = false, updatable = false)
|
||||
val randomTieBreaker: Double
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,49 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class RecommendationSnapshotPersistenceAdapter(
|
||||
private val repository: RecommendationSnapshotRepository
|
||||
) : RecommendationSnapshotPort {
|
||||
override fun findLatestSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() }
|
||||
}
|
||||
|
||||
override fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
) {
|
||||
repository.deleteBySectionTypeAndSnapshotAt(sectionType, snapshotAt)
|
||||
repository.saveAll(newSnapshots.map { it.toEntity() })
|
||||
}
|
||||
|
||||
private fun RecommendationSnapshot.toRecord(): RecommendationSnapshotRecord {
|
||||
return RecommendationSnapshotRecord(
|
||||
sectionType = sectionType,
|
||||
targetId = targetId,
|
||||
score = score,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = randomTieBreaker
|
||||
)
|
||||
}
|
||||
|
||||
private fun RecommendationSnapshotRecord.toEntity(): RecommendationSnapshot {
|
||||
return RecommendationSnapshot(
|
||||
sectionType = sectionType,
|
||||
targetId = targetId,
|
||||
score = score,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = randomTieBreaker
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
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 RecommendationSnapshotRepository : JpaRepository<RecommendationSnapshot, Long> {
|
||||
@Query(
|
||||
value = """
|
||||
select *
|
||||
from recommendation_snapshot rs
|
||||
where rs.section_type = :sectionType
|
||||
and rs.snapshot_at = (
|
||||
select max(latest.snapshot_at)
|
||||
from recommendation_snapshot latest
|
||||
where latest.section_type = :sectionType
|
||||
)
|
||||
order by rs.score desc, rs.random_tie_breaker asc
|
||||
limit :limit offset :offset
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findLatestSnapshots(
|
||||
@Param("sectionType") sectionType: String,
|
||||
@Param("offset") offset: Int,
|
||||
@Param("limit") limit: Int
|
||||
): List<RecommendationSnapshot>
|
||||
|
||||
fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshService
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class RecommendationSnapshotScheduler(
|
||||
private val refreshService: RecommendationSnapshotRefreshService
|
||||
) {
|
||||
@Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul")
|
||||
fun refreshDailySnapshots() {
|
||||
refreshService.refreshDailySnapshots()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.CreatorContentViewHistoryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.CreatorContentViewHistoryRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class CreatorContentViewHistoryService(
|
||||
private val port: CreatorContentViewHistoryPort
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun recordView(memberId: Long?, contentId: Long, viewedAt: LocalDateTime = LocalDateTime.now()) {
|
||||
if (memberId == null) {
|
||||
log.info("event=creator_content_view_history_record_skipped reason=anonymous contentId={}", contentId)
|
||||
return
|
||||
}
|
||||
|
||||
val genreId = port.findGenreIdByContentId(contentId)
|
||||
if (genreId == null) {
|
||||
log.info(
|
||||
"event=creator_content_view_history_record_skipped reason=genre_not_found memberId={} contentId={}",
|
||||
memberId,
|
||||
contentId
|
||||
)
|
||||
return
|
||||
}
|
||||
port.save(
|
||||
CreatorContentViewHistoryRecord(
|
||||
memberId = memberId,
|
||||
contentId = contentId,
|
||||
genreId = genreId,
|
||||
viewedAt = viewedAt
|
||||
)
|
||||
)
|
||||
afterCommit {
|
||||
log.info(
|
||||
"event=creator_content_view_history_record_success memberId={} contentId={} genreId={}",
|
||||
memberId,
|
||||
contentId,
|
||||
genreId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterCommit(action: () -> Unit) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
action()
|
||||
return
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() = action()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationGroup
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class HomeRecommendationQueryService(
|
||||
private val queryPort: HomeRecommendationQueryPort,
|
||||
private val snapshotPort: RecommendationSnapshotPort
|
||||
) {
|
||||
fun findLiveRecommendations(
|
||||
offset: Int = 0,
|
||||
limit: Int = DEFAULT_LIVE_LIMIT,
|
||||
memberId: Long? = null,
|
||||
includeAdultLives: Boolean = false
|
||||
): List<HomeLiveRecommendationRecord> {
|
||||
return queryPort.findLiveRecommendations(offset, limit, memberId, includeAdultLives)
|
||||
}
|
||||
|
||||
fun findHomeBanners(
|
||||
limit: Int = DEFAULT_BANNER_LIMIT,
|
||||
memberId: Long? = null
|
||||
): List<HomeBannerRecommendationRecord> {
|
||||
return queryPort.findHomeBanners(limit, memberId)
|
||||
}
|
||||
|
||||
fun findRecentlyActiveCreators(
|
||||
limit: Int = DEFAULT_ACTIVE_CREATOR_LIMIT,
|
||||
memberId: Long? = null,
|
||||
includeAdultActivities: Boolean = false
|
||||
): List<RecentlyActiveCreatorRecord> {
|
||||
return queryPort.findRecentlyActiveCreators(limit, memberId, includeAdultActivities)
|
||||
}
|
||||
|
||||
fun findRecentDebutCreators(
|
||||
now: LocalDateTime,
|
||||
offset: Int = 0,
|
||||
limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT,
|
||||
memberId: Long? = null,
|
||||
includeAdultContents: Boolean = false
|
||||
): List<RecentDebutCreatorRecord> {
|
||||
return queryPort.findRecentDebutCreators(now, offset, limit, memberId, includeAdultContents)
|
||||
}
|
||||
|
||||
fun findFirstAudioContents(
|
||||
now: LocalDateTime,
|
||||
offset: Int = 0,
|
||||
limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT,
|
||||
memberId: Long? = null,
|
||||
includeAdultContents: Boolean = false
|
||||
): List<HomeFirstAudioContentRecord> {
|
||||
return queryPort.findFirstAudioContents(now, offset, limit, memberId, includeAdultContents)
|
||||
}
|
||||
|
||||
fun findAiCharacterRecommendations(
|
||||
offset: Int = 0,
|
||||
limit: Int = DEFAULT_AI_CHARACTER_LIMIT
|
||||
): List<HomeAiCharacterRecommendationRecord> {
|
||||
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)
|
||||
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
|
||||
.associateBy { it.characterId }
|
||||
|
||||
return snapshots.mapNotNull { detailsById[it.targetId] }
|
||||
}
|
||||
|
||||
fun findCheerCreatorRecommendations(
|
||||
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT,
|
||||
memberId: Long? = null
|
||||
): List<HomeCheerCreatorRecommendationRecord> {
|
||||
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).take(CHEER_CREATOR_CANDIDATE_LIMIT)
|
||||
val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId)
|
||||
.associateBy { it.creatorId }
|
||||
|
||||
return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit)
|
||||
}
|
||||
|
||||
fun findPopularCommunityRecommendations(
|
||||
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
|
||||
memberId: Long? = null,
|
||||
includeAdultCommunities: Boolean = false
|
||||
): List<HomePopularCommunityRecommendationRecord> {
|
||||
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
|
||||
.take(POPULAR_COMMUNITY_CANDIDATE_LIMIT)
|
||||
val detailsById = queryPort.findPopularCommunityRecommendationDetails(
|
||||
snapshots.map { it.targetId },
|
||||
memberId,
|
||||
includeAdultCommunities
|
||||
)
|
||||
.associateBy { it.communityId }
|
||||
val selectedCreatorIds = mutableSetOf<Long>()
|
||||
|
||||
return snapshots.mapNotNull { snapshot ->
|
||||
detailsById[snapshot.targetId]?.takeIf { selectedCreatorIds.add(it.creatorId) }
|
||||
}.take(limit)
|
||||
}
|
||||
|
||||
fun findGenreCreatorRecommendations(
|
||||
memberId: Long?,
|
||||
includeAdultGenres: Boolean,
|
||||
genreLimit: Int = DEFAULT_GENRE_CREATOR_GENRE_LIMIT,
|
||||
creatorLimit: Int = DEFAULT_GENRE_CREATOR_CREATOR_LIMIT
|
||||
): List<HomeGenreCreatorRecommendationGroup> {
|
||||
val selectedCreatorIds = mutableSetOf<Long>()
|
||||
val candidateLimit = genreLimit * creatorLimit
|
||||
|
||||
return queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, candidateLimit)
|
||||
.map { group ->
|
||||
group.copy(creators = group.creators.filter { selectedCreatorIds.add(it.creatorId) }.take(creatorLimit))
|
||||
}
|
||||
.filter { it.creators.isNotEmpty() }
|
||||
.take(genreLimit)
|
||||
}
|
||||
|
||||
fun resolveAudioContentActivityType(theme: String): RecommendedActivityType {
|
||||
return if (theme == LIVE_REPLAY_THEME) {
|
||||
RecommendedActivityType.LIVE_REPLAY
|
||||
} else {
|
||||
RecommendedActivityType.AUDIO
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_LIVE_LIMIT = 20
|
||||
private const val DEFAULT_BANNER_LIMIT = 20
|
||||
private const val DEFAULT_ACTIVE_CREATOR_LIMIT = 10
|
||||
private const val DEFAULT_RECENT_DEBUT_CREATOR_LIMIT = 10
|
||||
private const val DEFAULT_FIRST_AUDIO_CONTENT_LIMIT = 10
|
||||
private const val DEFAULT_AI_CHARACTER_LIMIT = 10
|
||||
private const val DEFAULT_CHEER_CREATOR_LIMIT = 8
|
||||
private const val CHEER_CREATOR_CANDIDATE_LIMIT = 16
|
||||
private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10
|
||||
private const val DEFAULT_GENRE_CREATOR_GENRE_LIMIT = 5
|
||||
private const val DEFAULT_GENRE_CREATOR_CREATOR_LIMIT = 8
|
||||
private const val POPULAR_COMMUNITY_CANDIDATE_LIMIT = 20
|
||||
private const val LIVE_REPLAY_THEME = "다시듣기"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
@Service
|
||||
class RecommendationSnapshotRefreshService(
|
||||
private val snapshotPort: RecommendationSnapshotPort,
|
||||
private val queryPort: HomeRecommendationQueryPort
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
|
||||
return snapshotPort.findLatestSnapshots(sectionType)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun refreshDailySnapshots() {
|
||||
refreshDailySnapshots(LocalDateTime.now())
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun refreshDailySnapshots(now: LocalDateTime) {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val snapshotAt = now
|
||||
.atZone(UTC_ZONE)
|
||||
.withZoneSameInstant(KST_ZONE)
|
||||
.toLocalDate()
|
||||
.minusDays(1)
|
||||
.atTime(23, 59, 59)
|
||||
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
|
||||
|
||||
runCatching {
|
||||
val aiCharacterCount = replaceAiCharacterSnapshots(windowStart, snapshotAt)
|
||||
val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt)
|
||||
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt)
|
||||
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
|
||||
}.onSuccess { counts ->
|
||||
afterCommit {
|
||||
log.info(
|
||||
"event=recommendation_snapshot_refresh_success " +
|
||||
"snapshotAt={} aiCharacterCount={} cheerCreatorCount={} popularCommunityCount={} elapsedMs={}",
|
||||
snapshotAt,
|
||||
counts.aiCharacterCount,
|
||||
counts.cheerCreatorCount,
|
||||
counts.popularCommunityCount,
|
||||
System.currentTimeMillis() - startedAt
|
||||
)
|
||||
}
|
||||
}.onFailure { ex ->
|
||||
log.warn(
|
||||
"event=recommendation_snapshot_refresh_failure snapshotAt={} elapsedMs={} error={}",
|
||||
snapshotAt,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
||||
val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT)
|
||||
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots)
|
||||
return snapshots.size
|
||||
}
|
||||
|
||||
private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
||||
val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT)
|
||||
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots)
|
||||
return snapshots.size
|
||||
}
|
||||
|
||||
private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
|
||||
val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT)
|
||||
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots)
|
||||
return snapshots.size
|
||||
}
|
||||
|
||||
private data class RefreshCounts(
|
||||
val aiCharacterCount: Int,
|
||||
val cheerCreatorCount: Int,
|
||||
val popularCommunityCount: Int
|
||||
)
|
||||
|
||||
private fun afterCommit(action: () -> Unit) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
action()
|
||||
return
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() = action()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20
|
||||
private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16
|
||||
private const val POPULAR_COMMUNITY_SNAPSHOT_LIMIT = 20
|
||||
private val UTC_ZONE: ZoneId = ZoneId.of("UTC")
|
||||
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.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.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
|
||||
@Service
|
||||
class RecommendedCreatorFollowService(
|
||||
private val memberRepository: MemberRepository,
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Transactional
|
||||
fun followCreators(member: Member, creatorIds: List<Long>) {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
var savedCount = 0
|
||||
var reactivatedCount = 0
|
||||
var skippedCount = 0
|
||||
|
||||
runCatching {
|
||||
val distinctCreatorIds = creatorIds.distinct()
|
||||
val creatorById = distinctCreatorIds
|
||||
.filter { it != member.id }
|
||||
.associateWith { creatorId ->
|
||||
memberRepository.findCreatorByIdOrNull(creatorId)
|
||||
?: throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||
}
|
||||
|
||||
distinctCreatorIds.forEach { creatorId ->
|
||||
if (creatorId == member.id) {
|
||||
skippedCount += 1
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val existingFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
creatorId = creatorId,
|
||||
memberId = member.id!!
|
||||
)
|
||||
if (existingFollowing != null) {
|
||||
if (!existingFollowing.isActive) {
|
||||
existingFollowing.isNotify = true
|
||||
existingFollowing.isActive = true
|
||||
reactivatedCount += 1
|
||||
} else {
|
||||
skippedCount += 1
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
|
||||
creatorFollowingRepository.save(
|
||||
CreatorFollowing().apply {
|
||||
this.member = member
|
||||
creator = creatorById.getValue(creatorId)
|
||||
}
|
||||
)
|
||||
savedCount += 1
|
||||
}
|
||||
distinctCreatorIds.size
|
||||
}.onSuccess { distinctCount ->
|
||||
afterCommit {
|
||||
log.info(
|
||||
"event=recommended_creator_follow_success " +
|
||||
"memberId={} requestedCount={} distinctCount={} savedCount={} " +
|
||||
"reactivatedCount={} skippedCount={} elapsedMs={}",
|
||||
member.id,
|
||||
creatorIds.size,
|
||||
distinctCount,
|
||||
savedCount,
|
||||
reactivatedCount,
|
||||
skippedCount,
|
||||
System.currentTimeMillis() - startedAt
|
||||
)
|
||||
}
|
||||
}.onFailure { ex ->
|
||||
log.warn(
|
||||
"event=recommended_creator_follow_failure memberId={} requestedCount={} distinctCount={} elapsedMs={} error={}",
|
||||
member.id,
|
||||
creatorIds.size,
|
||||
creatorIds.distinct().size,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterCommit(action: () -> Unit) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
action()
|
||||
return
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() = action()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.domain
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class CreatorDebutPolicy {
|
||||
fun resolveDebutAt(firstContentPublishedAt: LocalDateTime?, firstLiveAt: LocalDateTime?): LocalDateTime? {
|
||||
return listOfNotNull(firstContentPublishedAt, firstLiveAt).minOrNull()
|
||||
}
|
||||
|
||||
fun isNewCreator(debutAt: LocalDateTime?, now: LocalDateTime): Boolean {
|
||||
if (debutAt == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val days = ChronoUnit.DAYS.between(debutAt.toLocalDate(), now.toLocalDate())
|
||||
return days in 0..30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.domain
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class RecommendationScorePolicy {
|
||||
fun calculateCreatorNewBoost(debutAt: LocalDateTime, now: LocalDateTime): Double {
|
||||
return calculateNewBoost(debutAt, now)
|
||||
}
|
||||
|
||||
fun calculateAiCharacterNewBoost(createdAt: LocalDateTime, now: LocalDateTime): Double {
|
||||
return calculateNewBoost(createdAt, now)
|
||||
}
|
||||
|
||||
fun calculateDebutCreatorScore(
|
||||
followIncrease: Long,
|
||||
contentActivityScore: Long,
|
||||
communicationScore: Long,
|
||||
newBoost: Double
|
||||
): Double {
|
||||
return (
|
||||
(followIncrease * RecommendationScoreSpec.DEBUT_FOLLOW_INCREASE_WEIGHT) +
|
||||
(contentActivityScore * RecommendationScoreSpec.DEBUT_CONTENT_ACTIVITY_WEIGHT) +
|
||||
(communicationScore * RecommendationScoreSpec.DEBUT_COMMUNICATION_WEIGHT)
|
||||
) * newBoost
|
||||
}
|
||||
|
||||
fun calculateAiChatScore(
|
||||
recentChatCount: Long,
|
||||
recentActiveUserCount: Long,
|
||||
newBoost: Double
|
||||
): Double {
|
||||
return (
|
||||
(RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT * recentChatCount) +
|
||||
(RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT * recentActiveUserCount)
|
||||
) * newBoost
|
||||
}
|
||||
|
||||
fun calculateCheerScore(
|
||||
donationAmount: Long,
|
||||
fanTalkCount: Long,
|
||||
donationCount: Long,
|
||||
newBoost: Double
|
||||
): Double {
|
||||
return (
|
||||
(RecommendationScoreSpec.CHEER_DONATION_AMOUNT_WEIGHT * donationAmount) +
|
||||
(RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT * fanTalkCount) +
|
||||
(RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT * donationCount)
|
||||
) * newBoost
|
||||
}
|
||||
|
||||
fun calculateCommunityScore(
|
||||
likeCount: Long,
|
||||
commentCount: Long,
|
||||
followerCount: Long,
|
||||
newBoost: Double
|
||||
): Double {
|
||||
return (
|
||||
(RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT * likeCount) +
|
||||
(RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT * commentCount) +
|
||||
(RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT * followerCount)
|
||||
) * newBoost
|
||||
}
|
||||
|
||||
fun calculateFirstAudioRecencyScore(releaseDate: LocalDateTime, now: LocalDateTime): Int {
|
||||
val days = ChronoUnit.DAYS.between(releaseDate.toLocalDate(), now.toLocalDate())
|
||||
return when {
|
||||
days <= 3 -> 100
|
||||
days <= 7 -> 80
|
||||
days <= 14 -> 60
|
||||
days <= 21 -> 40
|
||||
days <= 30 -> 20
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateNewBoost(baseAt: LocalDateTime, now: LocalDateTime): Double {
|
||||
val days = ChronoUnit.DAYS.between(baseAt.toLocalDate(), now.toLocalDate())
|
||||
return when {
|
||||
days <= RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT -> RecommendationScoreSpec.NEW_BOOST_10_DAYS
|
||||
days <= RecommendationScoreSpec.NEW_BOOST_20_DAY_LIMIT -> RecommendationScoreSpec.NEW_BOOST_20_DAYS
|
||||
days <= RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT -> RecommendationScoreSpec.NEW_BOOST_30_DAYS
|
||||
else -> RecommendationScoreSpec.DEFAULT_NEW_BOOST
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.domain
|
||||
|
||||
object RecommendationScoreSpec {
|
||||
const val NEW_BOOST_10_DAY_LIMIT = 10L
|
||||
const val NEW_BOOST_20_DAY_LIMIT = 20L
|
||||
const val NEW_BOOST_30_DAY_LIMIT = 30L
|
||||
|
||||
const val DEBUT_FOLLOW_INCREASE_WEIGHT = 0.35
|
||||
const val DEBUT_CONTENT_ACTIVITY_WEIGHT = 0.3
|
||||
const val DEBUT_COMMUNICATION_WEIGHT = 0.2
|
||||
|
||||
const val AI_RECENT_CHAT_WEIGHT = 0.45
|
||||
const val AI_RECENT_ACTIVE_USER_WEIGHT = 0.35
|
||||
|
||||
const val CHEER_DONATION_AMOUNT_WEIGHT = 0.6
|
||||
const val CHEER_FAN_TALK_WEIGHT = 0.3
|
||||
const val CHEER_DONATION_COUNT_WEIGHT = 0.1
|
||||
|
||||
const val COMMUNITY_LIKE_WEIGHT = 0.5
|
||||
const val COMMUNITY_COMMENT_WEIGHT = 0.5
|
||||
const val COMMUNITY_FOLLOWER_WEIGHT = 0.1
|
||||
|
||||
const val NEW_BOOST_10_DAYS = 1.5
|
||||
const val NEW_BOOST_20_DAYS = 1.3
|
||||
const val NEW_BOOST_30_DAYS = 1.2
|
||||
const val DEFAULT_NEW_BOOST = 1.0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.domain
|
||||
|
||||
enum class RecommendedActivityType(val code: String) {
|
||||
LIVE("LIVE"),
|
||||
AUDIO("AUDIO"),
|
||||
COMMUNITY("COMMUNITY"),
|
||||
LIVE_REPLAY("LIVE_REPLAY")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.domain
|
||||
|
||||
enum class RecommendedSectionType(val code: String) {
|
||||
LIVE("LIVE"),
|
||||
BANNER("BANNER"),
|
||||
ACTIVE_CREATOR("ACTIVE_CREATOR"),
|
||||
DEBUT_CREATOR("DEBUT_CREATOR"),
|
||||
FIRST_AUDIO_CONTENT("FIRST_AUDIO_CONTENT"),
|
||||
AI_CHARACTER("AI_CHARACTER"),
|
||||
GENRE_CREATOR("GENRE_CREATOR"),
|
||||
CHEER_CREATOR("CHEER_CREATOR"),
|
||||
POPULAR_COMMUNITY("POPULAR_COMMUNITY")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.port.out
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface CreatorContentViewHistoryPort {
|
||||
fun findGenreIdByContentId(contentId: Long): Long?
|
||||
|
||||
fun save(record: CreatorContentViewHistoryRecord)
|
||||
}
|
||||
|
||||
data class CreatorContentViewHistoryRecord(
|
||||
val memberId: Long,
|
||||
val contentId: Long,
|
||||
val genreId: Long,
|
||||
val viewedAt: LocalDateTime
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface HomeRecommendationQueryPort {
|
||||
fun findLiveRecommendations(
|
||||
offset: Int = 0,
|
||||
limit: Int,
|
||||
memberId: Long? = null,
|
||||
includeAdultLives: Boolean = false
|
||||
): List<HomeLiveRecommendationRecord>
|
||||
|
||||
fun findHomeBanners(
|
||||
limit: Int,
|
||||
memberId: Long? = null
|
||||
): List<HomeBannerRecommendationRecord>
|
||||
|
||||
fun findRecentlyActiveCreators(
|
||||
limit: Int,
|
||||
memberId: Long? = null,
|
||||
includeAdultActivities: Boolean = false
|
||||
): List<RecentlyActiveCreatorRecord>
|
||||
|
||||
fun findRecentDebutCreators(
|
||||
now: LocalDateTime,
|
||||
offset: Int = 0,
|
||||
limit: Int,
|
||||
memberId: Long? = null,
|
||||
includeAdultContents: Boolean = false
|
||||
): List<RecentDebutCreatorRecord>
|
||||
|
||||
fun findFirstAudioContents(
|
||||
now: LocalDateTime,
|
||||
offset: Int = 0,
|
||||
limit: Int,
|
||||
memberId: Long? = null,
|
||||
includeAdultContents: Boolean = false
|
||||
): List<HomeFirstAudioContentRecord>
|
||||
|
||||
fun findAiCharacterSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
|
||||
fun findCheerCreatorSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
|
||||
fun findPopularCommunitySnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
|
||||
fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord>
|
||||
|
||||
fun findCheerCreatorRecommendationDetails(
|
||||
creatorIds: List<Long>,
|
||||
memberId: Long? = null
|
||||
): List<HomeCheerCreatorRecommendationRecord>
|
||||
|
||||
fun findPopularCommunityRecommendationDetails(
|
||||
communityIds: List<Long>,
|
||||
memberId: Long? = null,
|
||||
includeAdultCommunities: Boolean
|
||||
): List<HomePopularCommunityRecommendationRecord>
|
||||
|
||||
fun findGenreCreatorRecommendations(
|
||||
memberId: Long?,
|
||||
includeAdultGenres: Boolean,
|
||||
genreLimit: Int,
|
||||
creatorLimit: Int
|
||||
): List<HomeGenreCreatorRecommendationGroup>
|
||||
}
|
||||
|
||||
data class HomeLiveRecommendationRecord(
|
||||
val liveRoomId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val title: String,
|
||||
val coverImage: String?,
|
||||
val beginDateTime: LocalDateTime,
|
||||
val channelName: String
|
||||
)
|
||||
|
||||
data class HomeBannerRecommendationRecord(
|
||||
val bannerId: Long,
|
||||
val type: String,
|
||||
val thumbnailImage: String,
|
||||
val eventId: Long?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?,
|
||||
val orders: Int,
|
||||
val randomTieBreaker: Double
|
||||
)
|
||||
|
||||
data class RecentlyActiveCreatorRecord(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val activityType: RecommendedActivityType,
|
||||
val activityAt: LocalDateTime,
|
||||
val targetId: Long?
|
||||
)
|
||||
|
||||
data class RecentDebutCreatorRecord(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val debutAt: LocalDateTime,
|
||||
val score: Double,
|
||||
val randomTieBreaker: Double
|
||||
)
|
||||
|
||||
data class HomeFirstAudioContentRecord(
|
||||
val contentId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val title: String,
|
||||
val coverImage: String?,
|
||||
val releaseDate: LocalDateTime,
|
||||
val recencyScore: Int,
|
||||
val randomTieBreaker: Double
|
||||
)
|
||||
|
||||
data class HomeAiCharacterRecommendationRecord(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val totalChatCount: Long,
|
||||
val originalWorkTitle: String?
|
||||
)
|
||||
|
||||
data class HomeCheerCreatorRecommendationRecord(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?
|
||||
)
|
||||
|
||||
data class HomePopularCommunityRecommendationRecord(
|
||||
val communityId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val content: String,
|
||||
val createdAt: LocalDateTime,
|
||||
val likeCount: Long,
|
||||
val commentCount: Long
|
||||
)
|
||||
|
||||
data class HomeGenreCreatorRecommendationGroup(
|
||||
val genreId: Long,
|
||||
val genreName: String,
|
||||
val creators: List<HomeGenreCreatorRecommendationRecord>
|
||||
)
|
||||
|
||||
data class HomeGenreCreatorRecommendationRecord(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface RecommendationSnapshotPort {
|
||||
fun findLatestSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
offset: Int = 0,
|
||||
limit: Int = Int.MAX_VALUE
|
||||
): List<RecommendationSnapshotRecord>
|
||||
|
||||
fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
)
|
||||
}
|
||||
|
||||
data class RecommendationSnapshotRecord(
|
||||
val sectionType: RecommendedSectionType,
|
||||
val targetId: Long,
|
||||
val score: Double,
|
||||
val snapshotAt: LocalDateTime,
|
||||
val randomTieBreaker: Double
|
||||
)
|
||||
@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.recommend.application.CreatorContentViewHistoryService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
@@ -28,10 +29,15 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class AudioContentServiceTest {
|
||||
private lateinit var repository: AudioContentRepository
|
||||
private lateinit var explorerQueryRepository: ExplorerQueryRepository
|
||||
@@ -41,6 +47,7 @@ class AudioContentServiceTest {
|
||||
private lateinit var limitedEditionOrderRepository: LimitedEditionOrderRepository
|
||||
private lateinit var themeQueryRepository: AudioContentThemeQueryRepository
|
||||
private lateinit var playbackTrackingRepository: PlaybackTrackingRepository
|
||||
private lateinit var creatorContentViewHistoryService: CreatorContentViewHistoryService
|
||||
private lateinit var commentRepository: AudioContentCommentRepository
|
||||
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
|
||||
private lateinit var pinContentRepository: PinContentRepository
|
||||
@@ -63,6 +70,7 @@ class AudioContentServiceTest {
|
||||
limitedEditionOrderRepository = Mockito.mock(LimitedEditionOrderRepository::class.java)
|
||||
themeQueryRepository = Mockito.mock(AudioContentThemeQueryRepository::class.java)
|
||||
playbackTrackingRepository = Mockito.mock(PlaybackTrackingRepository::class.java)
|
||||
creatorContentViewHistoryService = Mockito.mock(CreatorContentViewHistoryService::class.java)
|
||||
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
|
||||
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
|
||||
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
|
||||
@@ -82,6 +90,7 @@ class AudioContentServiceTest {
|
||||
limitedEditionOrderRepository = limitedEditionOrderRepository,
|
||||
themeQueryRepository = themeQueryRepository,
|
||||
playbackTrackingRepository = playbackTrackingRepository,
|
||||
creatorContentViewHistoryService = creatorContentViewHistoryService,
|
||||
commentRepository = commentRepository,
|
||||
audioContentLikeRepository = audioContentLikeRepository,
|
||||
pinContentRepository = pinContentRepository,
|
||||
@@ -230,6 +239,38 @@ class AudioContentServiceTest {
|
||||
|
||||
Mockito.verify(repository, Mockito.never()).findSeriesIdByContentId(audioContent.id!!, false)
|
||||
Mockito.verifyNoInteractions(commentRepository)
|
||||
val recordViewInvocation = Mockito.mockingDetails(creatorContentViewHistoryService).invocations
|
||||
.single { it.method.name == "recordView" }
|
||||
assertEquals(viewer.id!!, recordViewInvocation.arguments[0])
|
||||
assertEquals(audioContent.id!!, recordViewInvocation.arguments[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 조회 이력 기록 실패는 상세 응답을 실패시키지 않고 로그로 남긴다")
|
||||
fun shouldLogViewHistoryFailureWithoutFailingDetail(output: CapturedOutput) {
|
||||
val viewer = createMember(id = 1003L, nickname = "history-failure-viewer")
|
||||
val creator = createMember(id = 2003L, nickname = "history-failure-creator")
|
||||
val audioContent = createAudioContent(creator)
|
||||
stubSuccessfulDetailDependencies(viewer, creator, audioContent)
|
||||
Mockito.doThrow(IllegalStateException("history failed"))
|
||||
.`when`(creatorContentViewHistoryService)
|
||||
.recordView(
|
||||
memberId = Mockito.eq(viewer.id!!),
|
||||
contentId = Mockito.eq(audioContent.id!!),
|
||||
viewedAt = anyLocalDateTime()
|
||||
)
|
||||
|
||||
val response = service.getDetail(
|
||||
id = audioContent.id!!,
|
||||
member = viewer,
|
||||
isAdultContentVisible = false,
|
||||
timezone = "Asia/Seoul"
|
||||
)
|
||||
|
||||
assertEquals(audioContent.id!!, response.contentId)
|
||||
assertTrue(output.out.contains("event=creator_content_view_history_record_failure"))
|
||||
assertTrue(output.out.contains("memberId=${viewer.id}"))
|
||||
assertTrue(output.out.contains("contentId=${audioContent.id}"))
|
||||
}
|
||||
|
||||
private fun createMember(id: Long, nickname: String): Member {
|
||||
@@ -269,4 +310,64 @@ class AudioContentServiceTest {
|
||||
|
||||
return audioContent
|
||||
}
|
||||
|
||||
private fun stubSuccessfulDetailDependencies(viewer: Member, creator: Member, audioContent: AudioContent) {
|
||||
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
|
||||
Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator)
|
||||
Mockito.`when`(
|
||||
orderRepository.isExistOrderedAndOrderType(
|
||||
memberId = viewer.id!!,
|
||||
contentId = audioContent.id!!
|
||||
)
|
||||
).thenReturn(Pair(true, OrderType.KEEP))
|
||||
Mockito.`when`(explorerQueryRepository.getCreatorFollowing(creator.id!!, viewer.id!!)).thenReturn(null)
|
||||
Mockito.`when`(
|
||||
limitedEditionOrderRepository.getOrderSequence(
|
||||
contentId = audioContent.id!!,
|
||||
memberId = viewer.id!!
|
||||
)
|
||||
).thenReturn(null)
|
||||
Mockito.`when`(
|
||||
audioContentCloudFront.generateSignedURL(
|
||||
resourcePath = audioContent.content!!,
|
||||
expirationTime = 7_200_000L
|
||||
)
|
||||
).thenReturn("https://signed.test/audio")
|
||||
Mockito.`when`(
|
||||
repository.getCreatorOtherContentList(
|
||||
cloudfrontHost = "https://cdn.test",
|
||||
contentId = audioContent.id!!,
|
||||
creatorId = creator.id!!,
|
||||
isAdult = false
|
||||
)
|
||||
).thenReturn(emptyList())
|
||||
Mockito.`when`(
|
||||
repository.getSameThemeOtherContentList(
|
||||
cloudfrontHost = "https://cdn.test",
|
||||
contentId = audioContent.id!!,
|
||||
themeId = audioContent.theme!!.id!!,
|
||||
isAdult = false
|
||||
)
|
||||
).thenReturn(emptyList())
|
||||
Mockito.`when`(audioContentLikeRepository.totalCountAudioContentLike(audioContent.id!!)).thenReturn(0)
|
||||
Mockito.`when`(audioContentLikeRepository.findByMemberIdAndContentId(viewer.id!!, audioContent.id!!)).thenReturn(null)
|
||||
Mockito.`when`(
|
||||
pinContentRepository.findByContentIdAndMemberId(
|
||||
contentId = audioContent.id!!,
|
||||
memberId = viewer.id!!,
|
||||
active = true
|
||||
)
|
||||
).thenReturn(null)
|
||||
Mockito.`when`(pinContentRepository.getPinContentList(memberId = viewer.id!!, active = true)).thenReturn(emptyList())
|
||||
Mockito.`when`(
|
||||
contentThemeTranslationRepository.findByContentThemeIdAndLocale(
|
||||
contentThemeId = audioContent.theme!!.id!!,
|
||||
locale = "ko"
|
||||
)
|
||||
).thenReturn(null)
|
||||
}
|
||||
|
||||
private fun anyLocalDateTime(): LocalDateTime {
|
||||
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.data.redis.core.StringRedisTemplate
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class SpringBootIntegrationSampleTest {
|
||||
@Autowired
|
||||
private lateinit var entityManager: EntityManager
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||
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.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
|
||||
import kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryService
|
||||
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.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
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.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
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 org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class HomeRecommendationControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 비로그인 요청은 Spring Security에서 거부한다")
|
||||
fun shouldRejectAnonymousFollowRequest() {
|
||||
mockMvc.perform(
|
||||
post("/api/v2/home/recommendations/creators/follow")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"creatorIds":[1,2]}""")
|
||||
)
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 성공 응답은 id 목록 없이 성공 여부만 반환하고 신규 팔로우만 저장한다")
|
||||
fun shouldReturnSuccessOnlyAndPersistOnlyNewFollows() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val newCreator = saveMember("new-creator", MemberRole.CREATOR)
|
||||
val followedCreator = saveMember("followed-creator", MemberRole.CREATOR)
|
||||
saveFollowing(member = member, creator = followedCreator)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/home/recommendations/creators/follow")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"creatorIds":[${newCreator.id},${followedCreator.id},${member.id}]}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").doesNotExist())
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(newCreator.id!!, member.id!!))
|
||||
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(followedCreator.id!!, member.id!!))
|
||||
assertEquals(2, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우는 비활성 팔로우 이력을 신규 row 없이 다시 활성화한다")
|
||||
fun shouldReactivateInactiveFollowingThroughApi() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val creator = saveMember("reactivate-creator", MemberRole.CREATOR)
|
||||
val inactiveFollowing = saveFollowing(member = member, creator = creator).apply {
|
||||
isNotify = false
|
||||
isActive = false
|
||||
}
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/home/recommendations/creators/follow")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"creatorIds":[${creator.id}]}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").doesNotExist())
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
val reactivatedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
|
||||
assertNotNull(reactivatedFollowing)
|
||||
assertEquals(inactiveFollowing.id, reactivatedFollowing!!.id)
|
||||
assertTrue(reactivatedFollowing.isNotify)
|
||||
assertTrue(reactivatedFollowing.isActive)
|
||||
assertEquals(1, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 요청에 유효하지 않은 id가 있으면 실패하고 신규 저장하지 않는다")
|
||||
fun shouldFailAndSaveNothingWhenInvalidCreatorIdIsIncluded() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/home/recommendations/creators/follow")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"creatorIds":[${validCreator.id},999999]}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("크리에이터 정보를 확인해주세요."))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(0, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 요청 creatorIds가 비어 있으면 실패하고 신규 저장하지 않는다")
|
||||
fun shouldRejectEmptyCreatorIdsAndSaveNothing() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/home/recommendations/creators/follow")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"creatorIds":[]}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(0, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 요청 creatorIds가 null이면 실패하고 신규 저장하지 않는다")
|
||||
fun shouldRejectNullCreatorIdsAndSaveNothing() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/home/recommendations/creators/follow")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"creatorIds":null}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(0, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 요청 creatorIds가 50개를 초과하면 실패하고 신규 저장하지 않는다")
|
||||
fun shouldRejectTooManyCreatorIdsAndSaveNothing() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val creatorIds = (1..51).joinToString(",") { it.toString() }
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/home/recommendations/creators/follow")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"creatorIds":[$creatorIds]}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(0, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("메인 홈 통합 조회는 비회원도 호출 가능하고 섹션별 빈 배열을 포함해 성공 응답한다")
|
||||
fun shouldReturnHomeRecommendationsForAnonymous(output: CapturedOutput) {
|
||||
mockMvc.perform(get("/api/v2/home/recommendations"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.lives").isArray)
|
||||
.andExpect(jsonPath("$.data.banners").isArray)
|
||||
.andExpect(jsonPath("$.data.recentlyActiveCreators").isArray)
|
||||
.andExpect(jsonPath("$.data.recentDebutCreators").isArray)
|
||||
.andExpect(jsonPath("$.data.firstAudioContents").isArray)
|
||||
.andExpect(jsonPath("$.data.aiCharacters").isArray)
|
||||
.andExpect(jsonPath("$.data.genreCreators").isArray)
|
||||
.andExpect(jsonPath("$.data.cheerCreators").isArray)
|
||||
.andExpect(jsonPath("$.data.popularCommunities").isArray)
|
||||
|
||||
assertTrue(output.out.contains("event=home_recommendations_query_success"))
|
||||
assertTrue(output.out.contains("emptySections="))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("메인 홈 통합 조회는 인증 회원도 호출 가능하고 성공 응답한다")
|
||||
fun shouldReturnHomeRecommendationsForMember() {
|
||||
val member = saveMember("home-viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/recommendations").with(user(MemberAdapter(member)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.lives").isArray)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("메인 홈 통합 조회 실패는 응답 시간과 함께 로그로 관측된다")
|
||||
fun shouldLogHomeRecommendationFailure(output: CapturedOutput) {
|
||||
val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test")
|
||||
Mockito.`when`(failingQueryService.findLiveRecommendations(limit = 20, memberId = null, includeAdultLives = false))
|
||||
.thenThrow(IllegalStateException("home query failed"))
|
||||
|
||||
val exception = assertThrows(IllegalStateException::class.java) {
|
||||
facade.getHomeRecommendations(member = null)
|
||||
}
|
||||
|
||||
assertEquals("home query failed", exception.message)
|
||||
assertTrue(output.out.contains("event=home_recommendations_query_failure"))
|
||||
assertTrue(output.out.contains("memberId=null"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("라이브 전체보기는 page/size를 포함한 페이징 응답 형식으로 반환한다")
|
||||
fun shouldReturnPagedLives(output: CapturedOutput) {
|
||||
val member = saveMember("paged-live-viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(get("/api/v2/home/recommendations/lives").with(user(MemberAdapter(member))))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.items").isArray)
|
||||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
.andExpect(jsonPath("$.data.size").value(20))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
|
||||
assertTrue(output.out.contains("event=home_recommendations_page_query_success"))
|
||||
assertTrue(output.out.contains("section=LIVE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("세부 전체보기 조회 실패는 섹션과 응답 시간과 함께 로그로 관측된다")
|
||||
fun shouldLogHomeRecommendationPageFailure(output: CapturedOutput) {
|
||||
val member = saveMember("page-failure-viewer", MemberRole.USER)
|
||||
val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test")
|
||||
Mockito.`when`(preferenceService.initializeDefaultPreference(member)).thenReturn(MemberContentPreference())
|
||||
Mockito.`when`(
|
||||
failingQueryService.findLiveRecommendations(
|
||||
offset = 0,
|
||||
limit = 21,
|
||||
memberId = member.id,
|
||||
includeAdultLives = false
|
||||
)
|
||||
)
|
||||
.thenThrow(IllegalStateException("page query failed"))
|
||||
|
||||
val exception = assertThrows(IllegalStateException::class.java) {
|
||||
facade.getLives(member, page = 0, size = 20)
|
||||
}
|
||||
|
||||
assertEquals("page query failed", exception.message)
|
||||
assertTrue(output.out.contains("event=home_recommendations_page_query_failure"))
|
||||
assertTrue(output.out.contains("section=LIVE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("나머지 세부 전체보기 조회 실패도 섹션과 응답 시간과 함께 로그로 관측된다")
|
||||
fun shouldLogOtherHomeRecommendationPageFailures(output: CapturedOutput) {
|
||||
val member = saveMember("other-page-failure-viewer", MemberRole.USER)
|
||||
val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test")
|
||||
Mockito.`when`(preferenceService.initializeDefaultPreference(member)).thenReturn(MemberContentPreference())
|
||||
Mockito.`when`(
|
||||
failingQueryService.findRecentDebutCreators(
|
||||
now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN,
|
||||
offset = Mockito.eq(0),
|
||||
limit = Mockito.eq(21),
|
||||
memberId = Mockito.eq(member.id),
|
||||
includeAdultContents = Mockito.eq(false)
|
||||
)
|
||||
).thenThrow(IllegalStateException("debut page failed"))
|
||||
Mockito.`when`(
|
||||
failingQueryService.findFirstAudioContents(
|
||||
now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN,
|
||||
offset = Mockito.eq(0),
|
||||
limit = Mockito.eq(21),
|
||||
memberId = Mockito.eq(member.id),
|
||||
includeAdultContents = Mockito.eq(false)
|
||||
)
|
||||
).thenThrow(IllegalStateException("first audio page failed"))
|
||||
Mockito.`when`(failingQueryService.findAiCharacterRecommendations(offset = 0, limit = 21))
|
||||
.thenThrow(IllegalStateException("ai page failed"))
|
||||
|
||||
assertThrows(IllegalStateException::class.java) { facade.getRecentDebutCreators(member, page = 0, size = 20) }
|
||||
assertThrows(IllegalStateException::class.java) { facade.getFirstAudioContents(member, page = 0, size = 20) }
|
||||
assertThrows(IllegalStateException::class.java) { facade.getAiCharacters(member, page = 0, size = 20) }
|
||||
|
||||
assertTrue(output.out.contains("section=DEBUT_CREATOR"))
|
||||
assertTrue(output.out.contains("section=FIRST_AUDIO_CONTENT"))
|
||||
assertTrue(output.out.contains("section=AI_CHARACTER"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("섹션별 전체보기는 size 최대값 50으로 제한한다")
|
||||
fun shouldCapPageSizeAtFifty() {
|
||||
val member = saveMember("paged-debut-viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/recommendations/debut-creators")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.param("size", "100")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.size").value(50))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 오디오/AI 캐릭터 전체보기도 같은 페이징 응답 형식을 사용한다")
|
||||
fun shouldReturnPagedSectionsWithSameFormat() {
|
||||
val member = saveMember("paged-section-viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
for (path in listOf("/first-audio-contents", "/ai-characters")) {
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/recommendations$path")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.param("page", "1")
|
||||
.param("size", "10")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.items").isArray)
|
||||
.andExpect(jsonPath("$.data.page").value(1))
|
||||
.andExpect(jsonPath("$.data.size").value(10))
|
||||
.andExpect(jsonPath("$.data.hasNext").isBoolean)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("세부 전체보기 API는 비회원 요청을 거부한다")
|
||||
fun shouldRejectAnonymousSectionPages() {
|
||||
for (path in listOf("/lives", "/debut-creators", "/first-audio-contents", "/ai-characters")) {
|
||||
mockMvc.perform(get("/api/v2/home/recommendations$path"))
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("세부 전체보기 API는 음수 page를 0으로 보정한다")
|
||||
fun shouldNormalizeNegativePageToZero() {
|
||||
val member = saveMember("negative-page-viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/recommendations/lives")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.param("page", "-1")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 전체보기 API는 인증 회원에게도 제공하지 않는다")
|
||||
fun shouldNotExposeCommunitiesFullViewEndpoint() {
|
||||
val member = saveMember("removed-community-viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/recommendations/communities")
|
||||
.with(user(MemberAdapter(member)))
|
||||
)
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("라이브 전체보기 page=0에서 성인 라이브를 제외하고 최신순 첫 항목과 hasNext=true를 반환한다")
|
||||
fun shouldReturnFirstPageLivesExcludingAdult() {
|
||||
val member = saveMember("adult-hidden-live-viewer-p0", MemberRole.USER)
|
||||
val creator = saveMember("adult-hidden-live-creator-p0", MemberRole.CREATOR)
|
||||
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
val newest = saveLiveRoom(creator, baseAt.plusMinutes(2), "normal-newest-p0", isAdult = false)
|
||||
saveLiveRoom(creator, baseAt.plusMinutes(1), "adult-hidden-p0", isAdult = true)
|
||||
saveLiveRoom(creator, baseAt, "normal-oldest-p0", isAdult = false)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/recommendations/lives")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.param("page", "0")
|
||||
.param("size", "1")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.items[0].liveRoomId").value(newest.id))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("라이브 전체보기 page=1에서 성인 라이브를 제외하고 두 번째 항목과 hasNext=false를 반환한다")
|
||||
fun shouldReturnSecondPageLivesExcludingAdult() {
|
||||
val member = saveMember("adult-hidden-live-viewer-p1", MemberRole.USER)
|
||||
val creator = saveMember("adult-hidden-live-creator-p1", MemberRole.CREATOR)
|
||||
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
saveLiveRoom(creator, baseAt.plusMinutes(2), "normal-newest-p1", isAdult = false)
|
||||
saveLiveRoom(creator, baseAt.plusMinutes(1), "adult-hidden-p1", isAdult = true)
|
||||
val oldest = saveLiveRoom(creator, baseAt, "normal-oldest-p1", isAdult = false)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/recommendations/lives")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.param("page", "1")
|
||||
.param("size", "1")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.items[0].liveRoomId").value(oldest.id))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("세부 전체보기 API는 size=0을 기본값 20으로 보정한다")
|
||||
fun shouldNormalizeZeroSizeToDefault() {
|
||||
val member = saveMember("zero-size-viewer", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/recommendations/lives")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.param("size", "0")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.size").value(20))
|
||||
}
|
||||
|
||||
private fun saveMember(seed: String, role: MemberRole): Member {
|
||||
return memberRepository.saveAndFlush(
|
||||
Member(
|
||||
email = "$seed@test.com",
|
||||
password = "password",
|
||||
nickname = seed,
|
||||
role = role
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
|
||||
return creatorFollowingRepository.saveAndFlush(
|
||||
CreatorFollowing().apply {
|
||||
this.member = member
|
||||
this.creator = creator
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveLiveRoom(
|
||||
creator: Member,
|
||||
beginDateTime: LocalDateTime,
|
||||
channelName: String,
|
||||
isAdult: Boolean
|
||||
): LiveRoom {
|
||||
val room = LiveRoom(
|
||||
title = "live-${creator.nickname}-$channelName",
|
||||
notice = "notice",
|
||||
beginDateTime = beginDateTime,
|
||||
numberOfPeople = 0,
|
||||
isAdult = isAdult
|
||||
)
|
||||
room.member = creator
|
||||
room.channelName = channelName
|
||||
entityManager.persist(room)
|
||||
return room
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
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.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.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
class CreatorContentViewHistoryPersistenceAdapterTest @Autowired constructor(
|
||||
private val entityManager: EntityManager,
|
||||
private val repository: CreatorContentViewHistoryRepository,
|
||||
queryFactory: JPAQueryFactory
|
||||
) {
|
||||
private val adapter = CreatorContentViewHistoryPersistenceAdapter(repository, queryFactory)
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 조회 이력 저장용 genreId는 content_theme id를 조회한다")
|
||||
fun shouldFindContentThemeIdByContentId() {
|
||||
val creator = saveMember("history-theme-creator")
|
||||
val theme = saveTheme("history-theme")
|
||||
val content = saveAudioContent(creator, theme, isActive = true)
|
||||
flushAndClear()
|
||||
|
||||
val themeId = adapter.findGenreIdByContentId(content.id!!)
|
||||
|
||||
assertEquals(theme.id, themeId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 콘텐츠 또는 비활성 테마는 조회 이력 저장 대상 테마를 반환하지 않는다")
|
||||
fun shouldNotFindThemeIdWhenContentOrThemeIsInactive() {
|
||||
val creator = saveMember("history-inactive-creator")
|
||||
val activeTheme = saveTheme("history-active-theme")
|
||||
val inactiveTheme = saveTheme("history-inactive-theme", isActive = false)
|
||||
val inactiveContent = saveAudioContent(creator, activeTheme, isActive = false)
|
||||
val inactiveThemeContent = saveAudioContent(creator, inactiveTheme, isActive = true)
|
||||
flushAndClear()
|
||||
|
||||
assertNull(adapter.findGenreIdByContentId(inactiveContent.id!!))
|
||||
assertNull(adapter.findGenreIdByContentId(inactiveThemeContent.id!!))
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
|
||||
val theme = AudioContentTheme(
|
||||
theme = name,
|
||||
image = "$name.png",
|
||||
isActive = isActive
|
||||
)
|
||||
entityManager.persist(theme)
|
||||
return theme
|
||||
}
|
||||
|
||||
private fun saveAudioContent(creator: Member, theme: AudioContentTheme, isActive: Boolean): AudioContent {
|
||||
val content = AudioContent(
|
||||
title = "content-${creator.nickname}-${theme.theme}",
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
releaseDate = LocalDateTime.of(2026, 5, 30, 10, 0)
|
||||
)
|
||||
content.member = creator
|
||||
content.theme = theme
|
||||
content.isActive = isActive
|
||||
entityManager.persist(content)
|
||||
return content
|
||||
}
|
||||
|
||||
private fun flushAndClear() {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
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:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
|
||||
private val repository: RecommendationSnapshotRepository
|
||||
) {
|
||||
private val adapter = RecommendationSnapshotPersistenceAdapter(repository)
|
||||
|
||||
@Test
|
||||
fun shouldFindLatestSnapshotsByLatestSnapshotAtAndScoreDescendingTieBreakerAscending() {
|
||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
repository.saveAll(
|
||||
listOf(
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 999.0, snapshotAt = oldSnapshotAt),
|
||||
snapshot(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
targetId = 2L,
|
||||
score = 100.0,
|
||||
snapshotAt = latestSnapshotAt,
|
||||
randomTieBreaker = 0.9
|
||||
),
|
||||
snapshot(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
targetId = 3L,
|
||||
score = 200.0,
|
||||
snapshotAt = latestSnapshotAt,
|
||||
randomTieBreaker = 0.8
|
||||
),
|
||||
snapshot(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
targetId = 4L,
|
||||
score = 100.0,
|
||||
snapshotAt = latestSnapshotAt,
|
||||
randomTieBreaker = 0.1
|
||||
),
|
||||
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 5L, score = 300.0, snapshotAt = latestSnapshotAt)
|
||||
)
|
||||
)
|
||||
|
||||
val latestSnapshots = adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER)
|
||||
|
||||
assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.targetId })
|
||||
assertEquals(listOf(latestSnapshotAt, latestSnapshotAt, latestSnapshotAt), latestSnapshots.map { it.snapshotAt })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldFindLatestSnapshotsWithOffsetAndLimit() {
|
||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
repository.saveAll(
|
||||
listOf(
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 400.0, snapshotAt = latestSnapshotAt),
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 300.0, snapshotAt = latestSnapshotAt),
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 3L, score = 200.0, snapshotAt = latestSnapshotAt),
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 4L, score = 100.0, snapshotAt = latestSnapshotAt)
|
||||
)
|
||||
)
|
||||
|
||||
val snapshots = adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset = 1, limit = 2)
|
||||
|
||||
assertEquals(listOf(2L, 3L), snapshots.map { it.targetId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() {
|
||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
repository.saveAll(
|
||||
listOf(
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 100.0, snapshotAt = oldSnapshotAt),
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 200.0, snapshotAt = snapshotAt),
|
||||
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 3L, score = 300.0, snapshotAt = snapshotAt)
|
||||
)
|
||||
)
|
||||
|
||||
adapter.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
snapshotAt,
|
||||
listOf(
|
||||
RecommendationSnapshotRecord(
|
||||
sectionType = RecommendedSectionType.AI_CHARACTER,
|
||||
targetId = 4L,
|
||||
score = 400.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.4
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(4L), adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER).map { it.targetId })
|
||||
assertEquals(listOf(3L), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
|
||||
assertEquals(3, repository.findAll().size)
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
sectionType: RecommendedSectionType,
|
||||
targetId: Long,
|
||||
score: Double,
|
||||
snapshotAt: LocalDateTime,
|
||||
randomTieBreaker: Double = 0.1
|
||||
): RecommendationSnapshot {
|
||||
return RecommendationSnapshot(
|
||||
sectionType = sectionType,
|
||||
targetId = targetId,
|
||||
score = score,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = randomTieBreaker
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.CreatorContentViewHistoryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.CreatorContentViewHistoryRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class CreatorContentViewHistoryServiceTest {
|
||||
private val port = FakeCreatorContentViewHistoryPort()
|
||||
private val service = CreatorContentViewHistoryService(port)
|
||||
|
||||
@Test
|
||||
@DisplayName("인증 회원의 콘텐츠 상세 진입 시 memberId/contentId/genreId/viewedAt을 저장한다")
|
||||
fun shouldRecordAuthenticatedMemberContentViewWithGenreAndViewedAt(output: CapturedOutput) {
|
||||
val viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
port.genreIdByContentId[20L] = 30L
|
||||
|
||||
service.recordView(memberId = 10L, contentId = 20L, viewedAt = viewedAt)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
CreatorContentViewHistoryRecord(
|
||||
memberId = 10L,
|
||||
contentId = 20L,
|
||||
genreId = 30L,
|
||||
viewedAt = viewedAt
|
||||
)
|
||||
),
|
||||
port.savedRecords
|
||||
)
|
||||
assertTrue(output.out.contains("event=creator_content_view_history_record_success"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("조회 이력 저장 성공 로그는 트랜잭션 커밋 후 기록한다")
|
||||
fun shouldLogRecordSuccessAfterTransactionCommit(output: CapturedOutput) {
|
||||
port.genreIdByContentId[20L] = 30L
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
try {
|
||||
service.recordView(memberId = 10L, contentId = 20L)
|
||||
|
||||
assertEquals(false, output.out.contains("event=creator_content_view_history_record_success"))
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
|
||||
assertTrue(output.out.contains("event=creator_content_view_history_record_success"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비회원 콘텐츠 상세 진입은 조회 이력을 저장하지 않는다")
|
||||
fun shouldNotRecordAnonymousContentView(output: CapturedOutput) {
|
||||
service.recordView(memberId = null, contentId = 20L)
|
||||
|
||||
assertTrue(port.savedRecords.isEmpty())
|
||||
assertTrue(output.out.contains("event=creator_content_view_history_record_skipped"))
|
||||
assertTrue(output.out.contains("reason=anonymous"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠의 활성 장르를 찾지 못하면 조회 이력을 저장하지 않는다")
|
||||
fun shouldNotRecordWhenContentGenreDoesNotExist(output: CapturedOutput) {
|
||||
service.recordView(memberId = 10L, contentId = 20L)
|
||||
|
||||
assertTrue(port.savedRecords.isEmpty())
|
||||
assertTrue(output.out.contains("event=creator_content_view_history_record_skipped"))
|
||||
assertTrue(output.out.contains("reason=genre_not_found"))
|
||||
}
|
||||
|
||||
private class FakeCreatorContentViewHistoryPort : CreatorContentViewHistoryPort {
|
||||
val genreIdByContentId = mutableMapOf<Long, Long>()
|
||||
val savedRecords = mutableListOf<CreatorContentViewHistoryRecord>()
|
||||
|
||||
override fun findGenreIdByContentId(contentId: Long): Long? {
|
||||
return genreIdByContentId[contentId]
|
||||
}
|
||||
|
||||
override fun save(record: CreatorContentViewHistoryRecord) {
|
||||
savedRecords.add(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationGroup
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class HomeRecommendationQueryServiceTest {
|
||||
private val port = FakeHomeRecommendationQueryPort()
|
||||
private val snapshotPort = FakeHomeRecommendationSnapshotPort()
|
||||
private val service = HomeRecommendationQueryService(port, snapshotPort)
|
||||
|
||||
@Test
|
||||
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
|
||||
fun shouldClassifyLiveReplayThemeContentAsLiveReplay() {
|
||||
val activityType = service.resolveAudioContentActivityType(theme = "다시듣기")
|
||||
|
||||
assertEquals(RecommendedActivityType.LIVE_REPLAY, activityType)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("다시듣기가 아닌 테마 콘텐츠는 AUDIO 활동으로 분류한다")
|
||||
fun shouldClassifyNonLiveReplayThemeContentAsAudio() {
|
||||
val activityType = service.resolveAudioContentActivityType(theme = "수면")
|
||||
|
||||
assertEquals(RecommendedActivityType.AUDIO, activityType)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("활동 타입 enum code는 앱 다국어 처리를 위해 영문 값과 동일하게 유지한다")
|
||||
fun shouldKeepRecommendedActivityTypeCodeAsEnglishName() {
|
||||
assertEquals("LIVE", RecommendedActivityType.LIVE.code)
|
||||
assertEquals("AUDIO", RecommendedActivityType.AUDIO.code)
|
||||
assertEquals("COMMUNITY", RecommendedActivityType.COMMUNITY.code)
|
||||
assertEquals("LIVE_REPLAY", RecommendedActivityType.LIVE_REPLAY.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("섹션 타입 enum code는 앱 다국어 처리를 위해 영문 값과 동일하게 유지한다")
|
||||
fun shouldKeepRecommendedSectionTypeCodeAsEnglishName() {
|
||||
assertEquals("LIVE", RecommendedSectionType.LIVE.code)
|
||||
assertEquals("BANNER", RecommendedSectionType.BANNER.code)
|
||||
assertEquals("ACTIVE_CREATOR", RecommendedSectionType.ACTIVE_CREATOR.code)
|
||||
assertEquals("DEBUT_CREATOR", RecommendedSectionType.DEBUT_CREATOR.code)
|
||||
assertEquals("FIRST_AUDIO_CONTENT", RecommendedSectionType.FIRST_AUDIO_CONTENT.code)
|
||||
assertEquals("AI_CHARACTER", RecommendedSectionType.AI_CHARACTER.code)
|
||||
assertEquals("GENRE_CREATOR", RecommendedSectionType.GENRE_CREATOR.code)
|
||||
assertEquals("CHEER_CREATOR", RecommendedSectionType.CHEER_CREATOR.code)
|
||||
assertEquals("POPULAR_COMMUNITY", RecommendedSectionType.POPULAR_COMMUNITY.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 라이브 추천은 기본 20개를 최신순 조회 포트에 위임한다")
|
||||
fun shouldFindLatestLiveRecommendationsWithDefaultLimit() {
|
||||
val recommendations = service.findLiveRecommendations(memberId = 100L)
|
||||
|
||||
assertEquals(20, port.liveLimit)
|
||||
assertEquals(100L, port.liveMemberId)
|
||||
assertEquals(port.liveRecommendations, recommendations)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
|
||||
fun shouldFindHomeBannersWithDefaultLimit() {
|
||||
val banners = service.findHomeBanners(memberId = 100L)
|
||||
|
||||
assertEquals(20, port.bannerLimit)
|
||||
assertEquals(100L, port.bannerMemberId)
|
||||
assertEquals(port.banners, banners)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 활동 크리에이터는 기본 10개를 최신 활동 조회 포트에 위임한다")
|
||||
fun shouldFindRecentlyActiveCreatorsWithDefaultLimit() {
|
||||
val creators = service.findRecentlyActiveCreators(memberId = 100L)
|
||||
|
||||
assertEquals(10, port.activeCreatorLimit)
|
||||
assertEquals(100L, port.activeCreatorMemberId)
|
||||
assertEquals(false, port.activeCreatorIncludeAdultActivities)
|
||||
assertEquals(port.activeCreators, creators)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 활동 크리에이터는 성인 활동 노출 여부를 조회 포트에 위임한다")
|
||||
fun shouldFindRecentlyActiveCreatorsWithAdultVisibilityPolicy() {
|
||||
val creators = service.findRecentlyActiveCreators(limit = 8, memberId = 101L, includeAdultActivities = true)
|
||||
|
||||
assertEquals(8, port.activeCreatorLimit)
|
||||
assertEquals(101L, port.activeCreatorMemberId)
|
||||
assertEquals(true, port.activeCreatorIncludeAdultActivities)
|
||||
assertEquals(port.activeCreators, creators)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 데뷔 크리에이터는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다")
|
||||
fun shouldFindRecentDebutCreatorsWithDefaultLimitAndNow() {
|
||||
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
|
||||
val creators = service.findRecentDebutCreators(now, memberId = 102L)
|
||||
|
||||
assertEquals(now, port.recentDebutNow)
|
||||
assertEquals(102L, port.recentDebutMemberId)
|
||||
assertEquals(10, port.recentDebutLimit)
|
||||
assertEquals(port.recentDebutCreators, creators)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 오디오 콘텐츠는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다")
|
||||
fun shouldFindFirstAudioContentsWithDefaultLimitAndNow() {
|
||||
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
|
||||
val contents = service.findFirstAudioContents(now, memberId = 103L)
|
||||
|
||||
assertEquals(now, port.firstAudioNow)
|
||||
assertEquals(103L, port.firstAudioMemberId)
|
||||
assertEquals(10, port.firstAudioLimit)
|
||||
assertEquals(port.firstAudioContents, contents)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 10개를 기준으로 순서를 유지해 상세를 조립한다")
|
||||
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
|
||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
oldSnapshotAt,
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 999.0, oldSnapshotAt))
|
||||
)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
latestSnapshotAt,
|
||||
(1L..12L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, latestSnapshotAt)
|
||||
}
|
||||
)
|
||||
port.aiCharacterDetails = listOf(
|
||||
HomeAiCharacterRecommendationRecord(
|
||||
characterId = 1L,
|
||||
name = "character-1",
|
||||
description = "description-1",
|
||||
totalChatCount = 3L,
|
||||
originalWorkTitle = "original-work"
|
||||
),
|
||||
HomeAiCharacterRecommendationRecord(
|
||||
characterId = 2L,
|
||||
name = "character-2",
|
||||
description = "description-2",
|
||||
totalChatCount = 0L,
|
||||
originalWorkTitle = null
|
||||
)
|
||||
)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations()
|
||||
|
||||
assertEquals((1L..10L).toList(), port.aiCharacterDetailIds)
|
||||
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
||||
assertEquals("original-work", characters.first().originalWorkTitle)
|
||||
assertEquals(null, characters.last().originalWorkTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
|
||||
fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.CHEER_CREATOR,
|
||||
snapshotAt,
|
||||
(1L..9L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId, 100.0 - targetId, snapshotAt)
|
||||
}
|
||||
)
|
||||
port.cheerCreatorDetails = listOf(
|
||||
HomeCheerCreatorRecommendationRecord(
|
||||
creatorId = 1L,
|
||||
creatorNickname = "creator-1",
|
||||
creatorProfileImage = "profile-1.png"
|
||||
),
|
||||
HomeCheerCreatorRecommendationRecord(
|
||||
creatorId = 2L,
|
||||
creatorNickname = "creator-2",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
|
||||
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
|
||||
|
||||
assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds)
|
||||
assertEquals(104L, port.cheerCreatorMemberId)
|
||||
assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
|
||||
assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
||||
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
snapshotAt,
|
||||
(1L..11L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt)
|
||||
}
|
||||
)
|
||||
port.popularCommunityDetails = listOf(
|
||||
HomePopularCommunityRecommendationRecord(
|
||||
communityId = 1L,
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = "profile-10.png",
|
||||
content = "content-1",
|
||||
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0),
|
||||
likeCount = 3L,
|
||||
commentCount = 2L
|
||||
),
|
||||
HomePopularCommunityRecommendationRecord(
|
||||
communityId = 2L,
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = "profile-10.png",
|
||||
content = "content-2",
|
||||
createdAt = LocalDateTime.of(2026, 5, 29, 2, 0),
|
||||
likeCount = 1L,
|
||||
commentCount = 1L
|
||||
),
|
||||
HomePopularCommunityRecommendationRecord(
|
||||
communityId = 3L,
|
||||
creatorId = 11L,
|
||||
creatorNickname = "creator-11",
|
||||
creatorProfileImage = null,
|
||||
content = "content-3",
|
||||
createdAt = LocalDateTime.of(2026, 5, 29, 3, 0),
|
||||
likeCount = 0L,
|
||||
commentCount = 0L
|
||||
)
|
||||
)
|
||||
|
||||
val communities = service.findPopularCommunityRecommendations(memberId = 105L, includeAdultCommunities = true)
|
||||
|
||||
assertEquals((1L..11L).toList(), port.popularCommunityDetailIds)
|
||||
assertEquals(105L, port.popularCommunityMemberId)
|
||||
assertEquals(true, port.popularCommunityIncludeAdultCommunities)
|
||||
assertEquals(listOf(1L, 3L), communities.map { it.communityId })
|
||||
assertEquals(listOf(10L, 11L), communities.map { it.creatorId })
|
||||
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), communities.first().createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
|
||||
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
snapshotAt,
|
||||
(1L..20L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt)
|
||||
}
|
||||
)
|
||||
port.popularCommunityDetails = (1L..20L).map { communityId ->
|
||||
HomePopularCommunityRecommendationRecord(
|
||||
communityId = communityId,
|
||||
creatorId = if (communityId <= 10L) 1L else communityId,
|
||||
creatorNickname = "creator-$communityId",
|
||||
creatorProfileImage = null,
|
||||
content = "content-$communityId",
|
||||
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0).plusMinutes(communityId),
|
||||
likeCount = 0L,
|
||||
commentCount = 0L
|
||||
)
|
||||
}
|
||||
|
||||
val communities = service.findPopularCommunityRecommendations()
|
||||
|
||||
assertEquals(20, port.popularCommunityDetailIds.size)
|
||||
assertEquals(10, communities.size)
|
||||
assertEquals(listOf(1L) + (11L..19L).toList(), communities.map { it.communityId })
|
||||
assertEquals(communities.size, communities.map { it.creatorId }.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다")
|
||||
fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() {
|
||||
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), service.findAiCharacterRecommendations())
|
||||
assertEquals(emptyList<HomeCheerCreatorRecommendationRecord>(), service.findCheerCreatorRecommendations())
|
||||
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), service.findPopularCommunityRecommendations())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 기본 5개 장르와 장르별 8명을 조회하고 한 응답 안에서 크리에이터 중복을 제거한다")
|
||||
fun shouldFindGenreCreatorRecommendationsWithDefaultLimitsAndCreatorUniqueness() {
|
||||
port.genreCreatorRecommendations = listOf(
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 1L,
|
||||
genreName = "romance",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = null
|
||||
),
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 11L,
|
||||
creatorNickname = "creator-11",
|
||||
creatorProfileImage = "11.png"
|
||||
)
|
||||
)
|
||||
),
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 2L,
|
||||
genreName = "fantasy",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = null
|
||||
),
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 12L,
|
||||
creatorNickname = "creator-12",
|
||||
creatorProfileImage = "12.png"
|
||||
),
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 13L,
|
||||
creatorNickname = "creator-13",
|
||||
creatorProfileImage = "13.png"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val recommendations = service.findGenreCreatorRecommendations(
|
||||
memberId = 100L,
|
||||
includeAdultGenres = true
|
||||
)
|
||||
|
||||
assertEquals(100L, port.genreCreatorMemberId)
|
||||
assertEquals(true, port.genreCreatorIncludeAdultGenres)
|
||||
assertEquals(5, port.genreCreatorGenreLimit)
|
||||
assertEquals(40, port.genreCreatorCreatorLimit)
|
||||
assertEquals(listOf(10L, 11L), recommendations[0].creators.map { it.creatorId })
|
||||
assertEquals(listOf(12L, 13L), recommendations[1].creators.map { it.creatorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 중복 제거 후 빈 그룹을 제외하고 뒤 후보로 보충한다")
|
||||
fun shouldSkipEmptyGenreCreatorGroupsAfterCreatorDeduplication() {
|
||||
port.genreCreatorRecommendations = listOf(
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 1L,
|
||||
genreName = "theme-1",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
),
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 2L,
|
||||
genreName = "theme-2",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
),
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 3L,
|
||||
genreName = "theme-3",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 11L,
|
||||
creatorNickname = "creator-11",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val recommendations = service.findGenreCreatorRecommendations(
|
||||
memberId = 100L,
|
||||
includeAdultGenres = false,
|
||||
genreLimit = 2,
|
||||
creatorLimit = 8
|
||||
)
|
||||
|
||||
assertEquals(listOf(1L, 3L), recommendations.map { it.genreId })
|
||||
assertEquals(false, recommendations.any { it.creators.isEmpty() })
|
||||
}
|
||||
|
||||
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||
var liveLimit: Int? = null
|
||||
var liveOffset: Int? = null
|
||||
var liveMemberId: Long? = null
|
||||
var liveIncludeAdultLives: Boolean? = null
|
||||
var bannerLimit: Int? = null
|
||||
var bannerMemberId: Long? = null
|
||||
var activeCreatorLimit: Int? = null
|
||||
var activeCreatorMemberId: Long? = null
|
||||
var activeCreatorIncludeAdultActivities: Boolean? = null
|
||||
var recentDebutNow: LocalDateTime? = null
|
||||
var recentDebutLimit: Int? = null
|
||||
var recentDebutOffset: Int? = null
|
||||
var recentDebutMemberId: Long? = null
|
||||
var recentDebutIncludeAdultContents: Boolean? = null
|
||||
var firstAudioNow: LocalDateTime? = null
|
||||
var firstAudioLimit: Int? = null
|
||||
var firstAudioOffset: Int? = null
|
||||
var firstAudioMemberId: Long? = null
|
||||
var firstAudioIncludeAdultContents: Boolean? = null
|
||||
var aiCharacterDetailIds: List<Long> = emptyList()
|
||||
var cheerCreatorDetailIds: List<Long> = emptyList()
|
||||
var popularCommunityDetailIds: List<Long> = emptyList()
|
||||
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
||||
var genreCreatorMemberId: Long? = null
|
||||
var genreCreatorIncludeAdultGenres: Boolean? = null
|
||||
var genreCreatorGenreLimit: Int? = null
|
||||
var genreCreatorCreatorLimit: Int? = null
|
||||
val liveRecommendations = listOf(
|
||||
HomeLiveRecommendationRecord(
|
||||
liveRoomId = 1L,
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator",
|
||||
creatorProfileImage = "profile.png",
|
||||
title = "live",
|
||||
coverImage = "cover.png",
|
||||
beginDateTime = LocalDateTime.of(2026, 5, 31, 10, 0),
|
||||
channelName = "channel"
|
||||
)
|
||||
)
|
||||
val banners = listOf(
|
||||
HomeBannerRecommendationRecord(
|
||||
bannerId = 2L,
|
||||
type = "LINK",
|
||||
thumbnailImage = "banner.png",
|
||||
eventId = null,
|
||||
creatorId = null,
|
||||
seriesId = null,
|
||||
link = "https://example.com",
|
||||
orders = 1,
|
||||
randomTieBreaker = 0.1
|
||||
)
|
||||
)
|
||||
val activeCreators = listOf(
|
||||
RecentlyActiveCreatorRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator",
|
||||
creatorProfileImage = "profile.png",
|
||||
activityType = RecommendedActivityType.LIVE,
|
||||
activityAt = LocalDateTime.of(2026, 5, 31, 10, 0),
|
||||
targetId = null
|
||||
)
|
||||
)
|
||||
val recentDebutCreators = listOf(
|
||||
RecentDebutCreatorRecord(
|
||||
creatorId = 11L,
|
||||
creatorNickname = "debut-creator",
|
||||
creatorProfileImage = "debut-profile.png",
|
||||
debutAt = LocalDateTime.of(2026, 5, 20, 10, 0),
|
||||
score = 1.2,
|
||||
randomTieBreaker = 0.2
|
||||
)
|
||||
)
|
||||
val firstAudioContents = listOf(
|
||||
HomeFirstAudioContentRecord(
|
||||
contentId = 21L,
|
||||
creatorId = 11L,
|
||||
creatorNickname = "debut-creator",
|
||||
creatorProfileImage = "debut-profile.png",
|
||||
title = "first-audio",
|
||||
coverImage = "first-audio.png",
|
||||
releaseDate = LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||
recencyScore = 100,
|
||||
randomTieBreaker = 0.3
|
||||
)
|
||||
)
|
||||
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
||||
var cheerCreatorMemberId: Long? = null
|
||||
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
||||
var popularCommunityMemberId: Long? = null
|
||||
var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList()
|
||||
|
||||
override fun findLiveRecommendations(
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultLives: Boolean
|
||||
): List<HomeLiveRecommendationRecord> {
|
||||
liveOffset = offset
|
||||
liveLimit = limit
|
||||
liveMemberId = memberId
|
||||
liveIncludeAdultLives = includeAdultLives
|
||||
return liveRecommendations
|
||||
}
|
||||
|
||||
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> {
|
||||
bannerLimit = limit
|
||||
bannerMemberId = memberId
|
||||
return banners
|
||||
}
|
||||
|
||||
override fun findRecentlyActiveCreators(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultActivities: Boolean
|
||||
): List<RecentlyActiveCreatorRecord> {
|
||||
activeCreatorLimit = limit
|
||||
activeCreatorMemberId = memberId
|
||||
activeCreatorIncludeAdultActivities = includeAdultActivities
|
||||
return activeCreators
|
||||
}
|
||||
|
||||
override fun findRecentDebutCreators(
|
||||
now: LocalDateTime,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultContents: Boolean
|
||||
): List<RecentDebutCreatorRecord> {
|
||||
recentDebutNow = now
|
||||
recentDebutOffset = offset
|
||||
recentDebutLimit = limit
|
||||
recentDebutMemberId = memberId
|
||||
recentDebutIncludeAdultContents = includeAdultContents
|
||||
return recentDebutCreators
|
||||
}
|
||||
|
||||
override fun findFirstAudioContents(
|
||||
now: LocalDateTime,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultContents: Boolean
|
||||
): List<HomeFirstAudioContentRecord> {
|
||||
firstAudioNow = now
|
||||
firstAudioOffset = offset
|
||||
firstAudioLimit = limit
|
||||
firstAudioMemberId = memberId
|
||||
firstAudioIncludeAdultContents = includeAdultContents
|
||||
return firstAudioContents
|
||||
}
|
||||
|
||||
override fun findAiCharacterSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findCheerCreatorSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findPopularCommunitySnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> {
|
||||
aiCharacterDetailIds = characterIds
|
||||
return aiCharacterDetails
|
||||
}
|
||||
|
||||
override fun findCheerCreatorRecommendationDetails(
|
||||
creatorIds: List<Long>,
|
||||
memberId: Long?
|
||||
): List<HomeCheerCreatorRecommendationRecord> {
|
||||
cheerCreatorDetailIds = creatorIds
|
||||
cheerCreatorMemberId = memberId
|
||||
return cheerCreatorDetails
|
||||
}
|
||||
|
||||
override fun findPopularCommunityRecommendationDetails(
|
||||
communityIds: List<Long>,
|
||||
memberId: Long?,
|
||||
includeAdultCommunities: Boolean
|
||||
): List<HomePopularCommunityRecommendationRecord> {
|
||||
popularCommunityDetailIds = communityIds
|
||||
popularCommunityMemberId = memberId
|
||||
popularCommunityIncludeAdultCommunities = includeAdultCommunities
|
||||
return popularCommunityDetails
|
||||
}
|
||||
|
||||
override fun findGenreCreatorRecommendations(
|
||||
memberId: Long?,
|
||||
includeAdultGenres: Boolean,
|
||||
genreLimit: Int,
|
||||
creatorLimit: Int
|
||||
): List<HomeGenreCreatorRecommendationGroup> {
|
||||
genreCreatorMemberId = memberId
|
||||
genreCreatorIncludeAdultGenres = includeAdultGenres
|
||||
genreCreatorGenreLimit = genreLimit
|
||||
genreCreatorCreatorLimit = creatorLimit
|
||||
return genreCreatorRecommendations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
||||
|
||||
override fun findLatestSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
val latestSnapshotAt = snapshots
|
||||
.filter { it.sectionType == sectionType }
|
||||
.maxOfOrNull { it.snapshotAt }
|
||||
|
||||
val all = snapshots
|
||||
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
|
||||
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
|
||||
|
||||
if (offset == 0 && limit == Int.MAX_VALUE) return all
|
||||
return all.drop(offset).take(limit)
|
||||
}
|
||||
|
||||
override fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
) {
|
||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
sectionType: RecommendedSectionType,
|
||||
targetId: Long,
|
||||
score: Double,
|
||||
snapshotAt: LocalDateTime
|
||||
): RecommendationSnapshotRecord {
|
||||
return RecommendationSnapshotRecord(
|
||||
sectionType = sectionType,
|
||||
targetId = targetId,
|
||||
score = score,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = targetId.toDouble() / 100
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.adapter.out.scheduler.RecommendationSnapshotScheduler
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class RecommendationSnapshotRefreshServiceTest {
|
||||
@Test
|
||||
@DisplayName("섹션의 최신 기준 시각 스냅샷만 반환하고 없으면 빈 배열을 반환한다")
|
||||
fun shouldReadOnlyLatestSnapshotsOrEmptyList() {
|
||||
val snapshotPort = FakeRecommendationSnapshotPort()
|
||||
val service = service(snapshotPort = snapshotPort)
|
||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
oldSnapshotAt,
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 10.0, snapshotAt = oldSnapshotAt))
|
||||
)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
latestSnapshotAt,
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 20.0, snapshotAt = latestSnapshotAt))
|
||||
)
|
||||
|
||||
val latestSnapshots = service.getLatestSnapshots(RecommendedSectionType.AI_CHARACTER)
|
||||
val emptySnapshots = service.getLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
|
||||
|
||||
assertEquals(listOf(2L), latestSnapshots.map { it.targetId })
|
||||
assertEquals(emptyList<RecommendationSnapshotRecord>(), emptySnapshots)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다")
|
||||
fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt(output: CapturedOutput) {
|
||||
val snapshotPort = FakeRecommendationSnapshotPort()
|
||||
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||
val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
|
||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||
|
||||
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
||||
listOf(
|
||||
RecommendationSnapshotRecord(
|
||||
sectionType = RecommendedSectionType.AI_CHARACTER,
|
||||
targetId = 11L,
|
||||
score = 78.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.1
|
||||
)
|
||||
)
|
||||
)
|
||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
|
||||
listOf(
|
||||
RecommendationSnapshotRecord(
|
||||
sectionType = RecommendedSectionType.CHEER_CREATOR,
|
||||
targetId = 22L,
|
||||
score = 792.22,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.2
|
||||
)
|
||||
)
|
||||
)
|
||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
||||
listOf(
|
||||
RecommendationSnapshotRecord(
|
||||
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
targetId = 33L,
|
||||
score = 40.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.3
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
val aiSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER)
|
||||
val cheerSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
|
||||
val communitySnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
|
||||
|
||||
assertEquals(snapshotAt, aiSnapshots.single().snapshotAt)
|
||||
assertEquals(11L, aiSnapshots.single().targetId)
|
||||
assertEquals(78.0, aiSnapshots.single().score, 0.0001)
|
||||
assertEquals(0.1, aiSnapshots.single().randomTieBreaker, 0.0001)
|
||||
|
||||
assertEquals(22L, cheerSnapshots.single().targetId)
|
||||
assertEquals(792.22, cheerSnapshots.single().score, 0.0001)
|
||||
|
||||
assertEquals(33L, communitySnapshots.single().targetId)
|
||||
assertEquals(40.0, communitySnapshots.single().score, 0.0001)
|
||||
|
||||
Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20)
|
||||
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16)
|
||||
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20)
|
||||
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
|
||||
assertEquals(true, output.out.contains("aiCharacterCount=1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일 스냅샷 갱신 성공 로그는 트랜잭션 커밋 후 기록한다")
|
||||
fun shouldLogSnapshotRefreshSuccessAfterTransactionCommit(output: CapturedOutput) {
|
||||
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||
val service = service(queryPort = queryPort)
|
||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList())
|
||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
try {
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
assertEquals(false, output.out.contains("event=recommendation_snapshot_refresh_success"))
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
|
||||
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일 스냅샷 갱신은 DB에서 최종 점수순으로 제한된 결과만 저장한다")
|
||||
fun shouldStoreDbScoredSnapshotResultsWithoutServiceSideCandidateLimit() {
|
||||
val snapshotPort = FakeRecommendationSnapshotPort()
|
||||
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||
val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
|
||||
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
|
||||
|
||||
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = snapshotAt))
|
||||
)
|
||||
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
|
||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt))
|
||||
)
|
||||
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
|
||||
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 225L, score = 225.0, snapshotAt = snapshotAt))
|
||||
)
|
||||
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
assertEquals(listOf(25L), snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER).map { it.targetId })
|
||||
assertEquals(listOf(120L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
|
||||
assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 스냅샷 스케줄러는 매일 06:00 KST cron으로 갱신 서비스를 호출한다")
|
||||
fun shouldScheduleDailySnapshotRefreshAtKstSix() {
|
||||
val scheduled = RecommendationSnapshotScheduler::class.java
|
||||
.getDeclaredMethod("refreshDailySnapshots")
|
||||
.getAnnotation(Scheduled::class.java)
|
||||
val service = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val scheduler = RecommendationSnapshotScheduler(service)
|
||||
|
||||
scheduler.refreshDailySnapshots()
|
||||
|
||||
assertEquals("0 0 6 * * *", scheduled.cron)
|
||||
assertEquals("Asia/Seoul", scheduled.zone)
|
||||
Mockito.verify(service).refreshDailySnapshots()
|
||||
}
|
||||
|
||||
private fun service(
|
||||
snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(),
|
||||
queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||
): RecommendationSnapshotRefreshService {
|
||||
return RecommendationSnapshotRefreshService(
|
||||
snapshotPort = snapshotPort,
|
||||
queryPort = queryPort
|
||||
)
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
sectionType: RecommendedSectionType,
|
||||
targetId: Long,
|
||||
score: Double,
|
||||
snapshotAt: LocalDateTime
|
||||
): RecommendationSnapshotRecord {
|
||||
return RecommendationSnapshotRecord(
|
||||
sectionType = sectionType,
|
||||
targetId = targetId,
|
||||
score = score,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
||||
|
||||
override fun findLatestSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
val latestSnapshotAt = snapshots
|
||||
.filter { it.sectionType == sectionType }
|
||||
.maxOfOrNull { it.snapshotAt }
|
||||
|
||||
val all = snapshots
|
||||
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
|
||||
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
|
||||
|
||||
if (offset == 0 && limit == Int.MAX_VALUE) return all
|
||||
return all.drop(offset).take(limit)
|
||||
}
|
||||
|
||||
override fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
) {
|
||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.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.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class RecommendedCreatorFollowServiceTest @Autowired constructor(
|
||||
private val service: RecommendedCreatorFollowService,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("신규 크리에이터만 팔로우 저장하고 이미 팔로우/본인 id는 서버 내부에서 제외한다")
|
||||
fun shouldFollowOnlyNewCreatorsAndSkipExistingAndSelf(output: CapturedOutput) {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val newCreator = saveMember("new-creator", MemberRole.CREATOR)
|
||||
val followedCreator = saveMember("followed-creator", MemberRole.CREATOR)
|
||||
saveFollowing(member = member, creator = followedCreator)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val beforeCount = TransactionSynchronizationManager.getSynchronizations().size
|
||||
service.followCreators(
|
||||
member = member,
|
||||
creatorIds = listOf(newCreator.id!!, followedCreator.id!!, member.id!!)
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
TransactionSynchronizationManager.getSynchronizations().drop(beforeCount).forEach { it.afterCommit() }
|
||||
|
||||
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(newCreator.id!!, member.id!!))
|
||||
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(followedCreator.id!!, member.id!!))
|
||||
assertEquals(2, creatorFollowingRepository.findAll().size)
|
||||
assertTrue(output.out.contains("event=recommended_creator_follow_success"))
|
||||
assertTrue(output.out.contains("requestedCount=3"))
|
||||
assertTrue(output.out.contains("savedCount=1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 성공 로그는 트랜잭션 커밋 후 기록한다")
|
||||
fun shouldLogFollowSuccessAfterTransactionCommit(output: CapturedOutput) {
|
||||
val member = saveMember("after-commit-viewer", MemberRole.USER)
|
||||
val creator = saveMember("after-commit-creator", MemberRole.CREATOR)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val beforeCount = TransactionSynchronizationManager.getSynchronizations().size
|
||||
|
||||
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
|
||||
|
||||
assertEquals(false, output.out.contains("event=recommended_creator_follow_success"))
|
||||
TransactionSynchronizationManager.getSynchronizations().drop(beforeCount).forEach { it.afterCommit() }
|
||||
|
||||
assertTrue(output.out.contains("event=recommended_creator_follow_success"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 팔로우 이력이 있으면 신규 row를 만들지 않고 다시 활성화한다")
|
||||
fun shouldReactivateInactiveFollowingWithoutCreatingDuplicateRow() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val creator = saveMember("reactivate-creator", MemberRole.CREATOR)
|
||||
val inactiveFollowing = saveFollowing(member = member, creator = creator).apply {
|
||||
isNotify = false
|
||||
isActive = false
|
||||
}
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val reactivatedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
|
||||
assertNotNull(reactivatedFollowing)
|
||||
assertEquals(inactiveFollowing.id, reactivatedFollowing!!.id)
|
||||
assertTrue(reactivatedFollowing.isNotify)
|
||||
assertTrue(reactivatedFollowing.isActive)
|
||||
assertEquals(1, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이미 활성 팔로우 중이면 알림 설정을 바꾸지 않고 그대로 둔다")
|
||||
fun shouldKeepActiveExistingFollowingNotificationSetting() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val creator = saveMember("active-creator", MemberRole.CREATOR)
|
||||
val existingFollowing = saveFollowing(member = member, creator = creator).apply {
|
||||
isNotify = false
|
||||
isActive = true
|
||||
}
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val unchangedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
|
||||
assertNotNull(unchangedFollowing)
|
||||
assertEquals(existingFollowing.id, unchangedFollowing!!.id)
|
||||
assertFalse(unchangedFollowing.isNotify)
|
||||
assertTrue(unchangedFollowing.isActive)
|
||||
assertEquals(1, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("같은 회원과 크리에이터 팔로우 row는 중복 저장할 수 없다")
|
||||
fun shouldRejectDuplicateFollowingRowsForSameMemberAndCreator() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val creator = saveMember("duplicate-creator", MemberRole.CREATOR)
|
||||
saveFollowing(member = member, creator = creator)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val exception = assertThrows(DataIntegrityViolationException::class.java) {
|
||||
saveFollowing(member = member, creator = creator)
|
||||
entityManager.flush()
|
||||
}
|
||||
|
||||
assertNotNull(exception)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("존재하지 않는 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다")
|
||||
fun shouldFailAllAndSaveNothingWhenAnyCreatorIdDoesNotExist(output: CapturedOutput) {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.followCreators(member = member, creatorIds = listOf(validCreator.id!!, 999_999L))
|
||||
}
|
||||
|
||||
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||
assertEquals(0, creatorFollowingRepository.findAll().size)
|
||||
assertTrue(output.out.contains("event=recommended_creator_follow_failure"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터가 아닌 회원 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다")
|
||||
fun shouldFailAllAndSaveNothingWhenAnyMemberIdIsNotCreator() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
|
||||
val nonCreator = saveMember("non-creator", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.followCreators(member = member, creatorIds = listOf(validCreator.id!!, nonCreator.id!!))
|
||||
}
|
||||
|
||||
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||
assertEquals(0, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
private fun saveMember(seed: String, role: MemberRole): Member {
|
||||
return memberRepository.saveAndFlush(
|
||||
Member(
|
||||
email = "$seed@test.com",
|
||||
password = "password",
|
||||
nickname = seed,
|
||||
role = role
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
|
||||
return creatorFollowingRepository.saveAndFlush(
|
||||
CreatorFollowing().apply {
|
||||
this.member = member
|
||||
this.creator = creator
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.domain
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class CreatorDebutPolicyTest {
|
||||
private val policy = CreatorDebutPolicy()
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 공개 콘텐츠만 있으면 콘텐츠 공개일을 데뷔일로 선택한다")
|
||||
fun shouldResolveDebutAtFromFirstContentOnly() {
|
||||
val firstContentPublishedAt = LocalDateTime.of(2026, 5, 1, 10, 0)
|
||||
|
||||
val debutAt = policy.resolveDebutAt(firstContentPublishedAt, firstLiveAt = null)
|
||||
|
||||
assertEquals(firstContentPublishedAt, debutAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 라이브만 있으면 라이브 일시를 데뷔일로 선택한다")
|
||||
fun shouldResolveDebutAtFromFirstLiveOnly() {
|
||||
val firstLiveAt = LocalDateTime.of(2026, 5, 2, 10, 0)
|
||||
|
||||
val debutAt = policy.resolveDebutAt(firstContentPublishedAt = null, firstLiveAt)
|
||||
|
||||
assertEquals(firstLiveAt, debutAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 공개 콘텐츠와 첫 라이브가 모두 있으면 빠른 일시를 데뷔일로 선택한다")
|
||||
fun shouldResolveEarliestDebutAtWhenBothExist() {
|
||||
val firstContentPublishedAt = LocalDateTime.of(2026, 5, 3, 10, 0)
|
||||
val firstLiveAt = LocalDateTime.of(2026, 5, 2, 10, 0)
|
||||
|
||||
val debutAt = policy.resolveDebutAt(firstContentPublishedAt, firstLiveAt)
|
||||
|
||||
assertEquals(firstLiveAt, debutAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 공개 콘텐츠와 첫 라이브가 모두 없으면 데뷔일은 null이다")
|
||||
fun shouldReturnNullWhenDebutSourcesDoNotExist() {
|
||||
val debutAt = policy.resolveDebutAt(firstContentPublishedAt = null, firstLiveAt = null)
|
||||
|
||||
assertNull(debutAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("데뷔 후 30일 이내이면 신규 크리에이터로 판정한다")
|
||||
fun shouldReturnTrueWhenCreatorIsWithinThirtyDaysFromDebut() {
|
||||
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
|
||||
|
||||
assertTrue(policy.isNewCreator(now.minusDays(0), now))
|
||||
assertTrue(policy.isNewCreator(now.minusDays(30), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("데뷔 후 30일이 지났거나 데뷔일이 없으면 신규 크리에이터가 아니다")
|
||||
fun shouldReturnFalseWhenCreatorIsNotWithinThirtyDaysFromDebut() {
|
||||
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
|
||||
|
||||
assertFalse(policy.isNewCreator(now.minusDays(31), now))
|
||||
assertFalse(policy.isNewCreator(now.plusDays(1), now))
|
||||
assertFalse(policy.isNewCreator(debutAt = null, now))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.domain
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class RecommendationScorePolicyTest {
|
||||
private val policy = RecommendationScorePolicy()
|
||||
|
||||
@Test
|
||||
@DisplayName("데뷔일 기준 신규 부스트는 10일/20일/30일 구간을 적용한다")
|
||||
fun shouldApplyCreatorNewBoostByDebutDays() {
|
||||
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
|
||||
|
||||
assertEquals(10L, RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT)
|
||||
assertEquals(20L, RecommendationScoreSpec.NEW_BOOST_20_DAY_LIMIT)
|
||||
assertEquals(30L, RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT)
|
||||
assertEquals(1.5, policy.calculateCreatorNewBoost(now.minusDays(10), now), 0.0001)
|
||||
assertEquals(1.3, policy.calculateCreatorNewBoost(now.minusDays(20), now), 0.0001)
|
||||
assertEquals(1.2, policy.calculateCreatorNewBoost(now.minusDays(30), now), 0.0001)
|
||||
assertEquals(1.0, policy.calculateCreatorNewBoost(now.minusDays(31), now), 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 생성일 기준 신규 부스트는 10일/20일/30일 구간을 적용한다")
|
||||
fun shouldApplyAiCharacterNewBoostByCreatedDays() {
|
||||
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
|
||||
|
||||
assertEquals(1.5, policy.calculateAiCharacterNewBoost(now.minusDays(10), now), 0.0001)
|
||||
assertEquals(1.3, policy.calculateAiCharacterNewBoost(now.minusDays(20), now), 0.0001)
|
||||
assertEquals(1.2, policy.calculateAiCharacterNewBoost(now.minusDays(30), now), 0.0001)
|
||||
assertEquals(1.0, policy.calculateAiCharacterNewBoost(now.minusDays(31), now), 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 데뷔 크리에이터 추천 점수는 PRD 가중치와 신규 부스트를 적용한다")
|
||||
fun shouldCalculateDebutCreatorScore() {
|
||||
assertEquals(0.35, RecommendationScoreSpec.DEBUT_FOLLOW_INCREASE_WEIGHT, 0.0001)
|
||||
assertEquals(0.3, RecommendationScoreSpec.DEBUT_CONTENT_ACTIVITY_WEIGHT, 0.0001)
|
||||
assertEquals(0.2, RecommendationScoreSpec.DEBUT_COMMUNICATION_WEIGHT, 0.0001)
|
||||
|
||||
val score = policy.calculateDebutCreatorScore(
|
||||
followIncrease = 10,
|
||||
contentActivityScore = 20,
|
||||
communicationScore = 30,
|
||||
newBoost = 1.5
|
||||
)
|
||||
|
||||
assertEquals(23.25, score, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 채팅 추천 점수는 이번 스프린트에서 팔로우 증가량을 제외한다")
|
||||
fun shouldCalculateAiChatScore() {
|
||||
assertEquals(0.45, RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT, 0.0001)
|
||||
assertEquals(0.35, RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT, 0.0001)
|
||||
|
||||
val score = policy.calculateAiChatScore(
|
||||
recentChatCount = 100,
|
||||
recentActiveUserCount = 20,
|
||||
newBoost = 1.3
|
||||
)
|
||||
|
||||
assertEquals(67.6, score, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 응원 추천 점수는 후원 금액, 팬 Talk 수, 후원 수에 가중치를 적용한다")
|
||||
fun shouldCalculateCheerScore() {
|
||||
assertEquals(0.6, RecommendationScoreSpec.CHEER_DONATION_AMOUNT_WEIGHT, 0.0001)
|
||||
assertEquals(0.3, RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT, 0.0001)
|
||||
assertEquals(0.1, RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT, 0.0001)
|
||||
|
||||
val score = policy.calculateCheerScore(
|
||||
donationAmount = 1000,
|
||||
fanTalkCount = 20,
|
||||
donationCount = 10,
|
||||
newBoost = 1.2
|
||||
)
|
||||
|
||||
assertEquals(728.4, score, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 점수는 좋아요 수, 댓글 수, 팔로우 수에 가중치를 적용한다")
|
||||
fun shouldCalculateCommunityScore() {
|
||||
assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT, 0.0001)
|
||||
assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT, 0.0001)
|
||||
assertEquals(0.1, RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT, 0.0001)
|
||||
|
||||
val score = policy.calculateCommunityScore(
|
||||
likeCount = 40,
|
||||
commentCount = 20,
|
||||
followerCount = 100,
|
||||
newBoost = 1.2
|
||||
)
|
||||
|
||||
assertEquals(48.0, score, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 오디오 최신성 점수는 releaseDate 기준 경과일 구간을 적용한다")
|
||||
fun shouldCalculateFirstAudioRecencyScore() {
|
||||
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
|
||||
|
||||
assertEquals(100, policy.calculateFirstAudioRecencyScore(now.minusDays(3), now))
|
||||
assertEquals(80, policy.calculateFirstAudioRecencyScore(now.minusDays(7), now))
|
||||
assertEquals(60, policy.calculateFirstAudioRecencyScore(now.minusDays(14), now))
|
||||
assertEquals(40, policy.calculateFirstAudioRecencyScore(now.minusDays(21), now))
|
||||
assertEquals(20, policy.calculateFirstAudioRecencyScore(now.minusDays(30), now))
|
||||
assertEquals(0, policy.calculateFirstAudioRecencyScore(now.minusDays(31), now))
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
org.springframework.context.ApplicationContextInitializer=\
|
||||
kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
Reference in New Issue
Block a user