Compare commits
298 Commits
test
...
83028f7817
| Author | SHA1 | Date | |
|---|---|---|---|
| 83028f7817 | |||
| 70d1795557 | |||
| 8c6c681424 | |||
| 50bc9f4ff3 | |||
| f00ea03fad | |||
| f22e7b9ad1 | |||
| c7ec95f4bb | |||
| 229e7a8ccc | |||
| 3c616474ff | |||
| 56eb6b3ce3 | |||
| 545836d43c | |||
| 219f83dec0 | |||
| a76a841238 | |||
| c26680de84 | |||
| 8fffad9d3a | |||
| f4f0f203a2 | |||
| b7196f5a0c | |||
| 5d33a18890 | |||
| 96186a1a50 | |||
| bc8bc479d1 | |||
| 47595b1291 | |||
| 01a88964df | |||
| 3a2b77379f | |||
| dc4e5f75cd | |||
| d0178d551c | |||
| 827333108d | |||
| 587b90bd27 | |||
| 4dc20c5e90 | |||
| ac25782f2b | |||
| 20437d56e7 | |||
| f0b412828a | |||
| 367faac5c3 | |||
| 84deaaa970 | |||
| a2b39466c2 | |||
| 03586c4005 | |||
| 6ea69e1510 | |||
| 553c6dc539 | |||
| 6cc22f5b6d | |||
| 9103d67cc1 | |||
| 25083fb0e4 | |||
| d2dc045255 | |||
| b8621dfbb0 | |||
| 93633940dd | |||
| b6f5325351 | |||
| 7c32c08f1f | |||
| 1d268da08d | |||
| 797666ae0d | |||
| dcf470997e | |||
| 0974d1dbf8 | |||
| 12a35db6cd | |||
| 9abbb05ad8 | |||
| 1ecaf69b0b | |||
| e334d1e5d9 | |||
| b735e861d0 | |||
| 4eb433d372 | |||
| 2416ae61f3 | |||
| 01fb336985 | |||
| b6af88a732 | |||
| 58a2a17d6d | |||
| 79f5a0f520 | |||
| 7f6c0f7f04 | |||
| f658df4dca | |||
| 9d43b8e23a | |||
| 4270aef79b | |||
| 1c0dc82d44 | |||
| c1e325aadf | |||
| cec87da69d | |||
| f68f24cb2c | |||
| ed094347fc | |||
| b8afdffbe1 | |||
| f6ba79f31c | |||
| 5f3b1663d2 | |||
| 66e786b4bb | |||
| f671114574 | |||
| ce37060d94 | |||
| 7d19a4d184 | |||
| 22f28a2f8a | |||
| ceef9ca979 | |||
| efe8f4f939 | |||
| ba692a1195 | |||
| d732bad042 | |||
| 4c935c3bee | |||
| c160dd791f | |||
| 23cd1b4601 | |||
| 031fc8ba1b | |||
| c6853289ad | |||
| 2497bb69bc | |||
| a58a67e0a2 | |||
| 4315fe12a5 | |||
| 42f10a8899 | |||
| 1e4b47f989 | |||
| ff255dbfae | |||
| dbe9b72feb | |||
| 95a714b391 | |||
| 28f58c7f56 | |||
| 8bd46d8f21 | |||
| e1bb8e54ed | |||
| 1de705b063 | |||
| f6926ad356 | |||
| 2cdbbb1b37 | |||
| 4dce8c8f03 | |||
| 97a5bace6f | |||
| d4d51ec48f | |||
| fb91398462 | |||
| 105dadd798 | |||
| 2abf2837d3 | |||
| 422aa67af6 | |||
| 7fffab6985 | |||
| 5a4be3d2c1 | |||
| f39a7681db | |||
| c60a7580ba | |||
| 97edb56edc | |||
| 6ebca8d22b | |||
| 95371ad934 | |||
| 2c176825fd | |||
| fae7de48d3 | |||
| b8230646a2 | |||
| 43279541dd | |||
| b4791977c1 | |||
| ef917ecc25 | |||
| a93faad951 | |||
| fd001d24d3 | |||
| 7aa5884797 | |||
| 5b237a1547 | |||
| 2e37990d87 | |||
| dd07d724a8 | |||
| 03ce8618e7 | |||
| db1a7a7fd6 | |||
| 36a82d7f53 | |||
| 3a34401113 | |||
| 9927268330 | |||
| c45c97e29d | |||
| c64a315226 | |||
| a4cafca6ab | |||
| 46284a0660 | |||
| 05df86e15a | |||
| 8b433027e2 | |||
| 5bd4ff7610 | |||
| d693c397ea | |||
| 1d8d1ec9a5 | |||
| 5e491f11ee | |||
| 7cedea06ac | |||
| 2e5f750e50 | |||
| 20289cad10 | |||
| e0d64c31c7 | |||
| 8c1b95dc97 | |||
| fb5641343e | |||
| 87765941eb | |||
| 1809862c16 | |||
| 300f784f7d | |||
| 67a045eae6 | |||
| 2a79903a28 | |||
| d3222ce083 | |||
| 406a421742 | |||
| 10bf728faf | |||
| 607617747c | |||
| f0a69eb1a2 | |||
| 6b307a6e17 | |||
| 08d08a934a | |||
| c500c12668 | |||
| 62060adeba | |||
| b2fc75edb8 | |||
| a999dd2085 | |||
| 49f95ab100 | |||
| 1a84d5b30c | |||
| 3b65050632 | |||
| d0df31674c | |||
| 1fe88402e2 | |||
| 67097696e6 | |||
| 8e7e77067a | |||
| 9899390b61 | |||
| 80c476a908 | |||
| 59da1d6e49 | |||
| 5aef7dac33 | |||
| faf7aa06b6 | |||
| 38ef6e5583 | |||
| c0b15b5d94 | |||
| 2cfc067ea1 | |||
| a91db4f956 | |||
| 8a09780a02 | |||
| 45e8ec6505 | |||
| 4554b85914 | |||
| 8aa79c4a9c | |||
| c8d3210b57 | |||
| 2282a49563 | |||
| b82fdfb2c8 | |||
| 2d17eac199 | |||
| e482bc3aad | |||
| ec022b74d1 | |||
| dc42c09ce3 | |||
| 046a34d2a4 | |||
| 9ff6ec1888 | |||
| d2950106ec | |||
| 962f800d2e | |||
| 962107e507 | |||
| 039bd11963 | |||
| 5c250ea4ae | |||
| e3405bcec6 | |||
| 0fd1c2235f | |||
| b20c29b022 | |||
| 12d5dcd298 | |||
| 2c305dc6c6 | |||
| 62f76f7433 | |||
| 858ce524f9 | |||
| 3795fb4a40 | |||
| 0c01aeec50 | |||
| 892206744d | |||
| 9e2c1474db | |||
| 16328f73d9 | |||
| e0d4f53cf4 | |||
| e09a59c5b4 | |||
| 049e654535 | |||
| c927dc4ecd | |||
| fe4ecd0ad8 | |||
| 78d476fe80 | |||
| a11c8465d5 | |||
| 366304a9b7 | |||
| 4356663688 | |||
| 26b55e6fcf | |||
| 0d743f7204 | |||
| 6cbe113b3e | |||
| 6409b69d6c | |||
| c5164c76fc | |||
| baade8e138 | |||
| b848d6b4e0 | |||
| d8139d2ab0 | |||
| e96d8f7469 | |||
| 2acffd8afc | |||
| 3c8e72073c | |||
| 724d7a9d9b | |||
| 2da3b0db78 | |||
| 685ad7afaf | |||
| 264cf75964 | |||
| c773dbc7b5 | |||
| 37cbc64f52 | |||
| cb1dde17bb | |||
| c29988acf4 | |||
| eadbf56dae | |||
| 4b3b455135 | |||
| e6ac177396 | |||
| 3d0e29003f | |||
| 78b9b00f77 | |||
| 0ee7faa551 | |||
| e5fdced681 | |||
| afb99fef64 | |||
| 7dfaa36024 | |||
| 0496f665aa | |||
| 0d19e1be74 | |||
| 4aff0111aa | |||
| 63b3ba2bb2 | |||
| 7444b41f60 | |||
| 8e90dbc8b6 | |||
| 9f70722521 | |||
| 52fae596fa | |||
| ccb67957bc | |||
| fb82538d0d | |||
| 72ee39612e | |||
| 51fd5408dc | |||
| 3fae40fbef | |||
| 0745890af0 | |||
| 4abe1730a7 | |||
| 626f0e6989 | |||
| 9f42d9d173 | |||
| f90a93c4bc | |||
| 8000ad6c6a | |||
| 1f1f1bea1a | |||
| d95460c7cd | |||
| a3d93d4b08 | |||
| 07a92af982 | |||
| f4618877d4 | |||
| 2b914fd222 | |||
| 109e42a5a3 | |||
| fa515ad39c | |||
| f09673a795 | |||
| f71536c614 | |||
| 7bdddc7ae8 | |||
| aa8926a624 | |||
| be71e59be2 | |||
| 4d7753378f | |||
| 60257c4ef4 | |||
| 1e0b79bf62 | |||
| 6883434d0d | |||
| eda2193e64 | |||
| 99bf829c88 | |||
| 5feafe1b48 | |||
| c9292b7d04 | |||
| ae7e1a91c1 | |||
| 3e1887e0d1 | |||
| 474646db47 | |||
| 56f7b6c449 | |||
| 76b2b5f7e3 | |||
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
|||||||
indent_style = space
|
indent_style = space
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 130
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,9 +1,6 @@
|
|||||||
HELP.md
|
HELP.md
|
||||||
.gradle
|
.gradle
|
||||||
.envrc
|
.envrc
|
||||||
.omx/
|
|
||||||
.worktrees/
|
|
||||||
.omo/
|
|
||||||
build/
|
build/
|
||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
!**/src/test/**/build/
|
!**/src/test/**/build/
|
||||||
@@ -326,7 +323,4 @@ gradle-app.setting
|
|||||||
### Gradle Patch ###
|
### Gradle Patch ###
|
||||||
**/build/
|
**/build/
|
||||||
|
|
||||||
.kiro/
|
|
||||||
.junie
|
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||||
|
|||||||
380
.opencode/package-lock.json
generated
380
.opencode/package-lock.json
generated
@@ -1,380 +0,0 @@
|
|||||||
{
|
|
||||||
"name": ".opencode",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"@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.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.15.12",
|
|
||||||
"effect": "4.0.0-beta.66",
|
|
||||||
"zod": "4.1.8"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@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.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",
|
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"path-key": "^3.1.0",
|
|
||||||
"shebang-command": "^2.0.0",
|
|
||||||
"which": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"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",
|
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"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",
|
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"shebang-regex": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shebang-regex": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"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",
|
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"isexe": "^2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"node-which": "bin/node-which"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"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",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
112
AGENTS.md
112
AGENTS.md
@@ -1,112 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## 문서 목적
|
|
||||||
- 이 문서는 `/Users/klaus/Develop/sodalive/Server/sodalive` 저장소에서 작업하는 에이전트용 실행 가이드다.
|
|
||||||
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
|
|
||||||
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
|
|
||||||
|
|
||||||
## CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)
|
|
||||||
These principles override plugin behavior, skill behavior, workflow behavior, and default model behavior unless the user's direct instruction explicitly says otherwise.
|
|
||||||
|
|
||||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
|
||||||
|
|
||||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
|
||||||
|
|
||||||
## 1. Think Before Coding
|
|
||||||
|
|
||||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
|
||||||
|
|
||||||
Before implementing:
|
|
||||||
- State your assumptions explicitly. If uncertain, ask.
|
|
||||||
- If multiple interpretations exist, present them - don't pick silently.
|
|
||||||
- If a simpler approach exists, say so. Push back when warranted.
|
|
||||||
- If something is unclear, stop. Name what's confusing. Ask.
|
|
||||||
|
|
||||||
## 2. Simplicity First
|
|
||||||
|
|
||||||
**Minimum code that solves the problem. Nothing speculative.**
|
|
||||||
|
|
||||||
- No features beyond what was asked.
|
|
||||||
- No abstractions for single-use code.
|
|
||||||
- No "flexibility" or "configurability" that wasn't requested.
|
|
||||||
- No error handling for impossible scenarios.
|
|
||||||
- If you write 200 lines and it could be 50, rewrite it.
|
|
||||||
|
|
||||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
|
||||||
|
|
||||||
## 3. Surgical Changes
|
|
||||||
|
|
||||||
**Touch only what you must. Clean up only your own mess.**
|
|
||||||
|
|
||||||
When editing existing code:
|
|
||||||
- Don't "improve" adjacent code, comments, or formatting.
|
|
||||||
- Don't refactor things that aren't broken.
|
|
||||||
- Match existing style, even if you'd do it differently.
|
|
||||||
- If you notice unrelated dead code, mention it - don't delete it.
|
|
||||||
|
|
||||||
When your changes create orphans:
|
|
||||||
- Remove imports/variables/functions that YOUR changes made unused.
|
|
||||||
- Don't remove pre-existing dead code unless asked.
|
|
||||||
|
|
||||||
The test: Every changed line should trace directly to the user's request.
|
|
||||||
|
|
||||||
## 4. Goal-Driven Execution
|
|
||||||
|
|
||||||
**Define success criteria. Loop until verified.**
|
|
||||||
|
|
||||||
Transform tasks into verifiable goals:
|
|
||||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
|
||||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
|
||||||
- "Refactor X" → "Ensure tests pass before and after"
|
|
||||||
|
|
||||||
For multi-step tasks, state a brief plan:
|
|
||||||
```
|
|
||||||
1. [Step] → verify: [check]
|
|
||||||
2. [Step] → verify: [check]
|
|
||||||
3. [Step] → verify: [check]
|
|
||||||
```
|
|
||||||
|
|
||||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
|
||||||
|
|
||||||
## 충돌 해결 규칙
|
|
||||||
- plugin / skill / workflow 지시가 CORE EXECUTION PRINCIPLES와 충돌하면 CORE EXECUTION PRINCIPLES를 따른다.
|
|
||||||
- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
|
|
||||||
- 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
|
|
||||||
|
|
||||||
## 커뮤니케이션 규칙
|
|
||||||
- **"질문에 대한 답변과 설명은 한국어로 한다."**
|
|
||||||
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
|
|
||||||
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
|
|
||||||
|
|
||||||
## 프로젝트 개요
|
|
||||||
- 빌드 도구: Gradle Wrapper (`./gradlew`)
|
|
||||||
- 언어/런타임: Kotlin + Java 17
|
|
||||||
- 프레임워크: Spring Boot 2.7.14
|
|
||||||
- 주요 플러그인: `org.jlleitschuh.gradle.ktlint`
|
|
||||||
- 단일 루트 프로젝트: `settings.gradle.kts`의 `rootProject.name = "sodalive"`
|
|
||||||
|
|
||||||
## 프로젝트 핵심 규칙
|
|
||||||
- Kotlin/Spring 스타일, 테스트 스타일, 보안 유의사항, 작업 절차, 문서 유지보수, 실행 명령어, 커밋 메시지 상세 규칙은 아래 문서를 따른다.
|
|
||||||
- `docs/agent-guides/코드스타일.md`
|
|
||||||
- `docs/agent-guides/테스트스타일.md`
|
|
||||||
- `docs/agent-guides/설정보안.md`
|
|
||||||
- `docs/agent-guides/작업절차.md`
|
|
||||||
- `docs/agent-guides/문서유지보수.md`
|
|
||||||
- `docs/agent-guides/실행명령어.md`
|
|
||||||
- `docs/agent-guides/커밋메시지.md`
|
|
||||||
- 공개 API 스키마는 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
|
||||||
- 기존 코드베이스 관례를 우선하며, 불확실한 규칙은 추측하지 말고 근거 파일을 먼저 확인한다.
|
|
||||||
|
|
||||||
## PRD 및 구현 계획/TASK 문서 규칙
|
|
||||||
- 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다.
|
|
||||||
- 문서는 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 작성한다.
|
|
||||||
- 상세 작성/유지보수 규칙은 `docs/agent-guides/작업절차.md`와 `docs/agent-guides/문서유지보수.md`를 따른다.
|
|
||||||
|
|
||||||
## 에이전트 동작 원칙
|
|
||||||
- 추측하지 말고, 근거 파일을 읽고 결정한다.
|
|
||||||
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
|
|
||||||
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
|
|
||||||
@@ -18,7 +18,7 @@ version = "0.0.1-SNAPSHOT"
|
|||||||
val querydslVersion = "5.0.0"
|
val querydslVersion = "5.0.0"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -32,7 +32,6 @@ dependencies {
|
|||||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-websocket")
|
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
implementation("org.springframework.retry:spring-retry")
|
implementation("org.springframework.retry:spring-retry")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
@@ -42,8 +41,6 @@ dependencies {
|
|||||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
|
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
|
||||||
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
|
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
|
||||||
|
|
||||||
implementation("com.nimbusds:nimbus-jose-jwt:9.37.3")
|
|
||||||
|
|
||||||
// querydsl (추가 설정)
|
// querydsl (추가 설정)
|
||||||
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
|
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
|
||||||
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
|
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
|
||||||
@@ -68,22 +65,14 @@ dependencies {
|
|||||||
// android publisher
|
// android publisher
|
||||||
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
|
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
|
||||||
|
|
||||||
implementation("com.google.api-client:google-api-client:1.32.1")
|
|
||||||
|
|
||||||
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
||||||
|
|
||||||
// file mimetype check
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
implementation("org.apache.tika:tika-core:3.2.0")
|
runtimeOnly("com.h2database:h2")
|
||||||
|
|
||||||
runtimeOnly("com.mysql:mysql-connector-j")
|
runtimeOnly("com.mysql:mysql-connector-j")
|
||||||
|
|
||||||
testRuntimeOnly("com.h2database:h2")
|
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.springframework.security:spring-security-test")
|
testImplementation("org.springframework.security:spring-security-test")
|
||||||
testImplementation("com.github.codemonstur:embedded-redis:1.4.3")
|
|
||||||
|
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
allOpen {
|
allOpen {
|
||||||
@@ -95,13 +84,12 @@ allOpen {
|
|||||||
tasks.withType<KotlinCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs += "-Xjsr305=strict"
|
freeCompilerArgs += "-Xjsr305=strict"
|
||||||
jvmTarget = "17"
|
jvmTarget = "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
maxHeapSize = "1536m"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.getByName<Jar>("jar") {
|
tasks.getByName<Jar>("jar") {
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 '크리에이터 콘텐츠 시청 이력';
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
# 메인 홈 추천 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.recommendation`에 둔다. `v2.api.home`은 `v2.recommendation`의 application use case만 호출하며, `v2.recommendation`는 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")`로 등록한다.
|
|
||||||
- 다중 서버 인스턴스에서 스냅샷 일 배치가 중복 실행되지 않도록 기존 Redisson 기반 분산 lock을 사용한다.
|
|
||||||
- 추천 스냅샷 lock key는 `lock:recommendation-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
|
|
||||||
- 저장소에는 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/recommendation/HomeRecommendationResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationPageResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/FollowRecommendedCreatorsRequest.kt`
|
|
||||||
|
|
||||||
### 신규 추천 기능 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/CreatorDebutPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedActivityType.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/CreatorContentViewHistoryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistoryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/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/recommendation/domain/RecommendationScorePolicyTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/CreatorDebutPolicyTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryServiceTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowServiceTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/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/recommendation/domain/RecommendationScorePolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
|
|
||||||
- RED: `shouldApplyCreatorNewBoostByDebutDays`, `shouldApplyAiCharacterNewBoostByCreatedDays`, `shouldCalculateDebutCreatorScore`, `shouldCalculateAiChatScore`, `shouldCalculateCheerScore`, `shouldCalculateCommunityScore`, `shouldCalculateFirstAudioRecencyScore` 테스트를 먼저 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/domain/CreatorDebutPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/CreatorDebutPolicyTest.kt`
|
|
||||||
- RED: 첫 공개 콘텐츠 일시와 첫 라이브 일시 중 빠른 값을 데뷔일로 선택하는 테스트, 데뷔 후 30일 이내만 true인 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/domain/RecommendedActivityType.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: `LIVE_REPLAY` 테마 콘텐츠가 `AUDIO`가 아니라 `LIVE_REPLAY`로 분류되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/port/out/RecommendationSnapshotPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: 섹션 타입, 대상 id, 점수, 기준 시각, 랜덤 tie-breaker를 저장하고 기준 시각별 최신 스냅샷만 읽는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/RecommendationSnapshotRefreshService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: AI 캐릭터, 최근 응원, 인기 커뮤니티 점수를 전날 23:59:59 기준으로 생성하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: KST 매일 06:00:00 cron과 `Asia/Seoul` zone이 선언되는지 reflection 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
|
|
||||||
- GREEN: 스케줄러가 KST 06:00:00 cron으로 `RecommendationSnapshotRefreshService.refreshDailySnapshots()`를 호출하도록 구현한다.
|
|
||||||
- REFACTOR: 스케줄러에는 집계 로직을 두지 않고 호출만 남긴다.
|
|
||||||
- 기대 결과: KST 매일 06:00에 전날 23:59:59 KST 기준 집계가 실행되는 계약이 테스트로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 2.3.1: 일 스냅샷 스케줄러 Redisson lock 적용**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: Redisson lock 획득 성공 시 `RecommendationSnapshotRefreshService.refreshDailySnapshots()`를 1회 호출하고, 획득 실패 시 호출하지 않는 테스트를 작성한다. lock key가 `lock:recommendation-snapshot-refresh`인지도 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
|
|
||||||
- GREEN: 기존 `RedissonClient` bean을 스케줄러에 주입하고 `tryLock`으로 lock을 획득한 인스턴스만 refresh service를 호출한다. lock 획득 실패는 정상 skip으로 처리한다.
|
|
||||||
- REFACTOR: DB 기반 scheduler lock 테이블은 추가하지 않고, 기존 `AudioContentReleaseScheduledTask`의 Redisson lock 패턴을 참고하되 스케줄러에는 lock 획득/해제와 service 호출만 둔다.
|
|
||||||
- 기대 결과: 여러 서버 인스턴스에서 같은 cron이 동시에 실행돼도 클러스터 전체에서 한 인스턴스만 추천 스냅샷을 갱신한다.
|
|
||||||
|
|
||||||
- [x] **Task 2.4: Phase 2 스냅샷 집계 통합 검증과 경계 보강**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: QueryDSL 집계 통합 테스트를 추가해 AI 캐릭터 최근 채팅 수/활성 사용자 수, 최근 응원 `CHANNEL_DONATION` 후원 금액/후원 수와 팬 Talk 수, 인기 커뮤니티 좋아요/댓글/팔로워 수가 Phase 2 요구와 일치하는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/RecommendationSnapshotRefreshService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: 최근 응원/인기 커뮤니티 신규 부스트가 단순 `Member.createdAt`이 아니라 실제 데뷔일을 사용하도록 실패 테스트를 추가한다. 실제 데뷔일은 첫 공개 콘텐츠 일시와 첫 라이브 일시 중 빠른 값이며, 둘 다 없는 경우는 스냅샷 후보에서 제외되는 실패 테스트를 추가한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: AI 캐릭터 최근 채팅 수가 최근 7일 안에 해당 AI 캐릭터가 발화한 채팅 메시지 수만 세도록 실패 테스트를 추가한다. 사용자 메시지, 다른 캐릭터 메시지, 비활성 메시지, 기간 밖 메시지는 제외되는지 fixture로 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 최근 활성 사용자 수가 최근 7일 안에 해당 AI 캐릭터와 1회 이상 채팅한 중복 없는 사용자 수로 계산되도록 실패 테스트를 추가한다. 같은 사용자의 다중 메시지는 1명으로 세고, 다른 캐릭터와만 채팅한 사용자는 제외한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/RecommendationSnapshotRefreshService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 스냅샷 저장 결과가 최종 점수 내림차순과 `randomTieBreaker` 기준으로 AI 캐릭터 최대 20개, 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개만 저장되는 실패 테스트를 추가한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/domain/RecommendationScoreSpec.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/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.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/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.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.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 그대로 유지한다. 최근 활동 `COMMUNITY`의 이동 대상 id는 커뮤니티 게시글 id가 아니라 해당 게시글 작성자 크리에이터 id를 사용한다.
|
|
||||||
- REFACTOR: 차단 관계, 비활성 회원, 비활성 콘텐츠/배너 제외 조건을 공통 private 조건 함수로 정리한다. 단순 조회와 대상 활성 조건은 QueryDSL/JPA 우선으로 표현하고, native SQL은 SQL 고급 기능이 필요한 쿼리에만 남긴다.
|
|
||||||
- 기대 결과: 특정 섹션 데이터가 부족해도 service가 가능한 개수만 반환한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 최근 데뷔/첫 오디오 콘텐츠 조회 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: 데뷔 후 30일 이내 추천 점수순, 최근 데뷔 크리에이터 노출 정보의 프로필 이미지/닉네임, 첫 오디오 콘텐츠 3번째 이내 활성 콘텐츠만 인정, 최신성 점수 구간별 정렬, 예약 공개 콘텐츠 제외 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: 스냅샷 기준 AI 캐릭터 10개, AI 캐릭터 응답의 캐릭터 이름/소개/전체 채팅 수/오리지널 작품명 조건, 최근 응원 8명과 크리에이터 프로필 이미지/닉네임, 인기 커뮤니티 10개와 크리에이터 프로필 이미지/닉네임/UTC 시간/좋아요 수/댓글 수/커뮤니티 내용, 스냅샷 없음 빈 배열 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/port/out/CreatorContentViewHistoryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistoryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryServiceTest.kt`
|
|
||||||
- RED: 인증 회원의 콘텐츠 상세 진입 시 memberId/contentId/genreId/viewedAt이 저장되는 테스트와 비회원은 저장하지 않는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: 조회 이력 콘텐츠의 `content_theme` 기준 랜덤 5개, 부족분 랜덤 보충, 테마별 8명, 한 응답의 5개 테마 안에서 크리에이터 중복 제거, 서로 다른 조회 시점에서는 같은 크리에이터 재노출 허용, 팔로우 크리에이터 제외, 활성 크리에이터/활성 콘텐츠가 없어 빈 그룹이 되는 테마 제외, 크리에이터 프로필 이미지/닉네임/id 노출 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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 필드가 바뀌지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 4.4: 장르 기반 크리에이터 추천 본인 제외 보정**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
|
|
||||||
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: 조회자가 크리에이터인 경우 본인만 있는 장르는 제외하고, 8명 중 본인이 포함된 장르는 본인을 제외한 뒤 대체 크리에이터가 있으면 8명을 채우며, 대체 크리에이터가 없거나 장르 전체가 8명 미만이면 조회 가능한 크리에이터만 응답하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeRequesterOnlyGenreFromGenreCreatorRecommendations --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldBackfillCreatorAfterExcludingRequesterFromGenreCreatorRecommendations --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldReturnAvailableCreatorsAfterExcludingRequesterFromGenreCreatorRecommendations`
|
|
||||||
- GREEN: 장르 후보 eligibility, fallback 후보 count, 실제 장르별 크리에이터 조회 SQL에서 `memberId`가 있는 경우 조회자 본인 크리에이터를 제외한다.
|
|
||||||
- REFACTOR: 공개 API 응답 스키마와 service의 장르별 중복 제거/보충 정책은 유지하고, repository 후보 산정과 응답 크리에이터 목록이 같은 eligibility 기준을 쓰는지 회귀 테스트로 확인한다.
|
|
||||||
- 기대 결과: 본인만 있는 장르는 응답하지 않고, 본인을 제외한 추천 가능 크리에이터가 있으면 최대 8명까지 응답하며, 8명 미만이면 가능한 만큼만 응답한다.
|
|
||||||
|
|
||||||
### Phase 5: 추천 크리에이터 동시 팔로우
|
|
||||||
|
|
||||||
- [x] **Task 5.1: 팔로우 use case 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowServiceTest.kt`
|
|
||||||
- RED: mock 없이 실제 Spring/JPA 흐름으로 신규 팔로우 id와 이미 팔로우/본인 id 등 제외 id를 구분하는 테스트, 존재하지 않는 id/크리에이터가 아닌 id 포함 시 전체 실패 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/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.recommendation.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/recommendation/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/recommendation/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/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/HomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/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/recommendation/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.recommendation.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/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 차단 관계 양방향 제외, 커뮤니티 성인 노출 조건, 본인인증 여부 조건이 필요한 추천 필터, 팔로우 크리에이터 제외, 비활성 회원 제외 테스트를 추가한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/CreatorContentViewHistoryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/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/recommendation/application/CreatorContentViewHistoryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/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.recommendation.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: 라이브, 최근 활동, 최근 데뷔, 첫 오디오, 최근 응원 상세, 인기 커뮤니티 상세가 회원과 크리에이터의 양방향 활성 차단 관계를 제외하는 테스트를 추가한다. 커뮤니티 성인 노출, 본인인증 기반 성인 노출 조건, 팔로우 제외, 비활성 회원 제외 회귀 테스트 이름을 명시적으로 유지한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/CreatorContentViewHistoryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/CreatorContentViewHistoryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendedCreatorFollowServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: 조회 이력 저장, 추천 크리에이터 동시 팔로우, 일 스냅샷 갱신 성공 로그가 트랜잭션 커밋 전에는 기록되지 않고 커밋 후 기록되는 테스트를 추가한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.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/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: 홈 배너 `CREATOR` 대상 크리에이터와 `SERIES` 대상 시리즈 소유자가 회원과 양방향 활성 차단 관계인 경우 제외되는 테스트를 추가한다. `EVENT`와 `LINK` 배너는 기존 활성 조건 기준으로 유지한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners`
|
|
||||||
- GREEN: 홈 통합 조회에서 배너 조회에도 `memberId`를 전달하고, 배너 조회 포트/서비스/repository가 `CREATOR`의 `bannerCreator.id`, `SERIES`의 `seriesOwner.id` 기준으로 양방향 `block_member` 제외 조건을 적용한다.
|
|
||||||
- 기대 결과: 홈 배너 섹션에서도 차단 관계 크리에이터 또는 시리즈 소유자의 추천 데이터가 노출되지 않는다.
|
|
||||||
|
|
||||||
### Phase 8: AI 캐릭터 추천 item creator id 추가
|
|
||||||
|
|
||||||
- [x] **Task 8.1: AI 캐릭터 추천 record에 creator id 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: `HomeAiCharacterRecommendationRecord`에 `creatorId`가 없어서 컴파일이 실패하는 service 테스트를 먼저 작성한다. repository 테스트에는 활성 `ChatCharacter.creatorMember.id`가 record의 `creatorId`로 내려오는 케이스와 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 상세 응답에서 제외되는 케이스를 추가한다.
|
|
||||||
- 실패 확인:
|
|
||||||
```bash
|
|
||||||
./gradlew test \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest
|
|
||||||
```
|
|
||||||
- GREEN: `HomeAiCharacterRecommendationRecord`에 non-null `creatorId: Long`을 추가하고, `findAiCharacterRecommendationDetails(...)`에서 `chatCharacter.creatorMember`를 inner join해 `creatorMember.id`를 select한다. 상세 조회 조건은 기존 `chatCharacter.isActive = true`와 `characterIds` 조건을 유지하면서 `creatorMember.isActive = true`, `creatorMember.role = CREATOR`, `creatorMember.memberKind = AI_CHARACTER`를 함께 적용한다.
|
|
||||||
- REFACTOR: 스냅샷 target id는 계속 `characterId`로 유지하고, `creatorId`는 상세 조립 단계에서만 추가한다. AI 캐릭터 추천 점수 산식, 스냅샷 생성, 정렬 순서는 변경하지 않는다.
|
|
||||||
- 기대 결과: 내부 추천 record가 `characterId`와 `creatorId`를 모두 가지며, AI 캐릭터 전체보기와 홈 통합 조회가 같은 상세 조회 결과를 재사용할 수 있다.
|
|
||||||
|
|
||||||
- [x] **Task 8.2: 홈 추천 AI 캐릭터 API 응답에 creatorId 노출**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.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/dto/recommendation/HomeRecommendationResponseTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
|
||||||
- RED: `HomeAiCharacterItem` 생성자와 JSON 검증에 `creatorId`를 추가해 기존 구현이 컴파일 또는 JSON assertion에서 실패하도록 한다. 홈 통합 조회의 `$.data.aiCharacters[0].creatorId`와 AI 캐릭터 전체보기의 `$.data.items[0].creatorId`가 내려오는 controller 테스트를 추가한다.
|
|
||||||
- 실패 확인:
|
|
||||||
```bash
|
|
||||||
./gradlew test \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
|
|
||||||
```
|
|
||||||
- GREEN: `HomeAiCharacterItem`에 non-null `creatorId: Long`을 추가하고, `HomeRecommendationFacade.HomeAiCharacterRecommendationRecord.toItem()` 변환에서 `creatorId = creatorId`를 매핑한다.
|
|
||||||
- REFACTOR: 기존 `characterId` 필드명과 의미는 변경하지 않는다. 신규 `creatorId`는 additive schema 변경으로만 처리하고, 다른 추천 item DTO나 endpoint URL은 변경하지 않는다.
|
|
||||||
- 기대 결과: `GET /api/v2/home/recommendations`의 `aiCharacters[]`와 `GET /api/v2/home/recommendations/ai-characters`의 `items[]` 모두 `characterId`와 `creatorId`를 함께 반환한다.
|
|
||||||
|
|
||||||
- [x] **Task 8.3: Phase 8 회귀 검증과 문서 기록**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task라 제품 코드 테스트를 새로 작성하지 않는다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
```bash
|
|
||||||
./gradlew test \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
./gradlew tasks --all
|
|
||||||
```
|
|
||||||
- 기대 결과: Phase 8 관련 테스트, ktlint, Gradle task 목록 조회가 모두 성공하고, 이 문서 하단 Verification Log에 실행 명령/목적/결과를 누적한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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, Task 8.1, Task 8.2에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기, AI 캐릭터에 대응하는 `creatorId` 노출을 검증한다.
|
|
||||||
- Feature H: Task 4.1, Task 4.2, Task 4.3, Task 4.4에서 장르 조회 이력, 조회 이력 없을 때 랜덤 장르, 부족분 랜덤 보충, 한 응답 내 크리에이터 중복 제거, 조회 시점별 재노출 허용, 팔로우 제외, 조회자 본인 크리에이터 제외, 성인 장르 조건, 크리에이터 프로필 이미지/닉네임/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.3.1, 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.recommendation` 패키지 경계, `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-06-23: Phase 8 코드 리뷰 및 검증을 진행했다. 변경 범위가 `creatorId` additive schema 추가에 한정되어 있는지 확인했고, `HomeAiCharacterRecommendationRecord.creatorId` → `HomeAiCharacterItem.creatorId` 매핑, `ChatCharacter.creatorMember` inner join과 활성/CREATOR/AI_CHARACTER 필터, 홈 통합/AI 캐릭터 전체보기 JSON 응답 검증 테스트를 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
|
|
||||||
- 2026-06-23: Phase 8 구현을 진행했다. RED에서 `HomeAiCharacterRecommendationRecord`와 `HomeAiCharacterItem`의 `creatorId` 미구현으로 `compileTestKotlin`이 실패하는 것을 확인했고, GREEN에서 `HomeAiCharacterRecommendationRecord.creatorId`, AI 캐릭터 상세 조회의 `ChatCharacter.creatorMember` inner join 및 활성/CREATOR/AI_CHARACTER 조건, `HomeAiCharacterItem.creatorId`, facade 매핑을 추가했다. 회귀 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행해 모두 성공을 확인했다. `ktlintCheck`는 최초 실행에서 import 정렬 오류로 실패했고 import 순서 보정 후 `BUILD SUCCESSFUL`로 통과했다. 리뷰어 지적에 따라 `creatorMember` 누락 row 제외 테스트를 추가했고, 해당 단일 테스트와 Phase 8 대상 테스트 묶음, `ktlintCheck`를 재실행해 모두 성공한 뒤 리뷰어 재검토에서 승인받았다.
|
|
||||||
- 2026-06-23: 사용자 피드백에 따라 AI 캐릭터 추천 item에 `creatorId`를 추가하는 요구사항을 기존 홈 추천 API PRD와 plan-task에 후속 Phase 8로 보강했다. `creatorId`는 `ChatCharacter.creatorMember.id`로 확정하고, 기존 `characterId`는 AI 채팅 이동 대상 id로 유지하는 additive schema 변경으로 문서화했다. 검증으로 `rg -n "creatorId|Phase 8|Task 8\\.|ChatCharacter.creatorMember|Feature G" docs/20260529_메인_홈_추천_API/prd.md docs/20260529_메인_홈_추천_API/plan-task.md`, `git diff --check`, `./gradlew tasks --all`을 실행했다. `./gradlew tasks --all`은 최초 샌드박스 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했으나, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 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.recommendation.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.recommendation.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.recommendation.application.HomeRecommendationQueryServiceTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 2026-05-30: Phase 1 최종 검증으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./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.recommendation.application.RecommendationSnapshotRefreshServiceTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 2026-05-30: Phase 2 검증으로 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./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.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`를 순차 실행했고 모두 `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.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 2026-05-30: Phase 2 Task 2.5~2.8 최종 검증으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./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.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./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.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./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.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 2026-05-31: Phase 3 Task 3.1 추가 검증으로 `./gradlew ktlintCheck`와 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`를 실행했고 모두 `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.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.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.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.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.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./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.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*'`, `./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.recommendation.application.CreatorContentViewHistoryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.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.recommendation.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.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendedCreatorFollowServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.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.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`가 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 2026-06-06: 사용자 피드백에 따라 장르 기반 크리에이터 추천에서 조회자가 크리에이터인 경우 본인을 제외하도록 PRD와 Task 4.4를 보강했다. RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeRequesterOnlyGenreFromGenreCreatorRecommendations`, `shouldBackfillCreatorAfterExcludingRequesterFromGenreCreatorRecommendations`, `shouldReturnAvailableCreatorsAfterExcludingRequesterFromGenreCreatorRecommendations` 3건이 실패했고, GREEN에서 장르 후보 eligibility, fallback 후보 count, 실제 크리에이터 조회 native SQL에 `(:memberId is null or m.id <> :memberId)` 조건을 추가했다. service 경계에는 `HomeRecommendationQueryServiceTest.shouldReturnAvailableCreatorsWhenGenreCreatorCountIsUnderLimit`를 추가해 8명 미만이면 가능한 만큼 응답하는 정책을 고정했다. 검증으로 신규 RED 테스트 재실행, service 단일 테스트, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `./gradlew test`가 모두 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 2026-06-08: 홈 추천 API DTO 패키지 경계를 정리했다. 기존 `HomeRecommendationResponse`, `HomeRecommendationPageResponse`, `FollowRecommendedCreatorsRequest` 3개 DTO를 `kr.co.vividnext.sodalive.v2.api.home.dto.recommendation` 하위로 이동하고, Controller/Facade 및 DTO 테스트 import를 갱신했다. 기존 추천 API DTO 이동은 홈 추천 API 문서 범위에만 기록하며, 크리에이터 랭킹 문서는 변경하지 않았다. 검증으로 후속 focused test와 compile/test를 실행한다.
|
|
||||||
- 2026-06-08: 홈 추천 기능 본체 패키지를 단수 동사형 `recommend`에서 명사형 `recommendation` 기준인 `kr.co.vividnext.sodalive.v2.recommendation`으로 변경했다. `src/main`/`src/test` 디렉터리, Kotlin package/import, 문서의 파일 경로와 Gradle `--tests` 필터를 새 패키지명으로 맞췄다. `/api/v2/home/recommendations`, `v2.api.home`, `v2.api.home.dto.recommendation`, 클래스명과 API 스키마는 변경하지 않았다. 검증으로 stale reference 검색, `ktlintCheck`, 추천 패키지 테스트, 홈 API 테스트, 전체 테스트를 실행한다.
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
# 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 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다.
|
|
||||||
- 사용자는 추천 AI 캐릭터의 채팅 화면뿐 아니라 크리에이터 채널로도 이동할 수 있도록 AI 캐릭터에 대응하는 creator id를 받고 싶다.
|
|
||||||
- 사용자는 내가 봤던 콘텐츠 장르 또는 랜덤 장르 기준으로 팔로우하지 않은 크리에이터를 추천받고 싶다.
|
|
||||||
- 사용자는 장르 추천에서 여러 크리에이터를 한 번에 팔로우하고 싶다.
|
|
||||||
- 사용자는 최근 응원이 많은 크리에이터를 순위로 보고 싶다.
|
|
||||||
- 사용자는 인기 커뮤니티 게시글을 크리에이터별로 중복 없이 보고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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는 페이징으로 조회할 수 있어야 한다.
|
|
||||||
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
|
||||||
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
|
|
||||||
- 기존 `characterId`는 AI 채팅 이동 대상 id로 유지하고, 신규 `creatorId`는 크리에이터 채널/Member 기반 기능 이동 대상 id로 별도 제공한다.
|
|
||||||
- 작품명은 오리지널 작품 캐릭터인 경우에만 내려준다.
|
|
||||||
- 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
|
|
||||||
- 비활성 또는 노출 제한 캐릭터는 제외한다.
|
|
||||||
- 활성 `ChatCharacter`에 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다.
|
|
||||||
|
|
||||||
### Feature H. 장르의 크리에이터
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 사용자가 조회한 장르가 없으면 조회 가능한 장르 중 랜덤 5개를 선별한다.
|
|
||||||
- 사용자가 조회한 콘텐츠가 있으면 조회한 콘텐츠들의 장르 중 랜덤 5개를 선별한다.
|
|
||||||
- 조회 이력 기반 장르가 5개 미만이면 나머지 조회 가능한 장르 중 랜덤으로 채운다.
|
|
||||||
- 각 장르별로 해당 장르의 콘텐츠를 업로드한 크리에이터를 랜덤 8명씩 노출한다.
|
|
||||||
- 같은 크리에이터가 서로 다른 조회 시점의 여러 장르 섹션에 노출될 수는 있다.
|
|
||||||
- 한 번에 조회되는 5개 장르 안에서는 같은 크리에이터가 중복 노출되지 않아야 한다.
|
|
||||||
- 사용자가 팔로우한 크리에이터는 제외한다.
|
|
||||||
- 조회하는 사용자가 크리에이터이면 본인은 장르의 크리에이터 추천에서 제외한다.
|
|
||||||
- 성인 콘텐츠 장르는 `MemberContentPreference.isAdultContentVisible == true`인 회원에게만 노출한다.
|
|
||||||
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, id를 포함한다.
|
|
||||||
- 콘텐츠 조회 데이터는 콘텐츠 상세 진입 시점에 기록한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회하는 크리에이터 본인만 있는 장르는 후보에서 제외한다.
|
|
||||||
- 장르의 크리에이터 8명 중 조회자 본인이 포함되어 있으면 본인을 제외하고 다른 추천 가능한 크리에이터로 채운다.
|
|
||||||
- 본인을 제외한 뒤 대체 가능한 크리에이터가 없으면 남은 추천 가능한 크리에이터만 내려준다.
|
|
||||||
- 장르별 추천 가능한 크리에이터가 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 DTO는 `kr.co.vividnext.sodalive.v2.api.home.dto.recommendation` 하위에 둔다.
|
|
||||||
- 홈 API 외부에서도 재사용 가능한 추천, 점수 계산, 노출 정책, 스냅샷, 캐시, 콘텐츠 조회 이력 기능은 `kr.co.vividnext.sodalive.v2.recommendation` 하위에 둔다.
|
|
||||||
- 의존 방향은 `v2.api.home`에서 `v2.recommendation`를 호출하는 방향으로만 둔다. `v2.recommendation`는 `v2.api.home`의 DTO나 application service에 의존하지 않는다.
|
|
||||||
- `v2.api.home`과 `v2.recommendation` 모두 필요한 범위에서 경량 헥사고날 아키텍처를 적용하고, 기본 하위 패키지는 `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를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다.
|
|
||||||
- 홈 추천 AI 캐릭터 응답의 `creatorId` 추가는 기존 `characterId` 의미를 변경하지 않는 additive schema 변경으로만 처리한다.
|
|
||||||
- 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `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회 갱신 섹션은 조회 시점마다 무거운 집계를 하지 않도록 집계 테이블 또는 스냅샷 엔티티를 신규로 둔다.
|
|
||||||
- 일 1회 스냅샷 갱신 스케줄러는 다중 서버 인스턴스에서 동시에 실행되더라도 클러스터 전체에서 한 인스턴스만 실제 갱신을 수행해야 한다.
|
|
||||||
- 클러스터 단일 실행은 신규 DB 테이블을 추가하지 않고, 기존 프로젝트에 설정된 Redisson 기반 분산 lock을 우선 사용한다.
|
|
||||||
- 추천 스냅샷 lock key는 `lock:recommendation-snapshot-refresh`를 사용하며, lock 획득 실패 인스턴스는 스냅샷 갱신을 정상 skip한다.
|
|
||||||
- 랜덤 정렬이 필요한 섹션은 성능을 고려해 후보군 축소 후 랜덤화하거나 스냅샷 생성 시 랜덤 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`
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
-- MySQL 크리에이터 랭킹 스냅샷 테이블
|
|
||||||
-- 날짜/시간 표시 컬럼은 TIMESTAMP를 사용한다.
|
|
||||||
-- 같은 기간 재생성 시 삭제 기준:
|
|
||||||
-- delete from creator_ranking_snapshot
|
|
||||||
-- where aggregation_start_at_utc = :aggregationStartAtUtc
|
|
||||||
-- and aggregation_end_at_utc = :aggregationEndAtUtc;
|
|
||||||
|
|
||||||
create table creator_ranking_snapshot (
|
|
||||||
id bigint not null auto_increment comment '크리에이터 랭킹 스냅샷 ID',
|
|
||||||
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
|
|
||||||
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
|
|
||||||
creator_id bigint not null comment '크리에이터 회원 ID(member.id)',
|
|
||||||
nickname varchar(100) not null comment '스냅샷 생성 시점 크리에이터 닉네임',
|
|
||||||
profile_image_url varchar(500) null comment '스냅샷 생성 시점 크리에이터 프로필 이미지 URL',
|
|
||||||
final_score double not null comment '최종 랭킹 점수',
|
|
||||||
content_live_score double not null comment '콘텐츠/라이브 카테고리 점수',
|
|
||||||
engagement_score double not null comment '참여 반응 카테고리 점수',
|
|
||||||
support_score double not null comment '응원 카테고리 점수',
|
|
||||||
fan_loyalty_score double not null comment '팬 충성도 카테고리 점수',
|
|
||||||
live_can_amount bigint not null comment '라이브 계열 사용 캔 합계',
|
|
||||||
content_purchase_can_amount bigint not null comment '콘텐츠 구매 사용 캔 합계',
|
|
||||||
content_like_count bigint not null comment '콘텐츠 좋아요 수',
|
|
||||||
content_comment_count bigint not null comment '콘텐츠 댓글 및 대댓글 수',
|
|
||||||
channel_donation_can_amount bigint not null comment '채널 후원 사용 캔 합계',
|
|
||||||
channel_donation_count bigint not null comment '채널 후원 건수',
|
|
||||||
fan_talk_count bigint not null comment '최상위 팬 Talk 수',
|
|
||||||
final_follower_count bigint not null comment '집계 종료 시점 활성 팔로우 수',
|
|
||||||
follow_increase bigint 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)
|
|
||||||
) engine=InnoDB default charset=utf8mb4 comment='크리에이터 랭킹 주간 스냅샷';
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_period_score
|
|
||||||
on creator_ranking_snapshot (aggregation_end_at_utc, final_score desc);
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_replace_period
|
|
||||||
on creator_ranking_snapshot (aggregation_start_at_utc, aggregation_end_at_utc);
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_period_creator
|
|
||||||
on creator_ranking_snapshot (aggregation_start_at_utc, aggregation_end_at_utc, creator_id);
|
|
||||||
|
|
||||||
create table creator_ranking_snapshot_job (
|
|
||||||
id bigint not null auto_increment comment '크리에이터 랭킹 스냅샷 생성 job ID',
|
|
||||||
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
|
|
||||||
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
|
|
||||||
trigger_type varchar(20) not null comment '실행 트리거(SCHEDULED, MANUAL)',
|
|
||||||
status varchar(20) not null comment 'job 상태(PENDING, PROCESSING, DONE, FAILED)',
|
|
||||||
last_error text null comment '마지막 실패 사유',
|
|
||||||
processing_started_at timestamp null comment '처리 시작 시각',
|
|
||||||
processed_at timestamp 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)
|
|
||||||
) engine=InnoDB default charset=utf8mb4 comment='크리에이터 랭킹 스냅샷 생성 job 이력';
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_job_period_status
|
|
||||||
on creator_ranking_snapshot_job (aggregation_start_at_utc, aggregation_end_at_utc, status);
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_job_status_created_at
|
|
||||||
on creator_ranking_snapshot_job (status, created_at);
|
|
||||||
|
|
||||||
-- 이미 위 CREATE DDL이 적용된 DB의 시간 정책 변경용 DDL
|
|
||||||
-- 목적:
|
|
||||||
-- 1. 현재 기본 랭킹 타입(WEEKLY)을 명시한다.
|
|
||||||
-- 2. 공개 조회 노출 시작 시각(visible_from_at)을 저장한다.
|
|
||||||
-- 3. 최신 생성 스냅샷이 아니라 visible_from_at <= now 조건의 최신 공개 스냅샷을 조회할 수 있게 인덱스를 보강한다.
|
|
||||||
-- 주의:
|
|
||||||
-- 운영 DB 반영 시 중복 스냅샷이 있으면 uk_creator_ranking_snapshot_period_creator 생성 전 정리한다.
|
|
||||||
-- visible_from_at backfill 기준은 aggregation_end_at_utc + 9시간이다.
|
|
||||||
-- 예: 2026-06-07 15:00:00 UTC 집계 종료는 2026-06-08 00:00:00 KST이고,
|
|
||||||
-- 노출 전환 2026-06-08 09:00:00 KST는 2026-06-08 00:00:00 UTC다.
|
|
||||||
|
|
||||||
alter table creator_ranking_snapshot
|
|
||||||
add column ranking_type varchar(30) null comment '랭킹 타입(현재 WEEKLY, 향후 다중 타입 확장)' after id,
|
|
||||||
add column visible_from_at timestamp null comment '공개 조회 노출 시작 시각(UTC)' after aggregation_end_at_utc;
|
|
||||||
|
|
||||||
update creator_ranking_snapshot
|
|
||||||
set ranking_type = 'WEEKLY'
|
|
||||||
where ranking_type is null;
|
|
||||||
|
|
||||||
update creator_ranking_snapshot
|
|
||||||
set visible_from_at = timestampadd(hour, 9, aggregation_end_at_utc)
|
|
||||||
where visible_from_at is null;
|
|
||||||
|
|
||||||
alter table creator_ranking_snapshot
|
|
||||||
modify column ranking_type varchar(30) not null comment '랭킹 타입(현재 WEEKLY, 향후 다중 타입 확장)',
|
|
||||||
modify column visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)';
|
|
||||||
|
|
||||||
create unique index uk_creator_ranking_snapshot_period_creator
|
|
||||||
on creator_ranking_snapshot (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, creator_id);
|
|
||||||
|
|
||||||
drop index idx_creator_ranking_snapshot_period_score on creator_ranking_snapshot;
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_period_score
|
|
||||||
on creator_ranking_snapshot (ranking_type, aggregation_end_at_utc, final_score desc);
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_visible_score
|
|
||||||
on creator_ranking_snapshot (ranking_type, visible_from_at desc, final_score desc);
|
|
||||||
|
|
||||||
drop index idx_creator_ranking_snapshot_replace_period on creator_ranking_snapshot;
|
|
||||||
|
|
||||||
drop index idx_creator_ranking_snapshot_period_creator on creator_ranking_snapshot;
|
|
||||||
|
|
||||||
alter table creator_ranking_snapshot_job
|
|
||||||
add column ranking_type varchar(30) null comment '랭킹 타입(현재 WEEKLY, 향후 다중 타입 확장)' after id,
|
|
||||||
add column visible_from_at timestamp null comment '공개 조회 노출 시작 시각(UTC)' after aggregation_end_at_utc;
|
|
||||||
|
|
||||||
update creator_ranking_snapshot_job
|
|
||||||
set ranking_type = 'WEEKLY'
|
|
||||||
where ranking_type is null;
|
|
||||||
|
|
||||||
update creator_ranking_snapshot_job
|
|
||||||
set visible_from_at = timestampadd(hour, 9, aggregation_end_at_utc)
|
|
||||||
where visible_from_at is null;
|
|
||||||
|
|
||||||
alter table creator_ranking_snapshot_job
|
|
||||||
modify column ranking_type varchar(30) not null comment '랭킹 타입(현재 WEEKLY, 향후 다중 타입 확장)',
|
|
||||||
modify column visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)';
|
|
||||||
|
|
||||||
drop index idx_creator_ranking_snapshot_job_period_status on creator_ranking_snapshot_job;
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_job_period_status
|
|
||||||
on creator_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, status);
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_job_visible_status
|
|
||||||
on creator_ranking_snapshot_job (ranking_type, visible_from_at, status);
|
|
||||||
|
|
||||||
create index idx_creator_ranking_snapshot_job_trigger_period
|
|
||||||
on creator_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, trigger_type, created_at);
|
|
||||||
@@ -1,712 +0,0 @@
|
|||||||
# 크리에이터 랭킹 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 홈 내부 랭킹 탭에서 `GET /api/v2/home/rankings/creators`로 KST 기준 지난 주 크리에이터 랭킹 중 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷의 상위 20명을 조회한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 endpoint는 home 하위 URL을 사용하고, 클라이언트 API 표면(Controller, API 조합 Facade, DTO)은 기존 홈 API 관례에 맞춰 `kr.co.vividnext.sodalive.v2.api.home` 하위에 둔다. 랭킹 기능 본체(domain/application/port/persistence/scheduler)는 추천 기능과 분리된 `kr.co.vividnext.sodalive.v2.ranking` 하위에 둔다. 주간 스냅샷 생성 작업이 KST 기간을 UTC DB 조회 조건으로 변환해 원천 데이터를 집계하고, 조회 API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 읽어 응답을 조립한다. 단, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있고, fallback 응답도 공개 노출 전환 시각을 넘긴 기간에만 허용한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL 또는 native SQL, JUnit 5, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/home/rankings/creators`
|
|
||||||
- 랭킹 기능 본체 패키지: `kr.co.vividnext.sodalive.v2.ranking`
|
|
||||||
- 홈 공개 API 조립 패키지: `kr.co.vividnext.sodalive.v2.api.home`
|
|
||||||
- 집계 기준 시각: 매주 월요일 00:00:00 KST. 이 시각을 주간 집계 종료 경계로 사용한다.
|
|
||||||
- 집계 기간: 생성 시점 기준 KST 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만
|
|
||||||
- DB 조회 기간: KST 집계 기간을 UTC 기준 `LocalDateTime` 또는 프로젝트 표준 시간 타입으로 변환한 기간
|
|
||||||
- 스냅샷 생성 스케줄 후보: 매주 월요일 KST 01:00, `@Scheduled(cron = "0 0 1 * * MON", zone = "Asia/Seoul")`
|
|
||||||
- 스냅샷 노출 전환 시각: 매주 월요일 KST 09:00. 스냅샷과 job 이력에 `visibleFromAt`으로 저장한다.
|
|
||||||
- 현재 기본 크리에이터 랭킹 타입: `WEEKLY`. 스냅샷과 job 이력에 `rankingType`으로 저장한다.
|
|
||||||
- 다중 서버 인스턴스에서 스냅샷 스케줄러가 중복 실행되지 않도록 기존 Redisson 기반 분산 lock을 사용한다.
|
|
||||||
- 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
|
|
||||||
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 응답한다.
|
|
||||||
- 조회 시 09:00 KST 전에는 01:00 KST에 생성된 새 주차 스냅샷이 있어도 직전 공개 스냅샷을 유지한다.
|
|
||||||
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
|
|
||||||
- fallback 응답도 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족할 때만 공개한다.
|
|
||||||
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 공개 스냅샷 기준 응답을 유지한다.
|
|
||||||
- 스냅샷 테이블이 완전히 비어 있는 cold-start fallback 성공 시 조회 API는 fallback 응답을 반환하고, 같은 집계 기간의 스냅샷 생성은 조회 서비스가 직접 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임한다.
|
|
||||||
- cold-start fallback 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 보강책이며, 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용한다.
|
|
||||||
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 스케줄 실행과 관리자 수동 생성 모두 성공/실패 상태를 기록한다.
|
|
||||||
- 관리자는 날짜 범위를 직접 선택해 스냅샷 생성 job을 만들 수 있으며, 실패한 job은 관리자 전용 재시도 API로 대기 상태로 되돌려 재처리할 수 있어야 한다.
|
|
||||||
- 스냅샷은 현재 누적 저장하며, 보존 기간/정리 배치는 운영 데이터 규모 확인 후 별도 결정한다.
|
|
||||||
- API 응답은 `showRankChange`, `items[].rank`, `items[].rankChange`, `items[].isNew`, `items[].creatorId`, `items[].nickname`, `items[].profileImageUrl`만 포함한다.
|
|
||||||
- API 응답에는 집계 기간 날짜와 `finalScore`를 포함하지 않는다.
|
|
||||||
- raw value 방식으로 계산하며 0~100 정규화는 하지 않는다.
|
|
||||||
- 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다.
|
|
||||||
- 동점자는 조회 시 랜덤 정렬로 상위 20명을 추출하고, 별도 `randomTieBreaker`는 저장하지 않는다.
|
|
||||||
- 직전 공개 스냅샷이 없으면 `showRankChange=false`, `rankChange=null`, `isNew=false`로 응답한다.
|
|
||||||
- 비활성 및 탈퇴 크리에이터는 랭킹에 노출하지 않는다.
|
|
||||||
- 차단 관계가 있으면 row는 유지하되 `creatorId=0`, `nickname=""`, `profileImageUrl=기본 이미지 URL`로 마스킹한다.
|
|
||||||
- 신규 팔로우 수는 `CreatorFollowing.createdAt` 기준, 언팔로우 수는 `CreatorFollowing.isActive == false` 및 `CreatorFollowing.updatedAt` 기준으로 계산한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 신규 ranking domain/application
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingItem.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingBlockPort.kt`
|
|
||||||
|
|
||||||
### 신규 홈 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt`
|
|
||||||
|
|
||||||
### 신규 scheduler / persistence
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingBlockRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJobRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
|
||||||
|
|
||||||
### 신규 관리자 API
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobRequest.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Create: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
|
||||||
|
|
||||||
### 테스트
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicyTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.1 DDL 영향도: `visible_from_at`, `ranking_type`
|
|
||||||
|
|
||||||
- `creator_ranking_snapshot`에는 `ranking_type varchar(30) not null`, `visible_from_at timestamp not null`을 추가한다.
|
|
||||||
- `creator_ranking_snapshot_job`에는 `ranking_type varchar(30) not null`, `visible_from_at timestamp not null`을 추가한다.
|
|
||||||
- 현재 기본 타입 값은 `WEEKLY`로 문서화하고, 코드 구현 시 `CreatorRankingType` 또는 동등한 enum/상수로 고정한다.
|
|
||||||
- `visible_from_at`은 집계 종료일 월요일 09:00:00 KST를 UTC로 변환한 값이다. 예: 2026-06-08 09:00:00 KST는 2026-06-08 00:00:00 UTC다.
|
|
||||||
- `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`의 기존 CREATE DDL은 이미 적용된 기준으로 유지하고, 하단에 운영 반영용 ALTER DDL을 추가한다.
|
|
||||||
- 운영 DB 변경은 `ADD nullable column -> backfill -> MODIFY NOT NULL -> index 보강/교체` 순서로 적용한다.
|
|
||||||
- backfill은 `ranking_type='WEEKLY'`, `visible_from_at=aggregation_end_at_utc + interval 9 hour` 기준으로 수행한다.
|
|
||||||
- 같은 타입/기간 재생성 삭제 기준은 `ranking_type + aggregation_start_at_utc + aggregation_end_at_utc`다.
|
|
||||||
- 중복 방지 기준은 `ranking_type + aggregation_start_at_utc + aggregation_end_at_utc + creator_id` unique index다.
|
|
||||||
- 최신 공개 스냅샷 조회는 `ranking_type = WEEKLY and visible_from_at <= nowUtc` 조건에서 가장 큰 `visible_from_at`을 찾은 뒤 해당 스냅샷 row를 `final_score desc` 기준으로 읽는다.
|
|
||||||
- 직전 공개 스냅샷 조회는 최신 공개 스냅샷보다 작은 `visible_from_at` 중 가장 큰 값을 기준으로 읽는다.
|
|
||||||
- job 목록/재시도 조회는 `ranking_type + aggregation period + status`, `ranking_type + visible_from_at + status`, `ranking_type + aggregation period + trigger_type + created_at` 인덱스를 사용한다.
|
|
||||||
- 공개 API 응답 DTO에는 `rankingType`, `visibleFromAt`, 집계 기간, fallback 여부를 노출하지 않는다.
|
|
||||||
|
|
||||||
### Phase 1: 기간/점수 도메인 정책
|
|
||||||
|
|
||||||
- [x] **Task 1.1: KST 주간 기간 산출과 UTC 조회 기간 변환 정책 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicyTest.kt`
|
|
||||||
- RED: 월요일 KST 기준 지난 주 기간, 월/연도 경계, 서버 timezone UTC와 무관한 기간 산출, KST 2026-06-01 00:00:00~2026-06-08 00:00:00이 UTC 2026-05-31 15:00:00~2026-06-07 15:00:00으로 변환되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest`
|
|
||||||
- GREEN: `CreatorRankingPeriodPolicy.resolveLastCompletedWeek(now: ZonedDateTime)`와 `toUtcRange(period)`를 구현한다.
|
|
||||||
- REFACTOR: 기간 경계는 종료 미만(`< end`) 조건으로 사용할 수 있도록 `startInclusiveUtc`, `endExclusiveUtc` 명칭을 유지한다.
|
|
||||||
- 기대 결과: KST 기준 기간 산출과 UTC 변환이 테스트로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: raw value 기반 점수 정책 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt`
|
|
||||||
- RED: 콘텐츠/라이브 점수, 참여 반응 점수, 응원 점수, 팬 충성도 점수, 최종 점수 산식 테스트를 작성한다. 0~100 정규화 없이 캔/건수/팔로우 원천값이 그대로 가중합되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest`
|
|
||||||
- GREEN: 가중치 상수와 `calculateContentLiveScore`, `calculateEngagementScore`, `calculateSupportScore`, `calculateFanLoyaltyScore`, `calculateFinalScore`를 구현한다.
|
|
||||||
- REFACTOR: 소수 계산 비교는 `assertEquals(expected, actual, 0.0001)` 기준을 사용한다.
|
|
||||||
- 기대 결과: PRD의 raw value 정책과 음수 팔로우 증가 반영이 테스트로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 1.3: 스냅샷 후보/응답 내부 모델 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingItem.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- RED: `rankChange` 양수/음수/null과 `isNew`를 담을 수 있는 내부 item 모델이 없으면 컴파일 실패하는 테스트 골격을 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
|
|
||||||
- GREEN: 스냅샷 후보와 조회 item 내부 모델을 작성한다.
|
|
||||||
- REFACTOR: API DTO와 domain model을 분리해 Controller가 persistence entity에 의존하지 않도록 한다.
|
|
||||||
- 기대 결과: 이후 service/controller task가 같은 타입을 재사용할 수 있다.
|
|
||||||
|
|
||||||
### Phase 2: 스냅샷 저장소와 DDL
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 랭킹 스냅샷 엔티티/리포지토리 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
|
|
||||||
- RED: 같은 집계 기간의 스냅샷 replace, 최신 완료 주차 조회, 직전 완료 주차 조회, 20위 경계 동점 후보 저장 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`
|
|
||||||
- GREEN: 스냅샷 엔티티에 `aggregationStartAtUtc`, `aggregationEndAtUtc`, `creatorId`, `finalScore`, 카테고리별 점수, 원천 지표, `createdAt`을 저장한다. 저장 전 같은 기간 row를 삭제하고 새 후보를 저장한다.
|
|
||||||
- REFACTOR: 스냅샷 조회 port는 domain model만 반환하고 JPA entity를 application 계층으로 노출하지 않는다.
|
|
||||||
- 기대 결과: 같은 기간 재생성 시 중복 노출되지 않고 최신/직전 주차를 구분해 조회할 수 있다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: 운영 DB 반영용 스냅샷 DDL 문서 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
|
||||||
- RED: 테스트 작성 예외. `TDD 예외 사유`: SQL 운영 반영 문서 작성 task로, 실행 대상 DB가 현재 workspace에 없다.
|
|
||||||
- 대체 검증 방법: `rg -n "creator_ranking_snapshot|aggregation_start_at_utc|creator_id|final_score" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
- GREEN: `creator_ranking_snapshot` 테이블 생성 SQL, 기간/점수 조회용 index, 같은 기간 재생성 시 삭제 기준을 문서에 작성한다.
|
|
||||||
- REFACTOR: 컬럼명은 JPA entity와 1:1로 대응하도록 정리한다.
|
|
||||||
- 기대 결과: 운영 배포 전 DB 테이블 생성 SQL을 검토할 수 있다.
|
|
||||||
|
|
||||||
### Phase 3: 원천 지표 집계 repository
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 콘텐츠/라이브 캔 집계 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
|
|
||||||
- RED: `CanUsage.DONATION`, `LIVE`, `SPIN_ROULETTE`는 라이브 계열 캔으로, `ORDER_CONTENT`는 콘텐츠 구매 캔으로 집계되고 환불 row가 제외되는 repository 통합 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
|
|
||||||
- GREEN: KST에서 변환한 UTC 기간으로 `UseCan` 계열 데이터를 조회하고 크리에이터별 캔 합계를 반환한다.
|
|
||||||
- REFACTOR: can usage 조건은 private 함수 또는 enum set으로 분리해 산식과 조회 조건이 섞이지 않도록 한다.
|
|
||||||
- 기대 결과: 콘텐츠/라이브 카테고리의 원천 지표가 정확히 집계된다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 콘텐츠 좋아요/댓글 반응 집계 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
|
|
||||||
- RED: 활성 콘텐츠 좋아요 수, 댓글+대댓글 수, 크리에이터 본인 댓글/대댓글 제외, 비활성/삭제 정책 제외를 검증하는 repository 통합 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
|
|
||||||
- GREEN: `AudioContentLike`, `AudioContentComment`, `AudioContent`를 기준으로 크리에이터별 좋아요/댓글 원천 지표를 반환한다.
|
|
||||||
- REFACTOR: 댓글 작성자가 콘텐츠 소유 크리에이터와 같은 경우 제외하는 조건을 테스트 fixture 이름에 드러나게 정리한다.
|
|
||||||
- 기대 결과: 참여 반응 점수 입력값이 PRD 조건과 일치한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: 채널 후원/팬 Talk 응원 집계 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
|
|
||||||
- RED: `CanUsage.CHANNEL_DONATION` 캔 합계와 건수, 환불 제외, `CreatorCheers` 최상위 row만 팬 Talk로 집계하고 답글은 제외하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
|
|
||||||
- GREEN: 채널 후원 원천 지표와 팬 Talk 원천 지표를 크리에이터별로 반환한다.
|
|
||||||
- REFACTOR: 팬 Talk 답글 제외 조건은 `parent is null` 또는 기존 엔티티 구조에 맞는 조건으로 명확히 둔다.
|
|
||||||
- 기대 결과: 응원 점수 입력값이 캔/건수/최상위 팬 Talk 기준으로 집계된다.
|
|
||||||
|
|
||||||
- [x] **Task 3.4: 팔로우 최종 수/증가 수 집계 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
|
|
||||||
- RED: 최종 팔로우 수는 기간 종료 시점 활성 row, 신규 팔로우 수는 `createdAt` 기간 내, 언팔로우 수는 `isActive=false` 및 `updatedAt` 기간 내, 기간 내 재팔로우는 신규/언팔로우 이벤트로 별도 복원하지 않는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
|
|
||||||
- GREEN: `CreatorFollowing` 기준 최종 팔로우 수와 팔로우 증가 수를 반환한다.
|
|
||||||
- REFACTOR: 현재 row만으로 계산하는 정책 한계를 테스트명과 주석 한 줄로 남긴다.
|
|
||||||
- 기대 결과: 팬 충성도 점수 입력값이 PRD의 `createdAt`/`updatedAt` 정책과 일치한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.5: 랭킹 후보 통합 집계와 비활성/탈퇴 제외 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
|
|
||||||
- RED: 여러 원천 지표를 크리에이터별로 합쳐 후보를 만들고, 비활성/탈퇴 크리에이터와 최종 점수 1점 미만 후보가 제외되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
|
|
||||||
- GREEN: 원천 지표 aggregate를 크리에이터 id 기준으로 합쳐 `CreatorRankingSnapshotCandidate`를 반환한다.
|
|
||||||
- REFACTOR: 복잡한 집계가 QueryDSL로 과도해지면 native SQL을 사용하되, 테스트로 H2 호환성을 고정한다.
|
|
||||||
- 기대 결과: 스냅샷 생성 서비스가 별도 원천 조회를 여러 번 조합하지 않고 후보 목록을 받을 수 있다.
|
|
||||||
|
|
||||||
### Phase 4: 스냅샷 생성 서비스와 스케줄러
|
|
||||||
|
|
||||||
- [x] **Task 4.1: 주간 스냅샷 생성 서비스 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: KST 기간 산출, UTC 조회 기간 전달, raw value 점수 계산, 20위 점수 경계 동점 후보 전체 저장, 같은 기간 replace를 검증하는 service 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
|
|
||||||
- GREEN: aggregation port에서 후보를 조회하고 score policy로 점수를 계산한 뒤 저장 대상 후보만 snapshot port에 저장한다.
|
|
||||||
- REFACTOR: service는 계산 흐름만 담당하고 DB 조회 조건/저장 구현은 port 뒤로 숨긴다.
|
|
||||||
- 기대 결과: 스냅샷 저장 대상이 “20위 초과 점수 + 20위 동점 전체” 규칙을 만족한다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 매주 월요일 07:30 KST 스케줄러 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: scheduler method에 `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")`가 선언되어 있는지 reflection 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
|
|
||||||
- GREEN: 스케줄러가 `CreatorRankingSnapshotRefreshService.refreshLastCompletedWeek()`를 호출하도록 구현한다.
|
|
||||||
- REFACTOR: 스케줄러에는 기간/점수/DB 로직을 두지 않는다.
|
|
||||||
- 기대 결과: 주간 스냅샷 생성 트리거가 KST 기준으로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: 주간 스냅샷 스케줄러 Redisson lock 적용**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: Redisson lock 획득 성공 시 `CreatorRankingSnapshotRefreshService.refreshLastCompletedWeek()`를 1회 호출하고, 획득 실패 시 호출하지 않는 테스트를 작성한다. lock key가 `lock:creator-ranking-snapshot-refresh`인지도 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
|
|
||||||
- GREEN: 기존 `RedissonClient` bean을 스케줄러에 주입하고 `tryLock`으로 lock을 획득한 인스턴스만 refresh service를 호출한다. lock 획득 실패는 정상 skip으로 처리한다.
|
|
||||||
- REFACTOR: DB 기반 scheduler lock 테이블은 추가하지 않고, 기존 `AudioContentReleaseScheduledTask`의 Redisson lock 패턴을 참고하되 스케줄러에는 lock 획득/해제와 service 호출만 둔다.
|
|
||||||
- 기대 결과: 여러 서버 인스턴스에서 같은 cron이 동시에 실행돼도 클러스터 전체에서 한 인스턴스만 주간 랭킹 스냅샷을 생성한다.
|
|
||||||
|
|
||||||
### Phase 5: 조회 서비스, 순위 변화, 차단 마스킹
|
|
||||||
|
|
||||||
- [x] **Task 5.1: 최신/직전 스냅샷 기반 조회 서비스 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- RED: 최신 완료 주차 스냅샷 없음 빈 결과, 직전 주차 없음 `showRankChange=false`, 직전 주차 있음 `rankChange` 양수/음수/null 및 `isNew` 계산 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
|
|
||||||
- GREEN: 최신 스냅샷 후보를 최종 점수 내림차순과 동점 랜덤 정렬로 최대 20명 선정하고, 직전 스냅샷 순위와 비교해 순위 변화를 계산한다.
|
|
||||||
- REFACTOR: 동점 랜덤으로 인해 같은 동점 구간의 순위 변화가 조회마다 달라질 수 있음을 테스트에서 허용 범위로 표현한다.
|
|
||||||
- 기대 결과: 홈 API Facade가 사용할 `showRankChange`와 item 목록이 ranking application service에서 완성된다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: 차단 관계 마스킹 port 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingBlockPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingBlockRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- RED: 조회자와 랭킹 크리에이터 사이에 차단 관계가 있으면 row는 유지되고 `creatorId=0`, `nickname=""`, `profileImageUrl=기본 이미지 URL`로 마스킹되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
|
|
||||||
- GREEN: block port로 차단 대상 creator id를 조회하고, service에서 응답 item을 마스킹한다.
|
|
||||||
- REFACTOR: 기본 이미지 URL은 기존 프로젝트 상수/설정이 있으면 재사용하고, 없으면 ranking service 내부 상수로 분리한다.
|
|
||||||
- 기대 결과: 차단 관계가 있어도 순위 row 수는 유지되고 개인 식별 정보만 가려진다.
|
|
||||||
|
|
||||||
### Phase 6: 홈 API endpoint, Facade, DTO
|
|
||||||
|
|
||||||
- [x] **Task 6.1: 랭킹 조회 DTO, 홈 API Facade, Controller 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
|
|
||||||
- RED: `GET /api/v2/home/rankings/creators`가 `showRankChange`, `items[].rank`, `rankChange`, `isNew`, `creatorId`, `nickname`, `profileImageUrl`만 반환하고 날짜와 `finalScore`를 반환하지 않는 controller 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
|
|
||||||
- GREEN: controller, API Facade, response DTO를 구현하고 Facade가 `CreatorRankingQueryService`를 호출해 홈 API 응답으로 변환한다.
|
|
||||||
- REFACTOR: URL과 클라이언트 API 표면은 `v2.api.home` 하위에 두고, 랭킹 DTO는 `v2.api.home.dto.ranking` 하위에 둔다. 랭킹 계산/조회 본체는 `v2.ranking`에 유지한다.
|
|
||||||
- 기대 결과: 클라이언트 홈 랭킹 탭에서 사용할 공개 API 계약이 테스트로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 6.2: 인증/비인증 조회와 차단 마스킹 연결**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
|
|
||||||
- RED: 비회원 조회는 기본 랭킹을 반환하고, 인증 회원 조회는 차단 관계 마스킹을 적용하는 controller 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
|
|
||||||
- GREEN: 기존 인증 주입 패턴을 확인해 member nullable 흐름을 service에 전달한다.
|
|
||||||
- REFACTOR: 기존 API 응답 wrapper 관례와 상태 코드를 맞춘다.
|
|
||||||
- 기대 결과: 인증 여부에 따라 차단 마스킹만 달라지고 endpoint 계약은 동일하다.
|
|
||||||
|
|
||||||
### Phase 7: 관측/문서/회귀 검증
|
|
||||||
|
|
||||||
- [x] **Task 7.1: 스냅샷 생성/조회 로그 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- RED: 스냅샷 생성 성공/실패, 후보 수, 저장 수, 조회 성공/실패 로그가 남는지 output capture 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
|
|
||||||
- GREEN: 기존 프로젝트 관례대로 `LoggerFactory` 기반 구조화 로그를 추가한다.
|
|
||||||
- REFACTOR: 로그에 개인정보를 직접 남기지 않고 creator id/count/period만 남긴다.
|
|
||||||
- 기대 결과: PRD metrics 확인에 필요한 최소 로그가 남는다.
|
|
||||||
|
|
||||||
- [x] **Task 7.2: 전체 ranking 테스트와 포맷 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/**`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/**`
|
|
||||||
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
|
||||||
- RED: 테스트 작성 예외. `TDD 예외 사유`: 구현 완료 후 회귀 검증 task다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'`
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- `./gradlew test`
|
|
||||||
- GREEN: 실패하는 테스트가 있으면 해당 phase task로 돌아가 수정하고, 모든 명령을 통과시킨다.
|
|
||||||
- REFACTOR: plan-task 하단 검증 기록에 실행 명령, 목적, 결과를 누적한다.
|
|
||||||
- 기대 결과: ranking 기능 본체와 홈 API 조립 계층 테스트, 포맷, 전체 회귀 테스트가 통과한다.
|
|
||||||
|
|
||||||
### Phase 8: 스냅샷 job 이력과 스케줄 기록
|
|
||||||
|
|
||||||
- [x] **Task 8.1: 스냅샷 job 이력 모델/DDL 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJobRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
|
|
||||||
- Modify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepositoryTest.kt`
|
|
||||||
- RED: 집계 시작/종료 시각, 실행 트리거, 상태(`PENDING`, `PROCESSING`, `DONE`, `FAILED`), 실패 사유, 처리 시작/완료 시각을 저장하고 조회할 수 있는 repository 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest`
|
|
||||||
- GREEN: 기존 `charge_event_job` 관례를 참고해 스냅샷 job entity/repository/port와 운영 반영용 DDL을 작성한다.
|
|
||||||
- REFACTOR: 컬럼명은 관리자 목록과 worker 처리에 필요한 최소 필드로 제한하고 공개 API DTO와 분리한다.
|
|
||||||
- 기대 결과: 스냅샷 생성 이력이 기간/상태 기준으로 추적 가능해진다.
|
|
||||||
|
|
||||||
- [x] **Task 8.2: 스케줄 실행 전 job 생성과 성공/실패 기록 연결**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
|
|
||||||
- RED: 스케줄러가 스냅샷 생성 직전 집계 기간을 포함한 `SCHEDULED` job을 만들고, refresh 성공 시 `DONE`, 예외 발생 시 `FAILED`와 실패 사유를 기록하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
|
||||||
- GREEN: 스케줄러는 lock 획득 후 job service를 통해 job 생성/실행/상태 기록을 위임하고, refresh service는 기존 스냅샷 생성 책임을 유지한다.
|
|
||||||
- REFACTOR: lock 획득 실패는 job 실패로 기록하지 않고 기존 정상 skip 정책을 유지한다.
|
|
||||||
- 기대 결과: 매주 스케줄 실행 여부와 성공/실패가 관리자에서 추적 가능한 job 이력으로 남는다.
|
|
||||||
|
|
||||||
### Phase 9: 관리자 수동 생성과 실패 job 재시도 API
|
|
||||||
|
|
||||||
- [x] **Task 9.1: 관리자 날짜 범위 수동 생성 API 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobRequest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt`
|
|
||||||
- RED: `POST /admin/rankings/creators/snapshot-jobs`가 관리자 권한에서 날짜 범위를 받아 `MANUAL` job을 생성하고, 비관리자 요청은 거부되는 controller/service 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.ranking.creator.AdminCreatorRankingSnapshotJobControllerTest`
|
|
||||||
- GREEN: 기존 관리자 API 관례대로 `@PreAuthorize("hasRole('ADMIN')")`와 `ApiResponse.ok(...)`를 사용해 수동 생성 job id와 상태를 반환한다.
|
|
||||||
- REFACTOR: 날짜 범위 validation은 KST 주차/UTC 변환 정책과 중복되지 않도록 application service에 모은다.
|
|
||||||
- 기대 결과: 운영자가 별도 DB 확인 없이 필요한 날짜 범위의 스냅샷 생성을 요청할 수 있다.
|
|
||||||
|
|
||||||
- [x] **Task 9.2: 관리자 job 목록/실패 job 재시도 API 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt`
|
|
||||||
- RED: `GET /admin/rankings/creators/snapshot-jobs`가 날짜 범위/상태/실패 사유/재시도 가능 여부를 반환하고, `POST /admin/rankings/creators/snapshot-jobs/{jobId}/retry`가 `FAILED` job만 `PENDING`으로 되돌리는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.ranking.creator.AdminCreatorRankingSnapshotJobControllerTest`
|
|
||||||
- GREEN: 기존 `AdminChargeEventJobController`/`AdminChargeEventJobService` 패턴을 참고해 관리자 목록과 재시도 API를 구현한다.
|
|
||||||
- REFACTOR: `PENDING`, `PROCESSING`, `DONE` 상태 job은 재시도 대상으로 변경하지 않고 명확한 실패 응답을 반환한다.
|
|
||||||
- 기대 결과: 실패한 스냅샷 job을 관리자 버튼/API로 재시도할 수 있다.
|
|
||||||
|
|
||||||
### Phase 10: 스냅샷 완전 공백 fallback
|
|
||||||
|
|
||||||
- [x] **Task 10.1: 스냅샷 테이블 완전 공백 여부 조회 port 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
|
|
||||||
- RED: 스냅샷 row가 하나도 없을 때만 true를 반환하고, 과거 주차 스냅샷이 하나라도 있으면 false를 반환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`
|
|
||||||
- GREEN: snapshot port에 `isSnapshotTableEmpty()` 또는 동등한 메서드를 추가해 조회 서비스가 fallback 조건을 판단할 수 있게 한다.
|
|
||||||
- REFACTOR: “최신 주차 스냅샷 없음”과 “테이블 완전 공백”을 서로 다른 조건으로 유지한다.
|
|
||||||
- 기대 결과: cold-start fallback이 과거 스냅샷 존재 시 실행되지 않도록 조건이 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 10.2: 조회 API cold-start fallback 연결**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- RED: 최신 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있을 때만 fallback 집계를 시도하고, 과거 스냅샷이 있으면 fallback을 시도하지 않는 테스트를 작성한다. 공개 응답 스키마가 `showRankChange`와 `items`로 유지되는지도 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
|
|
||||||
- GREEN: query service가 snapshot-first 흐름을 유지하면서 완전 공백 상태에서만 제한적 fallback 집계를 호출하고 결과를 기존 ranking result로 변환한다.
|
|
||||||
- REFACTOR: fallback은 장기 실시간 랭킹 경로가 아니라 초기 스냅샷 부재 안전장치임을 service 경계와 테스트명에 드러낸다.
|
|
||||||
- 기대 결과: 초기 운영 상태에서는 빈 화면을 줄이고, 운영 중에는 기존 스냅샷 기반 정책을 유지한다.
|
|
||||||
|
|
||||||
- [x] **Task 10.3: fallback/job 관측 로그와 회귀 검증**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
|
|
||||||
- RED: fallback 시도/성공/실패와 job 상태 변경 로그가 남는지 output capture 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
|
||||||
- GREEN: 개인정보 없이 period, jobId, trigger, status, count, elapsedMs 중심의 구조화 로그를 추가한다.
|
|
||||||
- REFACTOR: 기존 Phase 7 로그와 이벤트명 충돌이 없도록 prefix를 정리한다.
|
|
||||||
- 기대 결과: 관리자 job과 cold-start fallback 상태를 운영 로그/메트릭으로 추적할 수 있다.
|
|
||||||
|
|
||||||
### Phase 11: cold-start fallback 스냅샷 생성 트리거
|
|
||||||
|
|
||||||
- [x] **Task 11.1: cold-start fallback 전용 기간 기반 lock 실행 경계 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
|
|
||||||
- RED: 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 같은 KST 지난 주 기간에 대해 lock을 획득한 경우에만 refresh 책임을 실행하고, lock 획득 실패 시 refresh를 호출하지 않는 테스트를 작성한다. lock key는 집계 시작/종료 UTC 시각을 포함한 `lock:creator-ranking-snapshot-refresh:{start}:{end}` 형식으로 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
|
||||||
- GREEN: `CreatorRankingSnapshotJobService`에 `ensureLastCompletedWeekSnapshotForColdStart()` 또는 동등한 메서드를 추가한다. 이 메서드는 `CreatorRankingPeriodPolicy`로 기간을 산출하고, Redisson lock을 `tryLock(0, -1, TimeUnit.SECONDS)`로 획득한 경우에만 기존 refresh service를 호출한다.
|
|
||||||
- REFACTOR: 조회 API가 직접 `creator_ranking_snapshot`을 저장하지 않도록 하고, lock 획득/해제와 refresh 위임 책임은 job service에 둔다. 스케줄러의 고정 lock key 정책은 유지하고, cold-start 전용 메서드에서만 기간 기반 lock key를 사용한다.
|
|
||||||
- 기대 결과: 운영 배포 직후 내부 테스트 등 초기 cold-start 상황에서 같은 기간 스냅샷 생성이 중복 실행되지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 11.2: fallback 성공 후 스냅샷 생성 책임 위임 연결**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
|
|
||||||
- RED: `getCreatorRankings()`가 최신 스냅샷 없음 + 스냅샷 테이블 완전 공백 상태에서 fallback 결과를 응답하면서 cold-start 스냅샷 생성 위임 메서드를 호출하는 테스트를 작성한다. 과거 스냅샷이 있거나 fallback 후보가 없으면 cold-start 생성 위임을 호출하지 않는 테스트도 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
|
||||||
- GREEN: query service는 fallback 응답 조립 후 job service에 스냅샷 생성 책임을 위임한다. 위임 실패는 공개 API 응답을 깨지 않도록 catch 후 구조화 로그로 남기고, fallback 응답 스키마는 `showRankChange`와 `items` 그대로 유지한다.
|
|
||||||
- REFACTOR: fallback은 장기 실시간 랭킹 경로가 아니라 초기 상태 보강책임을 테스트명과 로그 이벤트명에 드러낸다.
|
|
||||||
- 기대 결과: 첫 내부 조회에서 fallback 응답을 내려주면서 이후 조회가 스냅샷 기반으로 전환될 수 있다.
|
|
||||||
|
|
||||||
- [x] **Task 11.3: cold-start 스냅샷 생성 트리거 회귀 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
|
|
||||||
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
|
||||||
- RED: 테스트 작성 예외. `TDD 예외 사유`: 구현 완료 후 회귀 검증 task다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'`
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- GREEN: cold-start fallback, 스케줄러, 관리자 job, 차단 마스킹, CDN profile image 응답 테스트가 모두 통과해야 한다.
|
|
||||||
- REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다.
|
|
||||||
- 기대 결과: cold-start 스냅샷 생성 보강이 기존 스케줄/관리자/조회 경로를 깨지 않는다.
|
|
||||||
|
|
||||||
### Phase 12: 크리에이터 랭킹 시간 정책 변경
|
|
||||||
|
|
||||||
> Phase 1~11은 완료 당시의 구현 이력이다. 시간 정책 변경은 완료된 task를 다시 수행하는 방식이 아니라, Phase 12에서 기존 07:30 생성 스케줄, 최신 완료 주차 조회, 기존 DDL/엔티티/port 구조를 `01:00 생성 후보`, `09:00 노출 전환`, `visibleFromAt <= now` 최신 공개 스냅샷 조회 기준으로 변경한다.
|
|
||||||
|
|
||||||
- [x] **Task 12.1: 집계/생성/노출 시각 분리 정책 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicyTest.kt`
|
|
||||||
- RED: 월요일 00:00:00 KST를 집계 종료 경계로 유지하고, 집계 종료일 월요일 09:00:00 KST가 `visibleFromAtUtc`로 변환되는 테스트를 작성한다. 2026-06-08 09:00:00 KST가 2026-06-08 00:00:00 UTC로 변환되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest`
|
|
||||||
- GREEN: `resolveVisibleFromAtUtc(aggregationEndAtKst)` 또는 동등한 메서드를 추가하고, 기존 집계 기간 산출은 변경하지 않는다.
|
|
||||||
- REFACTOR: 생성 후보 시각(01:00 KST)은 scheduler 책임으로 두고, period policy는 집계 기간과 공개 노출 시각 계산에 집중한다.
|
|
||||||
- 기대 결과: 집계 기준 시각과 공개 노출 전환 시각이 코드와 테스트에서 분리된다.
|
|
||||||
|
|
||||||
- [x] **Task 12.2: `rankingType`, `visibleFromAt` 스냅샷/job 저장 구조 반영**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
|
|
||||||
- Modify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepositoryTest.kt`
|
|
||||||
- RED: 스냅샷과 job record가 `rankingType=WEEKLY`, `visibleFromAtUtc`를 저장하고, 같은 타입/기간/크리에이터 중복 저장이 불가능하며, 같은 타입/기간 replace가 기존 row를 제거하는 repository 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest`
|
|
||||||
- GREEN: entity/record/port에 `rankingType`, `visibleFromAtUtc`를 추가하고, 운영 DB 변경용 ALTER DDL을 문서화한다. 기본 타입 `WEEKLY`를 생성/조회 경로에 전달한다.
|
|
||||||
- REFACTOR: DDL 컬럼명은 `ranking_type`, `visible_from_at`으로 유지하고, Kotlin 필드명은 기존 시간 필드 관례에 맞춰 `visibleFromAtUtc`로 둔다.
|
|
||||||
- 기대 결과: 스냅샷과 job 이력이 공개 노출 기준으로 조회될 수 있는 데이터를 가진다.
|
|
||||||
|
|
||||||
- [x] **Task 12.3: 스냅샷 생성 스케줄을 월요일 01:00 KST로 변경**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
|
|
||||||
- RED: scheduler method에 `@Scheduled(cron = "0 0 1 * * MON", zone = "Asia/Seoul")`가 선언되어 있는지 검증하고, 스케줄 job이 `visibleFromAtUtc`를 월요일 09:00 KST 기준으로 저장하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
|
||||||
- GREEN: 기존 07:30 cron을 01:00 cron으로 변경하고, refresh/job 생성 경로에 `visibleFromAtUtc`를 전달한다.
|
|
||||||
- REFACTOR: lock key는 기존 중복 실행 방지 정책을 유지하되, 기간 기반 lock 내부에서 `rankingType`이 필요한 경우 lock key에 포함할지 테스트로 고정한다.
|
|
||||||
- 기대 결과: 생성 후보 시각이 집계 종료 1시간 뒤로 당겨져도 공개 노출은 09:00까지 지연된다.
|
|
||||||
|
|
||||||
- [x] **Task 12.4: 조회 API를 최신 생성 스냅샷이 아닌 최신 공개 스냅샷 기준으로 변경**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- RED: 01:00 KST에 새 스냅샷이 생성되어도 08:59:59 KST 조회는 직전 공개 스냅샷을 반환하고, 09:00:00 KST 조회는 새 스냅샷을 반환하는 테스트를 작성한다. 직전 공개 스냅샷 기준 `rankChange`, `isNew`, `showRankChange` 계산도 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
|
|
||||||
- GREEN: snapshot port에 `findLatestVisibleSnapshots(rankingType, nowUtc)`와 `findPreviousVisibleSnapshots(rankingType, nowUtc)` 또는 동등한 메서드를 추가하고, query service가 이 메서드만 사용하도록 변경한다.
|
|
||||||
- REFACTOR: 기존 `findLatestSnapshots()`/`findPreviousCompletedSnapshots()`가 더 이상 공개 조회에 쓰이지 않으면 제거하거나 관리자/테스트 전용으로 명확히 제한한다.
|
|
||||||
- 기대 결과: 공개 API가 latest generated가 아니라 latest visible 스냅샷만 응답한다.
|
|
||||||
|
|
||||||
- [x] **Task 12.5: cold-start fallback 공개 노출 조건 보강과 회귀 검증**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
|
|
||||||
- RED: 스냅샷 테이블이 완전히 비어 있어도 fallback 대상 기간의 `visibleFromAtUtc > nowUtc`이면 새 주차 결과를 응답하지 않는 테스트를 작성한다. `visibleFromAtUtc <= nowUtc`이면 기존 fallback 응답과 스냅샷 생성 위임이 유지되는지도 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
|
||||||
- GREEN: fallback 집계 전에 공개 가능 여부를 검사하고, 공개 불가 시 빈 응답 또는 직전 공개 스냅샷 응답을 유지한다.
|
|
||||||
- REFACTOR: 공개 API 응답 DTO에는 `visibleFromAtUtc`, `rankingType`, fallback 여부를 추가하지 않는다.
|
|
||||||
- 기대 결과: 초기 상태 보강책도 09:00 공개 전환 정책을 우회하지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 12.6: 시간 정책 변경 문서/DDL 정합성 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
|
||||||
- RED: 테스트 작성 예외. `TDD 예외 사유`: 문서와 DDL 변경 범위 검증 task다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `rg -n "07:30|01:00|09:00|visibleFromAt|visible_from_at|rankingType|ranking_type|latest visible|최신 공개|최신 생성" docs/20260608_크리에이터_랭킹`
|
|
||||||
- `rg -n "visible_from_at|ranking_type|creator_ranking_snapshot|creator_ranking_snapshot_job" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
- `./gradlew tasks --all`
|
|
||||||
- GREEN: 문서에 남은 07:30 표현은 과거 검증 기록 또는 기존 정책 언급인지 확인하고, 현재 목표/신규 task에는 01:00 생성과 09:00 노출 전환만 남긴다.
|
|
||||||
- REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다.
|
|
||||||
- 기대 결과: PRD, 구현 계획, DDL이 같은 시간 정책과 공개 조회 기준을 설명한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. PRD 요구사항 추적
|
|
||||||
|
|
||||||
- Feature A: Task 1.1, Task 4.1에서 KST 기간 산출과 UTC DB 조회 변환을 검증한다.
|
|
||||||
- Feature B: Task 1.2, Task 3.1, Task 4.1에서 콘텐츠/라이브 raw can 산식을 검증한다.
|
|
||||||
- Feature C: Task 1.2, Task 3.2, Task 4.1에서 좋아요/댓글/대댓글 및 크리에이터 본인 댓글 제외를 검증한다.
|
|
||||||
- Feature D: Task 1.2, Task 3.3, Task 4.1에서 채널 후원 캔/건수와 최상위 팬 Talk 집계를 검증한다.
|
|
||||||
- Feature E: Task 1.2, Task 3.4, Task 4.1에서 최종 팔로우 수와 `createdAt`/`updatedAt` 기반 팔로우 증가 수를 검증한다.
|
|
||||||
- Feature F: Task 1.2, Task 4.1, Task 5.1에서 raw value 최종 점수, 1점 미만 제외, 20위 동점 후보 저장, 동점 랜덤 조회를 검증한다.
|
|
||||||
- Feature G: Task 5.1, Task 5.2에서 ranking 조회 결과와 차단 마스킹을 검증하고, Task 6.1, Task 6.2에서 홈 API endpoint, 응답 스키마, 인증/비인증 연결을 검증한다. Task 10.1, Task 10.2에서 스냅샷 테이블 완전 공백 상태의 제한적 fallback과 공개 응답 스키마 유지를 검증하고, Task 11.2에서 fallback 성공 후 응답을 깨지 않고 스냅샷 생성 책임을 위임하는 흐름을 검증한다. Task 12.4, Task 12.5에서 조회 API가 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷만 응답하도록 검증한다.
|
|
||||||
- Feature H: Task 2.1, Task 2.2, Task 4.1, Task 4.2, Task 4.3에서 주간 스냅샷 저장, 스케줄, 클러스터 단일 실행 lock을 검증한다. Task 8.1, Task 8.2에서 스케줄 job 이력과 성공/실패 기록을 검증하고, Task 9.1, Task 9.2에서 관리자 날짜 범위 수동 생성과 실패 job 재시도 API를 검증한다. Task 11.1, Task 11.2에서 cold-start fallback 성공 후 기간 기반 lock으로 동일 기간 스냅샷 생성 중복을 방지하는 보강책을 검증한다. Task 12.1~12.6에서 집계 기준 00:00 KST, 생성 후보 01:00 KST, 노출 전환 09:00 KST, `rankingType`/`visibleFromAt` DDL 영향과 최신 공개 스냅샷 조회 정책을 검증한다.
|
|
||||||
- Feature I: Phase 5의 ranking 기능 본체는 `v2.ranking` 패키지 경계를 유지하고, Phase 6의 클라이언트 API 표면은 `v2.api.home` 하위에 둔다. Phase 8~10의 관리자/job/fallback 기능도 공개 API 응답 DTO를 변경하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 검증 기록
|
|
||||||
|
|
||||||
- 2026-06-08: PRD 기준 구현 계획/TASK 문서를 작성했다. 구현 시작 전 문서 산출물이므로 코드 테스트는 실행하지 않았고, 문서 규칙에 따라 `./gradlew tasks --all`로 Gradle 명령 유효성을 확인한다.
|
|
||||||
- 2026-06-08: `rg -n "TBD|TODO|작성 예정|fill in|placeholder|similar|위와 동일|적절한|나중" docs/20260608_크리에이터_랭킹/plan-task.md`로 placeholder 문구가 없음을 확인했다.
|
|
||||||
- 2026-06-08: `./gradlew tasks --all`은 sandbox 기본 권한에서 `~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 778ms`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 시 신규 ranking domain 타입 미정의로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-08: Phase 1 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`는 `BUILD SUCCESSFUL`을 확인했다. 병렬 실행한 period 단일 테스트 1건은 Kotlin/kapt cache 경합으로 실패해 후속 통합 검증에서 재확인한다.
|
|
||||||
- 2026-06-08: Phase 2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`는 production persistence 추가 전 실행했으나 Kotlin daemon heap 오류로 컴파일 단계에서 중단됐다. 당시 테스트가 참조하는 `CreatorRankingSnapshotRepository` 등 production 타입은 미구현 상태였다.
|
|
||||||
- 2026-06-08: Phase 2 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest` 재실행 결과 `BUILD SUCCESSFUL in 1m 49s`를 확인했다.
|
|
||||||
- 2026-06-08: DDL 대체 검증: `rg -n "creator_ranking_snapshot|aggregation_start_at_utc|creator_id|final_score" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 테이블명, 기간 컬럼, 크리에이터 id, 최종 점수 컬럼 및 index 문구를 확인했다.
|
|
||||||
- 2026-06-08: Phase 1~2 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 재실행 결과 `BUILD SUCCESSFUL in 22s`를 확인했다.
|
|
||||||
- 2026-06-08: 포맷 검증: `./gradlew ktlintCheck`는 최초 신규 테스트 긴 줄로 실패했고, 줄바꿈 수정 후 재실행해 `BUILD SUCCESSFUL in 10s`를 확인했다.
|
|
||||||
- 2026-06-08: 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 16s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 3 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest` 실행 결과 `DefaultCreatorRankingAggregationRepository` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-08: Phase 3 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest` 재실행 결과 `BUILD SUCCESSFUL in 13s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 3 focused 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest` 재실행 결과 `BUILD SUCCESSFUL in 14s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 3 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 12s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 3 포맷 검증: `./gradlew ktlintCheck`는 최초 신규 테스트 긴 줄로 실패했고, 줄바꿈 수정 후 재실행해 `BUILD SUCCESSFUL in 5s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 4 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest` 실행 결과 `CreatorRankingSnapshotRefreshService`, `CreatorRankingSnapshotScheduler` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-08: Phase 4 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest` 재실행 결과 `BUILD SUCCESSFUL in 3s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 4 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 4 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 22s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 4 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 4 reviewer gate: 스냅샷 생성 서비스/스케줄러/테스트/문서 변경에 대해 strict review를 수행했고 `PASS` 판정을 확인했다.
|
|
||||||
- 2026-06-08: Task 4.3 및 07:30 스케줄 변경 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest` 실행 결과 `BUILD SUCCESSFUL in 16s`를 확인했다.
|
|
||||||
- 2026-06-08: Task 4.3 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 18s`를 확인했다.
|
|
||||||
- 2026-06-08: Task 4.3 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 26s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 5 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 `CreatorRankingBlockPort`, `CreatorRankingQueryService` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-08: Phase 5 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 재실행 결과 `BUILD SUCCESSFUL in 29s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 5 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 18s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 5 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 25s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 5 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 1s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 5 reviewer gate: 조회 서비스/차단 마스킹/테스트/문서 변경에 대해 strict review를 수행했고 `PASS` 판정을 확인했다.
|
|
||||||
- 2026-06-08: Phase 6 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest` 실행 결과 신규 endpoint/permit rule 미구현으로 비회원 요청 401, 인증 요청 404 등 신규 controller 테스트 3건 실패를 확인했다.
|
|
||||||
- 2026-06-08: Phase 6 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest` 재실행 결과 `BUILD SUCCESSFUL in 33s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 6 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 36s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 6 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 19s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 6 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 10s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 신규 로그 assertion 4건이 이벤트 로그 부재로 실패하는 것을 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 40s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 39s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 21s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 reviewer gate 1차 검토: 스냅샷 생성 성공 로그가 transaction commit 이전에 기록되는 점과 PRD Metrics의 최종 점수 1점 미만 제외 수 관측 누락으로 `FAIL` 판정을 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 reviewer 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest` 실행 결과 신규 `lowScoreExcludedCount` 테스트가 fake 미구현으로 `compileTestKotlin` 실패하는 것을 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 reviewer 수정 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest` 실행 결과 `BUILD SUCCESSFUL in 50s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 reviewer 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 37s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck`는 최초 import 순서 위반으로 실패했고, import 정렬 후 재실행해 `BUILD SUCCESSFUL in 18s`를 확인했다.
|
|
||||||
- 2026-06-08: Phase 7 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 28s`를 확인했다.
|
|
||||||
- 후속 구현 중 각 task 완료 시 실행 명령, 목적, 결과를 이 섹션에 누적한다.
|
|
||||||
- 2026-06-09: 사용자 추가 요구에 따라 PRD와 plan-task에 스냅샷 job 이력, 스케줄 job 기록, 관리자 날짜 범위 수동 생성, 실패 job 관리자 전용 재시도 API, 스냅샷 테이블 완전 공백 시 제한적 fallback 계획을 문서화했다.
|
|
||||||
- 2026-06-09: Phase 8 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 신규 job port/entity/service 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 스케줄러 연결 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 4s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 DDL 대체 검증: `rg -n "creator_ranking_snapshot_job|aggregation_start_at_utc|aggregation_end_at_utc|trigger_type|status|processing_started_at|processed_at|last_error" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 job 테이블명, 기간/트리거/상태/처리 시각/실패 사유 컬럼 및 index 문구를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 19s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 14s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 51s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 reviewer gate 1차 검토: repository 테스트가 `PENDING` 저장 상태와 `PROCESSING` 전이를 직접 검증하지 않아 `FAIL` 판정을 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 reviewer 수정 후 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 16s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck`는 최초 unused import로 실패했고, import 제거 후 재실행해 `BUILD SUCCESSFUL in 6s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 reviewer 수정 후 ranking 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 8 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 51s`를 확인했다.
|
|
||||||
|
|
||||||
- 2026-06-09: Phase 9 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest --tests kr.co.vividnext.sodalive.v2.admin.ranking.creator.AdminCreatorRankingSnapshotJobControllerTest` 실행 결과 신규 관리자 API 클래스, `createManualJob`/`findJobs`/`retryFailedJob`, `markPending` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 9 focused GREEN 및 관리자 API 표면 검증: retry 전이 guard 보강 후 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 1m 21s`를 확인했다. `AdminCreatorRankingSnapshotJobControllerTest`의 `MockMvc` 요청으로 `POST /admin/rankings/creators/snapshot-jobs`, `GET /admin/rankings/creators/snapshot-jobs`, `POST /admin/rankings/creators/snapshot-jobs/{jobId}/retry`의 성공 응답과 비관리자 403/익명 401을 검증했다.
|
|
||||||
- 2026-06-09: Phase 9 ranking/admin 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.admin.ranking.creator.*'` 실행 결과 최초 병렬 Gradle 실행 중 Kotlin/kapt cache 경합으로 실패했고, 단독 재실행해 `BUILD SUCCESSFUL in 23s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 9 포맷 검증: `./gradlew ktlintCheck`는 최초 테스트 파일 닫는 brace 앞 공백과 main import 순서 위반으로 실패했고, 정리 후 재실행해 `BUILD SUCCESSFUL in 11s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 9 전체 회귀 검증: retry 전이 guard 보강 후 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 20s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 Task 10.1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest` 실행 결과 `isSnapshotTableEmpty` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 Task 10.1 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 27s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 Task 10.2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 `aggregationPort`, `nowProvider` 생성자 파라미터 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 Task 10.2 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 42s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 Task 10.3 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 fallback/job 로그 이벤트 부재로 신규 로그 테스트 4건 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 Task 10.3 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 10s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 40s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 포맷 검증: `./gradlew ktlintCheck`는 최초 테스트 import 순서 위반으로 실패했고, import 정렬 후 재실행해 `BUILD SUCCESSFUL in 6s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 59s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 reviewer gate 1차 검토: cold-start fallback 경로에서 인증 회원의 차단 크리에이터 마스킹이 누락되어 `FAIL` 판정을 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 reviewer 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 fallback 차단 마스킹 신규 테스트 1건 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 reviewer 수정 GREEN 확인: fallback 결과에도 기존 차단 마스킹을 적용한 뒤 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 6s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 reviewer 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 30s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 10 reviewer 수정 후 follow-up gate: 목표/보안 검증, 코드 품질 검토, QA focused 검증이 모두 `PASS` 판정을 반환했고 blocking issue가 없음을 확인했다.
|
|
||||||
|
|
||||||
- 2026-06-09: 사용자 후속 요청에 따라 cold-start fallback 성공 시 조회 API가 직접 스냅샷을 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임하도록 PRD와 plan-task를 갱신했다. 동일 집계 기간 중복 생성을 막기 위해 기간 기반 Redisson lock key(`lock:creator-ranking-snapshot-refresh:{start}:{end}`)와 신규 Phase 11 Task 11.1~11.3을 추가했다. 문서 변경 검증으로 `rg -n "cold-start|ensureLastCompletedWeekSnapshotForColdStart|lock:creator-ranking-snapshot-refresh|Task 11|fallback 성공" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md` 및 `git diff -- docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md`를 실행해 반영 범위를 확인했다.
|
|
||||||
|
|
||||||
- 2026-06-09: creator_ranking_snapshot 최신/직전 조회 기준 확인: `rg -n "max\(latest\.aggregation_end_at_utc\)|max\(previous\.aggregation_end_at_utc\)|order by .*id|findLatestSnapshots|findPreviousCompletedSnapshots" src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence` 및 repository 코드 확인 결과 최신/직전 조회는 `id`가 아니라 `aggregation_end_at_utc`의 max/previous max 기준이며, 기간 내 정렬은 `final_score desc`임을 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 Task 11.1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `RedissonClient` 생성자 인자와 `ensureLastCompletedWeekSnapshotForColdStart` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 Task 11.1 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 12s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 Task 11.2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `snapshotJobService` 생성자 파라미터 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 Task 11.2 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 16s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 focused 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 2s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 포맷 검증: `./gradlew ktlintCheck`는 최초 main import 순서 위반으로 실패했고, import 정렬 후 재실행해 `BUILD SUCCESSFUL in 23s`를 확인했다.
|
|
||||||
|
|
||||||
- 2026-06-09: Phase 11 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 2m 52s`를 확인했다.
|
|
||||||
|
|
||||||
- 2026-06-09: Phase 11 reviewer gate 1차 Code Quality 검토: 스케줄러 고정 lock과 cold-start 기간 lock이 달라 동일 기간 refresh가 동시에 실행될 수 있어 `FAIL` 판정을 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 스케줄 job이 cold-start와 같은 기간 lock을 사용하지 않아 신규 테스트 2건 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 수정 GREEN 확인: 스케줄 job refresh와 cold-start refresh가 공통 기간 기반 lock 경계를 사용하도록 수정한 뒤 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 수정 후 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 9s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 43s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 27s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 10s`를 확인했다.
|
|
||||||
|
|
||||||
- 2026-06-09: Phase 11 reviewer 2차 Code Quality 검토: 공통 period lock은 적용됐지만 transaction commit 전에 lock이 해제될 수 있어 `FAIL` 판정을 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 2차 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `TransactionTemplate`/transaction manager 생성자 인자 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 2차 수정 GREEN 확인: `PlatformTransactionManager`로 `PROPAGATION_REQUIRES_NEW` `TransactionTemplate`을 내부 생성하고, period lock 안의 transaction commit 이후 unlock되도록 수정한 뒤 job service focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 12s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 2차 수정 후 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 3s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 2차 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 45s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 2차 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 17s`를 확인했다.
|
|
||||||
- 2026-06-09: Phase 11 reviewer 2차 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
|
|
||||||
|
|
||||||
- 2026-06-09: Phase 11 reviewer 2차 수정 후 Code Quality 재검토 결과 이전 blocking issue가 해소되어 `PASS` 판정을 확인했다.
|
|
||||||
|
|
||||||
- 2026-06-24: 크리에이터 랭킹 시간 정책 변경 문서 작업을 시작해 PRD에 집계 기준 00:00:00 KST, 생성 후보 01:00:00 KST, 노출 전환 09:00:00 KST, 최신 공개 스냅샷(`visibleFromAt <= now`) 조회 정책을 반영했다. `plan-task.md`에는 `visible_from_at`/`ranking_type` DDL 영향도와 신규 Phase 12 Task 12.1~12.6을 추가했다.
|
|
||||||
- 2026-06-24: 문서 정합성 확인: `rg -n "07:30|0 30 7|최신 완료|완료 주차" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md` 실행 결과 현재 PRD에는 변경 전 정책 표현이 남아 있지 않고, `plan-task.md`의 남은 07:30/최신 완료 표현은 Phase 1~11 완료 당시 이력 또는 과거 검증 기록이며 Phase 12 note에서 신규 변경 범위를 구분했음을 확인했다.
|
|
||||||
- 2026-06-24: 시간 정책/DDL 키워드 확인: `rg -n "00:00:00 KST|01:00|09:00|visibleFromAt|visible_from_at|rankingType|ranking_type|최신 공개|최신 생성" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 PRD, plan-task, DDL에 신규 시간 정책과 컬럼명이 반영됐음을 확인했다.
|
|
||||||
- 2026-06-24: DDL 핵심 컬럼 확인: `rg -n "visible_from_at|ranking_type|creator_ranking_snapshot|creator_ranking_snapshot_job" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 두 테이블의 `ranking_type`, `visible_from_at` 컬럼과 조회/관리 인덱스를 확인했다.
|
|
||||||
- 2026-06-24: 문서 변경 후 Gradle 명령 유효성 확인: `./gradlew tasks --all`은 sandbox 기본 권한에서 `~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 2s`를 확인했다.
|
|
||||||
- 2026-06-24: 사용자 피드백에 따라 이미 적용된 `create-ranking-tables.sql`의 CREATE DDL 변경을 되돌리고, 파일 하단에 기존 적용 DB 변경용 ALTER DDL을 추가했다. 컬럼 추가는 기존 row를 고려해 nullable로 추가한 뒤 `WEEKLY`와 `aggregation_end_at_utc + 9시간` 기준으로 backfill하고, 이후 `MODIFY NOT NULL` 및 인덱스 보강/교체를 수행하는 순서로 정리했다.
|
|
||||||
- 2026-06-24: 피드백 반영 후 문서/DDL 재검증: `git diff -- docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 CREATE DDL 본문은 변경하지 않고 하단 ALTER 섹션만 추가됐음을 확인했다. `rg -n "이미 위 CREATE DDL|alter table creator_ranking_snapshot|add column ranking_type|update creator_ranking_snapshot|modify column ranking_type|drop index|create index idx_creator_ranking_snapshot_visible_score|alter table creator_ranking_snapshot_job|idx_creator_ranking_snapshot_job_visible_status" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 ALTER/backfill/modify/index 변경 순서를 확인했다.
|
|
||||||
- 2026-06-24: 피드백 반영 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 808ms`를 확인했다.
|
|
||||||
- 2026-06-24: 완료된 Phase 본문 수정에 대한 혼동을 줄이기 위해 Phase 2.1, Phase 4.2, Phase 5.1의 완료 task 문구는 기존 이력대로 되돌리고, Phase 12 시작부에 “Phase 1~11은 완료 당시 구현 이력이며 시간 정책 변경은 Phase 12에서 수행한다”는 note를 추가했다.
|
|
||||||
- 2026-06-24: 완료 Phase 문구 원복 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 823ms`를 확인했다.
|
|
||||||
- 2026-06-24: PRD/plan-task 크로스 체크 결과, PRD Feature A의 변경 전 기간 기준 표현이 09:00 공개 노출 전환 전 응답 정책과 충돌할 수 있어 “스냅샷 생성 또는 fallback 집계 기준 시점” 기준으로 수정했다. `rg -n "조회 시점 기준|2026-06-08 월요일 KST에 조회하면" docs/20260608_크리에이터_랭킹/prd.md`로 PRD 본문에 변경 전 표현이 남지 않았고, `rg -n "집계 기준 시각|생성 후보 시각|노출 전환 시각|visibleFromAt <= now|rankingType|visible_from_at|ranking_type|운영 반영용 ALTER" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 PRD, plan-task, DDL의 요구사항 반영 지점을 확인했다.
|
|
||||||
- 2026-06-24: PRD/plan-task 크로스 체크 수정 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 762ms`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 Task 12.1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest` 실행 결과 `resolveVisibleFromAtUtc` 미구현으로 `compileTestKotlin` 실패를 확인했다. GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 17s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 Task 12.2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest` 실행 결과 `CreatorRankingType`, `rankingType`, `visibleFromAtUtc` 미구현으로 `compileTestKotlin` 실패를 확인했다. GREEN 확인: 스냅샷/job 저장 구조 반영 후 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 51s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 Task 12.3~12.5 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.scheduler.CreatorRankingSnapshotSchedulerTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과 `findLatestVisibleSnapshots`/`findPreviousVisibleSnapshots` 미구현으로 `compileTestKotlin` 실패를 확인했다. GREEN 확인: 01:00 KST cron, 최신 공개 스냅샷 조회, cold-start fallback 공개 전 차단 반영 후 Phase 12 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 18s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 문서/DDL 정합성 검증: `rg -n "07:30|01:00|09:00|visibleFromAt|visible_from_at|rankingType|ranking_type|latest visible|최신 공개|최신 생성" docs/20260608_크리에이터_랭킹` 실행 결과 남은 07:30 표현은 완료된 Phase 4/과거 검증 기록과 Phase 12 변경 note임을 확인했고, 현재 목표/신규 task에는 01:00 생성과 09:00 노출 전환이 반영됐음을 확인했다. `rg -n "visible_from_at|ranking_type|creator_ranking_snapshot|creator_ranking_snapshot_job" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 ALTER/backfill/not-null/index 변경 순서를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 ranking/API/admin 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.admin.ranking.creator.*'` 실행 결과 `BUILD SUCCESSFUL in 42s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 포맷 검증: `./gradlew ktlintCheck`는 최초 신규 테스트 긴 줄과 import 순서 위반으로 실패했고, 포맷 정리 후 재실행해 `BUILD SUCCESSFUL in 10s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 2m 18s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 reviewer gate 1차 Code Quality 검토: 스케줄 job 생성/PROCESSING/refresh/DONE/FAILED 기록이 하나의 `REQUIRES_NEW` 트랜잭션에 묶여 refresh 실패 시 `FAILED` 기록도 롤백될 수 있어 `FAIL` 판정을 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 reviewer 수정 focused 검증: `CreatorRankingSnapshotJobService`의 scheduled job 상태 전이를 content ranking 패턴처럼 `savePendingJob`, `markProcessing`, `refresh`, `markDone`, `markFailed` 별도 transaction으로 분리하고, refresh rollback 이후 FAILED 상태 commit 순서 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 29s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 reviewer 수정 후 ranking/API/admin 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.admin.ranking.creator.*'` 실행 결과 `BUILD SUCCESSFUL in 49s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 reviewer 수정 후 포맷/전체 회귀 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 32s`, `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 43s`를 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 reviewer 수정 후 Code Quality 재검토 결과 이전 blocking issue가 해소되어 `PASS` 판정을 확인했다.
|
|
||||||
- 2026-06-24: Phase 12 코드 리뷰 및 재검증: 공개 조회 경로가 `findLatestVisibleSnapshots(WEEKLY, nowUtc)`/`findPreviousVisibleSnapshots(WEEKLY, currentAggregationStartAtUtc, nowUtc)`를 사용하고, 01:00 KST scheduler, 09:00 KST `visibleFromAtUtc`, cold-start fallback 공개 전 차단, scheduled job 실패 시 `FAILED` 상태 별도 transaction commit 흐름을 재확인했다. blocking issue는 발견하지 않았다.
|
|
||||||
- 2026-06-24: Phase 12 코드 리뷰 후 fresh 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.admin.ranking.creator.*'` 실행 결과 `BUILD SUCCESSFUL in 38s`를 확인했다. `./gradlew ktlintCheck`는 sandbox 기본 권한에서 `~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 855ms`를 확인했다. `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 41s`를 확인했다.
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
# PRD: 크리에이터 랭킹
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
지난 주 월요일 00:00:00 KST부터 이번 주 월요일 00:00:00 KST 미만까지의 활동 데이터를 기준으로 크리에이터 랭킹 점수를 계산하고, 공개 노출 전환 시각이 지난 스냅샷의 최종 점수 상위 20명을 조회할 수 있는 기능을 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 크리에이터의 매출, 콘텐츠 반응, 응원, 팬 충성도를 한 번에 비교할 수 있는 주간 랭킹 기준이 필요하다.
|
|
||||||
- 서버 시스템 timezone이 UTC로 동작하더라도 랭킹 산정 기간은 KST 기준 지난 주 월요일부터 일요일까지로 고정되어야 한다.
|
|
||||||
- DB와 서버 timezone은 UTC이므로, KST 기준으로 산출한 랭킹 기간을 UTC 조회 조건으로 변환해 원천 데이터를 조회해야 한다.
|
|
||||||
- 계산 산식이 여러 도메인 데이터에 걸쳐 있어 조회 API 내부에 직접 구현하면 테스트와 스냅샷 기반 성능 개선이 어려워진다.
|
|
||||||
- 동일한 랭킹 산식을 주간 스냅샷 생성, 운영 조회, 캐시 갱신에서 재사용할 수 있도록 계산 책임과 조회 책임을 분리해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- KST 기준 지난 주 월요일부터 일요일까지의 주간 크리에이터 랭킹을 계산한다.
|
|
||||||
- 최종 점수 기준 상위 20명의 크리에이터를 조회할 수 있다.
|
|
||||||
- 랭킹 계산 산식은 독립된 application/domain 컴포넌트로 분리한다.
|
|
||||||
- 계산 기간 산출은 서버 기본 timezone에 의존하지 않고 명시적으로 `Asia/Seoul` 기준을 사용한다.
|
|
||||||
- KST 기준 집계 시작/종료 시각을 UTC 기준 조회 시작/종료 시각으로 변환한 뒤 DB 데이터를 조회한다.
|
|
||||||
- 각 점수 카테고리의 원천 지표와 가중치를 테스트 가능한 형태로 관리한다.
|
|
||||||
- 조회 시 매번 무거운 원천 집계를 수행하지 않도록 주간 랭킹 계산 결과를 스냅샷으로 저장한다.
|
|
||||||
- 주간 랭킹 시간 정책을 집계 기준 시각, 스냅샷 생성 후보 시각, 공개 노출 전환 시각으로 분리한다.
|
|
||||||
- 조회 API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 응답한다.
|
|
||||||
- 추후 성능 개선을 위해 캐시 저장소를 추가할 수 있는 포트 경계를 둔다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 이번 PRD에서는 별도 관리자 화면 신규 개발을 포함하지 않는다. 단, 기존 관리자 영역에서 호출할 수 있는 스냅샷 수동 생성/재시도용 관리자 전용 API는 포함한다.
|
|
||||||
- 크리에이터 랭킹 산식의 머신러닝 모델화, 개인화, A/B 테스트는 포함하지 않는다.
|
|
||||||
- 실시간 랭킹 또는 현재 주 진행 중 랭킹은 포함하지 않는다. 단, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
|
|
||||||
- 기존 공개 API 스키마를 임의 변경하지 않는다.
|
|
||||||
- 랭킹 결과 수동 보정 기능은 포함하지 않는다.
|
|
||||||
- 점수 산식의 가중치를 관리자에서 동적으로 수정하는 기능은 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 주간 인기 크리에이터를 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 랭킹 화면에 상위 크리에이터 목록과 순위/순위 변화 정보를 노출하는 클라이언트
|
|
||||||
- 운영자: 주간 크리에이터 성과를 확인하고 랭킹 산식의 결과를 검증하는 내부 사용자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 지난 주 기준으로 가장 높은 최종 점수를 받은 크리에이터 20명을 보고 싶다.
|
|
||||||
- 사용자는 랭킹 순위, 지난 주 대비 순위 변화, 크리에이터 프로필 이미지, 닉네임을 확인하고 싶다.
|
|
||||||
- 앱 클라이언트는 홈 내부 랭킹 탭에서 동일한 API 응답으로 랭킹 화면을 구성하고 크리에이터 상세로 이동하고 싶다.
|
|
||||||
- 운영자는 특정 크리에이터의 최종 점수가 어떤 카테고리 점수로 구성되었는지 추적할 수 있어야 한다.
|
|
||||||
- 개발자는 시스템 timezone이 UTC여도 KST 기준 집계 기간이 흔들리지 않는지 테스트로 확인하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 주간 랭킹 기간 산출
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 랭킹 대상 기간은 스냅샷 생성 또는 fallback 집계 기준 시점의 "지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만"으로 계산한다.
|
|
||||||
- 예를 들어 2026-06-08 월요일 KST에 스냅샷을 생성하거나 fallback 집계를 수행하면 대상 기간은 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만이다.
|
|
||||||
- 집계 기준 시각은 매주 월요일 00:00:00 KST이며, 이 시각을 집계 종료 경계로 사용한다.
|
|
||||||
- 서버 기본 timezone이 UTC여도 기간 산출은 `Asia/Seoul` 기준으로 수행한다.
|
|
||||||
- DB와 서버 timezone은 UTC이므로, KST 기준 기간을 UTC 기준 `Instant` 또는 프로젝트 표준 시간 타입으로 변환해 DB 조회 조건에 사용한다.
|
|
||||||
- 예를 들어 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만은 2026-05-31 15:00:00 UTC 이상, 2026-06-07 15:00:00 UTC 미만으로 변환해 조회한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 월요일 00:00:00 KST 직후 조회해도 방금 시작한 이번 주 데이터가 포함되지 않아야 한다.
|
|
||||||
- 월요일 00:00:00 KST 이후 09:00:00 KST 전까지 조회해도 새로 종료된 주차가 공개 노출 전환 전이면 이전 공개 스냅샷을 응답해야 한다.
|
|
||||||
- 연도/월 경계를 넘어가는 주차도 동일한 규칙으로 계산한다.
|
|
||||||
- DST가 없는 KST 기준을 사용하되, 구현은 `ZoneId.of("Asia/Seoul")`처럼 명시적인 timezone을 사용한다.
|
|
||||||
|
|
||||||
### Feature B. 콘텐츠 + 라이브 점수
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 콘텐츠 + 라이브 점수는 라이브 계열 매출 합산 지표 70%, 콘텐츠 구매 합산 지표 30%로 계산한다.
|
|
||||||
- 라이브 계열 매출 합산 지표는 `CanUsage.DONATION`, `CanUsage.LIVE`, `CanUsage.SPIN_ROULETTE`의 사용 캔 합계로 계산한다.
|
|
||||||
- 콘텐츠 구매 합산 지표는 `CanUsage.ORDER_CONTENT` 1종의 사용 캔 합계로 계산한다.
|
|
||||||
- 환불된 사용 내역은 점수 계산에서 제외한다.
|
|
||||||
- 크리에이터별 기간 내 합계를 원천 지표로 보관하거나 응답 내부 추적이 가능해야 한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 라이브 또는 콘텐츠 구매 데이터가 없으면 해당 지표는 0점으로 계산한다.
|
|
||||||
- 음수 캔 또는 환불 데이터가 섞여 있으면 기존 `UseCan` 환불 정책과 동일한 방식으로 제외한다.
|
|
||||||
|
|
||||||
### Feature C. 참여 반응 점수
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 참여 반응 점수는 콘텐츠 좋아요 수 50%, 콘텐츠 댓글 수 50%로 계산한다.
|
|
||||||
- 콘텐츠 좋아요 수는 기간 내 활성 콘텐츠 좋아요 수를 크리에이터별로 합산한다.
|
|
||||||
- 콘텐츠 댓글 수는 기간 내 활성 콘텐츠 댓글과 대댓글 수를 크리에이터별로 합산한다.
|
|
||||||
- 해당 콘텐츠의 크리에이터가 직접 작성한 댓글과 대댓글은 콘텐츠 댓글 수에서 제외한다.
|
|
||||||
- 비활성 콘텐츠, 삭제 또는 비활성 처리된 좋아요/댓글은 기존 도메인 정책에 맞춰 제외한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 좋아요 또는 댓글이 없으면 해당 지표는 0점으로 계산한다.
|
|
||||||
- 콘텐츠 댓글 수가 없거나 크리에이터 본인 댓글/대댓글만 있으면 댓글 지표는 0점으로 계산한다.
|
|
||||||
|
|
||||||
### Feature D. 응원 점수
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 응원 점수는 채널 후원 캔 합계 60%, 채널 후원 수 20%, 팬 Talk 수 20%로 계산한다.
|
|
||||||
- 채널 후원 캔 합계는 `CanUsage.CHANNEL_DONATION`의 사용 캔 합계로 계산한다.
|
|
||||||
- 채널 후원 수는 `CanUsage.CHANNEL_DONATION` 사용 건수로 계산한다.
|
|
||||||
- 팬 Talk 수는 기존 `CreatorCheers`의 최상위 등록 수로 계산하고 답글은 포함하지 않는다.
|
|
||||||
- 환불된 채널 후원 내역은 점수 계산에서 제외한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 채널 후원 또는 팬 Talk 데이터가 없으면 해당 지표는 0점으로 계산한다.
|
|
||||||
- 팬 Talk 답글이 별도 row로 저장되어 있어도 팬 Talk 수에 포함하지 않는다.
|
|
||||||
|
|
||||||
### Feature E. 팬 충성도 점수
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 팬 충성도 점수는 최종 팔로우 수 70%, 팔로우 증가 수 30%로 계산한다.
|
|
||||||
- 최종 팔로우 수는 랭킹 대상 기간 종료 시점 기준 활성 팔로우 수를 의미한다.
|
|
||||||
- 팔로우 증가 수는 랭킹 대상 기간 동안 활성 팔로우 수가 몇 명 증가했는지를 의미한다.
|
|
||||||
- 기본 정의는 `기간 내 신규 활성 팔로우 수 - 기간 내 비활성화된 팔로우 수`로 한다.
|
|
||||||
- 신규 활성 팔로우 수는 `CreatorFollowing.createdAt`이 랭킹 대상 기간 안에 있는 팔로우만 집계한다.
|
|
||||||
- 비활성화된 팔로우 수는 `CreatorFollowing.isActive == false`이고 `CreatorFollowing.updatedAt`이 랭킹 대상 기간 안에 있는 팔로우만 집계한다.
|
|
||||||
- 과거 언팔로우 후 기간 내 재팔로우한 경우는 `createdAt`이 과거 시점이므로 신규 증가로 반영하지 않는다.
|
|
||||||
- 이번 산식은 현재 `creator_following` row의 `createdAt`, `updatedAt`, `isActive` 기준으로 계산하며, 한 기간 안에서 여러 번 발생한 팔로우/언팔로우 이벤트 히스토리까지 별도로 복원하지 않는다.
|
|
||||||
- 팔로우 증가 수가 음수이면 음수 원천 지표와 음수 카테고리 점수를 허용하고, 최종 점수에 그대로 반영한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 기간 내 재팔로우로 다시 활성화된 팔로우는 최종 팔로우 수에는 포함될 수 있지만 팔로우 증가 수의 신규 생성분에는 포함하지 않는다.
|
|
||||||
- 기간 내 언팔로우 후 재팔로우해 최종 상태가 활성인 row는 `isActive == false` 조건에 걸리지 않으므로 비활성화된 팔로우 수에도 포함하지 않는다.
|
|
||||||
|
|
||||||
### Feature F. 최종 점수 계산 및 정렬
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 최종 점수는 `(콘텐츠/라이브 카테고리 점수 * 0.35) + (참여 반응 점수 * 0.30) + (응원 점수 * 0.25) + (팬 충성도 점수 * 0.10)`으로 계산한다.
|
|
||||||
- 최종 점수 1점 이상인 크리에이터만 랭킹에 포함한다.
|
|
||||||
- 최종 점수 내림차순으로 최대 20명을 조회한다.
|
|
||||||
- 동점자는 랜덤으로 추출한다.
|
|
||||||
- 스냅샷에는 최종 점수 1점 이상인 모든 후보를 저장하지 않고, Top 20 산정에 필요한 후보만 저장한다.
|
|
||||||
- Top 20 산정에 필요한 후보는 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체를 의미한다.
|
|
||||||
- 조회 시 스냅샷에 저장된 후보 중 최종 점수 동점자를 랜덤 정렬해 상위 20명을 추출한다.
|
|
||||||
- 동점 랜덤 추출을 위한 별도 `randomTieBreaker` 값은 스냅샷에 저장하지 않는다.
|
|
||||||
- 각 하위 지표는 0~100 정규화하지 않고 원천 값(raw value)을 그대로 사용한다.
|
|
||||||
- 캔 단위 지표는 좋아요, 댓글, 팔로우 같은 개수 지표보다 최종 점수에 더 큰 영향을 줄 수 있으며, 이는 의도된 정책이다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 특정 지표 값이 없으면 해당 원천 값은 0으로 계산한다.
|
|
||||||
- 최종 점수가 1점 미만이면 20명이 되지 않아도 응답에서 제외한다.
|
|
||||||
|
|
||||||
### Feature G. 랭킹 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 홈 내부 랭킹 탭에서 주간 크리에이터 랭킹 상위 20명을 조회하는 API를 제공한다.
|
|
||||||
- API endpoint는 `GET /api/v2/home/rankings/creators`를 사용한다.
|
|
||||||
- API는 별도 query parameter 없이 기본 랭킹을 반환한다.
|
|
||||||
- API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 조회한다.
|
|
||||||
- 새 주차 스냅샷이 월요일 01:00:00 KST에 생성되었더라도 `visibleFromAt`인 월요일 09:00:00 KST 전에는 공개 조회에 사용하지 않는다.
|
|
||||||
- 예를 들어 2026-06-08 08:59:59 KST 조회는 2026-06-08 09:00:00 KST 공개 예정 스냅샷이 생성되어 있어도 직전 공개 스냅샷을 응답한다.
|
|
||||||
- 예를 들어 2026-06-08 09:00:00 KST 이후 조회는 해당 시각까지 공개된 최신 스냅샷을 응답한다.
|
|
||||||
- 응답에는 순위 변화 표시 여부, 순위, 지난 주 대비 순위 변화, 신규 진입 여부, 크리에이터 id, 닉네임, 프로필 이미지를 포함한다.
|
|
||||||
- `showRankChange`는 `items`와 같은 레벨에 내려주며, 클라이언트가 순위 변화 UI를 표시할지 판단하는 값이다.
|
|
||||||
- 각 크리에이터의 순위 변화 값은 `items[].rankChange`에 숫자로 내려준다.
|
|
||||||
- 순위가 올라갔으면 양수, 순위가 내려갔으면 음수로 내려준다.
|
|
||||||
- 예를 들어 직전 공개 스냅샷 10위, 최신 공개 스냅샷 5위이면 `rankChange`는 `5`다.
|
|
||||||
- 예를 들어 직전 공개 스냅샷 1위, 최신 공개 스냅샷 10위이면 `rankChange`는 `-9`다.
|
|
||||||
- 직전 공개 스냅샷에는 순위에 없고 최신 공개 스냅샷에 진입한 크리에이터는 `items[].isNew == true`로 내려주며, 클라이언트는 이를 `New`로 표시한다.
|
|
||||||
- 신규 진입 크리에이터의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다.
|
|
||||||
- 응답의 크리에이터 id는 크리에이터 상세 이동에 사용한다.
|
|
||||||
- 응답 스키마 예시는 다음과 같다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"showRankChange": true,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"rank": 1,
|
|
||||||
"rankChange": 5,
|
|
||||||
"isNew": false,
|
|
||||||
"creatorId": 123,
|
|
||||||
"nickname": "creator",
|
|
||||||
"profileImageUrl": "https://cdn.example.com/profile.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 2,
|
|
||||||
"rankChange": null,
|
|
||||||
"isNew": true,
|
|
||||||
"creatorId": 456,
|
|
||||||
"nickname": "new creator",
|
|
||||||
"profileImageUrl": "https://cdn.example.com/profile-new.png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 운영 검증 또는 디버깅이 필요하면 카테고리별 점수와 원천 지표를 내부용 응답 또는 로그로 확인할 수 있어야 한다.
|
|
||||||
- 비활성 및 탈퇴 크리에이터는 랭킹에 노출하지 않는다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 랭킹 row는 유지하되 응답의 크리에이터 id는 `0`, 닉네임은 빈 문자열로 내려준다.
|
|
||||||
- 차단 관계가 있는 크리에이터의 프로필 이미지는 기본 이미지 URL로 내려주고, 이동 대상 id는 `0`으로 내려준다.
|
|
||||||
- 인증 사용자 조건이 필요하지 않은 공개 조회를 기본으로 하되, 차단 마스킹 정책은 인증 사용자에게 적용한다.
|
|
||||||
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 공개 API 응답 스키마는 fallback 여부와 관계없이 변경하지 않는다.
|
|
||||||
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 조회 API가 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
|
|
||||||
- fallback 응답도 공개 노출 전환 정책을 따라야 하며, fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하지 않으면 새 주차 결과를 응답하지 않는다.
|
|
||||||
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷 기준으로 응답한다.
|
|
||||||
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 fallback 집계가 성공하면, 조회 API는 공개 가능한 기간의 응답을 반환하면서 스냅샷 생성 책임을 `CreatorRankingSnapshotRefreshService`/`CreatorRankingSnapshotJobService` 쪽으로 위임해 같은 기간의 `creator_ranking_snapshot` 생성을 트리거한다.
|
|
||||||
- cold-start fallback에서 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 상황을 위한 보강책이며, 장기 실시간 집계 경로로 사용하지 않는다.
|
|
||||||
- cold-start fallback 스냅샷 생성은 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용하고, lock 획득 실패 시 다른 요청 또는 작업이 처리 중인 정상 skip으로 간주한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 최종 점수 1점 이상인 랭킹 후보가 20명 미만이면 가능한 만큼만 내려준다.
|
|
||||||
- 랭킹 계산 결과가 없으면 빈 배열로 성공 응답한다.
|
|
||||||
- 공개 가능한 최신 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으며 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다.
|
|
||||||
- 공개 가능한 최신 스냅샷이 없고 fallback 대상 기간의 `visibleFromAt > now`이면 새 주차 결과를 조기 노출하지 않고 빈 배열로 성공 응답한다.
|
|
||||||
- fallback 성공 뒤 스냅샷 생성 트리거가 실패하더라도 공개 API 응답 스키마는 변경하지 않고, 실패는 로그/job 이력으로 추적한다.
|
|
||||||
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 공개 스냅샷 기준 응답을 유지한다.
|
|
||||||
- 직전 공개 스냅샷이 없으면 `showRankChange`는 `false`로 내려주고, 각 item의 `rankChange`는 `null`, `isNew`는 `false`로 내려준다.
|
|
||||||
|
|
||||||
### Feature H. 주간 랭킹 스냅샷
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 주간 랭킹은 조회 시 매번 원천 데이터를 집계하지 않고, 계산 결과를 스냅샷으로 저장한 뒤 조회 API는 스냅샷을 읽는다.
|
|
||||||
- 스냅샷 생성 기준 기간은 KST 기준 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만이다.
|
|
||||||
- 주간 랭킹 시간 정책은 다음 세 시각을 분리한다.
|
|
||||||
- 집계 기준 시각: 매주 월요일 00:00:00 KST. 이 시각을 집계 종료 경계로 사용한다.
|
|
||||||
- 생성 후보 시각: 매주 월요일 01:00:00 KST. 스케줄러가 새 주차 스냅샷 생성을 시도하는 후보 시각이다.
|
|
||||||
- 노출 전환 시각: 매주 월요일 09:00:00 KST. 생성된 새 주차 스냅샷의 `visibleFromAt`으로 저장하고, 이 시각 이후 공개 조회에 사용한다.
|
|
||||||
- 스냅샷 생성 시 원천 데이터 조회 조건은 KST 집계 기간을 UTC로 변환한 기간을 사용한다.
|
|
||||||
- 스냅샷에는 `rankingType`과 `visibleFromAt`을 저장한다.
|
|
||||||
- 현재 기본 크리에이터 랭킹의 `rankingType` 값은 `WEEKLY`로 시작하고, 향후 다중 크리에이터 랭킹 타입 확장 시 같은 스냅샷/job 구조를 재사용한다.
|
|
||||||
- 같은 랭킹 타입과 같은 집계 기간의 스냅샷을 재생성할 때는 기존 같은 `rankingType + aggregationStartAt + aggregationEndAt` row를 중복 노출하지 않는다.
|
|
||||||
- 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다.
|
|
||||||
- 최종 점수 1점 이상인 후보가 20명 미만이면 해당 후보만 저장한다.
|
|
||||||
- 스냅샷은 크리에이터 id, 최종 점수, 카테고리별 점수, 원천 지표, 집계 시작/종료 시각을 저장한다.
|
|
||||||
- 최종 순위는 스냅샷 저장 시 고정하지 않고 조회 시 최종 점수 내림차순과 동점 랜덤 정렬 결과에 따라 부여한다.
|
|
||||||
- 순위 변화는 최신 공개 스냅샷 응답에서 부여된 순위와 직전 공개 스냅샷 기준 순위를 비교해 계산한다.
|
|
||||||
- 동점 랜덤 정렬 정책 때문에 동점 구간에 포함된 크리에이터의 순위와 순위 변화는 조회 결과마다 달라질 수 있으며, 이는 허용한다.
|
|
||||||
- 스냅샷 생성은 이번 주 데이터가 포함되지 않도록 주간 집계 대상 기간이 종료된 뒤 실행한다.
|
|
||||||
- 기본 생성 스케줄 후보는 매주 월요일 KST 01:00이며, 스케줄러는 `Asia/Seoul` zone을 명시한다.
|
|
||||||
- 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 클러스터 전체에서 한 인스턴스만 스냅샷 생성을 수행해야 한다.
|
|
||||||
- 클러스터 단일 실행은 신규 DB 테이블을 추가하지 않고, 기존 프로젝트에 설정된 Redisson 기반 분산 lock을 우선 사용한다.
|
|
||||||
- 주간 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`를 사용하며, lock 획득 실패 인스턴스는 스냅샷 생성을 skip한다.
|
|
||||||
- 같은 랭킹 타입과 같은 집계 기간에 대해 스냅샷을 재생성할 수 있어야 하며, 재생성 시 기존 같은 기간 스냅샷을 중복 노출하지 않는다.
|
|
||||||
- 조회 API는 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 응답한다.
|
|
||||||
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 작업 완료 후 성공/실패 상태와 처리 결과를 기록한다.
|
|
||||||
- 스케줄러로 실행되는 주간 스냅샷 생성도 job 이력으로 기록한다.
|
|
||||||
- 스냅샷 job 이력에도 `rankingType`과 `visibleFromAt`을 저장해 관리자 목록, 재시도, DDL 인덱스 기준이 스냅샷 테이블과 일치해야 한다.
|
|
||||||
- 운영자는 관리자 전용 API를 통해 날짜 범위를 직접 선택해 스냅샷 생성 job을 생성할 수 있어야 한다.
|
|
||||||
- 실패한 스냅샷 생성 job은 관리자 전용 재시도 API로 재시도할 수 있어야 하며, 기존 관리자 job 패턴과 같이 실패 상태 job을 대기 상태로 되돌려 worker가 다시 처리하도록 한다.
|
|
||||||
- 관리자 전용 job 목록 API는 날짜 범위, 실행 트리거, 상태, 실패 사유, 재시도 가능 여부를 확인할 수 있어야 한다.
|
|
||||||
- cold-start fallback 성공 후 스냅샷 저장은 조회 서비스가 직접 DB에 쓰지 않고, 스냅샷 refresh 책임을 가진 job/service 경계로 위임한다.
|
|
||||||
- cold-start fallback 스냅샷 저장 트리거는 집계 기간을 포함한 Redisson lock key를 사용해 동일 기간 중복 생성을 방지한다. 예: `lock:creator-ranking-snapshot-refresh:{aggregationStartAtUtc}:{aggregationEndAtUtc}`.
|
|
||||||
- lock을 획득한 요청만 refresh job/service를 실행하고, lock을 획득하지 못한 요청은 이미 다른 실행자가 처리 중인 것으로 보고 fallback 응답만 반환한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 최신 공개 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으며 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다.
|
|
||||||
- fallback 성공 후 스냅샷 저장 트리거는 실패하더라도 조회 응답을 실패시키지 않되, job 상태 또는 구조화 로그로 실패를 추적할 수 있어야 한다.
|
|
||||||
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 공개 스냅샷 기준 응답을 유지한다.
|
|
||||||
- 스냅샷 생성 중 일부 원천 집계가 실패하면 해당 주차 스냅샷 저장을 실패 처리하고 부분 결과를 공개하지 않는다.
|
|
||||||
- 스냅샷 생성은 성공했지만 `visibleFromAt > now`이면 해당 스냅샷은 공개 조회 대상에서 제외한다.
|
|
||||||
- Redisson lock 획득 실패는 다른 인스턴스가 같은 작업을 수행 중인 정상 skip으로 처리하고, 스냅샷 생성 실패로 집계하지 않는다.
|
|
||||||
- 실패 job 재시도 API는 실패 상태 job만 대상으로 하며, 이미 대기/처리 중/성공 상태인 job은 재시도 대상으로 변경하지 않는다.
|
|
||||||
|
|
||||||
### Feature I. 랭킹 계산 컴포넌트 분리
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 랭킹 계산과 조회는 Controller나 Facade 내부에 직접 구현하지 않고 별도 application/domain 컴포넌트로 분리한다.
|
|
||||||
- 크리에이터 랭킹 기능 본체는 추천 기능과 독립된 성격이므로 `v2.recommendation`가 아니라 별도 `kr.co.vividnext.sodalive.v2.ranking` 하위 패키지에 작성한다.
|
|
||||||
- 예시 컴포넌트는 다음 책임을 갖는다.
|
|
||||||
- 기간 계산 정책: KST 기준 지난 주 기간을 산출한다.
|
|
||||||
- 점수 정책: 원천 지표의 raw value에 가중치를 적용해 카테고리/최종 점수를 계산한다.
|
|
||||||
- 집계 포트: `UseCan`, 콘텐츠 반응, `CreatorCheers`, `CreatorFollowing` 원천 데이터를 조회한다.
|
|
||||||
- 스냅샷 생성 서비스: 원천 지표를 집계하고 랭킹 스냅샷을 저장한다.
|
|
||||||
- 조회 서비스: 저장된 스냅샷을 상위 20명 ranking 조회 결과로 조립한다.
|
|
||||||
- 홈 API 조합 Facade: ranking 조회 결과를 클라이언트 공개 응답 DTO로 변환한다.
|
|
||||||
- 추후 캐싱을 추가할 수 있도록 조회 서비스는 스냅샷 조회 포트와 캐시 포트를 분리할 수 있는 경계를 둔다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 캐시가 추가되더라도 산식 테스트는 캐시와 분리된 순수 정책 테스트로 유지한다.
|
|
||||||
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 스냅샷 테이블이 완전히 비어 있는 초기 상태를 제외하고 원천 데이터 실시간 계산 fallback을 두지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
|
|
||||||
- 랭킹 계산, 스냅샷 생성, 스냅샷 조회, 차단 마스킹 등 기능 본체는 `kr.co.vividnext.sodalive.v2.ranking` 하위에 작성한다.
|
|
||||||
- 클라이언트 endpoint는 홈 내부 랭킹 탭에서 호출하므로 `/api/v2/home/rankings/creators`를 사용한다.
|
|
||||||
- 클라이언트 공개 API 표면인 Controller와 API 조합 Facade는 기존 홈 API 관례를 따라 `kr.co.vividnext.sodalive.v2.api.home` 하위에 작성하고, 크리에이터 랭킹 응답 DTO는 `kr.co.vividnext.sodalive.v2.api.home.dto.ranking` 하위에 작성한다.
|
|
||||||
- 기존 엔티티 후보는 `UseCan`, `CanUsage`, `AudioContent`, `AudioContentLike`, `AudioContentComment`, `CreatorCheers`, `CreatorFollowing`, `Member` 등이다.
|
|
||||||
- 기존 공개 API 스키마는 변경하지 않는다.
|
|
||||||
- 계산 기간은 서버 기본 timezone이 아니라 명시적인 KST 기준으로 산출하고, DB 조회 시에는 UTC 기간으로 변환한다.
|
|
||||||
- QueryDSL 또는 native SQL 중 기존 성능/패턴에 맞는 방식을 선택하되, 산식 자체는 테스트 가능한 domain/application 정책으로 분리한다.
|
|
||||||
- 주간 랭킹 조회는 스냅샷 기반으로 제공한다.
|
|
||||||
- 캐싱은 이번 PRD의 필수 구현은 아니지만, 랭킹 조회 서비스가 캐시 포트를 도입할 수 있는 구조여야 한다.
|
|
||||||
- 스냅샷 스케줄러는 기존 Redisson 설정을 재사용해 클러스터 단일 실행을 보장하고, 별도 scheduler lock용 DB 테이블은 추가하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Metrics
|
|
||||||
- 랭킹 조회 API latency
|
|
||||||
- 랭킹 계산 소요 시간
|
|
||||||
- 주간 스냅샷 생성 성공/실패 수
|
|
||||||
- 주간 스냅샷 생성 지연 시간
|
|
||||||
- 스냅샷 job 상태별 수와 실패 job 재시도 수
|
|
||||||
- 관리자 수동 생성 job 요청 수와 성공/실패 수
|
|
||||||
- 스냅샷 테이블 완전 공백 fallback 시도/성공/실패 수
|
|
||||||
- 랭킹 후보 크리에이터 수
|
|
||||||
- 최종 점수 1점 미만으로 제외된 크리에이터 수
|
|
||||||
- 랭킹 조회 성공/실패 로그 수
|
|
||||||
- 캐시 도입 후 cache hit ratio
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions
|
|
||||||
현재 PRD 기준 미결정 항목은 없다.
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
# openRoom 응답 상대방 프로필/닉네임 추가 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** `GET /api/v2/user-creator-chat/rooms/{roomId}/open` 응답에 현재 로그인 회원 기준 상대방 닉네임과 프로필 이미지 URL을 추가한다.
|
|
||||||
|
|
||||||
**Architecture:** 기존 `UserCreatorChatService.openRoom` 흐름을 유지하고, 이미 존재하는 `UserCreatorChatParticipantRepository.findActiveOpponent(roomId, memberId)`로 상대방 참여자를 조회한다. 응답 DTO인 `UserCreatorChatRoomOpenResponse`에 `opponentNickname`, `opponentProfileImageUrl`만 추가하며, 메시지 조회/페이징/참여자 검증 정책은 변경하지 않는다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, JUnit 5, Mockito, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- 대상 PRD: `docs/20260610_openRoom_상대방_프로필_닉네임/prd.md`
|
|
||||||
- 대상 API: `GET /api/v2/user-creator-chat/rooms/{roomId}/open`
|
|
||||||
- 응답 DTO: `UserCreatorChatRoomOpenResponse`
|
|
||||||
- 신규 응답 필드:
|
|
||||||
- `opponentNickname: String`
|
|
||||||
- `opponentProfileImageUrl: String`
|
|
||||||
- 상대방 기준: 현재 로그인 회원을 제외한 활성 참여자
|
|
||||||
- 프로필 이미지 URL 정책: `"$cloudFrontHost/$profilePath"`
|
|
||||||
- 기본 프로필 이미지 경로: `profile/default-profile.png`
|
|
||||||
- 기존 응답 필드(`roomId`, `messages`, `hasMore`, `nextCursor`)는 제거/이름 변경하지 않는다.
|
|
||||||
- `createOrGetRoom`, `getMessages`, 메시지 발송 API 응답은 변경하지 않는다.
|
|
||||||
- DB 스키마 변경은 하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 수정 대상
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
|
||||||
- `UserCreatorChatRoomOpenResponse`에 상대방 표시 필드 2개를 추가한다.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- `openRoom`에서 상대방 참여자를 조회하고 응답 필드를 채운다.
|
|
||||||
- 기존 메시지 DTO와 같은 기본 프로필 이미지 URL 조합 정책을 유지한다.
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- `openRoom` 응답에 상대방 닉네임/프로필 이미지 URL이 포함되는지 검증한다.
|
|
||||||
- 상대방 프로필 이미지가 `null`일 때 기본 이미지 URL을 반환하는지 검증한다.
|
|
||||||
|
|
||||||
### 신규 파일
|
|
||||||
- 없음.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: openRoom 응답 확장 TDD
|
|
||||||
|
|
||||||
- [x] **Task 1.1: openRoom 응답에 상대방 닉네임과 프로필 이미지 URL 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- RED: 기존 `UserCreatorChatServiceTest`에 아래 테스트 2개를 추가한다.
|
|
||||||
- `shouldOpenRoomWithOpponentProfileWhenOpponentHasProfileImage`
|
|
||||||
- `shouldOpenRoomWithDefaultOpponentProfileWhenOpponentProfileImageIsNull`
|
|
||||||
- RED 테스트 코드 예시:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
@DisplayName("방 입장 응답은 상대방 닉네임과 프로필 이미지 URL을 포함한다")
|
|
||||||
fun shouldOpenRoomWithOpponentProfileWhenOpponentHasProfileImage() {
|
|
||||||
val user = member(1L, "user")
|
|
||||||
val creator = member(2L, "creator").apply {
|
|
||||||
profileImage = "profile/creator.png"
|
|
||||||
}
|
|
||||||
val room = room(10L)
|
|
||||||
val userParticipant = participant(100L, room, user)
|
|
||||||
val creatorParticipant = participant(101L, room, creator)
|
|
||||||
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
|
||||||
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant)
|
|
||||||
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(creatorParticipant)
|
|
||||||
Mockito.`when`(
|
|
||||||
messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(
|
|
||||||
room,
|
|
||||||
PageRequest.of(0, 20)
|
|
||||||
)
|
|
||||||
).thenReturn(emptyList())
|
|
||||||
|
|
||||||
val response = service.openRoom(user, roomId = 10L)
|
|
||||||
|
|
||||||
assertEquals(10L, response.roomId)
|
|
||||||
assertEquals("creator", response.opponentNickname)
|
|
||||||
assertEquals("https://cdn.test/profile/creator.png", response.opponentProfileImageUrl)
|
|
||||||
assertEquals(emptyList<UserCreatorChatMessageItemDto>(), response.messages)
|
|
||||||
assertFalse(response.hasMore)
|
|
||||||
assertEquals(null, response.nextCursor)
|
|
||||||
Mockito.verify(participantRepository).findActiveOpponent(10L, 1L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("상대방 프로필 이미지가 없으면 방 입장 응답은 기본 프로필 이미지 URL을 반환한다")
|
|
||||||
fun shouldOpenRoomWithDefaultOpponentProfileWhenOpponentProfileImageIsNull() {
|
|
||||||
val user = member(1L, "user")
|
|
||||||
val creator = member(2L, "creator")
|
|
||||||
val room = room(10L)
|
|
||||||
val userParticipant = participant(100L, room, user)
|
|
||||||
val creatorParticipant = participant(101L, room, creator)
|
|
||||||
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
|
||||||
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant)
|
|
||||||
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(creatorParticipant)
|
|
||||||
Mockito.`when`(
|
|
||||||
messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(
|
|
||||||
room,
|
|
||||||
PageRequest.of(0, 20)
|
|
||||||
)
|
|
||||||
).thenReturn(emptyList())
|
|
||||||
|
|
||||||
val response = service.openRoom(user, roomId = 10L)
|
|
||||||
|
|
||||||
assertEquals("creator", response.opponentNickname)
|
|
||||||
assertEquals("https://cdn.test/profile/default-profile.png", response.opponentProfileImageUrl)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: `opponentNickname` 또는 `opponentProfileImageUrl` 프로퍼티가 없어 컴파일 실패한다.
|
|
||||||
- GREEN: `UserCreatorChatRoomOpenResponse` 생성자에 `opponentNickname`, `opponentProfileImageUrl`을 추가한다.
|
|
||||||
- GREEN 구현 방향:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class UserCreatorChatRoomOpenResponse(
|
|
||||||
val roomId: Long,
|
|
||||||
val opponentNickname: String,
|
|
||||||
val opponentProfileImageUrl: String,
|
|
||||||
val messages: List<UserCreatorChatMessageItemDto>,
|
|
||||||
val hasMore: Boolean,
|
|
||||||
val nextCursor: Long?
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- GREEN: `UserCreatorChatService.openRoom`에서 기존 참여자 검증 후 상대방을 조회해 응답에 채운다.
|
|
||||||
- GREEN 구현 방향:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Transactional
|
|
||||||
fun openRoom(member: Member, roomId: Long, limit: Int = 20): UserCreatorChatRoomOpenResponse {
|
|
||||||
val room = findRoom(roomId)
|
|
||||||
requireParticipant(roomId, member.id!!)
|
|
||||||
val opponentParticipant = participantRepository.findActiveOpponent(roomId, member.id!!)
|
|
||||||
?: throw SodaException(messageKey = "chat.room.invalid_access")
|
|
||||||
val opponent = opponentParticipant.member
|
|
||||||
val opponentProfilePath = opponent.profileImage ?: "profile/default-profile.png"
|
|
||||||
val page = getMessages(member, roomId, cursor = null, limit = limit)
|
|
||||||
return UserCreatorChatRoomOpenResponse(
|
|
||||||
roomId = room.id!!,
|
|
||||||
opponentNickname = opponent.nickname,
|
|
||||||
opponentProfileImageUrl = "$cloudFrontHost/$opponentProfilePath",
|
|
||||||
messages = page.messages,
|
|
||||||
hasMore = page.hasMore,
|
|
||||||
nextCursor = page.nextCursor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 기본 프로필 이미지 문자열 중복이 과하다고 판단되면 `UserCreatorChatService` 안에 private 함수로만 정리한다. 새 공용 유틸이나 별도 추상화는 만들지 않는다.
|
|
||||||
- REFACTOR 후보:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
private fun profileImageUrl(profileImage: String?): String {
|
|
||||||
val profilePath = profileImage ?: "profile/default-profile.png"
|
|
||||||
return "$cloudFrontHost/$profilePath"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 회귀 확인:
|
|
||||||
- Run: `./gradlew test`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- Run: `./gradlew ktlintCheck`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- 기대 결과:
|
|
||||||
- 메시지가 없는 방에서도 `opponentNickname`, `opponentProfileImageUrl`이 반환된다.
|
|
||||||
- 상대방 프로필 이미지가 있으면 CloudFront URL로 반환된다.
|
|
||||||
- 상대방 프로필 이미지가 없으면 `https://cdn.test/profile/default-profile.png` 형식으로 반환된다.
|
|
||||||
- 기존 메시지 조회, 페이징, 참여자 검증 동작은 유지된다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 구현 후 검증 기록
|
|
||||||
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- 목적: RED 확인. `openRoom` 상대방 닉네임/프로필 이미지 URL 테스트를 먼저 추가한 뒤 실행.
|
|
||||||
- 결과: `opponentNickname`, `opponentProfileImageUrl` 미정의 컴파일 오류로 실패.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- 목적: GREEN 확인. DTO/서비스 최소 구현 후 `UserCreatorChatServiceTest` 회귀 검증.
|
|
||||||
- 결과: `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- 목적: Kotlin lint 회귀 검증.
|
|
||||||
- 결과: `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew test`
|
|
||||||
- 목적: 전체 테스트 회귀 검증.
|
|
||||||
- 결과: `BUILD SUCCESSFUL`.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# PRD: openRoom 응답 상대방 프로필/닉네임 추가
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
`GET /api/v2/user-creator-chat/rooms/{roomId}/open` 응답에 채팅 상대방의 닉네임과 프로필 이미지 URL을 포함해, 클라이언트가 방 입장 직후 별도 조회 없이 상단 프로필 정보를 표시할 수 있게 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 현재 `openRoom` API는 `roomId`, 최신 메시지 목록, 페이징 정보만 반환한다.
|
|
||||||
- 클라이언트는 채팅방 화면에 필요한 상대방 표시 정보를 `openRoom` 응답에서 바로 얻을 수 없다.
|
|
||||||
- 메시지 목록의 `senderNickname`, `senderProfileImageUrl`은 각 메시지 발신자 정보라서, 메시지가 없거나 마지막 메시지가 본인 발신인 경우 채팅방 상대방 표시 정보로 안정적으로 쓰기 어렵다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- `openRoom` 응답에 현재 로그인 회원 기준 상대방 닉네임을 추가한다.
|
|
||||||
- `openRoom` 응답에 현재 로그인 회원 기준 상대방 프로필 이미지 URL을 추가한다.
|
|
||||||
- 기존 `messages`, `hasMore`, `nextCursor` 동작은 변경하지 않는다.
|
|
||||||
- 인증/참여자 검증, 방 조회 실패, 페이징 정책은 기존 `openRoom` 동작을 유지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- `createOrGetRoom`, `getMessages`, 메시지 발송 API 응답은 이번 범위에서 변경하지 않는다.
|
|
||||||
- 채팅방 리스트 API 응답 구조는 이번 범위에서 변경하지 않는다.
|
|
||||||
- DB 스키마 변경은 이번 범위에 포함하지 않는다.
|
|
||||||
- 상대방 프로필 스냅샷 저장, 닉네임 변경 이력 표시, 탈퇴 회원 표시 정책 변경은 이번 범위에 포함하지 않는다.
|
|
||||||
- 차단/비활성 회원 정책은 기존 유저-크리에이터 채팅 정책을 변경하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 유저: 크리에이터와의 채팅방에 입장해 상대방 프로필과 닉네임을 즉시 확인하려는 회원
|
|
||||||
- 크리에이터: 유저와의 채팅방에 입장해 상대방 프로필과 닉네임을 즉시 확인하려는 회원
|
|
||||||
- 모바일 클라이언트: 방 입장 응답만으로 채팅방 헤더를 렌더링하려는 클라이언트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 채팅방에 입장하자마자 상단에서 상대방 닉네임을 보고 싶다.
|
|
||||||
- 사용자는 채팅방에 메시지가 없어도 상대방 프로필 이미지를 보고 싶다.
|
|
||||||
- 클라이언트는 방 입장 후 상대방 정보를 얻기 위해 추가 API를 호출하지 않고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### openRoom 응답 확장
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 대상 API는 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`이다.
|
|
||||||
- 응답 DTO는 `UserCreatorChatRoomOpenResponse`를 확장한다.
|
|
||||||
- 응답에는 기존 필드에 더해 다음 필드를 포함한다.
|
|
||||||
- `opponentNickname`: 현재 로그인 회원을 제외한 활성 참여 회원의 `Member.nickname`
|
|
||||||
- `opponentProfileImageUrl`: 현재 로그인 회원을 제외한 활성 참여 회원의 프로필 이미지 URL
|
|
||||||
- `opponentProfileImageUrl`은 기존 메시지 DTO의 `senderProfileImageUrl`과 같은 CloudFront URL 조합 정책을 따른다.
|
|
||||||
- 상대방 `Member.profileImage`가 `null`이면 기존 메시지 DTO와 동일하게 `profile/default-profile.png`를 기본 이미지 경로로 사용한다.
|
|
||||||
- 상대방 산출은 기존 `UserCreatorChatParticipantRepository.findActiveOpponent(roomId, memberId)` 또는 같은 의미의 기존 조회 패턴을 재사용한다.
|
|
||||||
- 현재 회원이 방 참여자가 아니면 기존처럼 `chat.room.invalid_access` 예외를 유지한다.
|
|
||||||
- 활성 방이 아니면 기존처럼 `chat.error.room_not_found` 예외를 유지한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 메시지가 0개인 방도 `opponentNickname`, `opponentProfileImageUrl`을 반환해야 한다.
|
|
||||||
- 최신 메시지 발신자가 본인이어도 상대방 필드는 현재 로그인 회원을 제외한 참여자를 기준으로 반환해야 한다.
|
|
||||||
- 프로필 이미지가 없는 상대방은 기본 이미지 URL을 반환해야 한다.
|
|
||||||
- 잘못된 `limit` 값 보정 정책은 기존 `getMessages`의 `limit.coerceIn(1, 100)` 동작을 유지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- Spring Boot 2.7.14, Kotlin, Java 17, Gradle Wrapper 구조를 유지한다.
|
|
||||||
- 공개 API 응답 필드 추가이므로 기존 필드는 제거하거나 이름을 변경하지 않는다.
|
|
||||||
- 구현 변경 예상 파일은 다음 범위로 제한한다.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- 관련 테스트 파일
|
|
||||||
- `cloud.aws.cloud-front.host`를 사용하는 기존 URL 생성 방식을 유지한다.
|
|
||||||
- 새 추상화는 만들지 않고, 기존 `toMessageItemDto`의 기본 프로필 이미지 정책과 일치시키는 최소 변경을 우선한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Metrics
|
|
||||||
- 클라이언트의 채팅방 입장 후 상대방 프로필 조회용 추가 API 호출 제거 여부
|
|
||||||
- `openRoom` API 성공 응답에서 `opponentNickname`, `opponentProfileImageUrl` 누락 사례 0건
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions
|
|
||||||
- 없음. 필드명은 구현 계획에서 `opponentNickname`, `opponentProfileImageUrl` 기준으로 확정한다.
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
-- AI 캐릭터 크리에이터 기능 최소 연결 운영 DB 반영 SQL
|
|
||||||
-- MySQL 기준. 운영 반영 전 백업과 트랜잭션/락 영향을 점검한다.
|
|
||||||
|
|
||||||
-- 1. member.member_kind 추가
|
|
||||||
SET @member_kind_column_exists := (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = 'member'
|
|
||||||
AND column_name = 'member_kind'
|
|
||||||
);
|
|
||||||
|
|
||||||
SET @add_member_kind_sql := IF(
|
|
||||||
@member_kind_column_exists = 0,
|
|
||||||
'ALTER TABLE member ADD COLUMN member_kind VARCHAR(30) NOT NULL DEFAULT ''HUMAN'' COMMENT ''Member 주체 종류: HUMAN, AI_CHARACTER'' AFTER role',
|
|
||||||
'SELECT ''member.member_kind already exists'' AS message'
|
|
||||||
);
|
|
||||||
|
|
||||||
PREPARE add_member_kind_stmt FROM @add_member_kind_sql;
|
|
||||||
EXECUTE add_member_kind_stmt;
|
|
||||||
DEALLOCATE PREPARE add_member_kind_stmt;
|
|
||||||
|
|
||||||
-- 1번 결과 확인: varchar(30), NOT NULL, DEFAULT 'HUMAN'
|
|
||||||
SELECT column_name, column_type, is_nullable, column_default, column_comment
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = 'member'
|
|
||||||
AND column_name = 'member_kind';
|
|
||||||
|
|
||||||
-- 2. chat_character.creator_member_id nullable 컬럼 추가
|
|
||||||
SET @creator_member_column_exists := (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = 'chat_character'
|
|
||||||
AND column_name = 'creator_member_id'
|
|
||||||
);
|
|
||||||
|
|
||||||
SET @add_creator_member_sql := IF(
|
|
||||||
@creator_member_column_exists = 0,
|
|
||||||
'ALTER TABLE chat_character ADD COLUMN creator_member_id BIGINT NULL COMMENT ''크리에이터 기능 주체 Member ID'' AFTER character_type',
|
|
||||||
'SELECT ''chat_character.creator_member_id already exists'' AS message'
|
|
||||||
);
|
|
||||||
|
|
||||||
PREPARE add_creator_member_stmt FROM @add_creator_member_sql;
|
|
||||||
EXECUTE add_creator_member_stmt;
|
|
||||||
DEALLOCATE PREPARE add_creator_member_stmt;
|
|
||||||
|
|
||||||
-- 2번 결과 확인: bigint, NULL 허용
|
|
||||||
SELECT column_name, column_type, is_nullable, column_comment
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = 'chat_character'
|
|
||||||
AND column_name = 'creator_member_id';
|
|
||||||
|
|
||||||
-- 3. 기존 chat_character별 AI 캐릭터용 Member 생성 및 매핑
|
|
||||||
DROP TEMPORARY TABLE IF EXISTS tmp_chat_character_creator_member;
|
|
||||||
CREATE TEMPORARY TABLE tmp_chat_character_creator_member (
|
|
||||||
chat_character_id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
migration_email VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
creator_member_id BIGINT NULL
|
|
||||||
) COMMENT 'chat_character와 backfill member.id 임시 매핑';
|
|
||||||
|
|
||||||
INSERT INTO tmp_chat_character_creator_member (chat_character_id, migration_email)
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
CONCAT('__ai_character_creator_', c.id, '@migration.local')
|
|
||||||
FROM chat_character c
|
|
||||||
WHERE c.creator_member_id IS NULL;
|
|
||||||
|
|
||||||
-- member.email은 nullable이므로 backfill 중에만 임시 식별자로 사용하고, 매핑 후 NULL로 되돌린다.
|
|
||||||
INSERT INTO member (
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
nickname,
|
|
||||||
profile_image,
|
|
||||||
provider,
|
|
||||||
gender,
|
|
||||||
role,
|
|
||||||
member_kind,
|
|
||||||
is_visible_donation_rank,
|
|
||||||
donation_ranking_period,
|
|
||||||
is_active,
|
|
||||||
container,
|
|
||||||
introduce,
|
|
||||||
instagram_url,
|
|
||||||
fancimm_url,
|
|
||||||
x_url,
|
|
||||||
youtube_url,
|
|
||||||
website_url,
|
|
||||||
blog_url,
|
|
||||||
pg_charge_can,
|
|
||||||
pg_reward_can,
|
|
||||||
google_charge_can,
|
|
||||||
google_reward_can,
|
|
||||||
apple_charge_can,
|
|
||||||
apple_reward_can,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
m.migration_email,
|
|
||||||
'',
|
|
||||||
c.name,
|
|
||||||
c.image_path,
|
|
||||||
'EMAIL',
|
|
||||||
'NONE',
|
|
||||||
'CREATOR',
|
|
||||||
'AI_CHARACTER',
|
|
||||||
TRUE,
|
|
||||||
'CUMULATIVE',
|
|
||||||
c.is_active,
|
|
||||||
'web',
|
|
||||||
COALESCE(c.description, ''),
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM tmp_chat_character_creator_member m
|
|
||||||
INNER JOIN chat_character c
|
|
||||||
ON c.id = m.chat_character_id
|
|
||||||
LEFT JOIN member existing_member
|
|
||||||
ON existing_member.email = m.migration_email
|
|
||||||
WHERE existing_member.id IS NULL;
|
|
||||||
|
|
||||||
UPDATE tmp_chat_character_creator_member m
|
|
||||||
INNER JOIN member mb
|
|
||||||
ON mb.email = m.migration_email
|
|
||||||
SET m.creator_member_id = mb.id
|
|
||||||
WHERE m.creator_member_id IS NULL
|
|
||||||
AND m.chat_character_id IS NOT NULL;
|
|
||||||
|
|
||||||
UPDATE chat_character c
|
|
||||||
INNER JOIN tmp_chat_character_creator_member m
|
|
||||||
ON m.chat_character_id = c.id
|
|
||||||
SET c.creator_member_id = m.creator_member_id
|
|
||||||
WHERE c.creator_member_id IS NULL
|
|
||||||
AND m.creator_member_id IS NOT NULL;
|
|
||||||
|
|
||||||
UPDATE member mb
|
|
||||||
INNER JOIN tmp_chat_character_creator_member m
|
|
||||||
ON m.creator_member_id = mb.id
|
|
||||||
SET mb.email = NULL
|
|
||||||
WHERE mb.email = m.migration_email;
|
|
||||||
|
|
||||||
-- 4. unique index 추가
|
|
||||||
SET @creator_member_unique_exists := (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM information_schema.statistics
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = 'chat_character'
|
|
||||||
AND index_name = 'uk_chat_character_creator_member'
|
|
||||||
);
|
|
||||||
|
|
||||||
SET @add_creator_member_unique_sql := IF(
|
|
||||||
@creator_member_unique_exists = 0,
|
|
||||||
'ALTER TABLE chat_character ADD UNIQUE INDEX uk_chat_character_creator_member (creator_member_id)',
|
|
||||||
'SELECT ''uk_chat_character_creator_member already exists'' AS message'
|
|
||||||
);
|
|
||||||
|
|
||||||
PREPARE add_creator_member_unique_stmt FROM @add_creator_member_unique_sql;
|
|
||||||
EXECUTE add_creator_member_unique_stmt;
|
|
||||||
DEALLOCATE PREPARE add_creator_member_unique_stmt;
|
|
||||||
|
|
||||||
-- 5. foreign key 추가
|
|
||||||
SET @creator_member_fk_exists := (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM information_schema.table_constraints
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = 'chat_character'
|
|
||||||
AND constraint_name = 'fk_chat_character_creator_member'
|
|
||||||
AND constraint_type = 'FOREIGN KEY'
|
|
||||||
);
|
|
||||||
|
|
||||||
SET @add_creator_member_fk_sql := IF(
|
|
||||||
@creator_member_fk_exists = 0,
|
|
||||||
'ALTER TABLE chat_character ADD CONSTRAINT fk_chat_character_creator_member FOREIGN KEY (creator_member_id) REFERENCES member (id)',
|
|
||||||
'SELECT ''fk_chat_character_creator_member already exists'' AS message'
|
|
||||||
);
|
|
||||||
|
|
||||||
PREPARE add_creator_member_fk_stmt FROM @add_creator_member_fk_sql;
|
|
||||||
EXECUTE add_creator_member_fk_stmt;
|
|
||||||
DEALLOCATE PREPARE add_creator_member_fk_stmt;
|
|
||||||
|
|
||||||
-- 6. 운영 반영 전 필수 검증. 두 결과 모두 0이어야 한다.
|
|
||||||
SELECT COUNT(*) AS invalid_ai_character_member_count
|
|
||||||
FROM member
|
|
||||||
WHERE member_kind = 'AI_CHARACTER'
|
|
||||||
AND role <> 'CREATOR';
|
|
||||||
|
|
||||||
SELECT COUNT(*) AS missing_creator_member_count
|
|
||||||
FROM chat_character
|
|
||||||
WHERE creator_member_id IS NULL;
|
|
||||||
|
|
||||||
SELECT COUNT(*) AS remaining_migration_email_count
|
|
||||||
FROM member
|
|
||||||
WHERE email LIKE '__ai_character_creator_%@migration.local';
|
|
||||||
|
|
||||||
-- 7. 검증 완료 후 creator_member_id NOT NULL 전환
|
|
||||||
SET @missing_creator_member_count := (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM chat_character
|
|
||||||
WHERE creator_member_id IS NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
SET @creator_member_nullable := (
|
|
||||||
SELECT is_nullable
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = 'chat_character'
|
|
||||||
AND column_name = 'creator_member_id'
|
|
||||||
);
|
|
||||||
|
|
||||||
SET @modify_creator_member_not_null_sql := IF(
|
|
||||||
@missing_creator_member_count = 0 AND @creator_member_nullable = 'YES',
|
|
||||||
'ALTER TABLE chat_character MODIFY COLUMN creator_member_id BIGINT NOT NULL COMMENT ''크리에이터 기능 주체 Member ID''',
|
|
||||||
'SELECT ''chat_character.creator_member_id not modified; verify missing_creator_member_count is 0 and column is nullable'' AS message'
|
|
||||||
);
|
|
||||||
|
|
||||||
PREPARE modify_creator_member_not_null_stmt FROM @modify_creator_member_not_null_sql;
|
|
||||||
EXECUTE modify_creator_member_not_null_stmt;
|
|
||||||
DEALLOCATE PREPARE modify_creator_member_not_null_stmt;
|
|
||||||
|
|
||||||
DROP TEMPORARY TABLE IF EXISTS tmp_chat_character_creator_member;
|
|
||||||
|
|
||||||
-- Rollback 참고용. 운영 반영 후 문제가 있으면 백업 복구를 우선 검토한다.
|
|
||||||
-- 아래 SQL은 이 마이그레이션으로 연결된 AI_CHARACTER Member와 제약/컬럼을 되돌리는 전체 롤백 예시다.
|
|
||||||
-- 신규 기능을 이미 운영에서 사용한 뒤에는 후속 데이터 의존성이 생길 수 있으므로 실행 전 영향 범위를 재확인한다.
|
|
||||||
-- 1) FK 제거
|
|
||||||
-- ALTER TABLE chat_character DROP FOREIGN KEY fk_chat_character_creator_member;
|
|
||||||
-- 2) unique index 제거
|
|
||||||
-- ALTER TABLE chat_character DROP INDEX uk_chat_character_creator_member;
|
|
||||||
-- 3) creator_member_id를 NULL 허용으로 복구
|
|
||||||
-- ALTER TABLE chat_character MODIFY COLUMN creator_member_id BIGINT NULL COMMENT '크리에이터 기능 주체 Member ID';
|
|
||||||
-- 4) backfill로 연결된 AI 캐릭터용 Member 삭제 준비
|
|
||||||
-- DROP TEMPORARY TABLE IF EXISTS tmp_rollback_ai_character_member;
|
|
||||||
-- CREATE TEMPORARY TABLE tmp_rollback_ai_character_member (
|
|
||||||
-- member_id BIGINT NOT NULL PRIMARY KEY
|
|
||||||
-- ) COMMENT 'AI 캐릭터 크리에이터 backfill 롤백 대상 Member';
|
|
||||||
-- INSERT INTO tmp_rollback_ai_character_member (member_id)
|
|
||||||
-- SELECT DISTINCT mb.id
|
|
||||||
-- FROM member mb
|
|
||||||
-- INNER JOIN chat_character c
|
|
||||||
-- ON c.creator_member_id = mb.id
|
|
||||||
-- WHERE mb.member_kind = 'AI_CHARACTER'
|
|
||||||
-- AND mb.role = 'CREATOR';
|
|
||||||
-- 5) chat_character 연결 해제 후 Member 삭제
|
|
||||||
-- UPDATE chat_character c
|
|
||||||
-- INNER JOIN tmp_rollback_ai_character_member r
|
|
||||||
-- ON r.member_id = c.creator_member_id
|
|
||||||
-- SET c.creator_member_id = NULL;
|
|
||||||
-- DELETE mb
|
|
||||||
-- FROM member mb
|
|
||||||
-- INNER JOIN tmp_rollback_ai_character_member r
|
|
||||||
-- ON r.member_id = mb.id;
|
|
||||||
-- DROP TEMPORARY TABLE IF EXISTS tmp_rollback_ai_character_member;
|
|
||||||
-- 6) 컬럼 제거가 필요한 전체 스키마 롤백인 경우에만 실행
|
|
||||||
-- ALTER TABLE chat_character DROP COLUMN creator_member_id;
|
|
||||||
-- ALTER TABLE member DROP COLUMN member_kind;
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
# AI 캐릭터 크리에이터 기능 최소 연결 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 모든 `ChatCharacter`를 `Member(role = CREATOR, memberKind = AI_CHARACTER)`와 1:1로 연결해 로그인/DM을 제외한 기존 크리에이터 기능을 최소 변경으로 재사용한다.
|
|
||||||
|
|
||||||
**Architecture:** 기존 크리에이터 기능의 소유자는 계속 `Member`로 유지한다. `ChatCharacter`는 `creatorMember`를 단방향 `OneToOne`으로 가지며, AI 캐릭터용 Member의 표시 정보는 `ChatCharacter` 값에서 스냅샷으로 동기화한다. 사람/AI 주체 구분은 `Member.memberKind`로 처리하고, 로그인/DM만 명시적으로 차단한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Security, JPA/Hibernate, QueryDSL, MySQL, Gradle Wrapper, JUnit5, Mockito.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
- 포함: `MemberKind` 추가, `ChatCharacter.creatorMember` 1:1 관계 추가, MySQL DDL/backfill SQL, AI 캐릭터용 Member 생성/표시 정보 동기화, 로그인 차단, DM 차단.
|
|
||||||
- 제외: `creator_identity` 도입, `ChatCharacter` 독립 소유자화, 검색 카테고리 개편, `Member:ChatCharacter = 1:N`, AI 캐릭터용 콘텐츠/라이브/커뮤니티 대리 생성 API 설계.
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt`
|
|
||||||
- `MemberKind` enum과 `memberKind` 필드 추가.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt`
|
|
||||||
- `creatorMember: Member` 단방향 `OneToOne` 추가.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`
|
|
||||||
- `creatorMember` 조회/검증 메서드 추가.
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt`
|
|
||||||
- AI 캐릭터용 Member 생성 및 표시 정보 동기화 책임.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt`
|
|
||||||
- 캐릭터 생성/수정 시 `ChatCharacterCreatorMemberService` 호출.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
|
||||||
- 일반 로그인에서 `memberKind = AI_CHARACTER` 차단.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt`
|
|
||||||
- 크리에이터 관리자 로그인에서 `memberKind = AI_CHARACTER` 차단.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- DM 생성/메시지 발송 대상이 `memberKind = AI_CHARACTER`이면 차단.
|
|
||||||
- Create: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql`
|
|
||||||
- 운영 DB 반영용 MySQL DDL/backfill/검증 SQL.
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: MemberKind 및 DB 마이그레이션 기반 추가
|
|
||||||
|
|
||||||
- [x] **Task 1.1: `MemberKind` 필드 추가**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt`
|
|
||||||
- Test: 기존 컴파일 회귀
|
|
||||||
- RED: `Member.memberKind`를 참조하는 최소 컴파일 테스트 또는 이후 task 테스트를 먼저 작성하면 현재 컴파일이 실패해야 한다.
|
|
||||||
- GREEN: `Member` 생성자에 기본값 `MemberKind.HUMAN`을 가진 non-null 필드를 추가한다.
|
|
||||||
- 구현 기준:
|
|
||||||
```kotlin
|
|
||||||
@Enumerated(value = EnumType.STRING)
|
|
||||||
var memberKind: MemberKind = MemberKind.HUMAN
|
|
||||||
```
|
|
||||||
```kotlin
|
|
||||||
enum class MemberKind {
|
|
||||||
HUMAN, AI_CHARACTER
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- REFACTOR: `MemberRole`과 `MemberKind` 의미가 섞이지 않도록 주석은 최소화하고, 정책 판단은 각 서비스 task에서 명시한다.
|
|
||||||
- Verify:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: 기존 테스트 컴파일 및 통과.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: 운영 DB DDL/backfill SQL 작성**
|
|
||||||
- Create: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql`
|
|
||||||
- TDD 예외 사유: 운영 DDL 문서 작성은 단위 테스트 대상이 아니다.
|
|
||||||
- 대체 검증 방법: SQL 문법과 PRD 요구사항을 수동 점검하고 검증 쿼리를 포함한다.
|
|
||||||
- SQL 작성 기준:
|
|
||||||
- `member.member_kind varchar(30) not null default 'HUMAN' comment 'Member 주체 종류: HUMAN, AI_CHARACTER'`
|
|
||||||
- `chat_character.creator_member_id bigint null comment '크리에이터 기능 주체 Member ID'`
|
|
||||||
- `uk_chat_character_creator_member` unique index
|
|
||||||
- `fk_chat_character_creator_member` foreign key
|
|
||||||
- 기존 모든 `chat_character`별 AI 캐릭터용 Member 생성
|
|
||||||
- 생성된 Member를 `chat_character.creator_member_id`에 연결
|
|
||||||
- 검증 후 `chat_character.creator_member_id not null` 전환
|
|
||||||
- SQL backfill은 최종적으로 `email = null`, `password = ''`, `role = 'CREATOR'`, `member_kind = 'AI_CHARACTER'` 상태가 되도록 생성한다.
|
|
||||||
- `member.email`은 nullable이므로 저장 프로시저 대신 backfill 중 임시 식별자로 사용할 수 있다. 단, `chat_character.creator_member_id` 매핑 후 임시 email 값은 반드시 `NULL`로 되돌린다.
|
|
||||||
- 운영 반영 후 문제에 대비해 FK/index/연결 데이터/컬럼 제거 순서의 롤백 방법을 SQL 문서에 함께 기록한다.
|
|
||||||
- Verify:
|
|
||||||
- SQL 내 검증 쿼리 포함:
|
|
||||||
```sql
|
|
||||||
select count(*) as invalid_ai_character_member_count
|
|
||||||
from member
|
|
||||||
where member_kind = 'AI_CHARACTER' and role <> 'CREATOR';
|
|
||||||
|
|
||||||
select count(*) as missing_creator_member_count
|
|
||||||
from chat_character
|
|
||||||
where creator_member_id is null;
|
|
||||||
```
|
|
||||||
- Expected: 운영 반영 전 두 검증 쿼리 결과가 모두 0이어야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: ChatCharacter와 AI 캐릭터용 Member 연결
|
|
||||||
|
|
||||||
- [x] **Task 2.1: `ChatCharacter.creatorMember` 관계 추가**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`
|
|
||||||
- RED: 테스트에서 `ChatCharacter.creatorMember`에 접근하거나 `findByCreatorMemberId`를 호출하게 작성해 컴파일 실패를 확인한다.
|
|
||||||
- GREEN: `ChatCharacter`에 단방향 `OneToOne`을 추가한다.
|
|
||||||
```kotlin
|
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "creator_member_id", nullable = false, unique = true)
|
|
||||||
var creatorMember: Member? = null
|
|
||||||
```
|
|
||||||
- Repository 메서드 기준:
|
|
||||||
```kotlin
|
|
||||||
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
|
|
||||||
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
|
|
||||||
```
|
|
||||||
- REFACTOR: `ChatCharacter`에서 `Member` import만 추가하고, 기존 캐릭터 필드/관계는 변경하지 않는다.
|
|
||||||
- Verify:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
|
||||||
- Expected: 관계 접근 컴파일 및 테스트 통과.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: AI 캐릭터용 Member 생성/표시 정보 동기화 서비스 추가**
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`
|
|
||||||
- RED: 아래 테스트를 먼저 작성한다.
|
|
||||||
- `shouldCreateAiCharacterMemberAndCopyDisplayFields`
|
|
||||||
- `shouldSyncAiCharacterMemberDisplayFields`
|
|
||||||
- `shouldNotOverwriteHumanCreatorDisplayFields`
|
|
||||||
- 테스트 기대:
|
|
||||||
- 생성 시 `role = CREATOR`, `memberKind = AI_CHARACTER`, `email = null`, `password = ""`
|
|
||||||
- `Member.nickname = ChatCharacter.name`
|
|
||||||
- `Member.profileImage = ChatCharacter.imagePath`
|
|
||||||
- `Member.introduce = ChatCharacter.description`
|
|
||||||
- 연결된 `creatorMember.memberKind = HUMAN`이면 표시 정보 덮어쓰기 없음.
|
|
||||||
- GREEN: 서비스 API를 아래 기준으로 구현한다.
|
|
||||||
```kotlin
|
|
||||||
fun ensureAiCharacterCreatorMember(chatCharacter: ChatCharacter): Member
|
|
||||||
fun syncAiCharacterCreatorMemberDisplayFields(chatCharacter: ChatCharacter)
|
|
||||||
```
|
|
||||||
- 구현 정책:
|
|
||||||
- `chatCharacter.creatorMember == null`이면 AI 캐릭터용 Member를 생성하고 연결한다.
|
|
||||||
- `chatCharacter.creatorMember.memberKind == AI_CHARACTER`이면 표시 정보를 동기화한다.
|
|
||||||
- `chatCharacter.creatorMember.memberKind == HUMAN`이면 사람 크리에이터 프로필을 덮어쓰지 않는다.
|
|
||||||
- REFACTOR: 동기화 로직은 `ChatCharacterService`에 직접 흩뿌리지 않고 이 서비스로 모은다.
|
|
||||||
- Verify:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
|
||||||
- Expected: PASS.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: 캐릭터 생성/수정 흐름에 AI 캐릭터용 Member 연결**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`
|
|
||||||
- RED: 캐릭터 생성 후 `creatorMember`가 생성되고, 이미지 저장 후 `Member.profileImage`가 갱신되는 테스트를 작성한다.
|
|
||||||
- GREEN:
|
|
||||||
- `createChatCharacter` 또는 `createChatCharacterWithDetails` 저장 후 `ensureAiCharacterCreatorMember`를 호출한다.
|
|
||||||
- 관리자 등록 컨트롤러에서 이미지 저장 후 `chatCharacter.imagePath`를 설정하고 저장한 뒤 `syncAiCharacterCreatorMemberDisplayFields`를 호출한다.
|
|
||||||
- `updateChatCharacterWithDetails`에서 이름/설명/이미지 변경 후 `syncAiCharacterCreatorMemberDisplayFields`를 호출한다.
|
|
||||||
- REFACTOR: 외부 API 호출, S3 업로드, 원작 연결, 언어 감지 이벤트 흐름은 기존 순서를 유지한다.
|
|
||||||
- Verify:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
|
||||||
- Expected: PASS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: 로그인 및 DM 차단
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 일반 로그인에서 AI 캐릭터용 Member 차단**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
|
||||||
- RED: `memberKind = AI_CHARACTER`인 Member가 일반 로그인 요청 시 인증 매니저 호출 전에 예외가 발생하는 테스트를 작성한다.
|
|
||||||
- GREEN: `MemberService.login(...)`의 Member 조회/활성 검증 직후 아래 정책을 추가한다.
|
|
||||||
```kotlin
|
|
||||||
if (member.memberKind == MemberKind.AI_CHARACTER) {
|
|
||||||
throw SodaException(messageKey = "common.error.bad_credentials")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- REFACTOR: 기존 `provider`, `isCreator`, `isAdmin` 검증 순서는 불필요하게 바꾸지 않는다.
|
|
||||||
- Verify:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.member.MemberServiceTest`
|
|
||||||
- Expected: AI 캐릭터 로그인 차단 테스트 PASS.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 크리에이터 관리자 로그인에서 AI 캐릭터용 Member 차단**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberServiceTest.kt`
|
|
||||||
- RED: `memberKind = AI_CHARACTER`, `role = CREATOR`인 Member가 크리에이터 관리자 로그인 요청 시 `common.error.bad_credentials` 예외가 발생하는 테스트를 작성한다.
|
|
||||||
- GREEN: `CreatorAdminMemberService.login(email, password)`에서 role 검증 전 또는 직후 AI 캐릭터용 Member를 차단한다.
|
|
||||||
```kotlin
|
|
||||||
if (member.memberKind == MemberKind.AI_CHARACTER) {
|
|
||||||
throw SodaException(messageKey = "common.error.bad_credentials")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- REFACTOR: `AGENT` 로그인 허용 정책은 변경하지 않는다.
|
|
||||||
- Verify:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest`
|
|
||||||
- Expected: PASS.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: 유저-크리에이터 DM에서 AI 캐릭터용 Member 차단**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`
|
|
||||||
- RED: 아래 테스트를 추가한다.
|
|
||||||
- `shouldRejectCreateRoomWhenCreatorIsAiCharacterMember`
|
|
||||||
- `shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember`
|
|
||||||
- `memberRepository.findById(creatorId)`는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 Member를 반환한다.
|
|
||||||
- `service.createOrGetRoom(user, creatorId)`는 예외를 던진다.
|
|
||||||
- `roomRepository.save`와 `participantRepository.save`는 호출되지 않는다.
|
|
||||||
- 기존 방에 AI 캐릭터용 Member가 참여한 상태에서 `sendTextMessage`는 예외를 던진다.
|
|
||||||
- `messageRepository.save`와 푸시 발송 경로는 호출되지 않는다.
|
|
||||||
- GREEN: `validateRecipient` 또는 `createOrGetRoom`에서 recipient가 AI 캐릭터용 Member이면 차단한다.
|
|
||||||
```kotlin
|
|
||||||
if (recipient.memberKind == MemberKind.AI_CHARACTER) {
|
|
||||||
throw SodaException(messageKey = "message.error.recipient_not_found")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- REFACTOR: 기존 비활성/본인/차단 검증 메시지와 우선순위를 불필요하게 바꾸지 않는다.
|
|
||||||
- Verify:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: 기존 DM 테스트와 신규 생성/발송 차단 테스트 PASS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: 회귀 검증 및 문서 정리
|
|
||||||
|
|
||||||
- [x] **Task 4.1: 핵심 단위 테스트 실행**
|
|
||||||
- Files: 변경 없음
|
|
||||||
- TDD 예외 사유: 구현 완료 후 회귀 검증 task다.
|
|
||||||
- 대체 검증 방법: 관련 단일 테스트를 모두 실행한다.
|
|
||||||
- Run:
|
|
||||||
```bash
|
|
||||||
./gradlew test \
|
|
||||||
--tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceIntegrationTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest \
|
|
||||||
--tests kr.co.vividnext.sodalive.member.MemberServiceTest
|
|
||||||
```
|
|
||||||
- Expected: PASS.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 정적 검증 및 전체 회귀**
|
|
||||||
- Files: 변경 없음
|
|
||||||
- TDD 예외 사유: 전체 회귀 검증 task다.
|
|
||||||
- 대체 검증 방법: Gradle 테스트와 ktlint를 실행한다.
|
|
||||||
- Run:
|
|
||||||
```bash
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
./gradlew test
|
|
||||||
```
|
|
||||||
- Expected: 두 명령 모두 PASS.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: 검증 기록 누적**
|
|
||||||
- Modify: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/plan-task.md`
|
|
||||||
- TDD 예외 사유: 문서 기록 task다.
|
|
||||||
- 대체 검증 방법: 실행한 명령, 목적, 결과를 아래 검증 기록 섹션에 누적한다.
|
|
||||||
- 기록 형식:
|
|
||||||
```markdown
|
|
||||||
- `./gradlew test --tests ...`
|
|
||||||
- 목적: [무엇을 검증했는지]
|
|
||||||
- 결과: PASS 또는 실패 내용
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Log
|
|
||||||
- `./gradlew tasks --all`
|
|
||||||
- 목적: 문서 변경 후 Gradle 명령 유효성 확인.
|
|
||||||
- 결과: 최초 실행은 sandbox가 `/Users/.../gradle-8.1.1-bin.zip.lck` 파일에 접근하지 못해 실패. 권한 승인 후 재실행하여 `BUILD SUCCESSFUL in 13s`.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- 목적: `Member.memberKind` 추가 후 Phase 1 계획에 명시된 기존 DM 테스트 컴파일 및 회귀 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 2m 3s`.
|
|
||||||
- `./gradlew tasks --all`
|
|
||||||
- 목적: Phase 1 문서 및 운영 DB 반영용 SQL 추가 후 Gradle 명령 유효성 재확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 8s`.
|
|
||||||
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
|
||||||
- 목적: Phase 2 RED 테스트가 신규 서비스/관계/repository/wiring 부재로 실패하는지 확인.
|
|
||||||
- 결과: `compileTestKotlin`에서 `ChatCharacterCreatorMemberService`, `creatorMember`, `findByCreatorMemberId`, `existsByCreatorMemberId`, `creatorMemberService` 생성자 파라미터 unresolved reference로 실패.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
|
||||||
- 목적: `ChatCharacter.creatorMember` 관계, repository 메서드, AI 캐릭터용 Member 생성/동기화, 캐릭터 생성/수정 wiring 검증.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 11s`.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest`
|
|
||||||
- 목적: 관리자 캐릭터 컨트롤러 생성자 변경 후 기존 성별 매핑 회귀 테스트 컴파일 및 통과 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 3s`.
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- 목적: Phase 2 Kotlin production/test 변경의 ktlint 규칙 준수 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 14s`.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.member.MemberServiceTest --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- 목적: Phase 3 RED 검증. AI 캐릭터용 Member의 일반 로그인, 크리에이터 관리자 로그인, 유저-크리에이터 DM 방 생성이 기존 코드에서 차단되지 않음을 확인.
|
|
||||||
- 결과: `CreatorAdminMemberServiceTest`, `MemberServiceTest`, `UserCreatorChatServiceTest`의 신규 차단 테스트 3건 실패.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.member.MemberServiceTest --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- 목적: Phase 3 GREEN 검증. 로그인/DM 차단 정책과 기존 DM 회귀 테스트 통과 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 10s`.
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- 목적: Phase 3/4 Kotlin production/test 및 문서 변경 전 정적 규칙 준수 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 20s`.
|
|
||||||
- `./gradlew test`
|
|
||||||
- 목적: Phase 4 전체 회귀 테스트 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 1m 14s`.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceIntegrationTest --tests kr.co.vividnext.sodalive.member.MemberServiceTest --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- 목적: 계획 진행 중 추가한 테스트의 mock 사용 적합성 재검토 후, 저장소/JPA 관계/로그인/DM 정책 검증을 Spring 컨텍스트 + H2 repository 기반 테스트로 전환했는지 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 34s`.
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- 목적: 계획 관련 테스트 리팩터링 후 ktlint 규칙 준수 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 10s`.
|
|
||||||
- `./gradlew test`
|
|
||||||
- 목적: 계획 관련 테스트 리팩터링 후 전체 회귀 테스트 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 1m 20s`.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- 목적: 리뷰 보완. AI 캐릭터용 Member가 참여한 기존 DM 방에서 `sendTextMessage`도 `message.error.recipient_not_found`로 차단되고 메시지가 저장되지 않는지 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 31s`.
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- 목적: 리뷰 보완 후 ktlint 규칙 준수 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 8s`.
|
|
||||||
- `./gradlew test`
|
|
||||||
- 목적: 리뷰 보완 후 전체 회귀 테스트 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 1m 15s`.
|
|
||||||
- `rg -n "CREATE PROCEDURE|CURSOR|CALL backfill_chat_character_creator_member|LAST_INSERT_ID|Rollback|임시 식별자" docs/20260611_AI캐릭터_크리에이터기능_최소연결`
|
|
||||||
- 목적: 운영 DB 반영 SQL을 저장 프로시저 없는 단순 SQL로 변경했고, 임시 email 식별자 기준과 롤백 절차가 문서에 남았는지 확인.
|
|
||||||
- 결과: 저장 프로시저/커서/CALL/LAST_INSERT_ID 패턴은 미검출. `alter-existing-tables.sql`에 임시 식별자 정리와 Rollback 절차가 존재함을 확인.
|
|
||||||
- `./gradlew tasks --all`
|
|
||||||
- 목적: 문서 변경 후 Gradle 명령 유효성 확인.
|
|
||||||
- 결과: `BUILD SUCCESSFUL in 942ms`.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# PRD: AI 캐릭터 크리에이터 기능 최소 연결
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
`ChatCharacter`가 기존 크리에이터 기능을 최소 변경으로 사용할 수 있도록, 모든 `ChatCharacter`를 `Member(role = CREATOR)`와 1:1로 연결하고 실제 사람 Member와 AI 캐릭터용 Member를 `memberKind`로 구분한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 현재 `ChatCharacter`는 AI 대화 주체로만 동작하고, 라이브/콘텐츠/커뮤니티/채널 후원/정산/알림 등 크리에이터 기능의 주체가 될 수 없다.
|
|
||||||
- 기존 크리에이터 기능은 대부분 `Member(role = CREATOR)`와 `member.id` 기반 `creatorId`를 전제로 구현되어 있다.
|
|
||||||
- `ChatCharacter`를 독립 소유자로 직접 도입하면 콘텐츠, 라이브, 후원, 정산, 랭킹, 알림, 차단, 팔로우 등 넓은 범위의 소유자 모델 변경이 필요하다.
|
|
||||||
- 이번 변경은 기존 `Member` 기반 크리에이터 기능을 유지하면서, AI 캐릭터가 크리에이터 기능을 사용할 수 있는 최소 연결 구조가 필요하다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- `Member`에 `MemberKind`를 추가해 실제 사람 Member와 AI 캐릭터용 Member를 구분한다.
|
|
||||||
- 모든 기존 `ChatCharacter`에 대응되는 `Member(role = CREATOR, memberKind = AI_CHARACTER)`를 생성하고 1:1로 연결할 수 있는 마이그레이션 SQL을 준비한다.
|
|
||||||
- 신규/기존 `ChatCharacter`는 크리에이터 기능 주체인 `creatorMember`를 가진다.
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member는 로그인할 수 없도록 차단한다.
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member는 유저-크리에이터 DM 생성 대상이 될 수 없도록 차단한다.
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member는 로그인과 DM을 제외하고 `Member(role = CREATOR)`가 사용할 수 있는 기존 크리에이터 기능을 사용할 수 있어야 한다.
|
|
||||||
- AI 캐릭터용 Member의 표시 정보는 연결된 `ChatCharacter`의 이름, 프로필 이미지, 소개를 스냅샷으로 복사해 기존 Member 기반 화면과 쿼리를 재사용한다.
|
|
||||||
- 기존 사람 크리에이터의 콘텐츠/라이브/커뮤니티/채널 후원/정산/알림 동작은 유지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 이번 범위에서 `ChatCharacter`를 `Member`와 동급의 별도 소유자 타입으로 만들지 않는다.
|
|
||||||
- 이번 범위에서 `creator_identity` 같은 공통 크리에이터 소유자 테이블을 도입하지 않는다.
|
|
||||||
- 이번 범위에서 공개 API의 기존 `creatorId = member.id` 의미를 변경하지 않는다.
|
|
||||||
- 이번 범위에서 크리에이터 검색 결과 카테고리 개편은 구현하지 않는다.
|
|
||||||
- 이번 범위에서 `Member:ChatCharacter = 1:N` 관계를 허용하지 않는다.
|
|
||||||
- 이번 범위에서 AI 캐릭터용 Member의 직접 로그인, 직접 크리에이터 관리자 접속, 직접 DM 기능은 허용하지 않는다.
|
|
||||||
- 이번 범위에서 AI 캐릭터용 콘텐츠/라이브/커뮤니티 대리 생성 API를 새로 설계하지 않는다.
|
|
||||||
- 이번 범위에서 기존 정산 산식, 정산 비율, 랭킹 점수 산식은 변경하지 않는다.
|
|
||||||
- 이번 범위에서 AI 캐릭터용 Member를 정산 관리자 화면에서 사람 크리에이터와 별도 목록 또는 별도 필터로 분리하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 일반 사용자: AI 캐릭터와 AI 대화를 하고, AI 캐릭터 채널에 후원하거나 AI 캐릭터 콘텐츠를 소비하는 회원
|
|
||||||
- 사람 크리에이터: 필요 시 자신의 `Member`에 연결된 `ChatCharacter`를 통해 AI 대화 기능을 제공하는 크리에이터
|
|
||||||
- 운영/정산 담당자: AI 캐릭터용 Member를 기존 크리에이터 정산 흐름에서 식별하고 처리해야 하는 담당자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 모든 활성 `ChatCharacter`와 AI 대화를 시작하고 싶다.
|
|
||||||
- 사용자는 AI 캐릭터 채널에도 기존 크리에이터 채널처럼 채널 후원을 하고 싶다.
|
|
||||||
- 사용자는 AI 캐릭터가 업로드한 콘텐츠를 기존 콘텐츠와 같은 방식으로 보고 싶다.
|
|
||||||
- 시스템은 AI 캐릭터용 Member가 실제 사람 계정처럼 로그인하거나 DM 대상이 되는 것을 막고 싶다.
|
|
||||||
- 운영자는 사람 크리에이터와 AI 캐릭터용 크리에이터 Member를 데이터에서 명확히 구분하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. `MemberKind` 도입
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `Member`에 `memberKind` 필드를 추가한다.
|
|
||||||
- `MemberKind` 값은 최소 다음 2개를 가진다.
|
|
||||||
- `HUMAN`: 실제 사람 Member
|
|
||||||
- `AI_CHARACTER`: AI 캐릭터 기능을 위해 생성된 내부 크리에이터 Member
|
|
||||||
- `memberKind`는 `NOT NULL`이며 기본값은 `HUMAN`이다.
|
|
||||||
- 기존 모든 Member 데이터는 DDL 기본값에 의해 `memberKind = HUMAN`이 된다.
|
|
||||||
- 일반 회원가입, 관리자, 에이전트, 콘텐츠 관리자 등 실제 사람 계정은 `memberKind = HUMAN`을 사용한다.
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member도 `role = CREATOR`를 가진다.
|
|
||||||
- 크리에이터 기능 가능 여부는 기존처럼 기본적으로 `role = CREATOR`를 기준으로 유지한다.
|
|
||||||
- 사람 크리에이터 전용 기능 가능 여부는 `role = CREATOR`와 `memberKind = HUMAN`을 함께 기준으로 판단한다.
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member는 로그인과 DM을 제외한 팔로우, 채널 후원, 콘텐츠, 커뮤니티, 라이브, 정산, 알림 등 기존 CREATOR 기능의 대상이 될 수 있다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `memberKind = HUMAN`만으로 사람 크리에이터 여부를 판단하면 안 되며, 반드시 `role = CREATOR` 조건을 함께 확인해야 한다.
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member는 반드시 `role = CREATOR`여야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Feature B. `ChatCharacter`와 `Member` 1:1 연결
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `ChatCharacter`가 관계의 주인이며 `creatorMember`를 가진다.
|
|
||||||
- 관계는 초기에는 1:1로 제한한다.
|
|
||||||
- DB에는 `chat_character.creator_member_id`를 추가한다.
|
|
||||||
- `chat_character.creator_member_id`는 `member.id`를 참조한다.
|
|
||||||
- `chat_character.creator_member_id`에는 unique 제약을 둔다.
|
|
||||||
- `ChatCharacter.creatorMember.role`은 반드시 `CREATOR`여야 한다.
|
|
||||||
- 기존 모든 `ChatCharacter`는 마이그레이션 후 `creatorMember`가 있어야 한다.
|
|
||||||
- 기존 `ChatCharacter` 중 실제 사람 크리에이터와 연결해야 하는 데이터는 이번 마이그레이션 대상에 없다고 본다.
|
|
||||||
- 기존 모든 `ChatCharacter`는 새 `Member(role = CREATOR, memberKind = AI_CHARACTER)`를 생성해 연결한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 이미 연결된 `ChatCharacter`에 중복 `creatorMember`가 배정되면 안 된다.
|
|
||||||
- 하나의 `Member`에 여러 `ChatCharacter`가 연결되면 안 된다.
|
|
||||||
- 비활성 `ChatCharacter`도 기존 데이터 정합성을 위해 `creatorMember` 연결 대상에 포함한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Feature C. 기존 `ChatCharacter`용 Member 생성 마이그레이션
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 운영 DB 반영용 MySQL 기준 DDL과 backfill SQL을 작성한다.
|
|
||||||
- 마이그레이션 SQL은 기존 `ChatCharacter`별로 AI 캐릭터용 `Member`를 생성할 수 있어야 한다.
|
|
||||||
- 생성되는 AI 캐릭터용 Member는 다음 정책을 따른다.
|
|
||||||
- `role = CREATOR`
|
|
||||||
- `memberKind = AI_CHARACTER`
|
|
||||||
- `email = null`
|
|
||||||
- `password = ""`
|
|
||||||
- `nickname`은 기본적으로 `ChatCharacter.name` 기준
|
|
||||||
- `profileImage`는 기본적으로 `ChatCharacter.imagePath` 기준
|
|
||||||
- `introduce`는 기본적으로 `ChatCharacter.description` 기준
|
|
||||||
- AI 캐릭터용 Member의 `nickname`, `profileImage`, `introduce`는 기존 콘텐츠/라이브/커뮤니티/후원/정산/알림/팔로우/AGENT 소속 화면에서 별도 `ChatCharacter` JOIN 없이 표시하기 위한 스냅샷이다.
|
|
||||||
- backfill 후 `chat_character.creator_member_id`가 없는 row가 0건인지 검증하는 SQL을 포함한다.
|
|
||||||
- 검증 완료 후 `chat_character.creator_member_id`를 `NOT NULL`로 전환할 수 있어야 한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `ChatCharacter.name`이 중복되더라도 Member 생성이 가능해야 한다.
|
|
||||||
- AI 캐릭터용 Member의 로그인 차단은 `email/password` 값이 아니라 `memberKind = AI_CHARACTER` 정책으로 보장해야 한다.
|
|
||||||
- 기존 `ChatCharacter`의 사람 크리에이터 수동 매핑은 이번 범위에서 제공하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Feature D. AI 캐릭터 표시 정보 동기화
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- AI 캐릭터용 Member의 표시 정보는 연결된 `ChatCharacter` 값을 기준으로 유지한다.
|
|
||||||
- `ChatCharacter.name`은 `Member.nickname`에 동기화한다.
|
|
||||||
- `ChatCharacter.imagePath`는 `Member.profileImage`에 동기화한다.
|
|
||||||
- `ChatCharacter.description`은 `Member.introduce`에 동기화한다.
|
|
||||||
- `ChatCharacter` 생성 시 AI 캐릭터용 Member를 함께 생성하는 경우 같은 transaction 안에서 표시 정보를 복사한다.
|
|
||||||
- `ChatCharacter` 수정 시 연결된 AI 캐릭터용 Member의 표시 정보도 같은 transaction 안에서 갱신한다.
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member의 표시 정보는 직접 수정 API가 아니라 `ChatCharacter` 생성/수정 흐름을 기준으로 관리한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 동기화 대상 `creatorMember`가 없으면 저장을 실패시켜 데이터 불일치를 막아야 한다.
|
|
||||||
- 연결된 `creatorMember.memberKind != AI_CHARACTER`인 경우, 사람 크리에이터의 프로필을 덮어쓰지 않도록 동기화 대상에서 제외하거나 별도 정책을 명확히 적용해야 한다.
|
|
||||||
- 기존 Member 기반 화면은 AI 캐릭터 표시 정보를 조회할 때 별도 `ChatCharacter` JOIN을 추가하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Feature E. AI 캐릭터용 Member 로그인 차단
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member는 모든 일반 로그인 흐름에서 인증 성공 상태가 되면 안 된다.
|
|
||||||
- 크리에이터 관리자 로그인 흐름에서도 `memberKind = AI_CHARACTER`인 Member는 로그인할 수 없어야 한다.
|
|
||||||
- 소셜 로그인 또는 토큰 재발급 흐름에서 AI 캐릭터용 Member가 세션/토큰을 얻을 수 있는 경로가 있으면 차단한다.
|
|
||||||
- 차단 시 기존 인증 실패 응답 패턴을 우선 재사용한다.
|
|
||||||
- AI 캐릭터용 Member는 로그인에 사용하지 않으므로 `email`은 `null`을 허용하고, `password`는 기존 소셜 회원 생성 패턴과 같이 빈 문자열을 사용할 수 있다.
|
|
||||||
- 로그인 차단은 `email/password` 값이 아니라 `memberKind = AI_CHARACTER` 정책으로 보장한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 기존 토큰을 이미 가진 AI 캐릭터용 Member가 있을 수 없도록 마이그레이션 시점과 배포 순서를 점검한다.
|
|
||||||
- 후속 범위에서 관리자/콘텐츠 관리자가 AI 캐릭터용 콘텐츠를 등록하더라도, AI 캐릭터용 Member 자체가 로그인하는 것은 허용하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Feature F. AI 캐릭터용 Member DM 차단
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 유저-크리에이터 DM 생성 대상이 `memberKind = AI_CHARACTER`이면 DM 방을 생성하지 않는다.
|
|
||||||
- 기존 사람 크리에이터는 `ChatCharacter` 연결 여부와 무관하게 DM이 가능해야 한다.
|
|
||||||
- DM 차단 기준은 `ChatCharacter` 연결 여부가 아니라 `Member.memberKind`이다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `memberKind = HUMAN`인 사람 크리에이터가 `ChatCharacter`를 가진 경우에도 DM은 가능해야 한다.
|
|
||||||
- `memberKind = AI_CHARACTER`인 Member가 `role = CREATOR`이더라도 DM은 불가능해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
|
|
||||||
- 기존 공개 API의 `creatorId` 의미는 이번 범위에서 변경하지 않는다.
|
|
||||||
- 기존 콘텐츠/라이브/커뮤니티/후원/정산 테이블의 소유자 컬럼은 `Member` 기준을 유지한다.
|
|
||||||
- `ChatCharacter`와 `Member` 관계는 초기에는 `ChatCharacter` 단방향 `OneToOne`으로 구현한다.
|
|
||||||
- 운영 DB 반영용 DDL은 MySQL 기준으로 작성한다.
|
|
||||||
- DDL 컬럼에는 가능한 경우 `COMMENT`를 추가한다.
|
|
||||||
- `memberKind` 기반 정책 판단은 중복 분기를 줄이기 위해 정책 함수 또는 명확한 서비스 검증으로 모은다.
|
|
||||||
- 검색 결과 카테고리 개편은 이번 구현에서 제외하되, 향후 `memberKind`를 활용할 수 있도록 데이터 모델만 준비한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Data Migration Requirements
|
|
||||||
- Phase 1 DDL
|
|
||||||
- `member.member_kind`를 `NOT NULL DEFAULT 'HUMAN'`으로 추가
|
|
||||||
- `chat_character.creator_member_id` nullable 추가
|
|
||||||
- `chat_character.creator_member_id` FK 및 unique index 추가
|
|
||||||
- Phase 2 backfill
|
|
||||||
- 기존 모든 `ChatCharacter`별로 AI 캐릭터용 `Member(role = CREATOR, memberKind = AI_CHARACTER)`를 생성한다.
|
|
||||||
- 생성된 Member를 `chat_character.creator_member_id`에 연결한다.
|
|
||||||
- Phase 3 검증 및 제약 강화
|
|
||||||
- `member_kind = 'AI_CHARACTER' and role <> 'CREATOR'` row가 0건인지 확인한다.
|
|
||||||
- `chat_character.creator_member_id is null` row가 0건인지 확인한다.
|
|
||||||
- `chat_character.creator_member_id`를 `NOT NULL`로 변경한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Metrics
|
|
||||||
- 기존 `ChatCharacter` 중 `creator_member_id` 누락 0건
|
|
||||||
- `memberKind = AI_CHARACTER`이면서 `role != CREATOR`인 Member 0건
|
|
||||||
- `memberKind = AI_CHARACTER` Member 로그인 성공 0건
|
|
||||||
- `memberKind = AI_CHARACTER` Member 대상 DM 생성 성공 0건
|
|
||||||
- 기존 사람 크리에이터의 DM, 콘텐츠 등록, 채널 후원 흐름 회귀 실패 0건
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Open Questions
|
|
||||||
- AI 캐릭터용 콘텐츠/라이브/커뮤니티 등록 운영 흐름은 이번 범위에서 구현하지 않으므로, 후속 범위에서 `chatCharacterId` 기반 대리 생성 API 정책을 별도로 정해야 한다.
|
|
||||||
@@ -1,705 +0,0 @@
|
|||||||
# 크리에이터 채널 홈 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/home`으로 크리에이터 채널 홈 탭 데이터를 한 번에 조회할 수 있게 한다.
|
|
||||||
|
|
||||||
**Architecture:** 신규 크리에이터 채널 홈 API는 메인 페이지 홈 API와 분리해 `kr.co.vividnext.sodalive.v2.creator.channel` 하위에 둔다. Controller는 인증/HTTP 계약만 담당하고, application service는 섹션 조립과 정책 적용을 담당하며, persistence adapter는 기존 `explorer`, `content`, `live`, `series`, `chat_character` 도메인 데이터를 조회 전용 record로 반환한다. 추천 페이지에서 쓰던 `RecommendedActivityType`은 `CreatorActivityType`으로 이름을 변경해 공용 패키지로 이동하고, 추천 페이지와 크리에이터 채널 홈이 함께 사용한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL 또는 native SQL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/home`
|
|
||||||
- API 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 신규 기능 패키지: `kr.co.vividnext.sodalive.v2.creator.channel`
|
|
||||||
- 공용 활동 타입 enum: 기존 `RecommendedActivityType`을 `CreatorActivityType`으로 이름 변경하고 `kr.co.vividnext.sodalive.v2.common.domain` 하위로 이동한다.
|
|
||||||
- 스케줄 타입: `LIVE`, `AUDIO`만 사용한다. 오디오 콘텐츠가 `다시보기` 카테고리여도 `AUDIO`로 내려준다.
|
|
||||||
- 스케줄 정렬/개수: 현재 시각 이후 예약 중 오늘 날짜와 가장 근접한 3개, 예약 시각 오름차순, 같은 예약 시각이면 라이브 먼저 표시한다.
|
|
||||||
- 스케줄 성인 노출 정책: repository query에서 조회자의 성인 노출 정책을 먼저 반영하고, service 최종 조합에서도 내부 스케줄 후보의 `isAdult`로 한 번 더 보정한다. 공개 스케줄 응답에는 `isAdult`를 노출하지 않는다.
|
|
||||||
- 현재 라이브와 예약 라이브 스케줄은 기존 라이브 목록과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다. application service는 조회자의 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 `effectiveViewerGender`를 산출해 query port에 넘긴다.
|
|
||||||
- 신규 오디오 콘텐츠와 오디오 목록은 중복 노출하지 않는다. `latestAudioContent`로 내려간 가장 최신 콘텐츠를 오디오 목록에서 제외한다.
|
|
||||||
- 채널 후원 홈 섹션은 기존 채널 후원 목록과 동일하게 이번 달 기준 최신순 8개를 내려준다. 응답 메시지는 기본 문구를 조합하지 않고 후원자가 입력한 추가 메시지만 내려준다.
|
|
||||||
- 오리지널 시리즈 여부는 `Series.isOriginal == true`로 판단한다.
|
|
||||||
- 화보와 상단 탭별 전체보기 API는 이번 범위에서 제외한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 공용 enum 및 추천 페이지 영향 범위
|
|
||||||
- Move/Rename: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedActivityType.kt` → `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CreatorActivityType.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 신규 creator.channel API/application/domain/port
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
|
|
||||||
### 테스트
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CreatorActivityTypeTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Modify: `docs/20260612_크리에이터_채널_홈_API/plan-task.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`에 아래 응답 DTO를 기준으로 작성한다. 필드명은 공개 API 계약이므로 구현 중 변경이 필요하면 먼저 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
|
||||||
|
|
||||||
data class CreatorChannelHomeResponse(
|
|
||||||
val creator: CreatorChannelCreatorResponse,
|
|
||||||
val currentLive: CreatorChannelLiveResponse?,
|
|
||||||
val latestAudioContent: CreatorChannelAudioContentResponse?,
|
|
||||||
val channelDonations: List<CreatorChannelDonationResponse>,
|
|
||||||
val notices: List<CreatorChannelCommunityPostResponse>,
|
|
||||||
val schedules: List<CreatorChannelScheduleResponse>,
|
|
||||||
val audioContents: List<CreatorChannelAudioContentResponse>,
|
|
||||||
val series: List<CreatorChannelSeriesResponse>,
|
|
||||||
val communities: List<CreatorChannelCommunityPostResponse>,
|
|
||||||
val fanTalk: CreatorChannelFanTalkSummaryResponse,
|
|
||||||
val introduce: String,
|
|
||||||
val activity: CreatorChannelActivityResponse,
|
|
||||||
val sns: CreatorChannelSnsResponse
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelCreatorResponse(
|
|
||||||
val creatorId: Long,
|
|
||||||
val characterId: Long?,
|
|
||||||
val nickname: String,
|
|
||||||
val profileImageUrl: String,
|
|
||||||
val followerCount: Int,
|
|
||||||
@JsonProperty("isAiChatAvailable")
|
|
||||||
val isAiChatAvailable: Boolean,
|
|
||||||
@JsonProperty("isDmAvailable")
|
|
||||||
val isDmAvailable: Boolean,
|
|
||||||
@JsonProperty("isFollow")
|
|
||||||
val isFollow: Boolean,
|
|
||||||
@JsonProperty("isNotify")
|
|
||||||
val isNotify: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelLiveResponse(
|
|
||||||
val liveId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val beginDateTimeUtc: String,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelAudioContentResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean,
|
|
||||||
@JsonProperty("isPointAvailable")
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
@JsonProperty("isFirstContent")
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val seriesName: String?,
|
|
||||||
@JsonProperty("isOriginalSeries")
|
|
||||||
val isOriginalSeries: Boolean?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelDonationResponse(
|
|
||||||
val nickname: String,
|
|
||||||
val profileImageUrl: String,
|
|
||||||
val can: Int,
|
|
||||||
val message: String,
|
|
||||||
val createdAtUtc: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelScheduleResponse(
|
|
||||||
val scheduledAtUtc: String,
|
|
||||||
val title: String,
|
|
||||||
val type: CreatorActivityType,
|
|
||||||
val targetId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelSeriesResponse(
|
|
||||||
val seriesId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String,
|
|
||||||
val numberOfContent: Int,
|
|
||||||
@JsonProperty("isNew")
|
|
||||||
val isNew: Boolean,
|
|
||||||
@JsonProperty("isOriginal")
|
|
||||||
val isOriginal: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelCommunityPostResponse(
|
|
||||||
val postId: Long,
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileUrl: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val audioUrl: String?,
|
|
||||||
val content: String,
|
|
||||||
val price: Int,
|
|
||||||
val dateUtc: String,
|
|
||||||
val existOrdered: Boolean,
|
|
||||||
val likeCount: Int,
|
|
||||||
val commentCount: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkSummaryResponse(
|
|
||||||
val totalCount: Int,
|
|
||||||
val latestFanTalk: CreatorChannelFanTalkResponse?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkResponse(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val memberId: Long,
|
|
||||||
val nickname: String,
|
|
||||||
val profileImageUrl: String,
|
|
||||||
val content: String,
|
|
||||||
val languageCode: String?,
|
|
||||||
val createdAtUtc: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelActivityResponse(
|
|
||||||
val debutDateUtc: String?,
|
|
||||||
val dDay: String,
|
|
||||||
val liveCount: Long,
|
|
||||||
val liveDurationHours: Long,
|
|
||||||
val liveContributorCount: Long,
|
|
||||||
val audioContentCount: Long,
|
|
||||||
val seriesCount: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelSnsResponse(
|
|
||||||
val instagramUrl: String,
|
|
||||||
val fancimmUrl: String,
|
|
||||||
val xUrl: String,
|
|
||||||
val youtubeUrl: String,
|
|
||||||
val kakaoOpenChatUrl: String
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
> 스케줄 성인 여부는 service 최종 보정에 필요한 내부 domain/record 필드로만 유지하고, 위 공개 응답 DTO에는 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: 공용 활동 타입 정리
|
|
||||||
|
|
||||||
- [x] **Task 1.1: `RecommendedActivityType`을 공용 `CreatorActivityType`으로 이동**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CreatorActivityType.kt`
|
|
||||||
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedActivityType.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CreatorActivityTypeTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: `CreatorActivityTypeTest`를 먼저 추가해 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`의 `code`가 enum name과 같은지 검증한다. 추천 서비스/리포지토리 테스트 import를 `CreatorActivityType`으로 바꿔 기존 파일이 컴파일 실패하는 것을 확인한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
|
||||||
- GREEN: enum을 공용 패키지로 이동하고 추천 페이지 코드의 import/type을 모두 `CreatorActivityType`으로 변경한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
|
||||||
- REFACTOR: 더 이상 `RecommendedActivityType` 문자열이 남지 않도록 `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin`로 확인한다.
|
|
||||||
- 기대 결과: 추천 페이지 기존 동작은 유지되고, 크리에이터 채널 홈 스케줄도 같은 enum을 사용할 수 있다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: 응답 모델과 순수 정책
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 크리에이터 채널 홈 domain/response 모델 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- RED: service 테스트에서 `CreatorChannelHome`이 PRD 섹션 전체를 담는지 컴파일 기준으로 먼저 고정한다. 필드는 `creator`, `currentLive`, `latestAudioContent`, `channelDonations`, `notices`, `schedules`, `audioContents`, `series`, `communities`, `fanTalk`, `introduce`, `activity`, `sns`를 포함한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- GREEN: domain 모델과 response DTO를 추가하고, response는 domain model을 받아 API 노출 필드만 변환하는 `from(home: CreatorChannelHome)` factory를 둔다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- REFACTOR: API DTO는 JPA entity나 QueryDSL projection에 직접 의존하지 않도록 유지한다.
|
|
||||||
- 기대 결과: 이후 persistence/application/controller가 공유할 응답 표면이 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: 홈 섹션 정렬/필터 순수 정책 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt`
|
|
||||||
- RED: 다음 정책 테스트를 작성한다.
|
|
||||||
- 스케줄은 예약 시각 오름차순 최대 3개만 남긴다.
|
|
||||||
- 스케줄은 현재 시각 이후 예약만 남긴다.
|
|
||||||
- 같은 예약 시각이면 `CreatorActivityType.LIVE`가 `AUDIO`보다 먼저 온다.
|
|
||||||
- 조회자의 성인 노출 정책이 false이면 성인 스케줄을 제외한다.
|
|
||||||
- 오디오 목록에서는 `latestAudioContentId`와 같은 콘텐츠를 제외한다.
|
|
||||||
- 오디오 콘텐츠의 첫 공개 콘텐츠 여부는 공개 시각 오름차순, 동일 시각이면 id 오름차순으로 판정한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`
|
|
||||||
- GREEN: `limitSchedules(schedules, now, canViewAdultContent)`, `excludeLatestAudioContent`, `markFirstAudioContent` 같은 순수 함수를 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`
|
|
||||||
- REFACTOR: DB 정렬과 application 보정이 중복되더라도 최종 응답 전 정책 함수가 한 번 더 보장하도록 service에서 재사용할 수 있게 둔다.
|
|
||||||
- 기대 결과: 날짜/중복/첫 콘텐츠 정책이 DB fixture 없이 빠르게 검증된다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: 조회 port와 persistence adapter
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 조회 port와 record 타입 정의**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: repository 테스트에서 port 메서드 이름을 먼저 사용해 컴파일 실패를 만든다. 최소 port 메서드는 `findCreator`, `existsBlockedBetween`, `findCurrentLive`, `findLatestAudioContent`, `findChannelDonations`, `findCommunityPosts`, `findSchedules`, `findAudioContents`, `findSeries`, `findFanTalkSummary`, `findActivity`, `findSns`로 둔다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: port record와 `DefaultCreatorChannelHomeQueryRepository` 골격을 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: record 타입은 JPA entity를 노출하지 않는 data class로 둔다.
|
|
||||||
- 기대 결과: application service가 의존할 조회 인터페이스가 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 크리에이터 기본 정보/차단/팔로우/AI 채팅/DM 조회 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 다음 repository 통합 테스트를 작성한다.
|
|
||||||
- 활성 팔로워 수만 `followerCount`에 포함한다.
|
|
||||||
- `ChatCharacter.creatorMember.id == creatorId`이고 활성 캐릭터가 있으면 `isAiChatAvailable=true`다.
|
|
||||||
- `Member.memberKind == AI_CHARACTER`이면 `isDmAvailable=false`다.
|
|
||||||
- 인증 회원의 `CreatorFollowing.isFollow`, `isNotify`가 응답에 반영된다.
|
|
||||||
- 양방향 차단 관계가 있으면 `existsBlockedBetween(viewerId, creatorId)=true`다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `Member`, `CreatorFollowing`, `BlockMember`, `ChatCharacter` 기반 조회를 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 프로필 이미지 URL 조합은 application/DTO에서 cloudFrontHost로 처리할지 repository에서 처리할지 한 곳으로 고정한다. 기존 v2 홈 DTO 관례처럼 path record와 URL 변환 함수를 분리하는 방식을 우선한다.
|
|
||||||
- 기대 결과: 기본 정보와 접근 차단 판단이 기존 정책과 맞는다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: 현재 라이브와 예약 스케줄 조회 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 다음 repository 통합 테스트를 작성한다.
|
|
||||||
- 현재 라이브는 `channelName`이 있고 활성 상태이며 크리에이터가 진행 중인 라이브만 반환한다.
|
|
||||||
- 예약 라이브는 `beginDateTime > now`, 활성 상태인 row만 스케줄 후보로 반환한다.
|
|
||||||
- 예약 오디오는 `duration != null`, `releaseDate != null`, `releaseDate > now`이면 `isActive` 상태와 관계없이 스케줄 후보로 반환한다.
|
|
||||||
- 다시듣기 테마 예약 오디오도 스케줄 타입은 `AUDIO`다.
|
|
||||||
- 같은 예약 시각이면 라이브가 오디오보다 먼저 온다.
|
|
||||||
- 성인 라이브/오디오는 조회자의 성인 노출 정책이 false이면 제외된다.
|
|
||||||
- 현재 라이브와 예약 라이브 스케줄은 query port의 `effectiveViewerGender`, `viewerId`, `isViewerCreator` 입력으로 기존 라이브 목록의 성별 제한과 크리에이터 입장 제한을 반영한다.
|
|
||||||
- service 최종 보정을 위해 스케줄 후보 record에는 `isAdult`가 포함된다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `LiveRoom`, `AudioContent`, `AudioContentTheme` 조회를 구현하고 `CreatorActivityType.LIVE`/`AUDIO`와 `isAdult`를 record에 담는다. 예약 오디오는 `isActive`가 아니라 `duration`과 미래 `releaseDate`로 판정한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 최종 3개 제한은 repository query와 `CreatorChannelHomeQueryPolicy.limitSchedules` 양쪽 중복 방어를 허용하되, service에서 최종 보정한다.
|
|
||||||
- 기대 결과: 스케줄 섹션이 PRD의 타입/정렬/개수 정책을 만족한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.4: 최신 오디오와 오디오 목록 조회 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 다음 repository 통합 테스트를 작성한다.
|
|
||||||
- `latestAudioContent`는 예약 공개 전 콘텐츠를 제외하고 공개 시각 최신순 1개를 반환한다.
|
|
||||||
- 오디오 목록은 `latestAudioContent`를 제외하고 최대 9개를 최신순으로 반환한다.
|
|
||||||
- `releaseDate == null`인 오디오는 최신/목록/첫 콘텐츠 판정에서 제외한다.
|
|
||||||
- `isPointAvailable`, duration, cover image, price가 record에 포함된다.
|
|
||||||
- 공개 순서상 첫 콘텐츠만 `isFirstContent=true`다.
|
|
||||||
- 시리즈 콘텐츠이면 시리즈 이름과 `Series.isOriginal`이 포함된다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `AudioContent`, `SeriesContent`, `Series` 기반 조회를 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 공개 오디오 조회 조건은 `releaseDate != null && releaseDate <= now`로 작성한다. `releaseDate == null`은 삭제/미공개 데이터로 보고 제외한다.
|
|
||||||
- 기대 결과: 신규 오디오 영역과 오디오 목록이 중복 없이 구성된다.
|
|
||||||
|
|
||||||
- [x] **Task 3.5: 채널 후원, 공지, 커뮤니티, 팬 Talk 조회 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 다음 repository 통합 테스트를 작성한다.
|
|
||||||
- 채널 후원은 KST 기준 이번 달 범위의 최신순 8개만 반환한다.
|
|
||||||
- 채널 후원 응답에는 후원자 닉네임, 프로필 이미지, 후원 can, 후원자가 입력한 추가 메시지, UTC 생성 시각만 포함한다.
|
|
||||||
- 채널 후원 `message`는 기본 문구(`"%s캔을 비밀후원하셨습니다."`, `"%s캔을 후원하셨습니다."`)를 조합하지 않고 `additionalMessage`만 반환한다.
|
|
||||||
- 공지는 `CreatorCommunity.isFixed == true`, `fixedAt != null`인 데이터로 보고 최대 3개, 고정 시각 최신순으로 반환한다.
|
|
||||||
- 커뮤니티는 `isFixed == false`, 최대 3개, 작성 시각 최신순으로 반환한다.
|
|
||||||
- 성인 커뮤니티 글은 구매 여부와 무관하게 조회자의 성인 콘텐츠 노출 정책이 false이면 제외한다.
|
|
||||||
- 공지와 커뮤니티의 홈 응답 게시글 요약 필드는 기존 커뮤니티 전체보기 응답과 같은 의미로 계산한다.
|
|
||||||
- 팬 Talk는 `CreatorCheers.parent == null`, `isActive == true`인 최신 1개와 전체 개수를 반환한다.
|
|
||||||
- 차단 관계가 있는 팬 Talk 작성자는 기존 팬 Talk 목록 정책과 동일하게 제외한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `ChannelDonationMessage`, `CreatorCommunity`, `CreatorCheers` 기반 조회를 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 커뮤니티 유료 이미지/오디오 구매 여부(`existOrdered`)는 인증 회원 기준으로 기존 community query 의미와 동일하게 계산한다.
|
|
||||||
- 기대 결과: 홈 후원/공지/커뮤니티/팬 Talk 섹션이 기존 전체보기 의미와 맞게 내려간다.
|
|
||||||
|
|
||||||
- [x] **Task 3.6: 시리즈, 소개, 활동, SNS 조회 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 다음 repository 통합 테스트를 작성한다.
|
|
||||||
- 시리즈는 최대 8개, 시리즈에 속한 공개 콘텐츠 최신 공개 시각 내림차순으로 반환한다.
|
|
||||||
- 시리즈 응답 record에는 id, 제목, 커버 이미지, 콘텐츠 개수, 신규 표시, 오리지널 시리즈 여부가 포함된다.
|
|
||||||
- 소개는 `Member.introduce`를 반환한다.
|
|
||||||
- 데뷔일은 첫 라이브 시작 시각과 오디오 데뷔 후보 시각 중 빠른 값이다.
|
|
||||||
- 오디오 데뷔 후보는 업로드 순서 기준 첫 3개만 보고, 1~2번째 삭제 오디오는 건너뛰며, 3번째 삭제 오디오는 4번째로 넘어가지 않고 해당 `createdAt`을 후보로 쓴다.
|
|
||||||
- 업로드 오디오 콘텐츠 개수는 예약 업로드를 제외한다.
|
|
||||||
- 라이브 진행 횟수/누적 시간/누적 참여자는 기존 `ExplorerQueryRepository` 의미와 맞는다.
|
|
||||||
- SNS는 `instagramUrl`, `fancimmUrl`, `xUrl`, `youtubeUrl`, `websiteUrl`을 기존 상세 API 의미로 반환한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `Series`, `SeriesContent`, `Member`, `LiveRoom`, `LiveRoomVisit`, `AudioContent` 기반 조회를 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 기존 `ExplorerService.getCreatorDetail`과 의미가 같은 계산은 테스트명에 근거를 남기고, 구버전 service를 직접 호출하지 않는다.
|
|
||||||
- 기대 결과: 활동/SNS/시리즈가 구버전 상세 의미와 신규 홈 요구를 함께 만족한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.7: `findCreator` 기본 정보 조회를 count/exists 중심으로 개선**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 기존 `shouldFindCreatorProfileWithRelationshipFlags`, `shouldNotExposeNotifyForInactiveFollowing` 테스트를 유지하고, 활성 팔로워가 여러 명이어도 `followerCount` 결과가 정확한 회귀 테스트를 보강한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `findCreator`에서 팔로워 수는 `select(id).fetch().size` 대신 DB `count()`로 계산하고, AI 채팅 가능 여부는 필요한 id만 조회하는 `exists` 성격의 쿼리로 유지한다. creator 기본 정보처럼 record 생성자 인자로 바로 매핑할 수 있는 조회는 필요한 컬럼을 `Tuple`로 가져와 재조립하지 않고 QueryDSL `Projections.constructor`로 record를 생성한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: `CreatorChannelCreatorRecord` 조립을 위해 불필요한 `Member` entity 전체를 로딩하지 않는지 코드로 확인한다. QueryDSL `@QueryProjection`은 port record가 QueryDSL에 의존하게 되므로 사용하지 않는다.
|
|
||||||
- 기대 결과: `findCreator`가 기존 응답 의미를 유지하면서 대량 follower row를 애플리케이션 메모리로 가져오지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 3.8: 단건/단순 목록 조회를 필요한 컬럼 projection으로 개선**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: `findCurrentLive`, `findLatestAudioContent`, `findAudioContents`, `findChannelDonations`, `findSns`의 기존 repository 테스트를 유지해 projection 변경 전후 응답 값이 같음을 고정한다. 단, 채널 후원 공개 응답 계약 변경은 `Task 3.15`를 우선한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: 위 메서드들이 `selectFrom(entity).fetch().map { ... }`로 모든 컬럼을 가져오지 않도록 필요한 컬럼만 조회한다. record 생성자와 조회 컬럼이 1:1로 대응되는 부분은 `Tuple` 조회 후 수동 재조립하지 않고 QueryDSL `Projections.constructor`로 바로 record를 생성한다. `findAudioContents`는 목록 row마다 series/first content 조회가 반복되지 않도록 시리즈 정보와 첫 콘텐츠 id를 bulk 또는 별도 1회 쿼리로 계산한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: port record에는 QueryDSL annotation을 붙이지 않고, QueryDSL 의존은 persistence adapter 내부에만 둔다. 계산/병합/그룹핑 때문에 constructor projection으로 표현하기 어려운 부분에만 최소 범위로 `Tuple` 또는 별도 bulk map 조립을 허용한다.
|
|
||||||
- 기대 결과: 현재 라이브, 오디오, 후원, SNS 조회가 필요한 컬럼만 읽고 projection 변경 자체로는 응답 값이 바뀌지 않는다. 채널 후원 공개 응답 계약 변경은 `Task 3.15`에서 별도로 반영한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.9: `findSchedules` 스케줄 후보 조회를 projection으로 개선**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 기존 스케줄 테스트에 live/audio 후보가 여러 개 있을 때 예약 시각 오름차순, 같은 시각 live 우선, `limit` 적용 결과가 유지되는 케이스를 보강한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: 예약 라이브와 예약 오디오 조회에서 entity 전체를 가져오지 않고 `scheduledAt`, `title`, `targetId`, `isAdult` 등 record 구성에 필요한 컬럼만 조회한다. live/audio 후보 각각이 record 생성자 인자로 바로 매핑되는 경우 QueryDSL `Projections.constructor`를 사용하고, 후보 병합 이후 정렬과 `limit`는 기존 정책과 동일하게 유지한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 스케줄 공개 응답에는 `isAdult`가 노출되지 않고 내부 record에만 남는 구조를 유지한다.
|
|
||||||
- 기대 결과: 스케줄 후보 조회가 불필요한 `LiveRoom`, `AudioContent` 전체 컬럼을 읽지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 3.10: `findCommunityPosts` 게시글 요약 조회의 반복 쿼리와 전체 컬럼 조회 개선**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 공지/커뮤니티 테스트에 여러 게시글을 넣고 `existOrdered`, `likeCount`, `commentCount`, 성인 필터, `fixedAt != null` 공지 조건이 기존 의미대로 유지되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: 게시글 본문 조회는 필요한 컬럼만 조회하되, 본문 record로 바로 매핑 가능한 부분은 `Tuple`로 받은 뒤 재조립하지 않고 QueryDSL `Projections.constructor`를 사용한다. 게시글별 `existsCommunityOrder`, `countCommunityLikes`, `countCommunityComments` 반복 호출은 subquery 또는 게시글 id 목록 기반 bulk 조회로 줄인다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 기존 커뮤니티 전체보기의 구매 여부, 댓글 수, 댓글 불가 게시글 `commentCount = 0` 의미가 깨지지 않도록 테스트명을 근거로 남긴다.
|
|
||||||
- 기대 결과: 홈 공지/커뮤니티 최대 3개 조회가 게시글 수에 비례해 불필요한 추가 쿼리를 만들지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 3.11: `findSeries` 시리즈 조회의 전체 entity 로딩과 시리즈별 콘텐츠 조회 개선**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 시리즈 테스트에 여러 시리즈와 콘텐츠를 넣고 최신 공개 시각 정렬, 콘텐츠 개수, 신규 표시, 오리지널 시리즈 여부, 성인/콘텐츠 타입/차단 정책, `releaseDate == null` 콘텐츠 제외가 유지되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `findSeries`가 `selectFrom(series).fetch()` 뒤 시리즈마다 `publishedSeriesContents`, `hasNewSeriesContent`를 호출하지 않도록 `SeriesContent`/`AudioContent` join, group by, aggregate 기반 조회로 record를 만든다. aggregate 결과가 record 생성자 인자로 바로 대응되는 부분은 QueryDSL `Projections.constructor`를 사용하고, 후처리 집계가 필요한 경우에만 최소 범위로 bulk map 조립을 허용한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 홈 시리즈 공개 응답에 `publishedDaysOfWeek`, `isComplete`, `isPopular`가 다시 포함되지 않도록 DTO와 mapper를 확인한다.
|
|
||||||
- 기대 결과: 시리즈 최대 8개 조회가 시리즈 수만큼 콘텐츠 조회를 반복하지 않고 기존 시리즈 목록 의미를 유지한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.12: `findFanTalkSummary` 전체 row fetch 제거**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 팬 Talk 테스트에 활성 최상위 글 여러 개와 비활성/답글/차단 작성자 글을 넣고 `totalCount`와 최신 1건이 기존 정책대로 계산되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `findFanTalkSummary`에서 조건에 맞는 전체 `CreatorCheers` row를 `fetch()`하지 않고, `totalCount`는 DB `count()`로 계산하며 `latestFanTalk`는 필요한 컬럼만 `orderBy(...).limit(1)`로 조회한다. `latestFanTalk`처럼 생성자 인자로 바로 매핑 가능한 record는 QueryDSL `Projections.constructor`로 생성한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 차단 필터는 기존 팬 Talk 목록 정책과 동일하게 조회자 기준 양방향 차단만 반영한다.
|
|
||||||
- 기대 결과: 팬 Talk가 많은 크리에이터도 홈 조회에서 전체 팬 Talk row를 애플리케이션 메모리로 가져오지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 3.13: 크리에이터 기본 응답에 `characterId` 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- RED: 활성 `ChatCharacter`가 있는 크리에이터는 `creator.characterId`가 해당 캐릭터 ID로 내려가고, 활성 캐릭터가 없으면 `null`로 내려가는 repository/service/controller 응답 계약 테스트를 먼저 추가한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- GREEN: creator record/domain/response DTO에 nullable `characterId`를 추가하고, `findCreator`가 활성 `ChatCharacter` ID를 projection으로 함께 조회하도록 구현한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- REFACTOR: `isAiChatAvailable`은 `characterId != null` 기준과 의미가 어긋나지 않도록 한 곳에서 계산한다.
|
|
||||||
- 기대 결과: 클라이언트가 AI 채팅 진입에 필요한 `characterId`를 홈 응답에서 바로 사용할 수 있다.
|
|
||||||
|
|
||||||
- [x] **Task 3.14: 시리즈 응답에서 연재 요일/완결/인기 필드 제거**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- RED: 시리즈 JSON 응답에 `publishedDaysOfWeek`, `isComplete`, `isPopular`가 없고, `seriesId`, `title`, `coverImageUrl`, `numberOfContent`, `isNew`, `isOriginal`만 필요한 계약으로 내려가는 테스트를 먼저 추가한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- GREEN: series record/domain/response DTO와 mapper에서 `publishedDaysOfWeek`, `isComplete`, `isPopular`를 제거하고, repository projection도 더 이상 해당 응답 필드 조립을 위해 조회하지 않도록 정리한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- REFACTOR: `rg -n "publishedDaysOfWeek|isComplete|isPopular" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel`로 신규 홈 API 경계에 제거 대상 필드가 남지 않았는지 확인한다.
|
|
||||||
- 기대 결과: 홈 시리즈 응답이 클라이언트 요청 계약에 맞게 축소된다.
|
|
||||||
|
|
||||||
- [x] **Task 3.15: 채널 후원 응답에서 기본 후원 문구와 내부 식별 필드 제거**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- RED: 채널 후원 JSON 응답에 `donationId`, `memberId`, `isSecret`이 없고, `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`만 내려가는 테스트를 먼저 추가한다. repository 테스트에서는 `message`가 기본 문구 조합 없이 `ChannelDonationMessage.additionalMessage` 값만 반환되고, 추가 메시지가 없으면 빈 문자열이 되는지 검증한다. 비밀 후원은 기존 정책처럼 후원자 본인과 받은 크리에이터만 조회 가능하고, 제3자는 같은 달 비밀 후원을 조회할 수 없는 negative case를 추가한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- GREEN: donation record/domain/response DTO와 mapper에서 `donationId`, `memberId`, `isSecret` 공개 응답 필드를 제거하고, repository는 `additionalMessage.orEmpty()`를 `message`로 매핑한다. 비밀 후원 노출 여부와 이번 달 최신순 8개 조회 정책은 유지하되, `isSecret`은 repository 조회 조건에만 사용하고 공개 응답에는 포함하지 않는다. record 생성자 인자로 바로 매핑되는 조회는 QueryDSL `Projections.constructor`를 사용한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- REFACTOR: `SodaMessageSource`/`LangContext`가 채널 후원 메시지 조합만을 위해 주입되어 있다면 제거한다. `rg -n "donationId|memberId|isSecret|비밀후원|후원하셨습니다|SodaMessageSource|LangContext" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel`로 신규 홈 API 경계에 제거 대상 응답 필드나 기본 문구 조합이 남지 않았는지 확인한다.
|
|
||||||
- 기대 결과: 홈 채널 후원 섹션이 변경된 클라이언트 계약에 맞게 추가 메시지 중심의 최소 응답만 내려준다.
|
|
||||||
|
|
||||||
- [x] **Task 3.16: 구매한 삭제 유료 커뮤니티 게시글 조회 정책 보정**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 유료 커뮤니티 게시글을 구매한 조회자는 크리에이터가 이후 삭제해 `CreatorCommunity.isActive == false`가 되어도 해당 게시글을 조회하고, 비구매자는 조회하지 못하는 repository 테스트를 먼저 추가한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`
|
|
||||||
- GREEN: `findCommunityPosts`의 구현 방식은 게시글 후보 조회 후 구매 여부/좋아요 수/댓글 수를 bulk 조회하는 기존 구조를 유지한다. 게시글 후보 조건만 기존 커뮤니티 전체보기 의미에 맞춰 `CreatorCommunity.isActive == true` 또는 인증 조회자가 환불되지 않은 `PAID_COMMUNITY_POST` 구매 이력이 있는 경우로 보정한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`
|
|
||||||
- REFACTOR: 좋아요/댓글/구매 여부 조회를 `leftJoin` 하나로 합치지 않고, 현재의 id 목록 기반 bulk 조회 구조를 유지한다.
|
|
||||||
- 기대 결과: 구매자는 삭제된 유료 게시글도 기존 전체보기 의미와 동일하게 접근할 수 있고, 비구매자는 삭제된 게시글을 조회하지 못한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: application service 조립
|
|
||||||
|
|
||||||
- [x] **Task 4.1: `CreatorChannelHomeQueryService` 정상 응답 조립 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- RED: fake port를 사용해 모든 섹션 record를 넣고, service가 `CreatorChannelHome`으로 전체 섹션을 조립하는 테스트를 작성한다. `latestAudioContent`와 오디오 목록 중복 제거, 스케줄 최대 3개 제한, 같은 시각 라이브 우선 정렬, 성인 스케줄 최종 제외도 service 테스트에서 검증한다.
|
|
||||||
- RED: 조회자에게 `Auth.gender`가 있으면 `Member.gender`보다 `Auth.gender`를 우선해 `effectiveViewerGender`를 산출하고, `viewerId`, `isViewerCreator`, `effectiveViewerGender`가 `findCurrentLive`와 `findSchedules`에 전달되는지 fake port로 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- GREEN: service에서 creator 검증, 성인 노출 정책 입력, 조회자 라이브 정책 컨텍스트(`viewerId`, `isViewerCreator`, `effectiveViewerGender`) 산출, port 호출, policy 적용, URL 변환에 필요한 host 전달을 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- REFACTOR: service는 트랜잭션 경계 `@Transactional(readOnly = true)`를 갖고, persistence adapter의 세부 query에 의존하지 않도록 port만 사용한다.
|
|
||||||
- 기대 결과: controller가 단일 service 호출만으로 홈 응답을 받을 수 있다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 예외/접근 정책 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- RED: 다음 service 테스트를 작성한다.
|
|
||||||
- creatorId에 해당하는 회원이 없으면 `SodaException(messageKey = "member.validation.user_not_found")`를 던진다.
|
|
||||||
- 대상 회원 role이 `CREATOR`가 아니면 `member.validation.creator_not_found`를 던진다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 구버전 채널 접근 정책과 동일한 접근 차단 예외를 던진다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- GREEN: port의 creator/blocked 조회 결과에 따라 `SodaException`을 던진다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- REFACTOR: 차단 예외 메시지 조합에 `SodaMessageSource`가 필요하면 기존 `ExplorerService.getCreatorDetail` 패턴을 따른다.
|
|
||||||
- 기대 결과: 신규 API 접근 정책이 구버전 채널 정책과 맞는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 5: web API와 응답 계약
|
|
||||||
|
|
||||||
- [x] **Task 5.1: Controller 인증 정책과 endpoint 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- RED: MockMvc 테스트를 작성한다.
|
|
||||||
- `GET /api/v2/creator-channels/{creatorId}/home` 비회원 요청은 실패한다.
|
|
||||||
- 인증 회원 요청은 service를 호출해 `ApiResponse.ok(...)` 형식으로 성공 응답을 반환한다.
|
|
||||||
- path variable `creatorId`가 service에 전달된다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- GREEN: `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/home")` controller를 구현하고 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 패턴을 사용한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- REFACTOR: 인증 null 가드는 기존 v2 controller와 동일하게 `SodaException(messageKey = "common.error.bad_credentials")`를 사용한다.
|
|
||||||
- 기대 결과: 공개 API endpoint와 인증 정책이 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: 응답 JSON 필드 계약 고정**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- RED: MockMvc `jsonPath`로 다음 최상위 필드를 검증한다.
|
|
||||||
- `creator`
|
|
||||||
- `currentLive`
|
|
||||||
- `latestAudioContent`
|
|
||||||
- `channelDonations`
|
|
||||||
- `notices`
|
|
||||||
- `schedules`
|
|
||||||
- `audioContents`
|
|
||||||
- `series`
|
|
||||||
- `communities`
|
|
||||||
- `fanTalk`
|
|
||||||
- `introduce`
|
|
||||||
- `activity`
|
|
||||||
- `sns`
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- GREEN: response DTO factory에서 domain model을 JSON 계약에 맞게 변환한다. Boolean 필드는 `isAiChatAvailable`, `isDmAvailable`, `isPointAvailable`, `isFirstContent`, `isOriginalSeries`처럼 앱 계약이 읽기 쉬운 이름을 사용한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- REFACTOR: nullable 섹션은 단건이면 `null`, 목록이면 빈 배열로 일관되게 내려준다.
|
|
||||||
- 기대 결과: 클라이언트가 사용할 JSON 스키마가 테스트로 고정된다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 6: 통합 회귀와 문서 갱신
|
|
||||||
|
|
||||||
- [x] **Task 6.1: 크리에이터 채널 홈 통합 시나리오 검증**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 현실적인 fixture로 한 크리에이터에 라이브, 예약 라이브, 예약 오디오, 최신 오디오, 오디오 목록, 시리즈, 공지, 커뮤니티, 후원, 팬 Talk, SNS, 활동 데이터를 넣고 홈 응답 핵심 필드가 모두 내려오는 통합 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: 누락된 mapping이나 query 조건을 최소 수정한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 테스트 fixture helper가 과도하게 길어지면 같은 테스트 파일 내부 private helper로만 분리하고 운영 코드에는 테스트 편의를 위한 API를 추가하지 않는다.
|
|
||||||
- 기대 결과: PRD의 홈 전체 섹션이 한 요청에서 조립되는지 확인된다.
|
|
||||||
|
|
||||||
- [x] **Task 6.2: 추천 페이지 enum rename 회귀 확인**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 해당 없음. `TDD 예외 사유`: Task 1.1에서 이미 RED/GREEN으로 enum rename을 처리했고, 이 task는 영향 범위 회귀 실행이다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: 실패가 있으면 import/type mismatch 또는 enum value mapping만 최소 수정한다.
|
|
||||||
- REFACTOR: `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과가 없어야 한다.
|
|
||||||
- 기대 결과: 추천 페이지 최근 활동 타입 분류가 기존과 동일하게 유지된다.
|
|
||||||
|
|
||||||
- [x] **Task 6.3: 전체 검증 및 계획 문서 검증 기록 누적**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260612_크리에이터_채널_홈_API/plan-task.md`
|
|
||||||
- RED: 테스트 작성 예외. `TDD 예외 사유`: 검증 기록 문서화 task다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- GREEN: 모든 명령 결과를 아래 `검증 기록`에 누적한다.
|
|
||||||
- REFACTOR: 실패한 검증이 있으면 해당 phase/task로 돌아가 plan-task 체크박스를 완료 처리하지 않는다.
|
|
||||||
- 기대 결과: 구현 완료 시 어떤 검증으로 완료 판단했는지 문서에 남는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 구현 중 주의사항
|
|
||||||
|
|
||||||
- 기존 `ExplorerService.getCreatorDetail`의 활동/SNS 의미를 유지하되, 신규 API에서 구버전 service를 직접 호출하지 않는다.
|
|
||||||
- 메인 페이지 홈 패키지(`kr.co.vividnext.sodalive.v2.api.home`)와 크리에이터 채널 홈 패키지를 섞지 않는다.
|
|
||||||
- 공개 시간은 UTC ISO-8601 문자열로 내려주고, 앱 표시 포맷은 서버에서 조합하지 않는다.
|
|
||||||
- 목록 섹션은 데이터가 없으면 빈 배열, 단건 섹션은 없으면 `null`로 내려준다.
|
|
||||||
- 신규 API 공개 스키마 변경은 이 문서의 task 범위 안에서만 수행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 2026-06-12: plan-task 문서 생성 전 `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`, 기존 `docs/20260608_크리에이터_랭킹/plan-task.md`, `docs/20260612_크리에이터_채널_홈_API/prd.md`를 확인했다.
|
|
||||||
- 2026-06-12: Phase 1 RED 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest` 실행 시 `Unresolved reference: CreatorActivityType` 컴파일 오류를 확인했다.
|
|
||||||
- 2026-06-12: Phase 1 GREEN/회귀 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 통과.
|
|
||||||
- 2026-06-12: Phase 1 정리 확인 - `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과 없음, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 2 RED 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 실행 시 `Unresolved reference: CreatorChannelHome`, `Unresolved reference: CreatorChannelHomeResponse`, `Unresolved reference: CreatorChannelHomeQueryPolicy` 컴파일 오류를 확인했다.
|
|
||||||
- 2026-06-12: Phase 2 GREEN 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 통과.
|
|
||||||
- 2026-06-12: Phase 2 정리 확인 - `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 2 리뷰 보정 RED 확인 - 오디오 콘텐츠 `isAdult`와 스케줄 현재시각 필터 테스트 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 실행 시 `Unresolved reference: isAdult`, `Too many arguments for limitSchedules` 컴파일 오류를 확인했다.
|
|
||||||
- 2026-06-12: Phase 2 리뷰 보정 GREEN 확인 - `CreatorChannelAudioContent`/`CreatorChannelAudioContentResponse`에 `isAdult`를 추가하고 `CreatorChannelHomeQueryPolicy.limitSchedules(schedules, now)`가 `scheduledAt > now`만 남기도록 수정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 통과.
|
|
||||||
- 2026-06-12: 스케줄 성인 노출 정책 보강 - PRD와 plan-task에 repository query 1차 필터 + service 최종 보정 방식을 명시하고, 내부 `CreatorChannelSchedule.isAdult`와 `CreatorChannelHomeQueryPolicy.limitSchedules(schedules, now, canViewAdultContent)`를 반영했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 3 RED/GREEN 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 시 `Unresolved reference: DefaultCreatorChannelHomeQueryRepository` 컴파일 오류를 확인한 뒤 조회 port/record와 `DefaultCreatorChannelHomeQueryRepository`를 구현해 통과. 추가로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 3 예약 라이브 조건 보강 - 기존 `LiveRoomRepository.getLiveRoomListReservationWithoutDate` 의미에 맞춰 예약 라이브 스케줄은 `channelName`이 비어 있는 row만 조회하도록 테스트를 보강했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`가 스케줄 assertion에서 실패하는 것을 확인했고, `findSchedules` live 조건을 `channelName is null or empty`로 수정한 뒤 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 3 리뷰 보정 RED/GREEN 확인 - 비활성 팔로우 알림, `releaseDate == null` 공개 오디오, KST 월 경계/크리에이터 비밀 후원 열람, 팬 Talk 작성자→조회자 차단, 미래 라이브 데뷔일 제외 테스트를 추가한 뒤 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 5개 실패를 확인했다. 조회 조건을 기존 repository/service 의미에 맞게 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 3 Task 3.7~3.12 RED 확인 - `findCreator` 다중 활성 팔로워 count 회귀 테스트와 projection/bulk 구조 회귀 테스트를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 시 `shouldUseProjectionAndBulkQueriesForPhaseThreeOptimizedMethods`가 기존 `selectFrom(...)`, `fetch().size`, per-row helper 사용을 잡아 실패하는 것을 확인했다.
|
|
||||||
- 2026-06-12: Phase 3 Task 3.7~3.12 GREEN 확인 - `findCreator`, `findCurrentLive`, `findLatestAudioContent`, `findAudioContents`, `findChannelDonations`, `findSns`, `findSchedules`, `findCommunityPosts`, `findSeries`, `findFanTalkSummary`를 필요한 컬럼 projection, DB count, id 목록 기반 bulk 조회로 개선하고 `findActivity`의 id fetch count도 DB count로 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 통과.
|
|
||||||
- 2026-06-12: Phase 3 Task 3.7~3.12 회귀/정리 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest` 통과, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 3 Task 3.9/3.11 리뷰 보정 확인 - 스케줄 후보 병합 후 `limit` 적용 테스트와 `select(series)` entity fetch 금지 테스트를 추가해 RED를 확인했고, `findSeries`를 tuple projection과 `publishedDaysOfWeek` id 목록 기반 bulk join으로 변경했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과. `rg -n "\.select\(series\)|selectFrom\(series\)|publishedSeriesContents\(|hasNewSeriesContent\(" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` 결과 없음.
|
|
||||||
- 2026-06-12: Phase 3 추가 리뷰 보정 RED/GREEN 확인 - 커뮤니티 성인 필터/작성자 본인 구매 처리, 시리즈 성인·콘텐츠 타입·차단·신규 표시 정책, `releaseDate == null` 오디오의 `createdAt` 정렬 fallback, 팬 Talk 조회자 기준 차단 정책 테스트를 추가한 뒤 port/repository signature와 query 조건을 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 3 reviewer follow-up 보정 - 리뷰에서 지적된 보이는 첫 오디오 판정의 성인 정책 반영 누락과 시리즈 콘텐츠 후보의 `duration is not null` 조건 누락 가능성을 테스트로 고정했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 2개 실패를 확인했고, `firstAudioContentId`에 `canViewAdultContent`를 반영하고 시리즈 콘텐츠/신규 판정에 `duration.isNotNull`을 추가한 뒤 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 3 리뷰 결과 보정 RED/GREEN 확인 - 커뮤니티 댓글 수의 기존 목록 의미(parent null/양방향 차단/비밀 댓글 권한), 채널 후원 메시지 기본 문구+캔 포맷+추가 메시지 조합, 단독 오디오 duration null 제외 정책을 repository 테스트로 추가했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 3개 실패(`commentCount 5 -> 1`, null duration 최신 오디오 선택, 후원 메시지 additionalMessage 단독 반환)를 확인했고, query/메시지 조합을 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest` 통과.
|
|
||||||
- 2026-06-12: Phase 3 리뷰어 게이트 보정 - Context mining 리뷰에서 지적된 `isCommentAvailable == false` 게시글 댓글 수와 채널 후원 메시지 다국어 메시지 소스 의미를 추가 테스트로 고정했다. 보강 직후 constructor mismatch RED를 확인했고, repository에 `SodaMessageSource`/`LangContext` 기반 메시지 조합과 댓글 불가 게시글 `commentCount = 0` 처리를 반영한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest` 통과.
|
|
||||||
- 2026-06-12: Phase 3 문서 정합성 보정 - 성인 커뮤니티 글은 구매 여부와 무관하게 조회자의 성인 콘텐츠 노출 정책이 우선한다는 점과 `isFixed == true` 게시글은 `fixedAt != null`인 데이터로 본다는 전제를 PRD/plan-task에 명시했다.
|
|
||||||
- 2026-06-12: Phase 3 리뷰 결과 보정 - `isFixed == true`이지만 `fixedAt == null`인 공지 fixture와 팬 Talk 활성 최상위 글 전체 개수/최신 1건 테스트를 추가했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 공지 assertion 실패를 확인했고, 공지 조회에 `fixedAt.isNotNull` 조건을 추가하고 팬 Talk 요약을 `count` 쿼리와 최신 `limit(1).fetchFirst()` 쿼리로 분리한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-12: Phase 3 조회 효율 개선 Task 문서화 - `findCreator`의 count/exists 중심 개선과 entity 전체 컬럼 조회 후 record mapping을 줄이는 작업을 `Task 3.7`~`Task 3.12`로 분리해 plan-task에 추가했다. QueryDSL `@QueryProjection`은 사용하지 않고, persistence adapter 내부 projection/tuple/bulk/count 쿼리로 개선하는 방향을 명시했다.
|
|
||||||
- 2026-06-12: Phase 3 조회 효율 개선 Task 문서화 검증 - 문서 변경 후 `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했다. sandbox 권한에서는 `~/.gradle` lock 파일 접근 제한으로 실패했고, 권한 승격 후 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-12: 응답 계약 변경 문서화 - PRD와 plan-task에 `creator.characterId` 추가, 시리즈 응답의 `publishedDaysOfWeek`/`isComplete`/`isPopular` 제거를 반영했다. Phase 3의 미완료 항목 `Task 3.7`~`Task 3.12` 체크를 해제하고, 변경 반영 구현 항목을 `Task 3.13`, `Task 3.14`로 추가했다.
|
|
||||||
- 2026-06-12: 응답 계약 변경 문서 검증 - `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했다. sandbox 권한에서는 `~/.gradle` lock 파일 접근 제한으로 실패했고, 권한 승격 후 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-12: Phase 3 projection 구현 방향 문서화 - `Task 3.7`~`Task 3.12`에서 record 생성자 인자로 바로 매핑 가능한 조회는 `Tuple` 조회 후 수동 재조립하지 않고 QueryDSL `Projections.constructor`를 사용하도록 task 문구를 보정했다.
|
|
||||||
- 2026-06-12: Phase 3 projection 구현 방향 문서 검증 - `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했다. sandbox 권한에서는 `~/.gradle` lock 파일 접근 제한으로 실패했고, 권한 승격 후 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-13: Phase 3 Task 3.13/3.14 RED 확인 - `characterId`와 시리즈 응답 축소 계약 테스트를 먼저 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 시 `Unresolved reference: characterId`, `No value passed for parameter 'publishedDaysOfWeek'/'isComplete'/'isPopular'` 컴파일 오류를 확인했다.
|
|
||||||
- 2026-06-13: Phase 3 Task 3.13/3.14 GREEN 확인 - creator record/domain/response에 nullable `characterId`를 추가하고 `findCreator`가 활성 `ChatCharacter` id를 조회하도록 보정했다. series record/domain/response/repository projection에서는 `publishedDaysOfWeek`, `isComplete`, `isPopular`를 제거했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과. `rg -n "publishedDaysOfWeek|SeriesPublishedDaysOfWeek|SeriesState|isComplete|isPopular" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` 결과 없음.
|
|
||||||
- 2026-06-13: Phase 3 보안 리뷰 보정 RED/GREEN 확인 - 리뷰어 게이트에서 유료 커뮤니티 게시글 비구매자에게 원문 `content`와 `audioPath`가 노출될 수 있다는 차단 이슈를 확인했다. 비구매자/구매자/작성자 유료 커뮤니티 접근 테스트를 추가한 뒤 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 `shouldMaskPaidCommunityContentAndAudioForNonBuyer` 실패를 확인했고, 기존 커뮤니티 목록과 동일하게 유료/미구매/비작성자 본문 축약 및 오디오 숨김을 반영한 뒤 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 3 Task 3.7~3.12 constructor projection 조건 RED/GREEN 확인 - 직접 record 생성자 인자로 매핑 가능한 조회가 `Projections.constructor`를 사용하도록 source guardrail을 추가한 뒤 `shouldUseProjectionAndBulkQueriesForPhaseThreeOptimizedMethods` 실패를 확인했다. `findCurrentLive`, `findSchedules` live/audio 후보, `findFanTalkSummary` 최신 글, `findSns`를 QueryDSL `Projections.constructor`로 변경하고 후처리/마스킹/집계가 필요한 creator/audio/donation/community/series/activity 조회는 수동 조립을 유지했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과. `rg -n "publishedDaysOfWeek|SeriesPublishedDaysOfWeek|SeriesState|isComplete|isPopular" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` 결과 없음.
|
|
||||||
- 2026-06-13: Phase 3 오디오 공개 조건 문서/구현 정합성 보정 - 공개 또는 예약 공개 오디오는 `releaseDate != null`이고, `releaseDate == null`은 삭제/미공개 데이터로 조회에서 제외한다는 전제를 PRD/plan-task에 반영했다. `releaseDate == null` 오디오가 최신/목록/첫 콘텐츠/시리즈 공개 콘텐츠 집계에서 제외되는 테스트를 추가했고, 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 3개 실패를 확인했다. `findAudioContentRows`, `firstAudioContentId`, `seriesContentStats`의 공개 조건을 `releaseDate != null && releaseDate <= now`로 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 3 데뷔일 오디오 후보 정책 보정 - 오디오 데뷔 후보는 업로드 순서 기준 첫 3개만 보며, 1~2번째 삭제 오디오는 건너뛰고 3번째 삭제 오디오는 4번째로 넘어가지 않고 해당 `createdAt`을 후보로 쓰는 정책을 PRD/plan-task에 반영했다. 3번째 삭제 시 4번째 공개 오디오로 넘어가던 RED를 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 확인했고, `findActivity`가 `firstAudioDebutAt`으로 오디오 후보를 계산하도록 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 3 P1/P2 리뷰 보정 RED/GREEN 확인 - 예약 오디오는 `duration != null && releaseDate != null && releaseDate > now`이면 `isActive`와 관계없이 스케줄 후보라는 정책과, 시리즈 신규 표시가 기존 목록처럼 7일 전/현재 시각 경계를 포함한다는 정책을 repository 테스트로 추가했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 2개 실패를 확인했고, `findSchedules` 예약 오디오 조건과 `newSeriesIds`의 `between(now.minusDays(7), now)` 조건을 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과.
|
|
||||||
- 2026-06-13: 채널 후원 응답 계약 변경 문서화 - 채널 후원 홈 응답에서 기본 후원 문구 조합과 공개 응답의 `donationId`/`memberId`/`isSecret`을 제거하고, 후원자 닉네임/프로필 이미지/후원 can/추가 메시지/UTC 생성 시각만 내려주는 요구사항을 PRD와 plan-task에 반영했다. Phase 3에 `Task 3.15`를 추가해 repository/service/controller 테스트와 구현 범위를 문서화했다. `git diff --check`, `./gradlew tasks --all --no-daemon` 통과.
|
|
||||||
- 2026-06-13: 채널 후원 비밀 후원 정책/Projection 구현 기준 문서화 - `Task 3.15`에 비밀 후원은 후원자 본인과 받은 크리에이터만 조회 가능하고 제3자는 조회할 수 없는 negative case를 추가했다. `isSecret`은 repository 조회 조건에만 사용하고 공개 응답에는 포함하지 않는다고 명시했으며, record 생성자 인자로 바로 매핑되는 조회는 QueryDSL `Projections.constructor`를 사용하도록 구현 기준을 보강했다. `git diff --check`, `./gradlew tasks --all --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 3 Task 3.16 RED/GREEN 확인 - 구매한 유료 커뮤니티 게시글은 크리에이터가 이후 삭제해 `CreatorCommunity.isActive == false`가 되어도 구매자에게 조회되고, 비구매자에게는 조회되지 않는 요구사항을 PRD/plan-task에 반영했다. `shouldExposeDeletedPaidCommunityContentToBuyer` 테스트 추가 직후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`에서 해당 테스트 실패를 확인했고, `findCommunityPosts` 게시글 후보 조건을 `isActive == true` 또는 환불되지 않은 `PAID_COMMUNITY_POST` 구매 이력 존재로 보정했다. 구현 방식은 게시글 후보 조회 후 구매 여부/좋아요 수/댓글 수를 id 목록 기반 bulk 조회하는 기존 구조를 유지했다. 보정 후 같은 repository 테스트 통과.
|
|
||||||
- 2026-06-13: Phase 3 Task 3.16 정리 검증 - `git diff --check`, `./gradlew ktlintCheck --no-daemon`, `./gradlew tasks --all --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 3 리뷰 반영 RED/GREEN 확인 - `LiveRoomStatus` 자체가 아니라 라이브 홈 조회의 노출 정책만 보정하기 위해 현재/예약 라이브에 조회자 성별 제한과 크리에이터 입장 제한 테스트를 추가하고, 팬 Talk 작성자 닉네임의 `deleted_` prefix 제거 테스트를 추가했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`가 `viewerId`/`isViewerCreator`/`effectiveViewerGender` 미정의 컴파일 오류로 실패하는 것을 확인했고, `findCurrentLive`/`findSchedules`에 조회자 라이브 정책 입력을 추가해 라이브 후보만 필터링하고 `findFanTalkSummary` 최신 글 닉네임에 `removeDeletedNicknamePrefix()`를 적용했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 3 리뷰 반영 정리 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `git diff --check`, `./gradlew ktlintCheck --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 3 P2/P3 리뷰 반영 RED/GREEN 확인 - query port 계약을 기존 raw 조회자 성별 파라미터명이 아니라 기존 라이브 목록 의미와 같은 `effectiveViewerGender`로 명확히 바꾸기 위해 repository 테스트 호출부를 먼저 변경했고, `DefaultCreatorChannelHomeQueryRepositoryTest`에서 `Cannot find a parameter with this name: effectiveViewerGender` 컴파일 실패를 확인했다. 이후 `CreatorChannelHomeQueryPort`와 `DefaultCreatorChannelHomeQueryRepository`의 현재/예약 라이브 조회 계약을 `effectiveViewerGender`로 변경했다. PRD와 plan-task에는 현재/예약 라이브의 성별 제한·크리에이터 입장 제한 정책, service의 `Auth.gender` 우선 effective gender 산출, Phase 4 service fake port 테스트 요구사항을 명시했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `git diff --check`, `./gradlew ktlintCheck --no-daemon` 통과. `rg -n "viewer""Gender" docs/20260612_크리에이터_채널_홈_API src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` 결과 없음.
|
|
||||||
- 2026-06-13: Phase 4 Task 4.1/4.2 RED 확인 - `CreatorChannelHomeQueryServiceTest`에 fake port 기반 정상 조립/최종 정책 테스트와 user_not_found/creator_not_found/blocked_access 예외 테스트를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --no-daemon`에서 `Unresolved reference: CreatorChannelHomeQueryService`, `findCreatorRole overrides nothing` 컴파일 오류를 확인했다.
|
|
||||||
- 2026-06-13: Phase 4 Task 4.1/4.2 GREEN 확인 - `CreatorChannelHomeQueryService`를 추가해 port record를 domain 모델로 조립하고, `Auth.gender` 우선 `effectiveViewerGender`, 조회자 본인 여부, 성인 노출 정책, 최신 오디오 중복 제거, 스케줄 최종 제한/정렬/성인 제외, CloudFront URL 변환을 적용했다. 비크리에이터 예외 구분을 위해 `CreatorChannelHomeQueryPort.findCreatorRole`과 repository 구현을 최소 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 4 정리 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과. `rg -n "CreatorChannelHomeController|/api/v2/creator-channels" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` 결과 없음.
|
|
||||||
- 2026-06-13: Phase 4 리뷰 보정 RED/GREEN 확인 - 기존 채널 상세 정책과 동일하게 대상 회원 존재 확인 후 차단 관계를 먼저 검사하고 role 검사는 그 다음 수행하도록 조정했다. `findCreatorRole` 별도 port를 제거하고 `CreatorChannelCreatorRecord.role`로 기본 회원 조회 record에서 role을 함께 반환하게 변경했다. 차단 관계가 있으면 대상 회원이 비크리에이터여도 접근 차단 예외가 우선되는 service RED와, 비크리에이터 기본 회원도 role과 함께 반환되는 repository 계약 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 5 Task 5.1 RED 확인 - `CreatorChannelHomeControllerTest`를 먼저 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 실행 시 `Unresolved reference: CreatorChannelHomeController` 컴파일 오류를 확인했다.
|
|
||||||
- 2026-06-13: Phase 5 Task 5.1/5.2 GREEN 확인 - `CreatorChannelHomeController`를 추가해 `GET /api/v2/creator-channels/{creatorId}/home` 인증 회원 endpoint, `common.error.bad_credentials` null guard, `ApiResponse.ok(CreatorChannelHomeResponse.from(...))` 응답을 구현했다. MockMvc 테스트로 비회원 요청 401, 인증 회원 요청의 service creatorId/viewer 전달, 최상위 JSON 필드와 boolean `is` prefix 계약을 고정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 5 회귀/정리 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check`, `./gradlew test --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 6 Task 6.1 통합 시나리오 검증 - `DefaultCreatorChannelHomeQueryRepositoryTest`에 현실적인 단일 크리에이터 fixture로 creator/currentLive/latestAudioContent/channelDonations/notices/schedules/audioContents/series/communities/fanTalk/activity/sns 후보 조회를 모두 검증하는 `shouldFindCreatorChannelHomeIntegratedSections`를 추가했다. 기존 구현에서 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests '*shouldFindCreatorChannelHomeIntegratedSections' --no-daemon` 통과. MockMvc 응답 표면은 `CreatorChannelHomeControllerTest`에 schedule 내부 `isAdult`와 channelDonation 내부 `donationId`/`memberId`/`isSecret` 비노출 assertion을 보강했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과.
|
|
||||||
- 2026-06-13: Phase 6 Task 6.2 추천 페이지 enum rename 회귀 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --no-daemon` 통과. `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과 없음.
|
|
||||||
- 2026-06-13: Phase 6 Task 6.3 전체 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check`, `./gradlew test --no-daemon` 통과. 병렬 Gradle 실행 중 `build/snapshot/kotlin/kaptGenerateStubsTestKotlin` 삭제 경합이 한 번 발생했으나 동일 repository 테스트를 단독 재실행해 통과를 확인했다.
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
# PRD: 크리에이터 채널 홈 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
크리에이터 채널 신규 페이지의 홈 탭에 필요한 크리에이터 정보, 라이브/오디오/후원/공지/스케줄/시리즈/커뮤니티/팬 Talk/소개/활동/SNS 데이터를 한 번에 조회하는 v2 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 신규 크리에이터 채널 화면은 기존 `ExplorerService.getCreatorProfile`, `ExplorerService.getCreatorDetail`, 커뮤니티, 후원, 오디오, 라이브, 시리즈 도메인 데이터가 섞여 있어 홈 탭용 API 계약을 먼저 확정해야 한다.
|
|
||||||
- Figma 홈 화면에는 여러 섹션이 한 스크롤에 배치되어 있으므로 클라이언트가 섹션별 기존 API를 여러 번 호출하면 초기 진입 속도와 계약 관리가 불리하다.
|
|
||||||
- 공지와 커뮤니티는 같은 커뮤니티 게시글 데이터를 사용하지만 `isFixed` 여부에 따라 홈에서 다른 섹션으로 분리되어야 한다.
|
|
||||||
- 예약 라이브와 예약 업로드 오디오 콘텐츠를 하나의 스케줄 섹션으로 합쳐 노출해야 하므로 타입과 이동 대상 id를 명확히 내려줘야 한다.
|
|
||||||
- 활동 지수와 SNS는 기존 구버전 크리에이터 채널 상세(`ExplorerService.getCreatorDetail`)와 의미가 어긋나지 않아야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 크리에이터 채널 홈 탭 첫 화면을 구성하는 단일 조회 API를 제공한다.
|
|
||||||
- API는 인증 회원만 조회할 수 있도록 제공한다.
|
|
||||||
- API는 인증 회원 기준 성인 콘텐츠 노출 정책, 차단 관계, 구매 여부 등 기존 도메인 정책을 가능한 한 유지한다.
|
|
||||||
- 응답 시간은 앱 표시 포맷에 의존하지 않도록 UTC 기준 문자열로 내려준다.
|
|
||||||
- 공지, 커뮤니티 게시글은 홈 노출에 필요한 게시글 요약 필드를 제공한다.
|
|
||||||
- 채널 후원은 최신순 8개를 내려준다.
|
|
||||||
- 오디오 콘텐츠는 최근 업로드 기준 최대 9개를 내려주고, 예약 업로드 전 콘텐츠는 일반 오디오 목록에는 포함하지 않는다.
|
|
||||||
- 시리즈는 최대 8개를 내려주고, 해당 시리즈에 속한 콘텐츠의 최신 공개일 기준으로 정렬한다.
|
|
||||||
- 팬 Talk는 가장 최근에 남긴 팬 Talk 1개와 전체 팬 Talk 개수를 함께 내려준다.
|
|
||||||
- 활동 지수와 SNS는 `ExplorerService.getCreatorDetail`의 계산/필드 의미를 기준으로 확장한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 이번 범위는 크리에이터 채널 `홈` API만 포함한다.
|
|
||||||
- Figma 상단 탭의 `라이브`, `오디오`, `시리즈`, `커뮤니티`, `팬Talk`, `후원` 탭별 전체보기/페이징 API는 다음 범위에서 추가한다.
|
|
||||||
- `화보` 섹션과 화보 활동 지표는 이번 범위에서 제외한다.
|
|
||||||
- 기존 구버전 크리에이터 채널 API의 공개 스키마는 변경하지 않는다.
|
|
||||||
- 커뮤니티 글 작성, 팬 Talk 작성, 채널 후원 등록, 팔로우/알림/DM/AI 채팅 실행 API는 포함하지 않는다.
|
|
||||||
- 관리자 화면 신규 개발은 포함하지 않는다.
|
|
||||||
- 앱 표시용 다국어 문구, 날짜 포맷, 숫자 단위 축약 표시는 서버에서 처리하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 크리에이터 채널 홈에서 최신 활동과 대표 콘텐츠를 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 홈 탭 진입 시 한 API 응답으로 섹션을 구성하려는 클라이언트
|
|
||||||
- 크리에이터: 자신의 채널 홈에 공지, 콘텐츠, 후원, 활동 정보가 적절히 노출되기를 원하는 사용자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 크리에이터 채널에 진입하면 닉네임, 팔로워 수, AI 채팅 가능 여부, DM 가능 여부를 바로 확인하고 싶다.
|
|
||||||
- 사용자는 크리에이터가 현재 진행 중인 라이브가 있으면 홈에서 바로 보고 싶다.
|
|
||||||
- 사용자는 크리에이터가 최근 올린 오디오 콘텐츠와 시리즈를 홈에서 빠르게 탐색하고 싶다.
|
|
||||||
- 사용자는 고정된 커뮤니티 글은 공지로, 일반 커뮤니티 글은 커뮤니티 섹션으로 구분해 보고 싶다.
|
|
||||||
- 사용자는 예정된 라이브와 예약 업로드 오디오 콘텐츠를 시간순으로 보고 싶다.
|
|
||||||
- 사용자는 최근 채널 후원, 팬 Talk, 소개, 활동 지표, SNS 링크를 한 화면에서 확인하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 크리에이터 채널 홈 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API는 메인 페이지 홈 API와 분리된 크리에이터 채널 전용 v2 API로 작성한다.
|
|
||||||
- 신규 코드 위치는 `kr.co.vividnext.sodalive.v2.creator.channel` 하위 경계를 기본 후보로 한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/home`을 기본안으로 한다.
|
|
||||||
- 인증 회원만 조회할 수 있어야 한다.
|
|
||||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 구버전 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
|
||||||
- 섹션별 데이터가 부족하면 가능한 만큼만 내려주고 전체 API는 성공 처리한다.
|
|
||||||
- 섹션 데이터가 없으면 빈 배열 또는 `null`로 내려주되, 응답 스키마는 유지한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
|
||||||
|
|
||||||
### Feature B. 크리에이터 기본 정보
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 크리에이터 기본 정보에는 다음 값을 포함한다.
|
|
||||||
- `creatorId`
|
|
||||||
- `characterId`
|
|
||||||
- `nickname`
|
|
||||||
- `profileImageUrl`
|
|
||||||
- `followerCount`
|
|
||||||
- `isAiChatAvailable`
|
|
||||||
- `isDmAvailable`
|
|
||||||
- `isFollow`
|
|
||||||
- `isNotify`
|
|
||||||
- `followerCount`는 활성 팔로우 수 기준으로 계산한다.
|
|
||||||
- `characterId`는 해당 `Member`와 연결된 활성 `ChatCharacter` ID를 내려주고, 활성 캐릭터가 없으면 `null`로 내려준다.
|
|
||||||
- `isAiChatAvailable`은 해당 `Member`와 연결된 활성 `ChatCharacter`가 있는지로 판단한다. 구현 후보는 `ChatCharacterRepository.existsByCreatorMemberId(creatorId)`를 기준으로 한다.
|
|
||||||
- `isDmAvailable`은 `member.memberKind != MemberKind.AI_CHARACTER`이면 `true`, `AI_CHARACTER`이면 `false`로 판단한다.
|
|
||||||
- `isFollow`, `isNotify`는 인증 회원의 기존 `CreatorFollowing` 상태를 기준으로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 프로필 이미지가 없으면 기존 기본 프로필 이미지 URL을 내려준다.
|
|
||||||
- AI 캐릭터 크리에이터는 AI 채팅 가능 여부가 `true`일 수 있지만 DM 가능 여부는 `false`일 수 있다.
|
|
||||||
|
|
||||||
### Feature C. 현재 진행 중인 라이브
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 크리에이터가 현재 진행 중인 라이브를 내려준다.
|
|
||||||
- 현재 진행 중인 라이브는 기존 라이브 도메인의 `LiveRoomStatus.NOW` 의미와 동일하게 판단한다.
|
|
||||||
- 응답에는 라이브 ID, 제목, 커버 이미지, 시작 시각 UTC, 유료 여부 또는 가격, 성인 여부, 예약 여부가 아닌 현재 라이브 여부를 포함한다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책과 차단 정책을 반영한다.
|
|
||||||
- 현재 라이브 노출은 기존 라이브 목록 정책과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다.
|
|
||||||
- 성별 제한 판단에 사용하는 조회자 성별은 기존 라이브 목록과 동일하게 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 effective gender다.
|
|
||||||
- `LiveRoomStatus`는 라이브가 현재 진행 중인지/예약인지 구분하는 기준일 뿐, 라이브 노출 정책 자체로 사용하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 현재 진행 중인 라이브가 없으면 `currentLive`는 `null`로 내려준다.
|
|
||||||
|
|
||||||
### Feature D. 신규 오디오 콘텐츠
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- Figma 홈 상단에 노출되는 신규 오디오 콘텐츠 영역에 사용할 최신 공개 오디오 콘텐츠를 내려준다.
|
|
||||||
- 예약 공개 전 콘텐츠는 신규 오디오 콘텐츠로 노출하지 않는다.
|
|
||||||
- 공개 또는 예약 공개 오디오 콘텐츠는 항상 `releaseDate != null`인 데이터로 본다. `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 홈 오디오 조회에서 제외한다.
|
|
||||||
- 응답 필드는 홈 오디오 콘텐츠 카드와 동일하게 콘텐츠 ID, 제목, duration, 커버 이미지, 가격, 포인트 사용 가능 여부, 성인 여부를 포함한다.
|
|
||||||
- 정렬은 공개 시각 최신순이다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 공개된 오디오 콘텐츠가 없으면 `latestAudioContent`는 `null`로 내려준다.
|
|
||||||
|
|
||||||
### Feature E. 채널 후원
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 채널 후원은 최신순 최대 8개를 내려준다.
|
|
||||||
- 조회 범위는 기존 채널 후원 목록과 동일하게 이번 달 기준으로 한다.
|
|
||||||
- 응답에는 후원자 닉네임, 후원자 프로필 이미지, 후원한 can 수, 후원 시 추가한 메시지, 생성 시각 UTC를 포함한다.
|
|
||||||
- `message`는 기본 문구(`"%s캔을 비밀후원하셨습니다."`, `"%s캔을 후원하셨습니다."`)를 조합하지 않고, 후원자가 입력한 추가 메시지(`ChannelDonationMessage.additionalMessage`)만 내려준다.
|
|
||||||
- 비밀 후원 노출/숨김 정책은 기존 채널 후원 목록 정책을 따른다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 후원이 없으면 빈 배열을 내려준다.
|
|
||||||
- 후원자가 추가 메시지를 입력하지 않았으면 `message`는 빈 문자열로 내려준다.
|
|
||||||
|
|
||||||
### Feature F. 공지
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 커뮤니티 게시글 중 `isFixed == true`인 글을 홈의 공지 섹션으로 처리한다.
|
|
||||||
- 응답에는 게시글 ID, 크리에이터 ID, 크리에이터 닉네임, 크리에이터 프로필 이미지, 이미지 URL, 오디오 URL, 본문, 가격, 작성 시각 UTC, 구매 여부, 좋아요 수, 댓글 수를 포함한다.
|
|
||||||
- 홈 응답에 포함하는 게시글 요약 필드는 기존 크리에이터 커뮤니티 전체보기 게시글 리스트와 같은 의미를 유지한다.
|
|
||||||
- 유료 공지는 조회자가 이미 구매했다면 크리에이터가 이후 삭제해 `isActive == false`가 되어도 기존 커뮤니티 전체보기 의미와 동일하게 구매자에게 조회된다.
|
|
||||||
- `isFixed == true`인 게시글은 항상 `fixedAt != null`인 데이터로 본다.
|
|
||||||
- 정렬은 고정 시각 최신순으로 한다.
|
|
||||||
- 공지 최대 노출 개수는 기존 고정 글 제한 정책에 맞춰 최대 3개로 한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 고정 게시글이 없으면 빈 배열을 내려준다.
|
|
||||||
- 성인 커뮤니티 글은 조회자의 성인 콘텐츠 노출 정책을 따른다. 조회자가 해당 글을 구매했더라도 성인 콘텐츠 노출 정책이 false이면 노출하지 않는다.
|
|
||||||
|
|
||||||
### Feature G. 스케줄
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 예약 라이브와 예약 업로드 오디오 콘텐츠를 하나의 스케줄 배열로 내려준다.
|
|
||||||
- 스케줄은 오늘 날짜와 가장 근접한 예약 항목 최대 3개를 내려준다.
|
|
||||||
- 예약 라이브는 `LiveRoomStatus.RESERVATION` 의미와 동일하게, `LiveRoom.beginDateTime > now`이고 활성 상태인 라이브를 대상으로 한다.
|
|
||||||
- 예약 업로드 오디오 콘텐츠는 `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate > now`인 콘텐츠를 대상으로 한다. `AudioContent.isActive` 값은 예약 스케줄 후보 판정에 사용하지 않는다.
|
|
||||||
- 응답에는 예약 날짜/시간 UTC, 제목, 타입, 대상 ID를 포함한다.
|
|
||||||
- 타입 값은 기존 추천 페이지의 `RecommendedActivityType` 코드 체계를 사용한다.
|
|
||||||
- 구현 시 `RecommendedActivityType`은 `CreatorActivityType`으로 이름을 변경하고 공용 패키지로 이동한다.
|
|
||||||
- 추천 페이지와 크리에이터 채널 홈은 이동된 공용 `CreatorActivityType`을 함께 사용한다.
|
|
||||||
- 크리에이터 채널 홈 스케줄에서는 `LIVE`, `AUDIO`만 사용한다.
|
|
||||||
- 오디오 콘텐츠가 `다시보기` 카테고리여도 스케줄 타입은 `LIVE_REPLAY`가 아니라 `AUDIO`로 내려준다.
|
|
||||||
- 대상 ID는 타입이 `LIVE`이면 라이브 ID, `AUDIO`이면 오디오 콘텐츠 ID를 의미한다.
|
|
||||||
- 정렬은 예약 날짜/시간 오름차순이다. 같은 예약 시간이면 라이브를 오디오보다 먼저 표시한다.
|
|
||||||
- 성인 예약 라이브/오디오는 조회자의 성인 노출 정책이 false이면 노출하지 않는다.
|
|
||||||
- 성인 노출 정책은 DB 조회 조건에 먼저 반영하고, 라이브/오디오 스케줄 후보를 service에서 합친 뒤에도 최종 응답 전 한 번 더 보정한다.
|
|
||||||
- 예약 라이브 스케줄은 기존 예약 라이브 목록 정책과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다.
|
|
||||||
- 예약 라이브 성별 제한 판단에 사용하는 조회자 성별은 기존 라이브 목록과 동일하게 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 effective gender다.
|
|
||||||
- service 최종 보정에 필요한 성인 여부는 내부 스케줄 후보 record/domain model에만 포함하고, 공개 스케줄 응답 필드에는 포함하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 예약 데이터가 없으면 빈 배열을 내려준다.
|
|
||||||
|
|
||||||
### Feature H. 오디오 콘텐츠 목록
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 최근 업로드된 오디오 콘텐츠를 최대 9개 내려준다.
|
|
||||||
- 신규 오디오 콘텐츠 영역과 오디오 목록 영역의 첫 번째 항목이 겹치지 않도록, 오디오 목록에서는 Feature D의 `latestAudioContent`로 내려간 가장 최신 콘텐츠를 제외한다.
|
|
||||||
- 예약 업로드 전 콘텐츠는 포함하지 않는다.
|
|
||||||
- `releaseDate == null`인 오디오 콘텐츠는 목록, 최신 콘텐츠, 첫 콘텐츠 판정에서 제외한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- 오디오 콘텐츠 ID
|
|
||||||
- 제목
|
|
||||||
- duration
|
|
||||||
- 이미지
|
|
||||||
- 가격
|
|
||||||
- 포인트 사용 가능 여부
|
|
||||||
- 처음 올린 콘텐츠인지 여부
|
|
||||||
- 시리즈에 속해 있는 경우 시리즈 이름
|
|
||||||
- 시리즈에 속해 있는 경우 오리지널 시리즈 여부
|
|
||||||
- 기존 오디오 콘텐츠 목록의 `isPointAvailable`, `isScheduledToOpen`, 구매/대여 상태 의미를 유지한다.
|
|
||||||
- `처음 올린 콘텐츠인지 여부`는 해당 크리에이터의 공개 오디오 콘텐츠 중 공개 순서가 첫 번째인지로 판단한다.
|
|
||||||
- 공개 순서는 공개 시각이 가장 빠른 콘텐츠 1개를 첫 콘텐츠로 판단한다. 동일한 공개 시각이 있으면 `id` 오름차순을 2차 기준으로 한다.
|
|
||||||
- `오리지널 시리즈 여부`는 콘텐츠가 속한 `Series.isOriginal == true`이면 `true`로 판단한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 시리즈에 속하지 않은 콘텐츠는 시리즈 관련 필드를 `null`로 내려준다.
|
|
||||||
- 오디오 콘텐츠가 없으면 빈 배열을 내려준다.
|
|
||||||
|
|
||||||
### Feature I. 시리즈
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 시리즈는 최대 8개를 내려준다.
|
|
||||||
- 정렬은 각 시리즈에 속한 공개 콘텐츠의 최신 공개 시각 내림차순이다.
|
|
||||||
- 응답에는 기존 시리즈 카드 구성에 필요한 시리즈 ID, 제목, 커버 이미지, 콘텐츠 개수, 신규 표시, 오리지널 시리즈 여부를 포함한다.
|
|
||||||
- 시리즈 응답에는 `publishedDaysOfWeek`, `isComplete`, `isPopular`를 포함하지 않는다.
|
|
||||||
- 시리즈의 공개 콘텐츠 집계와 정렬에서도 `releaseDate == null`인 오디오 콘텐츠는 제외한다.
|
|
||||||
- 성인 콘텐츠 노출 정책과 조회자 콘텐츠 타입 선호 정책은 기존 `ContentSeriesService.getSeriesList` 정책을 따른다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 공개 콘텐츠가 없는 시리즈를 노출할지 여부는 기존 시리즈 목록 정책을 따른다.
|
|
||||||
- 시리즈가 없으면 빈 배열을 내려준다.
|
|
||||||
|
|
||||||
### Feature J. 커뮤니티
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 커뮤니티 섹션은 `isFixed == false`인 커뮤니티 게시글만 대상으로 한다.
|
|
||||||
- 최대 3개를 최신순으로 내려준다.
|
|
||||||
- 응답에는 게시글 ID, 크리에이터 ID, 크리에이터 닉네임, 크리에이터 프로필 이미지, 이미지 URL, 오디오 URL, 본문, 가격, 작성 시각 UTC, 구매 여부, 좋아요 수, 댓글 수를 포함한다.
|
|
||||||
- 홈 응답에 포함하는 게시글 요약 필드는 기존 크리에이터 커뮤니티 전체보기 게시글 리스트와 같은 의미를 유지한다.
|
|
||||||
- 유료 커뮤니티 게시글은 조회자가 이미 구매했다면 크리에이터가 이후 삭제해 `isActive == false`가 되어도 기존 커뮤니티 전체보기 의미와 동일하게 구매자에게 조회된다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 고정 공지는 커뮤니티 섹션에 중복 노출하지 않는다.
|
|
||||||
- 성인 커뮤니티 글은 조회자의 성인 콘텐츠 노출 정책을 따른다. 조회자가 해당 글을 구매했더라도 성인 콘텐츠 노출 정책이 false이면 노출하지 않는다.
|
|
||||||
- 커뮤니티 게시글이 없으면 빈 배열을 내려준다.
|
|
||||||
|
|
||||||
### Feature K. 팬 Talk
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 팬 Talk는 가장 최근에 남긴 최상위 팬 Talk 1개를 내려준다.
|
|
||||||
- 전체 팬 Talk 개수를 함께 내려준다.
|
|
||||||
- 기존 `CreatorCheers`에서 `parent == null`, `isActive == true`인 항목을 팬 Talk 대상으로 본다.
|
|
||||||
- 최근 팬 Talk 응답에는 팬 Talk ID, 작성자 ID, 작성자 닉네임, 작성자 프로필 이미지, 내용, 언어 코드, 작성 시각 UTC를 포함한다.
|
|
||||||
- 답글 목록은 홈 팬 Talk 요약에서는 내려주지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 팬 Talk가 없으면 `latestFanTalk`는 `null`, `totalCount`는 `0`으로 내려준다.
|
|
||||||
- 조회자와 차단 관계가 있는 작성자의 팬 Talk는 기존 팬 Talk 목록 정책과 동일하게 제외한다.
|
|
||||||
|
|
||||||
### Feature L. 소개
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 소개는 `member.introduce` 값을 내려준다.
|
|
||||||
- 값이 비어 있으면 빈 문자열을 내려준다.
|
|
||||||
|
|
||||||
### Feature M. 활동
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 활동 섹션은 `ExplorerService.getCreatorDetail`의 활동 지표 의미를 기준으로 한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- 데뷔일 UTC
|
|
||||||
- D-Day 표시 계산에 필요한 경과 일수 또는 `dDay`
|
|
||||||
- 라이브 진행 횟수
|
|
||||||
- 라이브 누적 진행 시간
|
|
||||||
- 라이브 누적 참여자
|
|
||||||
- 업로드한 오디오 콘텐츠 개수
|
|
||||||
- 시리즈 개수
|
|
||||||
- 데뷔일은 첫 라이브 시작 시각과 첫 오디오 데뷔 후보 시각 중 빠른 값으로 계산한다.
|
|
||||||
- 오디오 데뷔 후보는 오디오 업로드 순서(`createdAt`, 동일 시각이면 `id`) 기준 첫 3개만 본다.
|
|
||||||
- 첫 번째 또는 두 번째 오디오가 삭제되어 `releaseDate == null`이면 다음 업로드 오디오의 공개 시각을 후보로 본다.
|
|
||||||
- 세 번째 오디오가 삭제되어 `releaseDate == null`이면 네 번째 오디오로 넘어가지 않고 세 번째 오디오의 `createdAt`을 후보로 본다.
|
|
||||||
- 세 번째 오디오까지 공개 후보가 없고 세 번째 삭제 오디오도 없으면 오디오 데뷔 후보는 없다.
|
|
||||||
- 라이브 진행 횟수, 라이브 누적 진행 시간, 라이브 누적 참여자는 기존 `ExplorerQueryRepository.getLiveCount`, `getLiveTime`, `getLiveContributorCount`의 의미를 기준으로 한다.
|
|
||||||
- 업로드한 오디오 콘텐츠 개수는 공개된 오디오 콘텐츠만 포함하고 예약 업로드는 반영하지 않는다.
|
|
||||||
- 시리즈 개수는 크리에이터의 활성 시리즈 개수를 기준으로 한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 데뷔일 후보가 없으면 데뷔일은 `null`, `dDay`는 빈 문자열로 내려준다.
|
|
||||||
- 라이브 진행 시간이 없는 경우 `0`으로 내려준다.
|
|
||||||
|
|
||||||
### Feature N. SNS
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- SNS는 `ExplorerService.getCreatorDetail`의 SNS 필드 의미를 기준으로 한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- `instagramUrl`
|
|
||||||
- `fancimmUrl`
|
|
||||||
- `xUrl`
|
|
||||||
- `youtubeUrl`
|
|
||||||
- `kakaoOpenChatUrl`
|
|
||||||
- 값이 없으면 빈 문자열 또는 `null` 중 기존 응답 관례를 따른다. 현재 구버전 상세는 빈 문자열을 사용한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. UX / UI Expectations
|
|
||||||
- Figma node `296:14890` 기준 홈 화면 섹션 순서는 다음을 따른다.
|
|
||||||
- 크리에이터 기본 정보
|
|
||||||
- 홈 탭
|
|
||||||
- 현재 진행 중인 라이브
|
|
||||||
- 신규 오디오 콘텐츠
|
|
||||||
- 채널 후원
|
|
||||||
- 공지
|
|
||||||
- 스케줄
|
|
||||||
- 오디오 콘텐츠
|
|
||||||
- 시리즈
|
|
||||||
- 커뮤니티
|
|
||||||
- 팬 Talk
|
|
||||||
- 소개
|
|
||||||
- 활동
|
|
||||||
- SNS
|
|
||||||
- Figma에 상단 탭으로 `홈/라이브/오디오/시리즈/화보/커뮤니티/팬Talk/후원`이 보이지만, 이번 API는 홈 탭만 지원한다.
|
|
||||||
- Figma 활동 섹션에는 `화보` 항목이 보이지만 이번 범위에서는 제외한다.
|
|
||||||
- 서버는 앱 표시 문구를 조합하지 않고, 앱이 섹션 노출 여부와 텍스트 포맷을 결정할 수 있는 원천 데이터를 내려준다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Technical Constraints
|
|
||||||
- 빌드 도구는 Gradle Wrapper(`./gradlew`), 런타임은 Kotlin + Java 17, Spring Boot 2.7.14를 따른다.
|
|
||||||
- 기존 v2 공개 API처럼 Controller는 `ApiResponse.ok(...)` 형태를 사용한다.
|
|
||||||
- 신규 API/서비스/DTO는 메인 페이지 홈 패키지(`kr.co.vividnext.sodalive.v2.api.home`)와 섞지 않고 크리에이터 채널 전용 패키지 경계에 둔다.
|
|
||||||
- 구버전 `explorer`, `content`, `live`, `series` 구현 코드는 응답 의미와 도메인 정책을 맞추기 위한 근거로 참조한다. 신규 크리에이터 채널 홈 API의 application/DTO 경계는 별도로 둔다.
|
|
||||||
- 시간 응답은 UTC 기준 ISO-8601 문자열을 기본으로 한다.
|
|
||||||
- 공개 API 스키마는 구현 전 plan-task에서 DTO 필드명과 nullable 정책을 확정한 뒤 변경한다.
|
|
||||||
- 신규 쿼리는 차단 관계, 비활성 회원, 성인 콘텐츠 노출, 예약 공개 여부를 명시적으로 테스트해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Metrics
|
|
||||||
- 크리에이터 채널 홈 API 응답 성공률
|
|
||||||
- 크리에이터 채널 홈 API 평균/95퍼센타일 응답 시간
|
|
||||||
- 섹션별 빈 응답 비율
|
|
||||||
- 채널 홈 진입 후 라이브/오디오/시리즈/커뮤니티/팬 Talk/후원 탭 이동률
|
|
||||||
- AI 채팅 버튼, DM 버튼 노출 대비 클릭률
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Open Questions
|
|
||||||
- 없음.
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
# 크리에이터 채널 라이브 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/live`로 현재 진행 중인 라이브와 라이브 다시듣기 콘텐츠를 페이징/정렬 조회할 수 있게 한다.
|
|
||||||
|
|
||||||
**Architecture:** 라이브 탭 공개 API는 기존 크리에이터 채널 홈 API 경계를 확장하지 않고 `kr.co.vividnext.sodalive.v2.api.creator.channel.live` 조립 계층에 둔다. Controller와 Facade, API 응답 DTO는 이 계층에서 관리하고, 라이브/콘텐츠/시리즈/주문 상태처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에서 제공한다. 공용 콘텐츠 정렬 enum은 채널 전용 이름을 피하고 `kr.co.vividnext.sodalive.v2.common.domain.ContentSort`로 둔다. 기존 홈 API는 이번 구현 중 구조 이동하지 않고, 마지막 Phase에 다음 범위 작업용 리팩토링 프롬프트만 남긴다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/live`
|
|
||||||
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
|
||||||
- request:
|
|
||||||
- path variable: `creatorId`
|
|
||||||
- query parameter: `sort`, 기본값 `LATEST`
|
|
||||||
- query parameter: `page`, 기본값 `0`, 0부터 시작
|
|
||||||
- query parameter: `size`, 기본값 `20`
|
|
||||||
- response:
|
|
||||||
- `liveReplayContentCount`: 같은 필터를 적용한 라이브 다시듣기 콘텐츠 전체 개수
|
|
||||||
- `currentLive`: 기존 `CreatorChannelLiveResponse`와 같은 필드/의미를 가진 라이브 탭 API 응답 DTO
|
|
||||||
- `liveReplayContents`: 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미에 `isOwned`, `isRented`를 포함한 라이브 탭 API 응답 DTO
|
|
||||||
- `sort`: 실제 적용한 `ContentSort`
|
|
||||||
- `page`: 이번 요청에 적용된 page index
|
|
||||||
- `size`: 이번 요청에 적용된 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- 라이브 탭 API 응답의 오디오 콘텐츠 item에는 `isOwned`, `isRented`를 포함한다.
|
|
||||||
- `isOwned`/`isRented` 판정은 주문 row를 각각 확인한다. 유효한 `KEEP` 주문이 있으면 `isOwned == true`, 유효한 `RENTAL` 주문이 있으면 `isRented == true`다.
|
|
||||||
- `isOwned == true`와 `isRented == true`가 동시에 발생할 가능성은 없지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
|
|
||||||
- 라이브 다시듣기 콘텐츠 기준: `AudioContentTheme.theme == "다시듣기"`이고 `AudioContentTheme.isActive == true`인 공개 오디오 콘텐츠.
|
|
||||||
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
|
|
||||||
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
|
|
||||||
- 현재 라이브 노출은 기존 홈 API의 `findCurrentLive` 정책을 재사용한다.
|
|
||||||
- 정렬:
|
|
||||||
- `LATEST`: `releaseDate desc`, `price desc`, `audioContent.id desc`
|
|
||||||
- `POPULAR`: 구매 매출 합계 desc, `releaseDate desc`, `audioContent.id desc`
|
|
||||||
- `OWNED`: 조회자 소장 여부 desc, `releaseDate desc`, `audioContent.id desc`
|
|
||||||
- `PRICE_HIGH`: `price desc`, `releaseDate desc`, `audioContent.id desc`
|
|
||||||
- `PRICE_LOW`: `price asc`, `releaseDate desc`, `audioContent.id desc`
|
|
||||||
- 인기순 매출은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출인 `orders.can` 합계를 사용한다. `orders.point`는 포함하지 않고, `orders.is_active = true`인 주문만 포함한다. 환불/비활성 주문은 제외한다.
|
|
||||||
- page/size validation은 service에서 명시적으로 수행한다. `page < 0`, `size < 20`, `size > 50`이면 기존 `common.error.invalid_request` 계열 오류를 사용한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 공용 정렬 enum
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSortTest.kt`
|
|
||||||
|
|
||||||
### 기존 크리에이터 채널 DTO/domain 확장
|
|
||||||
> 이미 완료된 선행 범위다. 미완료 라이브 탭 구현은 아래 `v2.api.creator.channel.live` 조립 계층과 `v2.creator.channel.live` 도메인 조회 계층을 따른다.
|
|
||||||
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 라이브 탭 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt`
|
|
||||||
|
|
||||||
### 라이브 탭 도메인 조회 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveReplayQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveReplayQueryPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Modify: `docs/20260617_크리에이터_채널_라이브_API/plan-task.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
|
|
||||||
data class CreatorChannelLiveTabResponse(
|
|
||||||
val liveReplayContentCount: Int,
|
|
||||||
val currentLive: CreatorChannelLiveResponse?,
|
|
||||||
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(tab: CreatorChannelLiveTab): CreatorChannelLiveTabResponse {
|
|
||||||
return CreatorChannelLiveTabResponse(
|
|
||||||
liveReplayContentCount = tab.liveReplayContentCount,
|
|
||||||
currentLive = tab.currentLive?.let(CreatorChannelLiveResponse::from),
|
|
||||||
liveReplayContents = tab.liveReplayContents.map(CreatorChannelAudioContentResponse::from),
|
|
||||||
sort = tab.sort,
|
|
||||||
page = tab.page.page,
|
|
||||||
size = tab.page.size,
|
|
||||||
hasNext = tab.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelAudioContentResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean,
|
|
||||||
@JsonProperty("isPointAvailable")
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
@JsonProperty("isFirstContent")
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val seriesName: String?,
|
|
||||||
@JsonProperty("isOriginalSeries")
|
|
||||||
val isOriginalSeries: Boolean?,
|
|
||||||
@JsonProperty("isOwned")
|
|
||||||
val isOwned: Boolean,
|
|
||||||
@JsonProperty("isRented")
|
|
||||||
val isRented: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
|
|
||||||
return CreatorChannelAudioContentResponse(
|
|
||||||
audioContentId = content.audioContentId,
|
|
||||||
title = content.title,
|
|
||||||
duration = content.duration,
|
|
||||||
imageUrl = content.imageUrl,
|
|
||||||
price = content.price,
|
|
||||||
isAdult = content.isAdult,
|
|
||||||
isPointAvailable = content.isPointAvailable,
|
|
||||||
isFirstContent = content.isFirstContent,
|
|
||||||
seriesName = content.seriesName,
|
|
||||||
isOriginalSeries = content.isOriginalSeries,
|
|
||||||
isOwned = content.isOwned,
|
|
||||||
isRented = content.isRented
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelLiveResponse(
|
|
||||||
val liveId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val beginDateTimeUtc: String,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(live: CreatorChannelLive): CreatorChannelLiveResponse {
|
|
||||||
return CreatorChannelLiveResponse(
|
|
||||||
liveId = live.liveId,
|
|
||||||
title = live.title,
|
|
||||||
coverImageUrl = live.coverImageUrl,
|
|
||||||
beginDateTimeUtc = live.beginDateTime.toUtcIso(),
|
|
||||||
price = live.price,
|
|
||||||
isAdult = live.isAdult
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LocalDateTime.toUtcIso(): String {
|
|
||||||
return atOffset(ZoneOffset.UTC).toInstant().toString()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 위 예시는 라이브 탭 공개 API 응답 DTO 기준이다. 기존 `CreatorChannelHomeResponse` 파일은 이번 라이브 탭 구조 정렬 작업에서 이동하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: 공용 정렬 enum과 기존 오디오 응답 확장
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 공용 `ContentSort` enum 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSortTest.kt`
|
|
||||||
- RED: `ContentSortTest`를 먼저 추가해 `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 값이 존재하는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
|
|
||||||
- GREEN: `ContentSort` enum을 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
|
|
||||||
- REFACTOR: enum 이름에 크리에이터 채널 전용 의미가 남아 있지 않은지 `rg -n "CreatorChannel.*Sort|Live.*Sort" src/main/kotlin/kr/co/vividnext/sodalive/v2`로 확인한다.
|
|
||||||
- 검증 기록(2026-06-17): RED 단계에서 `ContentSort` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 `ContentSort` enum 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest` 성공을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: `CreatorChannelAudioContentResponse`에 소장/대여 필드 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- RED: controller 테스트에서 `latestAudioContent.isOwned`, `latestAudioContent.isRented`, `audioContents[0].isOwned`, `audioContents[0].isRented` JSON 필드를 기대하도록 추가한다.
|
|
||||||
- RED: service 테스트에서 `CreatorChannelAudioContentRecord` → `CreatorChannelAudioContent` 변환 시 `isOwned`, `isRented`가 유지되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- GREEN: domain model, record, response DTO, service 변환에 `isOwned`, `isRented`를 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- REFACTOR: 기존 홈 API 응답에 새 boolean 필드가 항상 존재하도록 null 불가능 `Boolean`으로 유지한다.
|
|
||||||
- 검증 기록(2026-06-17): RED 단계에서 `isOwned`/`isRented` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 domain/record/response/service mapper를 확장하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest` 성공을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 1.3: 기존 홈 오디오 조회에 주문 상태 bulk 판정 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: repository 테스트에 조회자가 `KEEP` 주문한 콘텐츠와 유효한 `RENTAL` 주문한 콘텐츠를 넣고, `findLatestAudioContent`, `findAudioContents` 결과의 `isOwned`/`isRented`가 각각 맞는지 검증한다.
|
|
||||||
- RED: 같은 콘텐츠에 `KEEP`과 유효한 `RENTAL`이 함께 있으면 `isOwned == true`, `isRented == true`를 기대한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: `findLatestAudioContent`, `findAudioContents`에 `viewerId`를 전달하고, 조회된 content id 묶음으로 주문 상태를 bulk 조회해 `CreatorChannelAudioContentRecord`에 채운다. 유효 대여 조건은 기존 주문 정책과 같이 `order.isActive == true`, `order.type == RENTAL`, `order.endDate > now`를 사용한다. 소장 조건은 `order.isActive == true`, `order.type == KEEP`이다. 소장/대여 상태는 서로 배타적으로 보정하지 않는다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: 콘텐츠마다 `OrderRepository.isExistOrderedAndOrderType`를 반복 호출하지 않고 content id 목록 기반 bulk 조회를 유지한다.
|
|
||||||
- 검증 기록(2026-06-17): RED 단계에서 repository method signature와 `isOwned`/`isRented` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 content id 목록 기반 bulk 주문 상태 조회를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 성공을 확인했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: 라이브 탭 domain/application 정책
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 라이브 탭 domain model과 page 정책 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveReplayQueryPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveReplayQueryPolicyTest.kt`
|
|
||||||
- RED: `page=0,size=20`이면 offset `0`, fetch limit `21`, 응답 items limit `20`, `hasNext == true` 판정이 되는 테스트를 작성한다.
|
|
||||||
- RED: `page < 0`, `size < 20`, `size > 50`이면 정책이 예외를 던지는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
|
|
||||||
- GREEN: `CreatorChannelPage(page: Int, size: Int)`와 `CreatorChannelLiveReplayQueryPolicy`를 추가한다. `offset = page * size`, `fetchLimit = size + 1`, `items = fetched.take(size)`, `hasNext = fetched.size > size`를 제공한다. `size`는 20 이상 50 이하로 검증해 `fetchLimit` overflow를 방지한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
|
|
||||||
- REFACTOR: page/size 계산은 service나 repository에 중복 구현하지 않는다.
|
|
||||||
- 검증 기록(2026-06-17): RED 단계에서 `CreatorChannelLiveReplayQueryPolicy` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 `CreatorChannelPage`, `CreatorChannelLiveTab`, `CreatorChannelLiveReplayQueryPolicy`를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest` 성공을 확인했다. 추가 리뷰 반영으로 `size < 20`, `size > 50`, `size = Int.MAX_VALUE`가 `common.error.invalid_request`를 던지고 `size = 50`의 `fetchLimit`이 51인지 검증했다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: `CreatorChannelLiveQueryService` 골격 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
|
|
||||||
- RED: service 테스트에서 `getLiveTab(creatorId, viewer, sort = ContentSort.LATEST, page = 0, size = 20)` 호출 시 port의 `findCreator`, `existsBlockedBetween`, `findCurrentLive`, `countLiveReplayAudioContents`, `findLiveReplayAudioContents`가 필요한 인자로 호출되는지 fake port로 검증한다.
|
|
||||||
- RED: 조회 대상이 없으면 `member.validation.user_not_found`, 크리에이터가 아니면 `member.validation.creator_not_found`, 차단 관계이면 기존 차단 메시지 예외를 기대한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- GREEN: 기존 `CreatorChannelHomeQueryService`의 인증 회원 기준 정책을 따라 creator 검증, 차단 검증, adult visibility, effective gender 계산을 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- REFACTOR: `CreatorChannelHomeQueryService`와 중복되는 private 변환/검증 코드가 과도해지면 같은 phase 안에서는 복사 유지하고, 추후 별도 공용 validator 추출은 구현 범위 밖으로 둔다.
|
|
||||||
- 검증 기록(2026-06-17): RED 단계에서 `CreatorChannelLiveQueryService`와 `CreatorChannelLiveQueryPort` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 홈 API 서비스의 creator 검증, 차단 검증, adult visibility, effective gender 전달 패턴을 반영하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest` 성공을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: 라이브 탭 service 응답 조립 완성**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
|
|
||||||
- RED: fake port가 `size + 1`개 콘텐츠를 반환하면 service 응답의 `liveReplayContents.size == size`, `hasNext == true`, `page == 0`, `size == 20`, `sort == LATEST`인지 검증한다.
|
|
||||||
- RED: invalid `page`/`size` 요청은 port bean 조회 전에 `common.error.invalid_request`를 던지는지 검증한다.
|
|
||||||
- RED: `page` 범위에 콘텐츠가 없으면 `liveReplayContents`는 빈 배열이고 count는 port count 값을 유지하는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- GREEN: service에서 policy로 page/size를 먼저 검증하고, 검증 후 port를 조회해 count와 `size + 1` 조회 결과를 조립해 `CreatorChannelLiveTab`을 반환한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- REFACTOR: `ContentSort` 기본값은 controller가 기본값을 넣되, service 테스트에서도 명시 인자를 사용해 정책 위치를 분리한다.
|
|
||||||
- 검증 기록(2026-06-17): RED 단계에서 service 조립 대상 domain/port/service 미존재 컴파일 실패를 확인했다. GREEN 단계에서 count, 현재 라이브, `size + 1` 다시듣기 목록을 `CreatorChannelLiveTab`으로 조립하고 `hasNext`, page, sort, 소장/대여 상태 보존을 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`로 확인했다. 추가 리뷰 반영으로 invalid `page`/`size` 요청이 `ObjectProvider.getObject()`보다 먼저 `common.error.invalid_request`로 중단되는지 검증했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: 라이브 다시듣기 persistence adapter
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 라이브 탭 repository 골격과 count 조회 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
|
||||||
- RED: fixture로 `다시듣기` 테마 콘텐츠 2개, 다른 테마 콘텐츠 1개, 예약 공개 콘텐츠 1개, 비활성 콘텐츠 1개를 만들고 count가 공개 `다시듣기` 콘텐츠만 세는지 검증한다.
|
|
||||||
- RED: 성인 노출 불가이면 성인 `다시듣기` 콘텐츠가 count에서 제외되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- GREEN: `countLiveReplayAudioContents(creatorId, now, canViewAdultContent)`를 구현한다. 조건은 creator, active member, active content, active theme, `theme == "다시듣기"`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, adult filter다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- REFACTOR: `"다시듣기"` 문자열은 repository companion object의 `LIVE_REPLAY_THEME` 상수로 둔다.
|
|
||||||
- 검증 기록(2026-06-17): RED 단계에서 `DefaultCreatorChannelLiveQueryRepository` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 live query repository interface/default 구현체와 `countLiveReplayAudioContents`를 추가하고, 공개 `다시듣기` 콘텐츠/성인 노출 정책 count를 `DefaultCreatorChannelLiveQueryRepositoryTest.shouldCountPublicLiveReplayAudioContentsOnly`로 검증했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 라이브 다시듣기 기본 목록과 페이징 조회 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
|
||||||
- RED: `offset=20, limit=21` 인자를 전달하면 21개까지만 조회하고, 앞 page 콘텐츠가 제외되는지 검증한다.
|
|
||||||
- RED: `LATEST` 정렬에서 공개일 최신순, 같은 공개일이면 높은 가격순이 먼저인지 검증한다.
|
|
||||||
- RED: `다시듣기`보다 오래된 다른 테마 공개 오디오 콘텐츠가 있으면 `다시듣기` 목록 item의 `isFirstContent`가 `false`인지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- GREEN: `findLiveReplayAudioContents(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)`를 구현한다. 우선 `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬을 QueryDSL order specifier로 처리한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- REFACTOR: content row 조회, first content id 조회, series summary 조회, order status 조회를 작은 private 함수로 분리한다.
|
|
||||||
- 검증 기록(2026-06-17): `findLiveReplayAudioContents`를 QueryDSL `orderBy`/`offset`/`limit` 기반으로 구현하고, `LATEST`의 공개일/가격 정렬, page offset/limit 적용, first content 및 series summary mapping을 `shouldFindLiveReplayAudioContentsWithPaginationAndLatestSort`로 확인했다. `PRICE_HIGH`, `PRICE_LOW` 정렬은 `shouldSortLiveReplayAudioContentsByPrice`로 확인했다.
|
|
||||||
- 보완 검증 기록(2026-06-17): `isFirstContent`는 `다시듣기` 테마 안의 첫 콘텐츠가 아니라 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠 기준이어야 하므로 `shouldMarkFirstContentByAllPublicAudioContentsNotLiveReplayTheme`를 추가했다. RED 단계에서 기존 구현이 `isFirstContent == true`를 반환해 실패하는 것을 확인했고, GREEN 단계에서 first content id 조회 조건에서 `다시듣기` 테마 필터를 제거해 기존 홈 API와 같은 전체 공개 오디오 기준으로 보정했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: `POPULAR` 정렬 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
|
||||||
- RED: 대여/소장 여부와 관계없이 `orders.can` 합계가 큰 콘텐츠가 먼저 나오고, 같은 매출이면 공개일 최신순이 먼저 나오는 테스트를 작성한다.
|
|
||||||
- RED: `orders.isActive == false` 주문과 `orders.point` 값은 매출 합계에서 제외되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- GREEN: orders left join 또는 집계 subquery로 콘텐츠별 활성 주문의 `can` 합계를 계산하고 `POPULAR` 정렬에 반영한다. `point`는 더하지 않는다. 매출이 없으면 0으로 처리한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- REFACTOR: 인기순 집계가 기본 목록 count를 중복시키지 않도록 count query와 list query를 분리 유지한다.
|
|
||||||
- 검증 기록(2026-06-17): `POPULAR` 정렬은 활성 주문의 `orders.can` 합계를 left join/group by로 계산하도록 구현했다. `orders.point`와 비활성 주문이 정렬에 반영되지 않는지 `shouldSortLiveReplayAudioContentsByPopularCanRevenue`로 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.4: `OWNED` 정렬과 소장/대여 응답 상태 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
|
||||||
- RED: `OWNED` 정렬에서 조회자가 `KEEP` 주문한 콘텐츠가 먼저 나오고, 나머지는 공개일 최신순으로 정렬되는지 검증한다.
|
|
||||||
- RED: 유효한 `RENTAL` 주문만 있는 콘텐츠는 `isRented == true`, `isOwned == false`인지 검증한다.
|
|
||||||
- RED: `KEEP`과 유효한 `RENTAL`이 모두 있으면 `isOwned == true`, `isRented == true`인지 검증한다.
|
|
||||||
- RED: 만료된 `RENTAL`은 `isRented == false`인지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- GREEN: 조회된 content id 목록 기준으로 주문 상태를 bulk 조회해 record에 채운다. `OWNED` 정렬은 `KEEP` 존재 여부를 1차 기준으로 삼는다. 응답의 `isOwned`, `isRented`는 각각의 주문 존재 여부를 그대로 반영하고 서로 배타적으로 보정하지 않는다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- REFACTOR: 응답 상태 판정과 `OWNED` 정렬 판정의 의미가 어긋나지 않도록 동일한 유효 주문 조건을 사용한다.
|
|
||||||
- 검증 기록(2026-06-17): `OWNED` 정렬은 조회자의 활성 `KEEP` 주문 존재 여부를 QueryDSL group by 정렬 기준으로 삼고, 응답의 `isOwned`/`isRented`는 조회된 content id 목록 기준 bulk 조회로 채우도록 구현했다. 유효 대여, 만료 대여, 소장+대여 동시 존재를 `shouldSortLiveReplayAudioContentsByOwnedAndReturnOrderStates`로 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.5: 현재 라이브 조회 위임 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
|
||||||
- RED: 현재 진행 중인 라이브 조회가 기존 홈 API와 같은 정책으로 성인 노출, 성별 제한, 크리에이터 입장 제한을 반영하는지 대표 케이스를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- GREEN: `DefaultCreatorChannelLiveQueryRepository.findCurrentLive`는 기존 `DefaultCreatorChannelHomeQueryRepository.findCurrentLive`와 같은 조건을 사용한다. 코드 중복이 과하면 private helper를 복사하되, 동작 보존을 우선한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- REFACTOR: 이후 공용 live query helper 추출은 이번 구현 범위에서 제외한다.
|
|
||||||
- 검증 기록(2026-06-17): 기존 홈 API의 현재 라이브 조건을 live tab repository에 복사해 성인 노출, 성별 제한, 크리에이터 입장 제한, 진행 중 라이브 정렬 정책을 맞췄다. `shouldFindCurrentLiveWithHomePolicy`와 `shouldFindCreatorAndBlockedRelationship`으로 current live/creator/block port 계약을 확인했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: Controller와 공개 응답
|
|
||||||
|
|
||||||
- [x] **Task 4.1: 라이브 탭 controller endpoint 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt`
|
|
||||||
- RED: `GET /api/v2/creator-channels/1/live`가 인증 회원, `creatorId`, 기본 `sort=LATEST`, 기본 `page=0`, 기본 `size=20`을 service에 전달하는 MockMvc 테스트를 작성한다.
|
|
||||||
- RED: 응답 JSON에 `liveReplayContentCount`, `currentLive`, `liveReplayContents`, `sort`, `page`, `size`, `hasNext`, `liveReplayContents[0].isOwned`, `liveReplayContents[0].isRented`가 존재하는지 검증한다.
|
|
||||||
- RED: anonymous 요청은 기존 홈 API와 같이 unauthorized가 되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`
|
|
||||||
- GREEN: `CreatorChannelLiveController`에 `@GetMapping("/{creatorId}/live")`를 추가하고 `CreatorChannelLiveFacade`를 주입한다. query parameter는 `@RequestParam(defaultValue = "LATEST") sort: ContentSort`, `@RequestParam(defaultValue = "0") page: Int`, `@RequestParam(defaultValue = "20") size: Int`로 받는다. Facade는 `CreatorChannelLiveQueryService` 결과를 공개 API DTO로 변환한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`
|
|
||||||
- REFACTOR: 기존 `CreatorChannelHomeController`에는 라이브 endpoint를 추가하지 않는다.
|
|
||||||
- 검증 기록(2026-06-17): RED 단계에서 `CreatorChannelLiveController`, `CreatorChannelLiveFacade`, 라이브 탭 공개 DTO 미존재 컴파일 실패를 확인했다. GREEN 단계에서 `v2.api.creator.channel.live` 하위 controller/facade/DTO를 추가하고, 인증 회원 기본 요청이 `sort=LATEST`, `page=0`, `size=20`을 facade에 전달하며 `liveReplayContentCount`, `currentLive`, `liveReplayContents`, `sort`, `page`, `size`, `hasNext`, `isOwned`, `isRented` 응답 필드를 반환하는지 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`로 확인했다. 비회원 요청은 기존 홈 API와 같은 테스트 보안 설정에서 401로 거부됨을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 잘못된 page/size validation 표면 확인**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
|
|
||||||
- RED: `page=-1` 또는 `size=0` 요청이 기존 `SodaExceptionHandler` 오류 표면인 HTTP 200 + `success=false`로 처리되는지 controller/service 테스트를 추가한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- GREEN: service에서 `CreatorChannelLiveReplayQueryPolicy.validatePage(page, size)`를 호출하고 invalid request 예외를 던진다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- REFACTOR: Spring enum binding 실패(`sort=UNKNOWN`)는 별도 custom handling을 추가하지 않고 기존 MVC 오류 흐름을 사용한다.
|
|
||||||
- 검증 기록(2026-06-17): controller 테스트에 `page=-1`, `size=0` 요청 표면을 추가하고, 기존 `SodaExceptionHandler` 흐름에 맞춰 HTTP 200 + `success=false` 응답으로 확인했다. service invalid 요청은 Phase 2에서 port 조회 전 `common.error.invalid_request`로 중단되도록 구현되어 있어 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`로 controller 표면과 service validation 회귀를 함께 확인했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 5: 회귀 및 문서 동기화
|
|
||||||
|
|
||||||
- [x] **Task 5.1: 기존 홈 API 회귀 테스트 보강**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 기존 홈 API의 `latestAudioContent`와 `audioContents`에 새 `isOwned`, `isRented` 필드가 내려오는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- GREEN: Phase 1 구현이 빠뜨린 변환/fixture를 보정한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- REFACTOR: test fixture의 `CreatorChannelAudioContent` 생성부가 반복되면 테스트 내부 helper만 추가하고 production abstraction은 만들지 않는다.
|
|
||||||
- 검증 기록(2026-06-17): 기존 controller/service 테스트의 `isOwned`/`isRented` 응답/변환 회귀에 더해, 홈 repository 통합 fixture에서 `latestAudioContent`의 `KEEP` 주문과 `audioContents`의 유효 `RENTAL` 주문 상태를 함께 검증하도록 보강했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 성공을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: 라이브 탭 통합 시나리오 검증**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
|
|
||||||
- RED: 실제 fixture 기반으로 현재 라이브 1개, 라이브 다시듣기 21개, 소장/대여/미구매 콘텐츠를 넣고 `page=0,size=20,sort=LATEST` 응답 표면을 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`
|
|
||||||
- GREEN: 누락된 mapping, count, hasNext, sort 응답을 보정한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`
|
|
||||||
- REFACTOR: 통합 테스트는 하나의 대표 시나리오만 유지하고 세부 정렬/상태 케이스는 repository 단위 테스트로 둔다.
|
|
||||||
- 검증 기록(2026-06-17): 기존 repository 테스트의 21개 조회/pagination/current live/order state 검증에 더해, controller 응답 표면에서 `liveReplayContentCount=21`, `liveReplayContents.length()==20`, `hasNext=true`, `sort=LATEST`, `page=0`, `size=20`, 소장/대여/미구매 상태가 JSON으로 내려오는 대표 시나리오를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest` 성공을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 5.3: 라이브 탭 end-to-end 통합 테스트 추가**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveEndToEndTest.kt`
|
|
||||||
- TDD 예외 사유: production 동작 변경 없이 기존 구현의 controller-service-repository-DB-JSON 연결을 고정하는 회귀 테스트 추가 task다.
|
|
||||||
- RED: `@SpringBootTest + MockMvc` 기반으로 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/live?page=0&size=20&sort=LATEST`를 호출하는 실제 end-to-end 테스트를 추가한다.
|
|
||||||
- 검증 대상:
|
|
||||||
- 현재 라이브 1개가 `currentLive`로 내려온다.
|
|
||||||
- 공개 `다시듣기` 콘텐츠 21개 중 응답 목록은 20개만 내려온다.
|
|
||||||
- `liveReplayContentCount=21`, `hasNext=true`, `sort=LATEST`, `page=0`, `size=20`이 내려온다.
|
|
||||||
- 조회자의 `KEEP`, 유효 `RENTAL`, 미구매 콘텐츠 상태가 `isOwned`/`isRented` JSON으로 내려온다.
|
|
||||||
- 이미지 경로는 실제 facade/service mapping을 거쳐 CDN URL로 내려온다.
|
|
||||||
- 실행 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest`
|
|
||||||
- GREEN: production 변경 없이 기존 구현이 통과하면 회귀 테스트로 유지한다. 실패하면 실패 원인이 테스트 fixture인지 실제 연결 결함인지 구분해 최소 수정한다.
|
|
||||||
- REFACTOR: fixture helper는 테스트 파일 내부에만 둔다. 기존 mock 기반 controller 테스트와 repository 세부 테스트는 유지한다.
|
|
||||||
- 검증 기록(2026-06-17): `CreatorChannelLiveEndToEndTest`를 추가해 실제 Spring context에서 `Controller -> Facade -> Service -> Repository -> DB -> Response JSON` 흐름을 검증했다. 테스트 fixture는 커밋된 DB 상태를 MockMvc 요청에서 조회하도록 `TransactionTemplate`으로 생성했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest` 성공을 확인했다. 최초 성공 실행에서 H2 shutdown 경고가 있어 테스트 전용 datasource URL에 `DB_CLOSE_ON_EXIT=FALSE`를 추가했고, 동일 명령 재실행 성공을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 5.4: 전체 회귀 검증과 문서 검증 기록 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260617_크리에이터_채널_라이브_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 문서 검증 기록 갱신 task로 production/test 코드 변경이 없다.
|
|
||||||
- 대체 검증 방법: 아래 명령 실행 결과를 이 task 아래와 문서 하단 검증 기록에 누적한다.
|
|
||||||
- 실행 명령:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- 기대 결과: 모든 명령이 성공한다.
|
|
||||||
- REFACTOR: 실패가 발생하면 실패 task로 돌아가 문서의 체크박스를 완료 처리하지 않는다.
|
|
||||||
- 검증 기록(2026-06-17): Phase 5 최종 회귀로 아래 명령이 모두 성공함을 확인했다.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- `git diff --check`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 6: 다음 범위 홈 API 구조 정렬 인계
|
|
||||||
|
|
||||||
- [ ] **Task 6.1: 크리에이터 채널 홈 API 리팩토링 후속 프롬프트 보존**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260617_크리에이터_채널_라이브_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 다음 범위 작업을 위한 인계 프롬프트 작성 task로 production/test 코드 변경이 없다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- 문서 내 프롬프트가 이번 라이브 탭 구현을 다시 수정하라고 지시하지 않는지 확인한다.
|
|
||||||
- 프롬프트가 기존 홈 API endpoint와 공개 응답 계약 보존, 테스트 선행, 패키지 의존 방향을 명시하는지 확인한다.
|
|
||||||
- 후속 작업용 GPT-5.5 프롬프트:
|
|
||||||
|
|
||||||
```text
|
|
||||||
너는 /Users/klaus/Develop/sodalive/Server/sodalive 저장소에서 작업하는 GPT-5.5 기반 코딩 에이전트다.
|
|
||||||
|
|
||||||
목표:
|
|
||||||
기존 크리에이터 채널 홈 API 구현을 현재 v2 공개 API 설계와 맞게 `v2.api.*` 조립 계층 + API 패키지 밖 도메인 패키지 구조로 정렬한다.
|
|
||||||
|
|
||||||
반드시 지킬 규칙:
|
|
||||||
- 사용자와 저장소의 AGENTS.md, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`를 먼저 읽고 따른다.
|
|
||||||
- 구현 전 기존 PRD/plan-task 문서를 확인하고, 이 작업이 새 범위라면 `docs/[날짜]_크리에이터_채널_홈_API_구조정렬/prd.md`, `docs/[날짜]_크리에이터_채널_홈_API_구조정렬/plan-task.md`를 작성한다.
|
|
||||||
- 기존 공개 endpoint `GET /api/v2/creator-channels/{creatorId}/home`과 응답 필드명/의미를 변경하지 않는다.
|
|
||||||
- 리팩토링 목적은 파일 위치와 책임 경계 정렬이다. 기능 추가, 응답 스키마 확장, 불필요한 공용화는 하지 않는다.
|
|
||||||
- 클라이언트 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home` 하위로 이동한다.
|
|
||||||
- 재사용 가능한 조회/정책/port/repository는 `kr.co.vividnext.sodalive.v2.creator.channel.home` 또는 더 적합한 도메인 패키지 하위에 둔다. 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*`에 의존하지 않는다.
|
|
||||||
- 의존 방향은 항상 `v2.api.creator.channel.home -> 도메인 패키지`로 유지한다.
|
|
||||||
- 기존 `v2.creator.channel.adapter.in.web.CreatorChannelHomeController`를 새 API 조립 계층으로 옮길 때 Spring mapping 충돌이 생기지 않도록 기존 controller 제거/이동 범위를 명확히 한다.
|
|
||||||
- 테스트는 먼저 실패하도록 작성하거나 이동한 뒤 실패를 확인하고, 최소 구현으로 통과시킨다.
|
|
||||||
- 기존 홈 API 회귀 테스트를 유지한다. 최소 검증 대상은 controller, facade 또는 service, repository 단위 테스트와 `./gradlew ktlintCheck`다.
|
|
||||||
- 이번 라이브 탭 API 구현(`v2.api.creator.channel.live`, `v2.creator.channel.live`)은 리팩토링 대상이 아니다. 필요한 경우 import 관계 확인만 하고 동작 변경은 하지 않는다.
|
|
||||||
|
|
||||||
권장 진행 순서:
|
|
||||||
1. 기존 홈 API 파일과 테스트를 모두 찾고 현재 public contract를 문서화한다.
|
|
||||||
2. 새 PRD에 “동작 보존 리팩토링” 범위와 non-goal을 명시한다.
|
|
||||||
3. plan-task에 TDD 기준으로 파일 이동, controller/facade 분리, domain package 정렬, 회귀 검증 task를 작성한다.
|
|
||||||
4. Controller/DTO를 `v2.api.creator.channel.home`으로 이동하고, 기존 service/domain/port/repository는 API 패키지 밖에 유지하거나 `v2.creator.channel.home` 하위로 정렬한다.
|
|
||||||
5. `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator src/main/kotlin/kr/co/vividnext/sodalive/v2/live src/main/kotlin/kr/co/vividnext/sodalive/v2/content src/main/kotlin/kr/co/vividnext/sodalive/v2/series`로 도메인 패키지가 API 패키지를 import하지 않는지 확인한다.
|
|
||||||
6. `GET /api/v2/creator-channels/{creatorId}/home` 회귀 테스트와 관련 단위 테스트를 실행하고, 검증 결과를 plan-task에 기록한다.
|
|
||||||
|
|
||||||
성공 기준:
|
|
||||||
- 홈 API endpoint와 응답 계약이 유지된다.
|
|
||||||
- 홈 API 공개 조립 계층은 `v2.api.creator.channel.home`에 있다.
|
|
||||||
- 도메인 패키지는 `v2.api.*`에 의존하지 않는다.
|
|
||||||
- 관련 테스트와 ktlint 검증 결과가 plan-task에 기록되어 있다.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 구현 순서 요약
|
|
||||||
|
|
||||||
1. `ContentSort` 공용 enum을 먼저 추가한다.
|
|
||||||
2. 기존 완료 범위인 `CreatorChannelAudioContentResponse`와 domain/record의 `isOwned`, `isRented` 확장 상태를 유지한다.
|
|
||||||
3. 라이브 탭 page 정책과 service 골격을 `v2.creator.channel.live` 하위에 만든다.
|
|
||||||
4. 라이브 다시듣기 count/list repository를 `v2.creator.channel.live` 하위에 구현한다.
|
|
||||||
5. controller/facade/응답 DTO를 `v2.api.creator.channel.live` 하위에 연결한다.
|
|
||||||
6. 기존 홈 API 회귀와 라이브 탭 통합 시나리오를 검증한다.
|
|
||||||
7. 다음 범위에서 홈 API 구조 정렬을 진행할 수 있도록 Phase 6 프롬프트를 보존한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 검증 기록
|
|
||||||
|
|
||||||
- 아직 구현 전 계획 문서 작성 단계다. 구현 task 완료 후 각 task 아래에 무엇을, 왜, 어떻게 검증했는지 실행 명령과 결과를 누적 기록한다.
|
|
||||||
- 2026-06-17 문서 갱신 검증: 라이브 탭 신규 구현을 `v2.api.creator.channel.live` 조립 계층과 `v2.creator.channel.live` 도메인 조회 계층으로 진행하도록 PRD/plan-task를 갱신했다. 기존 홈 API 구조 정렬은 Phase 6 후속 프롬프트로 분리했다. `git diff --check`로 whitespace 오류가 없음을 확인했고, `./gradlew tasks --all`로 Gradle 명령 유효성 확인에 성공했다.
|
|
||||||
- 2026-06-17 Phase 2 검증: `CreatorChannelLiveReplayQueryPolicyTest`와 `CreatorChannelLiveQueryServiceTest`를 먼저 추가해 RED 단계에서 Phase 2 production 클래스 미존재 컴파일 실패를 확인했다. GREEN 단계에서 라이브 탭 domain/page 정책, port, service 조립을 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. Phase 3 port 구현 전 Spring context가 깨지지 않도록 service는 `ObjectProvider<CreatorChannelLiveQueryPort>`로 port를 지연 조회하게 했고, `./gradlew test --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest` 성공으로 대표 context 기동을 확인했다. 추가 리뷰 반영으로 `page >= 0`, `20 <= size <= 50` 검증과 invalid 요청 시 port 조회 전 중단을 보강했고, 동일 targeted 테스트와 `ktlintCheck`, `git diff --check` 성공을 확인했다. 전체 `./gradlew test`는 실행 중 기존 SpringBoot 통합 테스트들의 bean/OOM 계열 실패와 timeout이 발생해 완료하지 못했으며, Phase 2 직접 변경 범위는 위 targeted/context 검증으로 확인했다.
|
|
||||||
- 2026-06-17 Phase 3 검증: `DefaultCreatorChannelLiveQueryRepositoryTest`를 먼저 추가해 RED 단계에서 `DefaultCreatorChannelLiveQueryRepository` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 live tab repository interface/default 구현체를 추가하고 count/list/pagination/sort/order state/current live policy를 구현했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. 리뷰 게이트에서 Phase 3 persistence adapter 범위와 시나리오 검증에 대한 unconditional approval을 받았다.
|
|
||||||
- 2026-06-17 Phase 3 리뷰 보완 검증: `isFirstContent` 의미를 PRD에 명시하고, 라이브 다시듣기 repository 테스트에 다른 테마의 더 오래된 공개 오디오가 있을 때 `다시듣기` item의 `isFirstContent`가 `false`인지 확인하는 회귀 테스트를 추가했다. RED 단계에서 기존 구현이 실패하는 것을 확인했고, first content id 조회를 기존 홈 API와 같은 전체 공개 오디오 기준으로 수정했다. 검증은 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`로 수행했고 모두 성공했다. 검증 중 Gradle 테스트 2개를 병렬 실행했을 때 한 세션에서 QueryDSL generated source compile race로 `compileJava`가 실패했으나, 단일 세션으로 재실행한 repository 전체 테스트는 성공했다.
|
|
||||||
- 2026-06-17 Phase 4 검증: 라이브 탭 공개 API 조립 계층을 `v2.api.creator.channel.live` 하위에 추가했다. RED 단계에서 controller/facade/DTO 미존재 컴파일 실패를 확인했고, GREEN 단계에서 `CreatorChannelLiveControllerTest`, `CreatorChannelLiveFacadeTest` 통과를 확인했다. invalid `page`/`size` 요청은 기존 오류 응답 표면인 HTTP 200 + `success=false`로 확인했고, `CreatorChannelLiveQueryServiceTest`와 함께 service validation 회귀를 확인했다. 검증은 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`, `./gradlew ktlintCheck`, `git diff --check`로 수행했고 모두 성공했다.
|
|
||||||
- 2026-06-17 Phase 5 검증: 기존 홈 API 회귀와 라이브 탭 대표 응답 표면을 보강했다. 홈 repository 통합 fixture는 `latestAudioContent.isOwned/isRented`와 `audioContents.isOwned/isRented`를 주문 상태 기반으로 검증하고, 라이브 탭 controller는 현재 라이브 1개, 다시듣기 20개 응답, 전체 count 21, `hasNext=true`, 소장/대여/미구매 상태를 확인한다. 추가로 `CreatorChannelLiveEndToEndTest`를 만들어 실제 Spring context에서 `Controller -> Facade -> Service -> Repository -> DB -> Response JSON` 흐름을 검증했다. Phase 5 지정 테스트와 `./gradlew ktlintCheck`, `git diff --check`가 모두 성공했다.
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
# PRD: 크리에이터 채널 라이브 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
크리에이터 채널의 라이브 탭에서 현재 진행 중인 라이브와 `다시듣기` 콘텐츠를 한 번에 조회하는 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 크리에이터 채널 홈 API는 홈 화면에 필요한 요약 데이터를 제공하지만, 라이브 탭은 현재 라이브와 `다시듣기` 콘텐츠 목록/개수를 함께 조회해야 한다.
|
|
||||||
- 클라이언트는 라이브 탭 진입 시 현재 진행 중인 라이브, `다시듣기` 콘텐츠 목록, 전체 개수, 적용된 정렬 순서를 일관된 계약으로 받아야 한다.
|
|
||||||
- `다시듣기` 콘텐츠 정렬 기준이 여러 개이고 이후 오디오 콘텐츠, 시리즈, 화보 등 채널 내 다른 콘텐츠 목록에서도 같은 정렬 기준을 사용할 예정이므로 서버와 클라이언트가 공유할 명시적인 enum 계약이 필요하다.
|
|
||||||
- 응답 필드는 기존 `CreatorChannelLiveResponse`, `CreatorChannelAudioContentResponse`와 의미가 어긋나지 않아야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 크리에이터 채널 라이브 탭 조회 API를 제공한다.
|
|
||||||
- 클라이언트에서 호출하는 공개 API는 `kr.co.vividnext.sodalive.v2.api.creator.channel.live` 하위 조립 계층에 둔다.
|
|
||||||
- 라이브, 다시듣기 콘텐츠, 시리즈/소장 상태처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
|
|
||||||
- 요청은 `creatorId`와 정렬 순서를 받는다.
|
|
||||||
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
|
|
||||||
- 응답에는 `다시듣기` 콘텐츠 개수, 현재 진행 중인 라이브, `다시듣기` 콘텐츠 목록, 실제 적용된 정렬 순서를 포함한다.
|
|
||||||
- 현재 진행 중인 라이브 응답은 기존 `CreatorChannelLiveResponse`와 동일한 필드/의미를 사용한다.
|
|
||||||
- `다시듣기` 콘텐츠 응답은 기존 `CreatorChannelAudioContentResponse`에 유료 콘텐츠의 소장/대여 상태를 추가해 사용한다.
|
|
||||||
- `다시듣기` 콘텐츠는 기존 프로젝트에서 사용하는 `AudioContentTheme.theme == "다시듣기"` 기준을 따른다.
|
|
||||||
- 정렬 순서는 enum으로 정의해 공개 API 계약을 고정한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 이번 범위는 크리에이터 채널 `라이브` 탭 조회 API만 포함한다.
|
|
||||||
- 기존 크리에이터 채널 홈 API endpoint와 기존 응답 필드의 의미는 변경하지 않는다.
|
|
||||||
- 기존 크리에이터 채널 홈 API를 `v2.api.*` 조립 계층 + 도메인 패키지 구조로 옮기는 리팩토링은 이번 범위에서 구현하지 않는다.
|
|
||||||
- 라이브 생성, 예약, 입장, 종료 API는 포함하지 않는다.
|
|
||||||
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
|
|
||||||
- `다시듣기` 테마명 관리 화면 또는 테마 마이그레이션은 포함하지 않는다.
|
|
||||||
- 앱 표시용 다국어 문구, 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 크리에이터 채널 라이브 탭에서 현재 라이브와 다시듣기 콘텐츠를 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 라이브 탭 구성에 필요한 데이터를 단일 API 응답으로 표시하려는 클라이언트
|
|
||||||
- 크리에이터: 자신의 현재 라이브와 다시듣기 콘텐츠가 적절한 정렬로 노출되기를 원하는 사용자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 크리에이터 채널 라이브 탭에 들어가면 현재 진행 중인 라이브가 있는지 바로 확인하고 싶다.
|
|
||||||
- 사용자는 크리에이터의 `다시듣기` 콘텐츠를 최신순으로 보고 싶다.
|
|
||||||
- 사용자는 인기순, 소장순, 높은 가격순, 낮은 가격순으로 `다시듣기` 콘텐츠를 바꿔 보고 싶다.
|
|
||||||
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
|
|
||||||
- 앱 클라이언트는 `다시듣기` 콘텐츠 전체 개수를 받아 탭/헤더/빈 상태 UI에 표시하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 크리에이터 채널 라이브 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
|
|
||||||
- 클라이언트 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.live` 하위에 작성한다.
|
|
||||||
- API 조립 계층은 필요한 도메인 조회 서비스를 호출해 라이브 탭 응답을 조립한다.
|
|
||||||
- API 조립 계층이 호출하는 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.live`, `kr.co.vividnext.sodalive.v2.content`, `kr.co.vividnext.sodalive.v2.series` 또는 채널 문맥이 필요한 경우 `kr.co.vividnext.sodalive.v2.creator.channel.live` 하위에 둔다.
|
|
||||||
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/live`를 기본안으로 한다.
|
|
||||||
- `creatorId`는 path variable로 받는다.
|
|
||||||
- 정렬 순서는 query parameter로 받는다.
|
|
||||||
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
|
|
||||||
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
|
|
||||||
- `다시듣기` 콘텐츠 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
|
||||||
- `page`는 0부터 시작하는 page index로 처리한다.
|
|
||||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
|
||||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
|
||||||
- API는 인증 회원만 조회할 수 있어야 한다.
|
|
||||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 구버전 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
|
||||||
- 현재 진행 중인 라이브가 없거나 `다시듣기` 콘텐츠가 없어도 전체 API는 성공 처리한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
|
||||||
- 알 수 없는 `sort` 값은 Spring enum binding 실패 또는 기존 validation 오류 흐름에 맞춰 400 계열 오류로 처리한다.
|
|
||||||
- `page`가 0보다 작거나 `size`가 20보다 작거나 50보다 크면 기존 validation 오류 흐름에 맞춰 400 계열 오류로 처리한다.
|
|
||||||
|
|
||||||
### Feature B. 응답 스키마
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
|
||||||
- 응답 최상위 DTO 이름은 `CreatorChannelLiveTabResponse`를 기본안으로 한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- `liveReplayContentCount`: `다시듣기` 카테고리 콘텐츠 전체 개수
|
|
||||||
- `currentLive`: 현재 진행 중인 라이브, 없으면 `null`
|
|
||||||
- `liveReplayContents`: `다시듣기` 콘텐츠 목록
|
|
||||||
- `sort`: 콘텐츠 조회에 실제 적용한 정렬 순서
|
|
||||||
- `page`: 현재 응답의 page index
|
|
||||||
- `size`: 현재 응답의 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- `currentLive`는 기존 `CreatorChannelLiveResponse`와 동일한 필드/의미를 사용한다.
|
|
||||||
- `liveReplayContents`의 각 item은 기존 `CreatorChannelAudioContentResponse`를 사용한다.
|
|
||||||
- `CreatorChannelAudioContentResponse`에는 다음 범위의 오디오 콘텐츠 조회 API에서도 재사용할 수 있도록 `isOwned`, `isRented`를 추가한다.
|
|
||||||
- `sort`는 요청값이 없으면 기본값 `LATEST`를 내려준다.
|
|
||||||
- `page`, `size`는 실제 적용된 값을 내려준다.
|
|
||||||
- `hasNext`는 같은 필터/정렬 조건에서 다음 page에 노출할 `다시듣기` 콘텐츠가 있으면 `true`로 내려준다.
|
|
||||||
- 응답 스키마 예시는 다음과 같다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class CreatorChannelLiveTabResponse(
|
|
||||||
val liveReplayContentCount: Int,
|
|
||||||
val currentLive: CreatorChannelLiveResponse?,
|
|
||||||
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelAudioContentResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val seriesName: String?,
|
|
||||||
val isOriginalSeries: Boolean?,
|
|
||||||
val isOwned: Boolean,
|
|
||||||
val isRented: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class ContentSort {
|
|
||||||
LATEST,
|
|
||||||
POPULAR,
|
|
||||||
OWNED,
|
|
||||||
PRICE_HIGH,
|
|
||||||
PRICE_LOW
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 현재 진행 중인 라이브가 없으면 `currentLive`는 `null`로 내려준다.
|
|
||||||
- `다시듣기` 콘텐츠가 없으면 `liveReplayContentCount`는 `0`, `liveReplayContents`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
|
||||||
|
|
||||||
### Feature C. 현재 진행 중인 라이브
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 크리에이터가 현재 진행 중인 라이브를 내려준다.
|
|
||||||
- 현재 진행 중인 라이브는 기존 라이브 도메인의 `LiveRoomStatus.NOW` 의미와 동일하게 판단한다.
|
|
||||||
- 응답 필드는 기존 `CreatorChannelLiveResponse`와 동일하게 다음 값을 포함한다.
|
|
||||||
- `liveId`
|
|
||||||
- `title`
|
|
||||||
- `coverImageUrl`
|
|
||||||
- `beginDateTimeUtc`
|
|
||||||
- `price`
|
|
||||||
- `isAdult`
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책과 차단 정책을 반영한다.
|
|
||||||
- 현재 라이브 노출은 기존 라이브 목록 정책과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다.
|
|
||||||
- 성별 제한 판단에 사용하는 조회자 성별은 기존 라이브 목록과 동일하게 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 effective gender다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 현재 진행 중인 라이브 후보가 여러 개이면 기존 라이브 목록/홈 API의 현재 라이브 선택 정책을 따른다.
|
|
||||||
- 성인 콘텐츠 노출 정책상 볼 수 없는 라이브만 있으면 `currentLive`는 `null`로 내려준다.
|
|
||||||
|
|
||||||
### Feature D. `다시듣기` 콘텐츠 목록과 개수
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `다시듣기` 콘텐츠는 `AudioContentTheme.theme == "다시듣기"`인 오디오 콘텐츠를 의미한다.
|
|
||||||
- `AudioContentTheme.isActive == true`인 테마만 대상으로 한다.
|
|
||||||
- 조회 대상은 지정한 `creatorId`의 콘텐츠로 제한한다.
|
|
||||||
- 공개된 콘텐츠만 조회한다.
|
|
||||||
- 예약 공개 전 콘텐츠는 포함하지 않는다.
|
|
||||||
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 조회에서 제외한다.
|
|
||||||
- 응답 item 필드는 기존 `CreatorChannelAudioContentResponse`와 동일하게 다음 값을 포함한다.
|
|
||||||
- `audioContentId`
|
|
||||||
- `title`
|
|
||||||
- `duration`
|
|
||||||
- `imageUrl`
|
|
||||||
- `price`
|
|
||||||
- `isAdult`
|
|
||||||
- `isPointAvailable`
|
|
||||||
- `isFirstContent`
|
|
||||||
- `seriesName`
|
|
||||||
- `isOriginalSeries`
|
|
||||||
- `CreatorChannelAudioContentResponse`에는 유료 콘텐츠 상태 표시를 위해 다음 값을 추가한다.
|
|
||||||
- `isOwned`: 조회자가 해당 콘텐츠를 소장 중이면 `true`
|
|
||||||
- `isRented`: 조회자가 해당 콘텐츠를 대여 중이고 대여 기간이 유효하면 `true`
|
|
||||||
- 무료 콘텐츠 또는 조회자가 구매/대여하지 않은 콘텐츠는 `isOwned == false`, `isRented == false`로 내려준다.
|
|
||||||
- 일반적으로 `isOwned == true`와 `isRented == true`가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
|
|
||||||
- 콘텐츠 개수는 같은 필터를 적용한 `다시듣기` 콘텐츠 전체 개수로 계산한다.
|
|
||||||
- 콘텐츠 목록은 `page`, `size` 기준으로 페이징 조회한다.
|
|
||||||
- 기본 page size는 20개다.
|
|
||||||
- 클라이언트는 `hasNext == true`이면 같은 `creatorId`, `sort`, `size`와 다음 `page` 값으로 추가 로딩할 수 있어야 한다.
|
|
||||||
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
|
||||||
- 목록 조회와 개수 조회는 성인 콘텐츠 노출 정책, 차단 정책, 공개 여부 필터가 서로 어긋나지 않아야 한다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책이 false이면 성인 콘텐츠는 목록과 개수에서 제외한다.
|
|
||||||
- `isFirstContent`, `seriesName`, `isOriginalSeries`, 구매/대여/포인트 사용 가능 여부의 의미는 기존 오디오 콘텐츠/시리즈 콘텐츠 응답과 동일하게 유지한다.
|
|
||||||
- `isFirstContent`는 `다시듣기` 테마 안에서 첫 콘텐츠인지가 아니라, 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 시리즈에 속하지 않은 콘텐츠는 `seriesName`, `isOriginalSeries`를 `null`로 내려준다.
|
|
||||||
- 공개된 `다시듣기` 콘텐츠가 없으면 빈 배열을 내려준다.
|
|
||||||
- 요청한 page 범위에 콘텐츠가 없으면 `liveReplayContents`는 빈 배열, `hasNext`는 `false`로 내려주되 `liveReplayContentCount`는 전체 개수를 유지한다.
|
|
||||||
|
|
||||||
### Feature E. 콘텐츠 정렬
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 정렬 순서는 enum으로 처리한다.
|
|
||||||
- enum 이름은 `ContentSort`를 기본안으로 한다.
|
|
||||||
- `ContentSort`는 크리에이터 채널에 한정하지 않고, 서비스 전반에서 콘텐츠 목록 정렬이 필요할 때 재사용할 수 있는 공용 정렬 enum으로 둔다.
|
|
||||||
- `ContentSort` 파일 위치는 구현 시 `kr.co.vividnext.sodalive.v2.common.domain.ContentSort`를 기본 후보로 한다.
|
|
||||||
- `ContentSort`는 라이브 탭의 `다시듣기` 콘텐츠뿐 아니라 다음 범위의 오디오 콘텐츠, 시리즈, 화보 등 콘텐츠형 목록에서 같은 정렬 의미를 공유한다.
|
|
||||||
- 공개 요청/응답 값은 다음을 사용한다.
|
|
||||||
- `LATEST`: 최신순, 기본값
|
|
||||||
- `POPULAR`: 인기순
|
|
||||||
- `OWNED`: 소장순
|
|
||||||
- `PRICE_HIGH`: 높은 가격순
|
|
||||||
- `PRICE_LOW`: 낮은 가격순
|
|
||||||
- `LATEST`는 공개일 최신순을 1차 정렬로 사용한다.
|
|
||||||
- `LATEST`의 2차 정렬은 높은 가격순이다.
|
|
||||||
- `LATEST`의 3차 정렬은 `audioContent.id desc`다.
|
|
||||||
- `POPULAR`은 매출이 많은 콘텐츠를 먼저 노출한다.
|
|
||||||
- `OWNED`는 조회자가 소장한 콘텐츠를 먼저 노출한다.
|
|
||||||
- `PRICE_HIGH`는 가격이 높은 콘텐츠를 먼저 노출한다.
|
|
||||||
- `PRICE_LOW`는 가격이 낮은 콘텐츠를 먼저 노출한다.
|
|
||||||
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 2차 정렬은 최신순이다.
|
|
||||||
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 3차 정렬은 `audioContent.id desc`다.
|
|
||||||
- 최신순 기준에 사용하는 날짜는 기존 `CreatorChannelAudioContentResponse` 목록 정책과 동일하게 공개 시각(`releaseDate`)을 기준으로 한다.
|
|
||||||
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 하며, 포인트 사용액은 매출 기준에 포함하지 않는다.
|
|
||||||
- 환불되었거나 비활성 처리된 구매 내역은 기존 콘텐츠 구매/매출 정책과 동일하게 제외한다.
|
|
||||||
- 소장순은 조회자가 해당 콘텐츠를 유효하게 소장 또는 구매한 상태를 기준으로 한다.
|
|
||||||
- 같은 1차/2차 정렬 값을 가진 항목은 `audioContent.id desc`로 안정적으로 정렬한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 매출이 없는 콘텐츠의 인기순 매출값은 0으로 처리한다.
|
|
||||||
- 조회자가 소장한 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `audioContent.id desc` 보조 정렬과 같은 결과가 될 수 있다.
|
|
||||||
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
|
||||||
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
|
|
||||||
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
|
|
||||||
- 기존 `CreatorChannelLiveResponse`, `CreatorChannelAudioContentResponse`와 필드/의미가 어긋나지 않도록 라이브 탭 API 응답 DTO를 작성한다.
|
|
||||||
- 라이브 탭 API의 `CreatorChannelAudioContentResponse`에는 `isOwned`, `isRented`를 포함하고, 다음 범위의 오디오 콘텐츠 조회 API에서도 같은 의미를 재사용할 수 있게 한다.
|
|
||||||
- 기존 크리에이터 채널 홈 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용하되, 신규 공개 API 파일 위치는 `v2.api.*` 조립 계층을 따른다.
|
|
||||||
- 기존 크리에이터 채널 홈 API가 `v2.creator.channel.adapter.in.web`에 위치한 것은 현재 구조의 예외로 보고, 이번 라이브 탭 구현에서는 같은 예외를 확장하지 않는다.
|
|
||||||
- 페이징 응답은 기존 v2 홈 추천 페이지 응답과 같은 `page`, `size`, `hasNext` 패턴을 따른다.
|
|
||||||
- `다시듣기` 테마명은 기존 코드의 문자열 상수와 중복되지 않도록 구현 시 공용 상수 또는 정책 객체로 관리하는 방안을 검토한다.
|
|
||||||
- `ContentSort`는 API binding, service 정책, 테스트에서 같은 타입을 사용한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Metrics
|
|
||||||
- 라이브 탭 API 성공/실패 건수
|
|
||||||
- 라이브 탭 API 응답 시간
|
|
||||||
- 정렬 순서별 요청 건수
|
|
||||||
- `currentLive`가 있는 응답 비율
|
|
||||||
- `다시듣기` 콘텐츠 개수와 실제 목록 노출 개수
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Resolved Decisions
|
|
||||||
- 인기순 매출 기준은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출만 사용한다.
|
|
||||||
- 라이브 탭 신규 API는 기존 크리에이터 채널 홈 API 위치를 따라가지 않고, `v2.api.creator.channel.live` 공개 API 조립 계층으로 작성한다.
|
|
||||||
- 기존 크리에이터 채널 홈 API의 패키지 구조 정렬은 이번 라이브 탭 구현과 분리해 다음 범위에서 별도 리팩토링한다.
|
|
||||||
- `isOwned == true`와 `isRented == true`가 동시에 발생할 가능성은 없지만, 만약 그런 상황이 발생하면 둘 다 `true`로 내려준다.
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# 크리에이터 채널 홈 API 구조 정렬 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** `GET /api/v2/creator-channels/{creatorId}/home`의 공개 계약을 보존하면서 홈 API 공개 조립 계층을 `v2.api.creator.channel.home`으로 옮기고 도메인 조회 계층을 API 패키지 밖으로 정렬한다.
|
|
||||||
|
|
||||||
**Architecture:** Controller, facade, response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home` 하위에 두고, HTTP 계약과 공개 응답 변환만 담당한다. 조회 service, 순수 정책, domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.home` 하위에 두며 `v2.api.*`를 import하지 않는다. 기존 endpoint와 DTO 필드명은 그대로 유지하고, 기존 `v2.creator.channel.adapter.in.web.CreatorChannelHomeController`는 이동 후 남기지 않아 Spring mapping 충돌을 방지한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper, ktlint
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- 작업 성격: 동작 보존 리팩토링
|
|
||||||
- 기존 공개 endpoint: `GET /api/v2/creator-channels/{creatorId}/home`
|
|
||||||
- 기존 인증 정책: 인증 회원만 조회 가능, 비회원은 `common.error.bad_credentials` 계열 오류
|
|
||||||
- 공개 API 조립 패키지:
|
|
||||||
- `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web`
|
|
||||||
- `kr.co.vividnext.sodalive.v2.api.creator.channel.home.application`
|
|
||||||
- `kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto`
|
|
||||||
- 도메인 조회 패키지:
|
|
||||||
- `kr.co.vividnext.sodalive.v2.creator.channel.home.application`
|
|
||||||
- `kr.co.vividnext.sodalive.v2.creator.channel.home.domain`
|
|
||||||
- `kr.co.vividnext.sodalive.v2.creator.channel.home.port.out`
|
|
||||||
- `kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence`
|
|
||||||
- 의존 방향: `v2.api.creator.channel.home -> v2.creator.channel.home`
|
|
||||||
- 금지 사항:
|
|
||||||
- endpoint 변경 금지
|
|
||||||
- 응답 필드명/의미 변경 금지
|
|
||||||
- 기능 추가 금지
|
|
||||||
- 라이브 탭 API 동작 변경 금지
|
|
||||||
- 불필요한 공용화 금지
|
|
||||||
|
|
||||||
## 1. 현재 공개 계약
|
|
||||||
|
|
||||||
현재 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt` 기준 최상위 응답 필드는 아래와 같다. 구조 정렬 후에도 필드명과 의미를 유지한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class CreatorChannelHomeResponse(
|
|
||||||
val creator: CreatorChannelCreatorResponse,
|
|
||||||
val currentLive: CreatorChannelLiveResponse?,
|
|
||||||
val latestAudioContent: CreatorChannelAudioContentResponse?,
|
|
||||||
val channelDonations: List<CreatorChannelDonationResponse>,
|
|
||||||
val notices: List<CreatorChannelCommunityPostResponse>,
|
|
||||||
val schedules: List<CreatorChannelScheduleResponse>,
|
|
||||||
val audioContents: List<CreatorChannelAudioContentResponse>,
|
|
||||||
val series: List<CreatorChannelSeriesResponse>,
|
|
||||||
val communities: List<CreatorChannelCommunityPostResponse>,
|
|
||||||
val fanTalk: CreatorChannelFanTalkSummaryResponse,
|
|
||||||
val introduce: String,
|
|
||||||
val activity: CreatorChannelActivityResponse,
|
|
||||||
val sns: CreatorChannelSnsResponse
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
아래 `@JsonProperty` 기반 boolean 필드명은 이동 후에도 유지한다.
|
|
||||||
|
|
||||||
- `creator.isAiChatAvailable`
|
|
||||||
- `creator.isDmAvailable`
|
|
||||||
- `creator.isFollow`
|
|
||||||
- `creator.isNotify`
|
|
||||||
- `currentLive.isAdult`
|
|
||||||
- `latestAudioContent.isAdult`
|
|
||||||
- `latestAudioContent.isPointAvailable`
|
|
||||||
- `latestAudioContent.isFirstContent`
|
|
||||||
- `latestAudioContent.isOriginalSeries`
|
|
||||||
- `latestAudioContent.isOwned`
|
|
||||||
- `latestAudioContent.isRented`
|
|
||||||
- `audioContents[*].isAdult`
|
|
||||||
- `audioContents[*].isPointAvailable`
|
|
||||||
- `audioContents[*].isFirstContent`
|
|
||||||
- `audioContents[*].isOriginalSeries`
|
|
||||||
- `audioContents[*].isOwned`
|
|
||||||
- `audioContents[*].isRented`
|
|
||||||
- `series[*].isNew`
|
|
||||||
- `series[*].isOriginal`
|
|
||||||
|
|
||||||
## 2. 파일 구조 계획
|
|
||||||
|
|
||||||
### 공개 API 조립 계층
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
|
|
||||||
### 도메인 조회 계층
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicy.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
|
|
||||||
### 테스트
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicyTest.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Modify: `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: 현재 계약 고정과 이동 전 실패 확인
|
|
||||||
|
|
||||||
- [x] **Task 1.1: controller 테스트를 새 API 패키지 기준으로 이동해 실패 확인**
|
|
||||||
- Files:
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
|
|
||||||
- RED: 테스트 package와 import를 새 controller 위치인 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeController` 기준으로 변경한다. 기존 endpoint `/api/v2/creator-channels/1/home`, 비회원 거부, 대표 JSON field path 검증은 유지한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- Expected: 새 controller 패키지가 아직 없어 컴파일 실패한다.
|
|
||||||
- GREEN: 아직 구현하지 않는다. 이 task는 이동 대상 controller 부재로 RED를 확인하는 단계다.
|
|
||||||
- REFACTOR: 없음.
|
|
||||||
- 기대 결과: 공개 API 조립 계층 이동 필요성이 테스트 실패로 고정된다.
|
|
||||||
- 검증 기록(2026-06-17): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest` 실행 결과 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt: (45, 13): Unresolved reference: CreatorChannelHomeController`로 실패했다. 새 API 패키지 controller가 아직 없어 실패한다는 RED 기대와 일치한다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: facade 테스트를 추가해 공개 응답 변환 책임을 고정**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
|
|
||||||
- RED: `CreatorChannelHomeFacade`가 `CreatorChannelHomeQueryService.getHome(...)` 결과를 `CreatorChannelHomeResponse`로 변환하고 기존 필드명 의미를 유지하는지 검증하는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest`
|
|
||||||
- Expected: `CreatorChannelHomeFacade` 미존재로 컴파일 실패한다.
|
|
||||||
- GREEN: 아직 구현하지 않는다. 이 task는 facade 책임 부재로 RED를 확인하는 단계다.
|
|
||||||
- REFACTOR: 없음.
|
|
||||||
- 기대 결과: API 조립 계층이 service 대신 response DTO 변환 책임을 갖는다는 기준이 고정된다.
|
|
||||||
- 검증 기록(2026-06-17): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest` 실행 결과 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt: (32, 22): Unresolved reference: CreatorChannelHomeFacade`로 실패했다. facade가 아직 없어 실패한다는 RED 기대와 일치한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: 공개 API 조립 계층 이동
|
|
||||||
|
|
||||||
- [x] **Task 2.1: response DTO를 `v2.api.creator.channel.home.dto`로 이동**
|
|
||||||
- Files:
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- RED: Task 1.1, Task 1.2에서 response DTO 새 package import 기준 컴파일 실패를 확인한 상태를 유지한다.
|
|
||||||
- GREEN: DTO 파일 package를 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto`로 변경하고, domain model import는 새 도메인 패키지 이동 전까지 기존 경로를 임시로 사용한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest`
|
|
||||||
- Expected: facade가 아직 없으면 실패가 유지된다. DTO 자체 import 오류는 해결되어야 한다.
|
|
||||||
- REFACTOR: `@JsonProperty`가 이동 중 누락되지 않았는지 파일 diff로 확인한다.
|
|
||||||
- 기대 결과: 공개 응답 DTO가 API 조립 계층에 위치한다.
|
|
||||||
- 검증 기록(2026-06-17): `CreatorChannelHomeResponse.kt`를 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto`로 이동하고 `CreatorChannelHomeQueryServiceTest`의 DTO import를 새 패키지로 갱신했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`로 기존 DTO 변환 회귀 테스트 통과를 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: `CreatorChannelHomeFacade`를 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
|
|
||||||
- RED: Task 1.2의 facade 미존재 실패를 사용한다.
|
|
||||||
- GREEN: `CreatorChannelHomeFacade`를 추가하고 `CreatorChannelHomeQueryService`를 호출한 뒤 `CreatorChannelHomeResponse.from(...)`으로 변환한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest`
|
|
||||||
- Expected: PASS
|
|
||||||
- REFACTOR: facade에 조회 정책이나 repository 접근이 들어가지 않았는지 확인한다.
|
|
||||||
- 기대 결과: 공개 API 조립 계층의 응답 변환 책임이 controller에서 facade로 이동한다.
|
|
||||||
- 검증 기록(2026-06-17): `CreatorChannelHomeFacade`를 추가해 기존 `CreatorChannelHomeQueryService.getHome(...)` 결과를 `CreatorChannelHomeResponse.from(...)`으로 변환하도록 구현했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest` 실행 결과 `BUILD SUCCESSFUL`로 facade 단위 테스트 통과를 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: controller를 `v2.api.creator.channel.home.adapter.in.web`으로 이동**
|
|
||||||
- Files:
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- RED: Task 1.1의 새 controller package 미존재 실패를 사용한다.
|
|
||||||
- GREEN: controller package를 변경하고 직접 `CreatorChannelHomeQueryService` 대신 `CreatorChannelHomeFacade`를 주입한다. `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/home")`, `requireMember` 동작은 유지한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- Expected: PASS
|
|
||||||
- REFACTOR: 기존 경로에 `CreatorChannelHomeController.kt`가 남아 있지 않은지 확인한다.
|
|
||||||
- Run: `rg -n "class CreatorChannelHomeController|/\\{creatorId\\}/home" src/main/kotlin/kr/co/vividnext/sodalive/v2`
|
|
||||||
- Expected: home controller mapping은 새 API 패키지 controller 1건만 확인된다.
|
|
||||||
- 기대 결과: Spring mapping 충돌 없이 홈 API controller가 API 조립 계층에 위치한다.
|
|
||||||
- 검증 기록(2026-06-17): `CreatorChannelHomeController`를 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web`으로 이동하고 직접 query service 주입 대신 `CreatorChannelHomeFacade` 주입으로 변경했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 controller 테스트 통과를 확인했다. `rg -n "class CreatorChannelHomeController|@GetMapping\(\"/\{creatorId\}/home\"\)|package kr\.co\.vividnext\.sodalive\.v2\.creator\.channel\.adapter\." src/main/kotlin/kr/co/vividnext/sodalive/v2` 실행 결과 home controller class와 `@GetMapping("/{creatorId}/home")`은 새 API 패키지 controller 1건만 확인했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: 도메인 조회 계층 패키지 정렬
|
|
||||||
|
|
||||||
- [x] **Task 3.1: domain model과 query policy를 `v2.creator.channel.home.domain`으로 이동**
|
|
||||||
- Files:
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicy.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicyTest.kt`
|
|
||||||
- Modify: imports in moved API DTO, service, tests
|
|
||||||
- RED: 이동한 테스트 package를 새 domain package 기준으로 바꾼 뒤 기존 main class 미이동 상태의 컴파일 실패를 확인한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest`
|
|
||||||
- Expected: 새 domain package class 미존재로 컴파일 실패한다.
|
|
||||||
- GREEN: domain model과 policy package를 변경하고 import를 갱신한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest`
|
|
||||||
- Expected: PASS
|
|
||||||
- REFACTOR: domain model이 `kr.co.vividnext.sodalive.v2.api`를 import하지 않는지 확인한다.
|
|
||||||
- 기대 결과: 순수 domain 책임이 API 패키지 밖의 home 도메인 패키지에 위치한다.
|
|
||||||
- 검증 기록(2026-06-17): `CreatorChannelHomeQueryPolicyTest`를 새 `kr.co.vividnext.sodalive.v2.creator.channel.home.domain` package로 먼저 이동한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest`를 실행해 `Unresolved reference: CreatorChannelHomeQueryPolicy` 컴파일 실패를 확인했다. 이후 `CreatorChannelHome.kt`, `CreatorChannelHomeQueryPolicy.kt`를 새 domain package로 이동하고 API DTO, service, 관련 테스트 import를 갱신했다. 같은 테스트 재실행 결과 `BUILD SUCCESSFUL`을 확인했고, domain package의 API import 및 기존 domain package import 검색 결과 0건을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: port와 query service를 `v2.creator.channel.home` 하위로 이동**
|
|
||||||
- Files:
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
|
|
||||||
- RED: service 테스트 package와 imports를 새 경로로 바꾼 뒤 기존 main class 미이동 상태의 컴파일 실패를 확인한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- Expected: 새 service/port package class 미존재로 컴파일 실패한다.
|
|
||||||
- GREEN: service와 port package를 변경하고 API facade가 새 service package를 import하도록 갱신한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest`
|
|
||||||
- Expected: PASS
|
|
||||||
- REFACTOR: service가 API DTO를 import하지 않는지 확인한다.
|
|
||||||
- 기대 결과: 도메인 application service가 API 조립 계층에 의존하지 않는다.
|
|
||||||
- 검증 기록(2026-06-17): `CreatorChannelHomeQueryServiceTest`를 새 `kr.co.vividnext.sodalive.v2.creator.channel.home.application` package로 먼저 이동한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`를 실행해 `Unresolved reference: CreatorChannelHomeQueryService` 컴파일 실패를 확인했다. 이후 `CreatorChannelHomeQueryService.kt`와 `CreatorChannelHomeQueryPort.kt`를 각각 새 application/port package로 이동하고 facade, repository adapter, 관련 테스트 import를 갱신했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했고, 기존 service/port package 참조 검색 결과 0건을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: repository adapter를 `v2.creator.channel.home.adapter.out.persistence`로 이동**
|
|
||||||
- Files:
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- Modify: imports in service and tests
|
|
||||||
- RED: repository 테스트 package와 imports를 새 경로로 바꾼 뒤 기존 main class 미이동 상태의 컴파일 실패를 확인한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- Expected: 새 repository package class 미존재로 컴파일 실패한다.
|
|
||||||
- GREEN: repository interface와 기본 구현체 package를 변경하고 port import를 새 경로로 갱신한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- Expected: PASS
|
|
||||||
- REFACTOR: repository 조회 조건과 정렬 조건의 동작 변경이 diff에 포함되지 않았는지 확인한다.
|
|
||||||
- 기대 결과: persistence adapter가 home 도메인 패키지 하위에 위치하고 기존 조회 정책을 유지한다.
|
|
||||||
- 검증 기록(2026-06-17): `DefaultCreatorChannelHomeQueryRepositoryTest`를 새 `kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence` package로 먼저 이동한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`를 실행해 `Unresolved reference: DefaultCreatorChannelHomeQueryRepository` 컴파일 실패를 확인했다. 이후 repository interface/default 구현체를 새 persistence package로 이동하고 테스트의 hard-coded source path를 새 경로로 갱신했다. 같은 테스트 재실행 결과 Kotlin daemon fallback 경고 후 `BUILD SUCCESSFUL`을 확인했다. Phase 3 관련 5개 테스트 묶음 실행도 `BUILD SUCCESSFUL`로 통과했고, 기존 Phase 3 package 참조 검색 결과 0건을 확인했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: 의존 방향과 회귀 검증
|
|
||||||
|
|
||||||
- [x] **Task 4.1: 도메인 패키지의 API 패키지 의존 여부 확인**
|
|
||||||
- Files:
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/live`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/series`
|
|
||||||
- RED: 해당 없음. 검색 기반 검증 task다.
|
|
||||||
- TDD 예외 사유: package import 방향 검증은 실패 테스트보다 정적 검색이 더 직접적인 검증이다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator src/main/kotlin/kr/co/vividnext/sodalive/v2/live src/main/kotlin/kr/co/vividnext/sodalive/v2/content src/main/kotlin/kr/co/vividnext/sodalive/v2/series`
|
|
||||||
- Expected: 도메인 패키지에서 API 패키지 import 결과 0건
|
|
||||||
- GREEN: 검색 결과가 있으면 API DTO 의존을 제거하고 domain model 또는 port record 의존으로 되돌린다.
|
|
||||||
- REFACTOR: 라이브 탭 API 패키지는 이번 범위에서 동작 변경하지 않았는지 diff로 확인한다.
|
|
||||||
- 기대 결과: 의존 방향이 `v2.api.creator.channel.home -> 도메인 패키지`로 유지된다.
|
|
||||||
- 검증 기록(2026-06-17): 계획서의 전체 검색 명령은 `src/main/kotlin/kr/co/vividnext/sodalive/v2/live`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/content`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/series` 경로가 현재 작업트리에 없어 경로 오류로 중단됨을 확인했다. 실제 `src/main/kotlin/kr/co/vividnext/sodalive/v2` 하위 경로는 `admin`, `api`, `can`, `chat`, `common`, `creator`, `ranking`, `recommendation`, `usercreatorchat`이며, `live`, `content`, `series`는 현재 작업트리에 없다. 실제 존재하는 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator` 경로 대상으로 `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator`를 재실행했고 결과 0건으로 도메인 패키지의 API 패키지 의존이 없음을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 홈 API 관련 단위/통합 회귀 테스트 실행**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: Phase 1부터 Phase 3의 실패 확인 기록을 유지한다.
|
|
||||||
- GREEN: 아래 테스트를 모두 통과시킨다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- Expected: PASS
|
|
||||||
- REFACTOR: 실패가 있으면 동작 변경 없이 package/import/bean wiring 문제만 수정한다.
|
|
||||||
- 기대 결과: controller, facade, service, policy, repository 회귀 테스트가 모두 통과한다.
|
|
||||||
- 검증 기록(2026-06-17): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: ktlint와 문서 검증 기록 갱신**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md`
|
|
||||||
- RED: 해당 없음. 포맷과 문서 기록 검증 task다.
|
|
||||||
- TDD 예외 사유: ktlint와 문서 기록은 구현 동작 테스트가 아니라 최종 품질 게이트다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `./gradlew ktlintCheck`
|
|
||||||
- Expected: PASS
|
|
||||||
- Run: `./gradlew tasks --all`
|
|
||||||
- Expected: Gradle task 목록 출력 성공
|
|
||||||
- GREEN: 검증 결과를 각 task 아래와 하단 검증 기록에 한국어로 누적 기록한다.
|
|
||||||
- REFACTOR: `git diff --name-only`로 이번 범위 밖 파일 변경이 없는지 확인한다.
|
|
||||||
- 기대 결과: 포맷 검증과 문서 유지보수 검증 결과가 기록된다.
|
|
||||||
- 검증 기록(2026-06-17): `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. `./gradlew tasks --all` 실행 결과 Gradle task 목록 출력 후 `BUILD SUCCESSFUL`을 확인했다. `git diff --name-only` 실행 결과 `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md` 1건만 출력되어 Phase 4 문서 범위 밖 변경이 없음을 확인했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 전체 검증 기록
|
|
||||||
|
|
||||||
- 문서 생성 검증(2026-06-17): `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md` 규칙에 따라 `docs/20260617_크리에이터_채널_홈_API_구조정렬/prd.md`와 `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md`를 생성했다.
|
|
||||||
- Gradle 명령 유효성 검증(2026-06-17): sandbox 내 `./gradlew tasks --all`은 `~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했다. 승인 후 동일 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Phase 1 RED 검증(2026-06-17): controller 테스트를 새 API 패키지로 이동하고 facade 테스트를 추가한 뒤 각 Gradle test filter를 실행했다. `CreatorChannelHomeController`와 `CreatorChannelHomeFacade`의 새 API 패키지 production class 미존재로 `compileTestKotlin`이 실패해 Phase 1의 실패 확인 목표를 충족했다.
|
|
||||||
- Phase 3 패키지 정렬 검증(2026-06-17): domain, service/port, repository adapter를 `kr.co.vividnext.sodalive.v2.creator.channel.home` 하위로 순차 이동했다. 각 task에서 테스트 파일 선이동 RED를 확인한 뒤 production package/import를 갱신했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Phase 4 의존 방향 및 회귀 검증(2026-06-17): 실제 `src/main/kotlin/kr/co/vividnext/sodalive/v2` 하위 경로는 `admin`, `api`, `can`, `chat`, `common`, `creator`, `ranking`, `recommendation`, `usercreatorchat`이며, 계획서에 포함된 `live`, `content`, `series` 경로는 현재 작업트리에 없어 존재 경로 기준으로 검증 기록을 남겼다. `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator` 대상 API 패키지 의존 검색 결과 0건을 확인했다. 홈 API 관련 5개 테스트 묶음, `./gradlew ktlintCheck`, `./gradlew tasks --all`은 모두 `BUILD SUCCESSFUL`로 통과했다. `git diff --name-only` 결과 Phase 4 문서 범위인 `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md` 1건만 변경됐음을 확인했다.
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# PRD: 크리에이터 채널 홈 API 구조 정렬
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
기존 `GET /api/v2/creator-channels/{creatorId}/home` API의 endpoint와 응답 계약을 유지하면서, 공개 API 조립 계층을 `kr.co.vividnext.sodalive.v2.api.creator.channel.home`으로 옮기고 재사용 가능한 조회/정책/port/repository 책임을 API 패키지 밖 도메인 패키지로 정렬한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 기존 크리에이터 채널 홈 API는 controller와 response DTO가 `kr.co.vividnext.sodalive.v2.creator.channel` 하위에 있어, 현재 라이브 탭 API가 따르는 `v2.api.*` 공개 조립 계층 구조와 맞지 않는다.
|
|
||||||
- 라이브 탭 API는 `kr.co.vividnext.sodalive.v2.api.creator.channel.live`와 `kr.co.vividnext.sodalive.v2.creator.channel.live`로 공개 API와 도메인 조회 책임을 분리했지만, 홈 API는 같은 v2 공개 API 설계와 패키지 경계가 어긋나 있다.
|
|
||||||
- 공개 API DTO가 도메인 패키지 안에 남아 있으면 도메인 패키지가 API 응답 계약을 소유하는 형태가 되어 이후 탭별 API 확장 시 의존 방향이 혼동될 수 있다.
|
|
||||||
- 구조 정렬 과정에서 기존 controller를 제거하지 않고 새 controller를 추가하면 `GET /api/v2/creator-channels/{creatorId}/home` mapping 충돌이 발생할 수 있다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 기존 홈 API endpoint `GET /api/v2/creator-channels/{creatorId}/home`을 유지한다.
|
|
||||||
- 기존 홈 API 응답 필드명과 필드 의미를 변경하지 않는다.
|
|
||||||
- 홈 API의 controller, facade, response DTO를 `kr.co.vividnext.sodalive.v2.api.creator.channel.home` 하위 공개 API 조립 계층으로 이동한다.
|
|
||||||
- 홈 API의 조회 service, 순수 정책, port, repository는 API 패키지 밖 도메인 패키지에 둔다.
|
|
||||||
- 도메인 패키지가 `kr.co.vividnext.sodalive.v2.api.*`에 의존하지 않도록 보장한다.
|
|
||||||
- 새 API controller 이동 시 기존 `v2.creator.channel.adapter.in.web.CreatorChannelHomeController`로 인한 Spring mapping 충돌이 없도록 기존 controller 제거 또는 이동 범위를 명확히 한다.
|
|
||||||
- 기존 홈 API controller, facade 또는 service, repository 회귀 테스트를 유지하고 새 패키지 구조에 맞게 이동한다.
|
|
||||||
- 검증 결과와 의존성 확인 결과를 `plan-task.md`에 누적 기록할 수 있게 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 홈 API 기능 추가는 하지 않는다.
|
|
||||||
- 홈 API 응답 스키마 확장, 필드명 변경, 필드 의미 변경은 하지 않는다.
|
|
||||||
- 기존 공개 endpoint path, HTTP method, 인증 정책은 변경하지 않는다.
|
|
||||||
- 라이브 탭 API(`v2.api.creator.channel.live`, `v2.creator.channel.live`) 구현은 리팩토링 대상이 아니다.
|
|
||||||
- 오디오, 시리즈, 커뮤니티, 팬 Talk, 후원 탭별 전체보기 API는 이번 범위에 포함하지 않는다.
|
|
||||||
- 불필요한 공용화, 신규 추상화, 도메인 정책 재설계는 하지 않는다.
|
|
||||||
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 앱 클라이언트: 기존 홈 API 계약을 그대로 호출하는 클라이언트
|
|
||||||
- 서버 개발자: v2 공개 API 조립 계층과 도메인 조회 계층의 의존 방향을 일관되게 유지해야 하는 개발자
|
|
||||||
- QA/릴리즈 담당자: 리팩토링 후 기존 홈 API 동작 회귀 여부를 확인해야 하는 담당자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 앱 클라이언트는 기존과 동일하게 `GET /api/v2/creator-channels/{creatorId}/home`을 호출하고 동일한 응답 필드와 의미를 받고 싶다.
|
|
||||||
- 서버 개발자는 홈 API controller와 response DTO가 `v2.api.creator.channel.home`에 있어 공개 API 조립 계층을 쉽게 찾고 싶다.
|
|
||||||
- 서버 개발자는 도메인 조회 service와 repository가 `v2.api.*`에 의존하지 않는다는 것을 검색 명령으로 확인하고 싶다.
|
|
||||||
- 서버 개발자는 기존 홈 API controller가 남아 새 controller와 mapping 충돌을 일으키지 않는지 테스트와 검색으로 확인하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 공개 API 조립 계층 이동
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `CreatorChannelHomeController`는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web` 하위로 이동한다.
|
|
||||||
- 홈 API facade는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.application` 하위에 둔다.
|
|
||||||
- `CreatorChannelHomeResponse`와 하위 response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto` 하위로 이동한다.
|
|
||||||
- controller는 기존 endpoint `GET /api/v2/creator-channels/{creatorId}/home`을 그대로 제공한다.
|
|
||||||
- controller는 기존과 동일하게 인증 회원을 요구하고, 비회원은 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- facade는 공개 API 응답 DTO 조립 책임만 갖고 도메인 조회 service를 호출한다.
|
|
||||||
- 기존 `kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeController`는 남기지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 새 controller와 기존 controller가 동시에 bean으로 등록되어 같은 path mapping을 제공하면 안 된다.
|
|
||||||
|
|
||||||
### Feature B. 도메인 조회 계층 정렬
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 홈 API 조회 service, 순수 정책, domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.home` 하위로 정렬한다.
|
|
||||||
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
|
|
||||||
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
|
|
||||||
- 의존 방향은 항상 `v2.api.creator.channel.home -> v2.creator.channel.home`이다.
|
|
||||||
- repository는 기존 QueryDSL 조회 의미와 정책을 변경하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 라이브 탭 API의 `v2.api.creator.channel.live`, `v2.creator.channel.live` 패키지는 이번 구조 정렬 대상이 아니므로 동작 변경 없이 import 영향만 확인한다.
|
|
||||||
|
|
||||||
### Feature C. 공개 계약 보존 회귀 검증
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 홈 API 최상위 응답 필드는 기존과 동일하게 유지한다.
|
|
||||||
- `creator`
|
|
||||||
- `currentLive`
|
|
||||||
- `latestAudioContent`
|
|
||||||
- `channelDonations`
|
|
||||||
- `notices`
|
|
||||||
- `schedules`
|
|
||||||
- `audioContents`
|
|
||||||
- `series`
|
|
||||||
- `communities`
|
|
||||||
- `fanTalk`
|
|
||||||
- `introduce`
|
|
||||||
- `activity`
|
|
||||||
- `sns`
|
|
||||||
- 기존 하위 DTO 필드명과 의미를 변경하지 않는다.
|
|
||||||
- controller 테스트는 기존 endpoint와 대표 JSON field path를 검증한다.
|
|
||||||
- facade 또는 service 테스트는 도메인 조회 결과가 기존 응답 DTO로 변환되는 흐름을 검증한다.
|
|
||||||
- repository 테스트는 기존 조회 정책 회귀를 유지한다.
|
|
||||||
- `./gradlew ktlintCheck`를 실행하고 결과를 계획 문서에 기록한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- response DTO 패키지 이동으로 Jackson `@JsonProperty`가 누락되어 `is*` 필드명이 바뀌면 안 된다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- 언어/런타임은 Kotlin + Java 17을 유지한다.
|
|
||||||
- 빌드와 검증은 Gradle Wrapper(`./gradlew`)를 사용한다.
|
|
||||||
- Spring Boot 2.7.14, JUnit 5, MockMvc, QueryDSL 기존 관례를 따른다.
|
|
||||||
- 패키지 구조는 `docs/agent-guides/코드스타일.md`의 공개 API 조립 계층과 도메인 패키지 의존 방향 규칙을 따른다.
|
|
||||||
- 테스트는 `docs/agent-guides/테스트스타일.md`의 RED, GREEN, REFACTOR 절차를 따른다.
|
|
||||||
- 문서와 검증 기록은 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`를 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Metrics
|
|
||||||
- 홈 API endpoint와 응답 계약 회귀 테스트 통과 여부
|
|
||||||
- facade 또는 service 단위 테스트 통과 여부
|
|
||||||
- repository 단위 테스트 통과 여부
|
|
||||||
- `./gradlew ktlintCheck` 통과 여부
|
|
||||||
- 도메인 패키지의 `v2.api.*` import 검색 결과 0건 여부
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions
|
|
||||||
- 없음. 이번 범위는 동작 보존 리팩토링이며, 응답 계약이나 기능 정책 변경은 포함하지 않는다.
|
|
||||||
@@ -1,952 +0,0 @@
|
|||||||
# 유저-크리에이터 채팅 WebSocket 전환 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 유저-크리에이터 1:1 채팅의 SSE 실시간 연결을 제거하고, 채팅방 화면 진입 중에만 유지되는 raw WebSocket + Redis presence/pub-sub 구조로 전환한다.
|
|
||||||
|
|
||||||
**Architecture:** REST는 방 생성, 방 열기, 과거 메시지 조회, 음성 업로드에 유지하고 텍스트 실시간 송수신과 방 presence는 WebSocket으로 처리한다. 서버는 다중 인스턴스를 전제로 local WebSocket session registry와 Redis presence/pub-sub을 함께 사용한다. 상대방이 해당 `roomId`에 접속 중이면 WebSocket으로만 전달하고, 접속 중이 아니면 기존 FCM/APNs 푸시 흐름에 `deep_link` 이동 정보만 포함해 발송한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring WebSocket, Spring Data Redis, Redis pub/sub, Spring Data JPA, JUnit 5, Mockito, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- 대상 PRD: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
|
|
||||||
- WebSocket endpoint: `/ws/v2/user-creator-chat`
|
|
||||||
- WebSocket protocol: STOMP 없는 raw JSON envelope
|
|
||||||
- WebSocket 연결 수명: 채팅방 화면 진입 중에만 유지
|
|
||||||
- 서버 인스턴스 전제: 여러 대
|
|
||||||
- presence 저장소: Redis
|
|
||||||
- 서버 간 메시지 전달: Redis pub/sub
|
|
||||||
- local memory에는 현재 서버에 붙은 WebSocket session만 저장
|
|
||||||
- 기존 SSE는 완전히 제거
|
|
||||||
- 기존 REST 유지:
|
|
||||||
- `POST /api/v2/user-creator-chat/rooms/create`
|
|
||||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/open`
|
|
||||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/messages`
|
|
||||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
|
||||||
- 텍스트 메시지 전송은 WebSocket `SEND_TEXT`로 전환
|
|
||||||
- 푸시 발송 기준:
|
|
||||||
- 상대방이 같은 `roomId`에 WebSocket presence 있음: 푸시 미발송
|
|
||||||
- 상대방이 같은 `roomId`에 WebSocket presence 없음: 푸시 발송
|
|
||||||
- 푸시 payload 필수값:
|
|
||||||
- `deep_link`: 운영 `voiceon://chat/{roomId}`, 개발/테스트 `voiceon-test://chat/{roomId}`
|
|
||||||
- v2 채팅 푸시에서는 `room_id`, `message_id`, `chat_type` data payload를 사용하지 않는다.
|
|
||||||
- Redis key 기본안:
|
|
||||||
- `v2:user-creator-chat:ws:presence:{roomId}:{memberId}:{sessionId}`
|
|
||||||
- `v2:user-creator-chat:ws:room:{roomId}:member:{memberId}:sessions`
|
|
||||||
- `v2:user-creator-chat:ws:room`
|
|
||||||
- presence TTL 기본값: 90초
|
|
||||||
- 운영 Redis는 AWS ElastiCache Serverless Valkey 7.2 또는 Redis OSS 7.1을 사용할 수 있다.
|
|
||||||
- AWS ElastiCache Serverless는 Redis pattern subscribe에 필요한 `PSUBSCRIBE`를 지원하지 않으므로, Redis listener는 `PatternTopic` 대신 `ChannelTopic` 기반 고정 채널 `SUBSCRIBE`만 사용한다.
|
|
||||||
- OCI Cache Redis/Valkey 호환성을 위해서도 Redis Pub/Sub은 `PUBLISH`/`SUBSCRIBE` 기본 명령만 사용하고, `roomId` 필터링은 channel name이 아니라 payload의 `roomId/memberId`로 수행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 의존성/설정
|
|
||||||
- Modify: `build.gradle.kts`
|
|
||||||
- `spring-boot-starter-websocket` 의존성을 추가한다.
|
|
||||||
- Modify: `src/main/resources/application.yml`
|
|
||||||
- lazy loading 의존 API 점검과 수정 후 `spring.jpa.open-in-view=false`를 명시한다.
|
|
||||||
- Modify: `src/test/resources/application.yml`
|
|
||||||
- 테스트에서 OSIV off 회귀를 확인할 수 있도록 동일 설정을 검토한다.
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt`
|
|
||||||
- WebSocket handler endpoint를 등록한다.
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt`
|
|
||||||
- handshake에서 JWT 인증 정보를 추출한다.
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt`
|
|
||||||
|
|
||||||
### WebSocket protocol
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessage.kt`
|
|
||||||
- request/response envelope와 message type enum을 둔다.
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessageTest.kt`
|
|
||||||
|
|
||||||
### WebSocket session/presence
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt`
|
|
||||||
- local WebSocket session을 sessionId 기준으로 관리한다.
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt`
|
|
||||||
- Redis presence 등록, 갱신, 제거, 조회를 담당한다.
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt`
|
|
||||||
- Redis pub/sub publish/subscribe와 local session 전송을 담당한다.
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt`
|
|
||||||
|
|
||||||
### WebSocket handler/application
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
|
||||||
- WebSocket lifecycle과 JSON envelope dispatch를 담당한다.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- WebSocket 텍스트 메시지 저장/전달용 application method를 추가하고 SSE 의존성을 제거한다.
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandshakeIntegrationTest.kt`
|
|
||||||
|
|
||||||
### 푸시 payload
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`
|
|
||||||
- 필요 시 v2 채팅용 deep link 값을 추가한다.
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt`
|
|
||||||
- v2 채팅 푸시는 `deep_link`만 data payload에 포함하고 `room_id`, `message_id`, `chat_type`은 제외한다.
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmServiceTest.kt`
|
|
||||||
|
|
||||||
### SSE 제거
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
|
||||||
- `events`, `events/disconnect`, `messages/text` endpoint 제거 또는 WebSocket 전환에 맞춰 제거한다.
|
|
||||||
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`
|
|
||||||
- Modify: `docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
|
||||||
- 클라이언트 연동 문서의 SSE 안내를 WebSocket 기준으로 갱신한다.
|
|
||||||
|
|
||||||
### 클라이언트 반영 문서
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
|
|
||||||
- iOS/Android 앱 변경 사항을 PRD에 유지한다.
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- 서버 구현 task와 별도로 앱 반영 체크리스트를 유지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. WebSocket 메시지 계약 초안
|
|
||||||
|
|
||||||
구현 전 클라이언트와 공유할 JSON envelope 기준이다. 필드명 변경이 필요하면 PRD와 이 계획 문서를 먼저 갱신한다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "SEND_TEXT",
|
|
||||||
"requestId": "c7f1a263-0e4f-4f98-9bd4-70988575e9d7",
|
|
||||||
"roomId": 10,
|
|
||||||
"payload": {
|
|
||||||
"textMessage": "hello"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
서버 응답 예시:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "MESSAGE",
|
|
||||||
"requestId": null,
|
|
||||||
"roomId": 10,
|
|
||||||
"payload": {
|
|
||||||
"messageId": 200,
|
|
||||||
"messageType": "TEXT",
|
|
||||||
"mine": false,
|
|
||||||
"createdAt": 1781690400000,
|
|
||||||
"textMessage": "hello",
|
|
||||||
"voiceMessageUrl": null,
|
|
||||||
"senderId": 2,
|
|
||||||
"senderNickname": "creator",
|
|
||||||
"senderProfileImageUrl": "https://cdn.test/profile/creator.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
서버 ack 예시:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "SEND_ACK",
|
|
||||||
"requestId": "c7f1a263-0e4f-4f98-9bd4-70988575e9d7",
|
|
||||||
"roomId": 10,
|
|
||||||
"payload": {
|
|
||||||
"messageId": 201,
|
|
||||||
"messageType": "TEXT",
|
|
||||||
"mine": true,
|
|
||||||
"createdAt": 1781690401000,
|
|
||||||
"textMessage": "hello",
|
|
||||||
"voiceMessageUrl": null,
|
|
||||||
"senderId": 1,
|
|
||||||
"senderNickname": "user",
|
|
||||||
"senderProfileImageUrl": "https://cdn.test/profile/user.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. iOS/Android 클라이언트 변경 사항
|
|
||||||
|
|
||||||
앱은 서버 배포와 같은 릴리스 범위에서 아래 변경을 반영해야 한다. 서버 구현자가 직접 앱 코드를 수정하지 않더라도, API 계약과 검증 기준은 이 문서에 유지한다.
|
|
||||||
|
|
||||||
### 채팅방 진입
|
|
||||||
- 기존 SSE 연결 생성 코드를 제거한다.
|
|
||||||
- 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`을 먼저 호출한다.
|
|
||||||
- `openRoom` 응답으로 기존 메시지 목록과 상대방 프로필/닉네임을 렌더링한다.
|
|
||||||
- 이후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
|
|
||||||
- handshake에는 `Authorization: Bearer <accessToken>` 헤더를 포함한다.
|
|
||||||
- 연결 직후 아래 메시지를 보낸다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "JOIN_ROOM",
|
|
||||||
"requestId": "client-request-id",
|
|
||||||
"roomId": 10,
|
|
||||||
"payload": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `JOINED` 수신 전에는 텍스트 전송 버튼을 비활성화하거나 전송 대기 상태로 처리한다.
|
|
||||||
|
|
||||||
### 텍스트 메시지 전송
|
|
||||||
- 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출을 제거한다.
|
|
||||||
- 텍스트 메시지는 아래 WebSocket 메시지로 전송한다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "SEND_TEXT",
|
|
||||||
"requestId": "client-request-id",
|
|
||||||
"roomId": 10,
|
|
||||||
"payload": {
|
|
||||||
"textMessage": "hello"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 앱은 `requestId`를 pending 메시지와 매칭한다.
|
|
||||||
- `SEND_ACK`를 수신하면 pending 메시지를 서버 응답의 `messageId`, `createdAt`, `senderProfileImageUrl` 기준으로 확정한다.
|
|
||||||
- `ERROR` 또는 timeout이 발생하면 메시지를 실패 상태로 표시하고 재시도 UI를 제공한다.
|
|
||||||
|
|
||||||
### 메시지 수신
|
|
||||||
- `MESSAGE` 이벤트 수신 시 현재 열려 있는 `roomId`와 일치하는지 확인한 뒤 메시지 목록에 append한다.
|
|
||||||
- 현재 채팅방과 다른 `roomId`의 `MESSAGE`를 받으면 버리거나 로그로 남긴다. 이번 서버 설계에서는 같은 WebSocket session이 하나의 `roomId`만 활성 방으로 가지므로 정상 상황에서는 발생하지 않아야 한다.
|
|
||||||
|
|
||||||
### 화면 이탈과 재연결
|
|
||||||
- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 아래 메시지를 보낸 뒤 WebSocket을 close한다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "LEAVE_ROOM",
|
|
||||||
"requestId": "client-request-id",
|
|
||||||
"roomId": 10,
|
|
||||||
"payload": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 현재 채팅방 화면에 남아 있는 동안 WebSocket이 끊기면 지수 백오프로 재연결한다.
|
|
||||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고, 필요하면 `GET /api/v2/user-creator-chat/rooms/{roomId}/messages`로 누락 메시지를 동기화한다.
|
|
||||||
- access token이 refresh되면 기존 WebSocket을 닫고 새 token으로 다시 연결한다.
|
|
||||||
|
|
||||||
### 푸시 이동
|
|
||||||
- 푸시 payload의 `deep_link`를 확인한다.
|
|
||||||
- 사용자가 푸시를 터치하면 `voiceon://chat/{roomId}` 또는 `voiceon-test://chat/{roomId}`의 `{roomId}`에 해당하는 채팅방 화면으로 이동한다.
|
|
||||||
- 푸시 진입 후에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결과 `JOIN_ROOM`을 수행한다.
|
|
||||||
- `deep_link`가 없거나 채팅방 room id를 해석할 수 없으면 채팅방 직접 이동을 하지 않고 앱 기본 알림 처리로 fallback 한다.
|
|
||||||
|
|
||||||
### 클라이언트 제거 대상
|
|
||||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출
|
|
||||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출
|
|
||||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출
|
|
||||||
- SSE reconnect/retry 처리 코드
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 0: OSIV 비활성화 사전 점검
|
|
||||||
|
|
||||||
- [x] **Task 0.1: 현재 OSIV 설정과 위험 패턴 조사**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- TDD 예외 사유: 코드 변경 전 사전 조사 task로, 자동화 테스트보다 정적 검색과 결과 문서화가 목적이다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`
|
|
||||||
- Expected: 현재 OSIV 명시 여부를 확인한다.
|
|
||||||
- Run: `rg -n "member\\?\\.auth|member\\.auth|member\\?\\.notification|member\\.notification|ApiResponse\\.ok\\([^\\n]*(repository|findBy|findAll)|ResponseEntity\\.ok\\([^\\n]*(repository|findBy|findAll)" src/main/kotlin/kr/co/vividnext/sodalive`
|
|
||||||
- Expected: controller 또는 응답 직렬화 단계에서 lazy loading이 일어날 수 있는 후보를 찾는다.
|
|
||||||
- Run: `rg -n "@Service|@Transactional|findByIdOrNull|findById\\(|findAll\\(|\\.member|\\.sender|\\.recipient|\\.auth|\\.chatRoom|\\.series|\\.audioContent" src/main/kotlin/kr/co/vividnext/sodalive`
|
|
||||||
- Expected: 트랜잭션 없는 service/facade/query method에서 LAZY 연관을 접근하는 후보를 찾는다.
|
|
||||||
- GREEN: 발견 항목을 이 문서의 `OSIV 점검 기록` 섹션에 API/파일/위험/수정 방향 형식으로 기록한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `rg -n "OSIV 점검 기록|lazy loading|open-in-view" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- Expected: 조사 결과와 후속 조치가 문서에 기록되어야 한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: 현재 OSIV 명시 여부와 lazy loading 위험 후보를 정적 검색으로 조사했다.
|
|
||||||
- 왜: `spring.jpa.open-in-view=false` 전환 전에 controller 응답 직렬화나 인증 principal 접근에서 트랜잭션 밖 lazy 접근 가능성을 확인하기 위해서다.
|
|
||||||
- 어떻게: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`, `rg -n "member\\?\\.auth|member\\.auth|member\\?\\.notification|member\\.notification|ApiResponse\\.ok\\([^\\n]*(repository|findBy|findAll)|ResponseEntity\\.ok\\([^\\n]*(repository|findBy|findAll)" src/main/kotlin/kr/co/vividnext/sodalive`, `rg -n "@Service|@Transactional|findByIdOrNull|findById\\(|findAll\\(|\\.member|\\.sender|\\.recipient|\\.auth|\\.chatRoom|\\.series|\\.audioContent" src/main/kotlin/kr/co/vividnext/sodalive`를 실행했다.
|
|
||||||
- 결과: main/test `application.yml` 모두 `spring.jpa`는 있으나 `open-in-view`는 명시되어 있지 않았다. `EventController`, `UserActionController`, `AuditionController`, `CreatorAdminContentSeriesGenreController`, `AudioContentCommentController` 등에서 `MemberAdapter.member.auth` 직접 접근 후보가 확인되었다. QueryDSL repository의 `member.auth`, `series.member`, `audioContent.member` 접근은 쿼리 식 내부 경로가 대부분이라 즉시 lazy 직렬화 위험으로 분류하지 않았다.
|
|
||||||
|
|
||||||
- [x] **Task 0.2: OSIV off 테스트 실행 범위 선정**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- TDD 예외 사유: 구현 전 테스트 전략 정의 task다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `find src/test/kotlin/kr/co/vividnext/sodalive -name '*ControllerTest.kt' | sort`
|
|
||||||
- Expected: OSIV off 영향이 드러날 가능성이 높은 controller 테스트 목록을 선별한다.
|
|
||||||
- GREEN: 인증 principal의 lazy 접근, entity 직접 반환, controller DTO 변환이 있는 API를 우선순위로 선정한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `rg -n "OSIV off 우선 테스트|ControllerTest|MockMvc" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- Expected: 우선 실행할 테스트 목록 또는 기준이 문서에 기록되어야 한다.
|
|
||||||
- OSIV off 우선 테스트:
|
|
||||||
- `kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@Transactional`, `MemberAdapter`를 함께 사용해 실제 JPA/MockMvc 경계와 인증 principal 전달 표면을 확인할 수 있다.
|
|
||||||
- `kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@Transactional`, `MemberAdapter`를 함께 사용해 인증 회원 id 기반 조회 표면을 확인할 수 있다.
|
|
||||||
- `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`: `@WebMvcTest`라 JPA lazy loading 자체 검증은 제한적이지만 controller가 `MemberAdapter.member`를 facade로 전달하는 표면 회귀를 확인할 수 있다.
|
|
||||||
- `kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`: `@WebMvcTest`라 JPA lazy loading 자체 검증은 제한적이지만 인증 principal 전달 표면 회귀를 확인할 수 있다.
|
|
||||||
- `kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`, `kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`: 현재 유저-크리에이터 채팅 전용 `ControllerTest`가 없어, service 트랜잭션 안 DTO 변환과 lazy 접근 안전성을 보조 확인한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: OSIV off 영향이 드러날 가능성이 높은 controller/service 테스트 범위를 선정했다.
|
|
||||||
- 왜: Phase 0.3에서 무작위 전체 테스트가 아니라 인증 principal lazy 접근, entity 직접 반환, DTO 변환 경계가 있는 테스트를 우선 실행하기 위해서다.
|
|
||||||
- 어떻게: `rg --files src/test/kotlin/kr/co/vividnext/sodalive | rg 'ControllerTest\\.kt$'`, `rg -n "@SpringBootTest|@AutoConfigureMockMvc|@Transactional|MockMvc|MemberAdapter" src/test/kotlin/kr/co/vividnext/sodalive -g "*ControllerTest.kt"`, `rg --files src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat`를 실행했다.
|
|
||||||
- 결과: `HomeRecommendationControllerTest`, `CreatorRankingControllerTest`를 우선 통합 테스트로 선정하고, `CreatorChannelHomeControllerTest`, `CreatorChannelLiveControllerTest`, user-creator-chat service 테스트를 보조 범위로 선정했다.
|
|
||||||
|
|
||||||
- [x] **Task 0.3: OSIV off로 후보 테스트를 실행하고 실패를 분류**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- TDD 예외 사유: 설정 전환 영향 조사 task다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<선정한 ControllerTest 클래스명>'`
|
|
||||||
- Expected: 성공하면 해당 API는 우선 위험 낮음으로 기록한다. `LazyInitializationException`이 발생하면 파일/필드/API를 기록한다.
|
|
||||||
- GREEN: 실패를 다음 유형으로 분류한다.
|
|
||||||
- controller에서 인증 principal lazy 연관 접근
|
|
||||||
- 응답 직렬화 중 entity lazy 연관 접근
|
|
||||||
- service/facade 트랜잭션 밖 DTO 변환 중 lazy 연관 접근
|
|
||||||
- 테스트 fixture가 `@Transactional`로 문제를 숨기는 경우
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `rg -n "LazyInitializationException|OSIV off 실패|OSIV off 성공" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- Expected: 테스트 결과와 분류가 문서에 기록되어야 한다.
|
|
||||||
- OSIV off 성공:
|
|
||||||
- Run: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests '*CreatorChannelHomeControllerTest' --tests '*CreatorChannelLiveControllerTest' --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
|
|
||||||
- Result: `BUILD SUCCESSFUL in 1m 24s`
|
|
||||||
- XML 확인: `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개, `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개, `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0`.
|
|
||||||
- 분류: 선정한 user-creator-chat service/integration, 홈/랭킹 controller 통합 테스트, 보조 WebMvc controller 테스트 범위에서는 확인된 `LazyInitializationException` 없음. user-creator-chat DTO 변환은 현재 service 트랜잭션 안에서 수행되는 것으로 판단했다.
|
|
||||||
|
|
||||||
- [x] **Task 0.4: lazy loading 의존 제거 후 OSIV off 명시**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/resources/application.yml`
|
|
||||||
- Modify: `src/test/resources/application.yml`
|
|
||||||
- Modify: lazy loading 의존 API별 service/repository/controller/test 파일
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- RED: Task 0.3에서 확인한 `LazyInitializationException` 재현 테스트를 먼저 고정한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<실패 재현 테스트>'`
|
|
||||||
- Expected: 기존 코드에서는 `LazyInitializationException` 또는 동등한 실패가 발생한다.
|
|
||||||
- GREEN: controller lazy 접근을 service/query 계층의 트랜잭션 안 DTO projection, fetch join, 명시 조회로 이동한다.
|
|
||||||
- GREEN: `application.yml`과 필요한 경우 `test application.yml`에 아래 설정을 명시한다.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spring:
|
|
||||||
jpa:
|
|
||||||
open-in-view: false
|
|
||||||
```
|
|
||||||
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<수정한 테스트>'`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: WebSocket 전환 작업과 관계없는 API 스키마 변경은 하지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: main/test 설정에 `spring.jpa.open-in-view=false`를 명시했다.
|
|
||||||
- 왜: Phase 0.3에서 user-creator-chat service/integration, 홈/랭킹 controller 통합 테스트, 보조 WebMvc controller 테스트 범위에 `LazyInitializationException`이 없었고, OSIV 정책을 명시해야 이후 WebSocket 전환 작업의 트랜잭션 경계를 안전하게 유지할 수 있기 때문이다.
|
|
||||||
- 어떻게: `src/main/resources/application.yml`, `src/test/resources/application.yml`의 `spring.jpa` 아래에 `open-in-view: false`를 추가했다.
|
|
||||||
- 결과: 확인된 lazy loading 재현 실패가 없어 production code의 service/repository/controller 수정은 하지 않았다. 공개 API 스키마 변경도 없다.
|
|
||||||
|
|
||||||
- [x] **Task 0.5: 운영 LazyInitializationException 회귀 보완**
|
|
||||||
- Files:
|
|
||||||
- Add: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt`
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- RED: `ChatCharacterService.getCharacterDetail` 반환 후 `tagMappings.tag.tag`, `getOtherCharactersBySharedTags` 반환 후 `tagMappings.tag.tag`, `RankingRepository.getCreatorRankings` 반환 후 `Member.toExplorerSectionCreator`를 트랜잭션 밖에서 접근하는 테스트를 추가한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest`
|
|
||||||
- Expected: 기존 코드에서는 `LazyInitializationException` 또는 동등한 실패가 발생한다.
|
|
||||||
- GREEN: 응답 조립에 필요한 `ChatCharacter.tagMappings.tag`, `Member.tags.tag`를 조회 쿼리에서 fetch join으로 선로딩한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 공개 API 응답 스키마와 WebSocket 관련 구현은 변경하지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: OSIV off 상태에서 운영 오류와 같은 lazy loading 경계를 재현하는 회귀 테스트를 추가하고, 필요한 연관을 fetch join으로 선로딩했다.
|
|
||||||
- 왜: `ChatCharacterController.getCharacterDetail`에서 `ChatCharacterTagMapping.tag`, `HomeService.fetchData`에서 `Member.tags`가 트랜잭션 밖에서 열려 `LazyInitializationException`이 발생했기 때문이다.
|
|
||||||
- 어떻게: `OsivLazyLoadingRegressionTest`를 추가해 `ChatCharacterService.getCharacterDetail`, `ChatCharacterService.getOtherCharactersBySharedTags`, `RankingRepository.getCreatorRankings` 반환 후 트랜잭션 밖 DTO 변환을 검증했다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest` 실행 결과 3개 테스트 모두 `LazyInitializationException`으로 실패했다.
|
|
||||||
- GREEN: 같은 명령을 재실행해 `BUILD SUCCESSFUL in 1m 6s`로 통과했다.
|
|
||||||
- 인접 회귀: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest --tests kr.co.vividnext.sodalive.api.home.HomeServiceTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest`가 `BUILD SUCCESSFUL in 24s`로 통과했다.
|
|
||||||
- 전체 테스트 중단: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false`는 `UserCreatorChatRedisIntegrationTest` 실행 중 `OutOfMemoryError`가 발생해 즉시 중단했다. 이후 검증 범위는 OSIV 회귀와 인접 테스트로 간결화했다.
|
|
||||||
- lint: `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 14s`로 통과했다.
|
|
||||||
- 정적 점검: `rg -n "toExplorerSectionCreator\\(|tagMappings\\.map|tagMappings\\.joinToString|\\.tagMappings" src/main/kotlin/kr/co/vividnext/sodalive -S`로 동일 패턴 후보를 확인했다. `ExplorerService`는 클래스 단위 `@Transactional(readOnly = true)` 안에서 변환하고, `HomeService`/`RankingService`는 공통 `RankingRepository.getCreatorRankings` 선로딩으로 보완했다. `TranslationSourceExtractor`와 관리자/원작 DTO 변환의 `tagMappings` 접근은 운영 stacktrace 표면이 아니므로 별도 회귀 후보로 남겼다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: WebSocket 의존성과 인증 handshake 기반 추가
|
|
||||||
|
|
||||||
- [x] **Task 1.1: WebSocket 의존성 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `build.gradle.kts`
|
|
||||||
- RED: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt`를 추가해 `/ws/v2/user-creator-chat` handler bean 등록을 기대한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest`
|
|
||||||
- Expected: WebSocket 관련 타입 또는 config bean 부재로 실패한다.
|
|
||||||
- GREEN: `implementation("org.springframework.boot:spring-boot-starter-websocket")`를 추가한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew tasks --all`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 의존성 추가 외 다른 dependency 정렬/버전 변경은 하지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: `spring-boot-starter-websocket` 의존성을 추가했다.
|
|
||||||
- 왜: raw WebSocket endpoint와 handshake interceptor 타입을 사용하기 위해서다.
|
|
||||||
- 어떻게: WebSocket 타입 부재로 `compileTestKotlin` RED를 확인한 뒤 의존성을 추가하고 Phase 1 focused 테스트를 재실행했다.
|
|
||||||
- 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest`가 `BUILD SUCCESSFUL in 1m 11s`로 통과했다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: WebSocket config와 handler 등록**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt`
|
|
||||||
- RED: config 테스트에서 handler가 `/ws/v2/user-creator-chat` 경로로 등록되는지 검증한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest`
|
|
||||||
- Expected: config class 부재로 실패한다.
|
|
||||||
- GREEN: `WebSocketConfigurer`를 구현하고 `TextWebSocketHandler` 기반 handler를 등록한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: CORS origin은 기존 `WebConfig`의 허용 origin 정책과 어긋나지 않게 제한한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: `/ws/v2/user-creator-chat` endpoint를 `WebSocketConfigurer`로 등록하고 `TextWebSocketHandler` 기반 handler를 추가했다.
|
|
||||||
- 왜: 채팅방 화면 진입 중 유지되는 raw WebSocket 연결 기반을 만들기 위해서다.
|
|
||||||
- 어떻게: `UserCreatorChatWebSocketConfigTest`에서 endpoint path 등록을 검증했다.
|
|
||||||
- 결과: Phase 1 focused 테스트가 `BUILD SUCCESSFUL`로 통과했다. allowed origin은 기존 `WebConfig` origin 목록과 동일하게 제한했다.
|
|
||||||
|
|
||||||
- [x] **Task 1.3: WebSocket handshake JWT 인증 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt`
|
|
||||||
- RED: `Authorization: Bearer <token>` 헤더가 없거나 유효하지 않으면 handshake가 실패하는 테스트를 작성한다.
|
|
||||||
- RED: 유효한 토큰이면 attributes에 `memberId`와 인증 principal이 저장되는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest`
|
|
||||||
- Expected: interceptor class 부재로 실패한다.
|
|
||||||
- GREEN: 기존 `TokenProvider.getAuthentication(token)`을 사용해 인증하고, `MemberAdapter.member.id`를 session attributes에 저장한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: JWT parsing 로직을 새로 만들지 않고 기존 `TokenProvider`를 재사용한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: `Authorization: Bearer <token>` handshake 인증 interceptor를 추가했다.
|
|
||||||
- 왜: WebSocket session attributes에 인증 member id와 authentication을 저장해 이후 room join/message 처리에서 사용할 수 있게 하기 위해서다.
|
|
||||||
- 어떻게: `TokenProvider.validateToken(token)`과 `TokenProvider.getAuthentication(token)`을 재사용하고, `MemberAdapter.member.id`를 `memberId` attribute로 저장했다.
|
|
||||||
- 결과: 유효 token 성공, Authorization header 누락 실패, invalid token 실패 테스트가 Phase 1 focused 테스트에서 통과했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: 메시지 프로토콜과 local session registry 추가
|
|
||||||
|
|
||||||
- [x] **Task 2.1: WebSocket message envelope 정의**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessage.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessageTest.kt`
|
|
||||||
- RED: `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG` enum 값과 JSON deserialize 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest`
|
|
||||||
- Expected: message class 부재로 실패한다.
|
|
||||||
- GREEN: request/response envelope와 type enum을 추가한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: payload는 초기 구현에서 `JsonNode`로 받고, 타입별 request DTO 변환은 handler dispatch에서 수행한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: WebSocket request/response envelope와 message type enum을 추가했다.
|
|
||||||
- 왜: Phase 4 handler dispatch에서 raw JSON 메시지를 공통 계약으로 역직렬화하기 위해서다.
|
|
||||||
- 어떻게: `UserCreatorChatWebSocketMessage`는 `type`, `requestId`, `roomId`, `payload: JsonNode`를 갖는 data class로 두고, `UserCreatorChatWebSocketMessageType`에 `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`을 정의했다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest` 실행 시 `UserCreatorChatWebSocketMessageType`, `UserCreatorChatWebSocketMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 4m 30s`로 통과했다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: local WebSocket session registry 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt`
|
|
||||||
- RED: `roomId/memberId/sessionId` 등록, 조회, 제거, 같은 session의 room 전환 시 기존 room 제거 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`
|
|
||||||
- Expected: registry class 부재로 실패한다.
|
|
||||||
- GREEN: `ConcurrentHashMap` 기반 registry를 추가한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: registry는 local session만 관리하고 Redis를 직접 호출하지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: local WebSocket session registry를 추가했다.
|
|
||||||
- 왜: 다중 인스턴스 구조에서 현재 서버에 붙은 session만 roomId/memberId/sessionId 기준으로 관리하기 위해서다.
|
|
||||||
- 어떻게: `ConcurrentHashMap`으로 room/member별 session map과 sessionId index를 관리하고, 같은 session이 다른 room으로 등록되면 기존 room mapping을 제거하도록 했다. 동시 같은 session room 전환은 sessionId hash 기반 고정 striped lock으로 `register`/`remove`를 직렬화했다. Redis 호출은 포함하지 않았다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest` 실행 시 `UserCreatorChatWebSocketSessionRegistry` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- RED: reviewer 지적 후 추가한 동시 같은 session room 전환 테스트가 기존 구현에서 `Expected concurrent same-session room switch to leave exactly one active room mapping` assertion으로 실패함을 확인했다.
|
|
||||||
- RED: 운영 트래픽에서 sessionId별 lock map이 누적될 수 있다는 리뷰를 반영해 추가한 `sessionId별 lock map을 유지하지 않는다` 테스트가 기존 구현에서 실패함을 확인했다.
|
|
||||||
- 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 3m 41s`로 통과했다.
|
|
||||||
- 결과: 실제 실행한 Phase 2 focused 명령 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 1m 46s`로 통과했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: Redis presence와 Redis pub/sub 추가
|
|
||||||
|
|
||||||
- [x] **Task 3.1: Redis presence service 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt`
|
|
||||||
- RED: `markJoined`, `refresh`, `markLeft`, `hasPresence(roomId, memberId)` 동작을 embedded Redis 또는 mock RedisTemplate으로 검증한다.
|
|
||||||
- RED: TTL이 설정되는지 검증한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest`
|
|
||||||
- Expected: presence service 부재로 실패한다.
|
|
||||||
- GREEN: Redis key/value와 session set index를 저장하고 TTL 90초를 적용한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: key prefix는 companion object 상수로 모으고 기존 SSE presence key와 섞이지 않게 `ws` segment를 포함한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: WebSocket session 단위 Redis presence service를 추가했다.
|
|
||||||
- 왜: 다중 서버에서 상대방이 같은 `roomId`에 접속 중인지 판단하고, session별 join/refresh/leave 상태를 TTL 기반으로 관리하기 위해서다.
|
|
||||||
- 어떻게: `StringRedisTemplate`으로 `v2:user-creator-chat:ws:presence:{roomId}:{memberId}:{sessionId}`, `v2:user-creator-chat:ws:room:{roomId}:member:{memberId}:sessions`, `v2:user-creator-chat:ws:room:{roomId}` 키를 저장하고 90초 TTL을 적용했다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` 실행 시 `UserCreatorChatPresenceService` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- Reviewer RED: 남은 session id가 TTL 만료로 stale 상태일 때 마지막 live session이 leave 해도 member presence가 즉시 제거되지 않는다는 지적을 받고, stale session 테스트가 `ArgumentsAreDifferent`로 실패함을 확인했다.
|
|
||||||
- 결과: stale session id를 presence key 기준으로 정리하고 live session이 없을 때 member/room presence를 제거하도록 수정했다. `UserCreatorChatPresenceServiceTest`가 `BUILD SUCCESSFUL in 1m 22s`로 통과했고, Phase 3 WebSocket focused 테스트가 `BUILD SUCCESSFUL in 31s`로 통과했다.
|
|
||||||
- Reviewer 보강: PRD의 Redis presence value 계약에 맞춰 value를 문자열 `1`에서 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt` JSON으로 변경했다. `user-creator-chat.websocket.server-id`가 비어 있으면 애플리케이션 시작 시 UUID를 serverId로 사용한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: Redis pub/sub room broker 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt`
|
|
||||||
- RED: publish 시 `v2:user-creator-chat:ws:room:{roomId}` channel로 메시지를 발행하는 테스트를 작성한다.
|
|
||||||
- RED: subscribe callback이 local registry에서 대상 member session만 찾아 전송하는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`
|
|
||||||
- Expected: broker class 부재로 실패한다.
|
|
||||||
- GREEN: Redis pub/sub publisher와 listener를 추가한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: broker는 DB 저장을 하지 않고 이미 만들어진 message DTO만 전달한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: Redis pub/sub room broker와 `RedisMessageListenerContainer` bean을 추가했다.
|
|
||||||
- 왜: 서버 인스턴스 간 room 메시지를 Redis channel로 전달하고, 수신 인스턴스가 local registry에서 대상 member session만 찾아 WebSocket으로 전송하기 위해서다.
|
|
||||||
- 어떻게: publish는 `v2:user-creator-chat:ws:room:{roomId}` channel에 `roomId`, `memberId`, `payload` JSON을 발행하고, listener는 `v2:user-creator-chat:ws:room:*` pattern topic을 구독해 대상 local session에 `TextMessage(payload)`를 전송한다. broker는 DB 저장을 하지 않는다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` 실행 시 `UserCreatorChatRoomMessageBroker`, `UserCreatorChatRoomPublishedMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- Reviewer RED: 같은 member의 local session 중 하나가 `IOException`으로 실패하면 이후 정상 session 전송이 중단되는 테스트가 기존 구현에서 실패함을 확인했다.
|
|
||||||
- 결과: session별 전송 실패를 격리하고 실패 session만 local registry에서 제거하도록 수정했다. `UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 21s`로 통과했고, Phase 3 WebSocket focused 테스트가 `BUILD SUCCESSFUL in 21s`로 통과했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: Redis presence/pub-sub embedded Redis 통합 테스트 추가**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt`
|
|
||||||
- RED: `@SpringBootTest`와 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 실제 Redis에 `markJoined`를 호출하면 presence key, member session set, room set이 저장되고 presence key TTL이 0보다 큰지 검증한다.
|
|
||||||
- RED: `markLeft` 호출 시 실제 Redis에서 session presence key와 마지막 member session set, room member entry가 정리되는지 검증한다.
|
|
||||||
- RED: stale session id가 member session set에 남아 있고 presence key가 없으면 `hasPresence(roomId, memberId)`가 false를 반환하고 stale session id를 set에서 제거하는지 검증한다.
|
|
||||||
- RED: `UserCreatorChatRoomMessageBroker.publish`가 실제 Redis pub/sub을 통해 listener까지 도달하고, local registry의 대상 member session에 payload를 전달하는지 검증한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
|
||||||
- Expected: 통합 테스트 파일 부재 또는 실제 Redis 경계 미검증으로 실패한다.
|
|
||||||
- GREEN: production code 변경 없이 기존 `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `RedisMessageListenerContainer`가 embedded Redis에서 동작하도록 테스트를 추가한다. 실패가 있으면 Redis serialization/listener wiring에 필요한 최소 수정만 적용한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: Redis 통합 테스트는 `src/test/resources/META-INF/spring.factories` 전역 등록 없이 `EmbeddedRedisInitializer`를 명시적으로 opt-in 한다.
|
|
||||||
- 범위 한계:
|
|
||||||
- Phase 3에서는 Redis presence/pub-sub 인프라를 실제 Redis 기준으로 검증한다.
|
|
||||||
- 실제 WebSocket `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, 메시지 저장/ack/푸시 분기까지 포함한 end-to-end 흐름은 Phase 4에서 검증한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: embedded Redis 기반 `UserCreatorChatRedisIntegrationTest`를 추가했다.
|
|
||||||
- 왜: mock 단위 테스트가 아니라 실제 Redis key/TTL/set 저장, stale session pruning, Redis pub/sub listener 전달 경계를 확인하기 위해서다.
|
|
||||||
- 어떻게: `@SpringBootTest`와 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 opt-in embedded Redis를 사용하고, `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `UserCreatorChatWebSocketSessionRegistry`, `StringRedisTemplate`을 실제 Spring context에서 주입받아 검증했다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest` 실행 시 테스트 파일 부재로 `No tests found for given includes` 실패를 확인했다.
|
|
||||||
- GREEN: 같은 focused 명령을 `cleanTest`와 함께 순차 재실행해 `BUILD SUCCESSFUL in 33s`로 통과했다. join presence key/member session set/room set/TTL, last session leave 정리, stale session pruning, Redis pub/sub listener를 통한 target local session payload 전달을 확인했다.
|
|
||||||
- Reviewer 보강 GREEN: embedded Redis 테스트에서 `user-creator-chat.websocket.server-id=redis-test-server`를 주입하고 실제 Redis presence value JSON의 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt`과 TTL을 함께 검증하도록 갱신했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.4: ElastiCache Serverless 호환 Redis pub/sub channel 보정**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt`
|
|
||||||
- Verify Docs: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
|
|
||||||
- 배경:
|
|
||||||
- 운영 Redis는 AWS ElastiCache Serverless Valkey 7.2 또는 Redis OSS 7.1이다.
|
|
||||||
- 현재 `PatternTopic("v2:user-creator-chat:ws:room:*")`는 Redis `PSUBSCRIBE`를 사용한다.
|
|
||||||
- AWS ElastiCache Serverless는 `PSUBSCRIBE`를 지원하지 않아 애플리케이션 시작 시 `redisMessageListenerContainer` bean 시작이 실패한다.
|
|
||||||
- RED: broker 생성 테스트를 `PatternTopic` 검증에서 `ChannelTopic("v2:user-creator-chat:ws:room")` 검증으로 변경한다.
|
|
||||||
- RED: publish 테스트를 room별 channel `v2:user-creator-chat:ws:room:{roomId}`가 아니라 고정 channel `v2:user-creator-chat:ws:room`에 `roomId`, `memberId`, `payload` JSON을 발행하는 검증으로 변경한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`
|
|
||||||
- Expected: 기존 구현이 `PatternTopic("v2:user-creator-chat:ws:room:*")`와 room별 channel publish를 사용하므로 변경된 테스트가 실패한다.
|
|
||||||
- GREEN: `UserCreatorChatRoomMessageBroker`에서 `PatternTopic` import와 room별 subscribe를 제거하고 `ChannelTopic("v2:user-creator-chat:ws:room")`만 등록한다.
|
|
||||||
- GREEN: `publish(roomId, memberId, payload)`는 기존 `UserCreatorChatRoomPublishedMessage` payload 구조를 유지하되 고정 channel `v2:user-creator-chat:ws:room`으로만 발행한다.
|
|
||||||
- GREEN: `onMessage`는 기존처럼 payload의 `roomId/memberId`로 local session을 필터링한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- 통합 회귀:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`; embedded Redis pub/sub listener가 고정 channel 구독만으로 대상 local session에 payload를 전달한다.
|
|
||||||
- 인접 회귀:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- Lint:
|
|
||||||
- Run: `./gradlew --no-daemon ktlintCheck`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: channel 상수명은 room별 channel이 아님을 드러내도록 `ROOM_CHANNEL` 또는 동등한 이름으로 정리한다. 기존 external WebSocket/REST API 계약은 변경하지 않는다.
|
|
||||||
- 문서 확인:
|
|
||||||
- Run: `rg -n "PatternTopic|PSUBSCRIBE|v2:user-creator-chat:ws:room:\\{roomId\\}|v2:user-creator-chat:ws:room:\\*" docs/20260618_유저크리에이터채팅_WebSocket전환`
|
|
||||||
- Expected: 과거 검증 기록을 제외한 현재 요구사항/계획에는 `PatternTopic`, `PSUBSCRIBE`, room별 pub/sub channel 요구가 남아 있지 않다.
|
|
||||||
- 문서 작성 기록:
|
|
||||||
- 무엇: PRD의 Redis pub/sub channel 요구사항을 고정 channel `v2:user-creator-chat:ws:room` 기준으로 갱신하고, 계획 문서에 ElastiCache Serverless 호환 보정 Task를 추가했다.
|
|
||||||
- 왜: AWS ElastiCache Serverless Valkey 7.2/Redis OSS 7.1에서 `PSUBSCRIBE`가 지원되지 않아 `PatternTopic` 기반 구현이 애플리케이션 시작 실패를 유발하기 때문이다.
|
|
||||||
- 어떻게: `PatternTopic`/room별 channel을 제거하고 `ChannelTopic`/고정 channel을 사용하는 RED-GREEN 검증 절차, embedded Redis 통합 회귀, 인접 회귀, lint 검증 명령을 문서화했다.
|
|
||||||
- 문서 규칙 검증 Run: `./gradlew --no-daemon tasks --all`
|
|
||||||
- 문서 규칙 검증 Result: sandbox 권한 문제로 최초 실행은 `/Users/klaus/.gradle/.../gradle-8.1.1-bin.zip.lck (Operation not permitted)` 실패. 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 6s`로 통과했다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: `UserCreatorChatRoomMessageBroker`의 Redis pub/sub을 room별 `PatternTopic` 구독/room별 channel publish에서 고정 `ChannelTopic("v2:user-creator-chat:ws:room")` 구독/고정 channel publish로 변경했다.
|
|
||||||
- 왜: AWS ElastiCache Serverless에서 `PSUBSCRIBE`가 지원되지 않으므로 Spring Data Redis `PatternTopic` 경로를 제거하고 `SUBSCRIBE` 기반 channel만 사용하기 위해서다.
|
|
||||||
- RED: broker unit test를 먼저 고정 channel 기대값으로 변경한 뒤 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`를 실행해 publish channel과 listener topic 검증이 `ArgumentsAreDifferent`로 실패함을 확인했다.
|
|
||||||
- GREEN: `PatternTopic` import와 `roomChannel(roomId)`를 제거하고 `ChannelTopic(ROOM_CHANNEL)`, `convertAndSend(ROOM_CHANNEL, ...)`로 변경했다. 같은 focused test는 최초 120초 timeout 후 300초 timeout으로 재실행해 `BUILD SUCCESSFUL in 2m 45s`로 통과했다.
|
|
||||||
- 통합 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`가 `BUILD SUCCESSFUL in 35s`로 통과했고, embedded Redis pub/sub listener가 고정 channel 구독만으로 대상 local session에 payload를 전달함을 확인했다.
|
|
||||||
- 인접 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`가 `BUILD SUCCESSFUL in 42s`로 통과했다.
|
|
||||||
- Lint: `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 31s`로 통과했다.
|
|
||||||
- 전체 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`가 `BUILD SUCCESSFUL in 1m 32s`로 통과했다.
|
|
||||||
- 문서 확인: `rg -n "PatternTopic|PSUBSCRIBE|v2:user-creator-chat:ws:room:\\{roomId\\}|v2:user-creator-chat:ws:room:\\*" docs/20260618_유저크리에이터채팅_WebSocket전환` 실행 결과, 남은 항목은 PRD의 현재 금지 요구사항과 Task 3.4/Phase 3 과거 기록 및 presence key 설명으로 확인했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: WebSocket handler와 메시지 저장/전달
|
|
||||||
|
|
||||||
- [x] **Task 4.1: JOIN_ROOM 처리**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt`
|
|
||||||
- RED: 인증 member가 참여 중인 room에 `JOIN_ROOM`을 보내면 `JOINED` 응답과 local/Redis presence 등록이 수행되는 테스트를 작성한다.
|
|
||||||
- RED: 참여자가 아닌 room이면 `ERROR` 후 close 되는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest`
|
|
||||||
- Expected: JOIN_ROOM dispatch 부재로 실패한다.
|
|
||||||
- GREEN: handler에서 `JOIN_ROOM`을 처리하고 service의 참여자 검증 method를 호출한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 기존 private `requireParticipant` 재사용이 필요하면 public/internal 검증 method로 최소 노출한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: WebSocket handler의 `JOIN_ROOM` dispatch, service 참여자 검증 method, local session registry 등록, Redis presence 등록, `JOINED`/`ERROR` envelope 응답을 추가했다.
|
|
||||||
- 왜: 채팅방 화면 진입 시 인증 member가 해당 room 참여자인지 확인한 뒤 현재 서버 session과 Redis presence를 등록해야 하기 때문이다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` 실행 시 `validateParticipant`, handler constructor dependency, dispatch 부재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 1m 1s`로 통과했다.
|
|
||||||
- 인접 검증: `UserCreatorChatWebSocketConfigTest`, `UserCreatorChatPresenceServiceTest`, `UserCreatorChatRoomMessageBrokerTest` 포함 명령이 `BUILD SUCCESSFUL in 1m`로 통과했고, 이후 focused+인접 통합 명령이 `BUILD SUCCESSFUL in 3m 28s`로 통과했다.
|
|
||||||
- Reviewer 보강 RED: 같은 session이 다른 room으로 다시 `JOIN_ROOM`할 때 기존 Redis presence가 제거되지 않고, WebSocket close 시 local session/Redis presence가 정리되지 않는 테스트가 기존 구현에서 실패함을 확인했다.
|
|
||||||
- Reviewer 보강 GREEN: session attribute에 joined room을 저장하고, 재JOIN/close 시 `presenceService.markLeft`와 `sessionRegistry.remove`를 호출하도록 수정했다. `UserCreatorChatWebSocketHandlerTest`가 `BUILD SUCCESSFUL in 1m`로 통과했고, focused+인접 WebSocket 테스트 묶음이 `BUILD SUCCESSFUL in 15s`로 통과했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: SEND_TEXT 저장, sender ack, 수신자 WebSocket 전달**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt`
|
|
||||||
- RED: 상대방 presence가 있으면 메시지를 저장하고 `broker.publish`를 호출하며 푸시 이벤트를 발행하지 않는 테스트를 작성한다.
|
|
||||||
- RED: sender에게 `SEND_ACK`가 전송되는 handler 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest`
|
|
||||||
- Expected: WebSocket send method 부재로 실패한다.
|
|
||||||
- GREEN: `sendTextMessage`의 저장 로직을 재사용하되 `UserCreatorChatPresenceService`와 `UserCreatorChatRoomMessageBroker` 기준으로 전달 여부를 판단한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 기존 REST text endpoint 제거 전까지 중복 저장 로직이 생기지 않도록 private save method로만 분리한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: WebSocket 전용 `sendTextMessageByWebSocket`을 추가해 기존 텍스트 저장 로직을 private `saveTextMessage`로 재사용하고, 상대방 presence가 있으면 `UserCreatorChatRoomMessageBroker.publish`로 `MESSAGE` envelope를 발행하며 sender에게는 handler가 `SEND_ACK`를 응답하도록 했다.
|
|
||||||
- 왜: REST text endpoint 제거 전까지 중복 저장 로직을 만들지 않고, WebSocket 송신 경로에서 저장/ack/수신자 전달을 처리하기 위해서다.
|
|
||||||
- 범위: 상대방 미접속 시 push payload 보강은 Task 4.3 범위라 이번 task에서는 변경하지 않았다. 현재 계약은 v2 채팅 푸시 payload에 `deep_link`만 포함하는 것이다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` 실행 시 `sendTextMessageByWebSocket`, service WebSocket dependency, handler `SEND_TEXT` dispatch 부재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: focused 명령 재실행 결과 `BUILD SUCCESSFUL in 1m 1s`로 통과했다. 이후 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 3m 28s`로 통과했다.
|
|
||||||
- Reviewer 보강 RED: `JOIN_ROOM` 완료 전 `SEND_TEXT`를 보내도 메시지 저장 경로로 들어갈 수 있다는 테스트가 기존 구현에서 실패함을 확인했다.
|
|
||||||
- Reviewer 보강 GREEN: `SEND_TEXT` 처리 전 session의 joined room id가 요청 `roomId`와 일치하는지 검증하고, 미JOIN/다른 room이면 `chat.room.join_required` `ERROR`를 응답하도록 수정했다. `UserCreatorChatWebSocketHandlerTest`가 `BUILD SUCCESSFUL in 1m`로 통과했고, focused+인접 WebSocket 테스트 묶음이 `BUILD SUCCESSFUL in 15s`로 통과했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: 상대방 미접속 시 푸시 발송**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmServiceTest.kt`
|
|
||||||
- RED: 상대방 presence가 없으면 `FcmEvent`가 v2 채팅용 `deep_link` 생성 정보를 포함하고 `roomId`, `messageId`, `chatType=USER_CREATOR` data payload를 포함하지 않는 테스트를 작성한다.
|
|
||||||
- RED: `FcmService`가 FCM data payload에 `deep_link=voiceon[-test]://chat/{roomId}`만 넣는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
|
||||||
- Expected: v2 채팅 deep_link payload 부재 또는 기존 `room_id`/`message_id`/`chat_type` 잔존으로 실패한다.
|
|
||||||
- GREEN: v2 채팅 push 발행 시 `deep_link`만 채우고 기존 `room_id`, `message_id`, `chat_type` data payload는 제거한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 기존 live/content/community deep link payload와 충돌하지 않게 v2 채팅 deep link는 `voiceon[-test]://chat/{roomId}` 규칙으로만 생성한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 현재 요구사항 변경: v2 채팅 푸시 payload는 `deep_link`만 사용하고 `room_id`, `message_id`, `chat_type`은 제거한다. 기존 완료 기록은 이전 계약 기준 이력이며, 후속 구현에서 새 계약에 맞춰 수정한다.
|
|
||||||
- 무엇: WebSocket 텍스트/REST 음성 전송에서 상대방 presence가 없으면 `FcmEvent.deepLinkValue=CHAT`, `deepLinkId=roomId`만 포함한 FCM 이벤트를 발행하도록 수정했다. FCM data payload 생성은 `deep_link=voiceon[-test]://chat/{roomId}`만 포함하고 `room_id`, `message_id`, `chat_type`을 제외한다.
|
|
||||||
- 왜: 상대방이 같은 `roomId`에 접속 중이 아닐 때 푸시 터치로 유저-크리에이터 채팅방에 이동해야 하기 때문이다.
|
|
||||||
- 이전 계약 기준 RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` 실행 시 `FcmEvent.chatType`, `FcmService.buildDataPayload` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- 이전 계약 기준 GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 4m 30s`로 통과했다. 현재 요구사항은 위 RED/GREEN 항목처럼 `deep_link` 단일 payload로 후속 수정한다.
|
|
||||||
- Fresh 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --rerun-tasks --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
|
||||||
- Fresh 검증 Result: `BUILD SUCCESSFUL in 3m 45s`; focused 테스트 10 actionable tasks가 실제 실행됐다.
|
|
||||||
- Fresh 정적 확인 Run: `rg -n "room_id|message_id|chat_type|deep_link|voiceon://chat|voiceon-test://chat" docs/20260618_유저크리에이터채팅_WebSocket전환 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat src/test/kotlin/kr/co/vividnext/sodalive/fcm src/main/kotlin/kr/co/vividnext/sodalive/fcm`
|
|
||||||
- Fresh 정적 확인 Result: v2 채팅 service/test 경로는 `deepLinkValue=CHAT`, `deepLinkId=roomId`, `deep_link` 테스트로 확인됐고, `room_id`/`message_id`/`chat_type`은 공통 FCM payload helper와 문서상 제외 조건에만 남아 있음을 확인했다.
|
|
||||||
- 코드 리뷰 메모: `UserCreatorChatService.publishMessagePush`, `FcmSendListener.sendPush`, `FcmService.buildDeepLink/buildDataPayload`, 관련 단위 테스트를 대조했다. Task 4.3 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.4: LEAVE_ROOM, close, heartbeat 처리**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt`
|
|
||||||
- RED: `LEAVE_ROOM`과 WebSocket close 시 local registry와 Redis presence가 제거되는 테스트를 작성한다.
|
|
||||||
- RED: `PING` 수신 시 presence TTL 갱신과 `PONG` 응답 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest`
|
|
||||||
- Expected: LEAVE_ROOM/PING 처리 부재로 실패한다.
|
|
||||||
- GREEN: handler lifecycle callback과 message dispatch에 정리/heartbeat 처리를 추가한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: close와 LEAVE_ROOM 정리 로직은 같은 private method를 사용한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: handler dispatch에 `LEAVE_ROOM`과 `PING`을 추가했다. `LEAVE_ROOM`은 기존 close cleanup 경로인 `clearJoinedRoom`을 재사용하고, `PING`은 joined room 검증 후 Redis presence TTL을 갱신하고 `PONG`을 응답한다.
|
|
||||||
- 왜: 채팅방 화면 이탈 시 presence를 즉시 제거하고, 화면 유지 중 heartbeat로 90초 TTL presence를 연장하기 위해서다.
|
|
||||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest` 실행 시 `LEAVE_ROOM`의 `markLeft`, `PING`의 `refresh`가 호출되지 않아 handler 테스트 2개가 실패했다.
|
|
||||||
- GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 4m 9s`로 통과했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.5: WebSocket handshake slice 테스트 추가**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandshakeIntegrationTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` (필요한 경우에만)
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt` (필요한 경우에만)
|
|
||||||
- RED: WebSocket config slice에서 `/ws/v2/user-creator-chat` handler에 `UserCreatorChatWebSocketAuthInterceptor`가 등록되어 있고, 등록된 interceptor가 handshake 인증 성공/실패를 판정하는 테스트를 작성한다.
|
|
||||||
- RED: 유효한 `Authorization: Bearer <accessToken>` header가 있으면 handshake가 성공하고, header가 없거나 유효하지 않으면 handshake가 실패하는 테스트를 작성한다.
|
|
||||||
- RED: 전체 Spring Boot server/DB/Redis context를 띄우지 않고 `TokenProvider`는 mock으로 고정해 token parsing 세부 로직은 기존 interceptor 단위 테스트에 위임한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`
|
|
||||||
- Expected: 통합 테스트 파일 부재 또는 실제 client handshake 경계 미구현으로 실패한다.
|
|
||||||
- GREEN: 테스트가 실패하면 최소 수정만 적용한다. 예를 들어 security filter가 `/ws/v2/user-creator-chat` handshake를 interceptor까지 통과시키지 못하는 경우에만 해당 경로 정책을 조정한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 단위 테스트가 이미 검증하는 token parsing 세부 로직을 중복하지 않고, endpoint 등록과 auth interceptor handshake 성공/실패 연결 경계만 검증한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: WebSocket config slice에서 `/ws/v2/user-creator-chat` handler 등록과 등록된 `UserCreatorChatWebSocketAuthInterceptor`의 handshake 성공/실패를 검증하는 테스트를 추가했다.
|
|
||||||
- 왜: `@SpringBootTest(webEnvironment = RANDOM_PORT)` 기반 실제 server/client 테스트가 전체 suite 말미에 `java.lang.OutOfMemoryError: Java heap space`를 유발해, 동일 Phase 4 경계를 더 가벼운 slice로 검증하기 위해서다.
|
|
||||||
- 어떻게: `TokenProvider`는 mock으로 고정하고, 유효 `Authorization: Bearer <token>`은 handshake 성공, header 누락과 invalid token은 handshake 실패를 검증했다. 실제 token parsing 세부 로직은 `UserCreatorChatWebSocketAuthInterceptorTest`에 맡겼다.
|
|
||||||
- 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`가 `BUILD SUCCESSFUL in 1m 11s`로 통과했다. production `SecurityConfig`/interceptor 수정은 필요하지 않았다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 5: 기존 SSE 제거와 REST 경계 정리
|
|
||||||
|
|
||||||
- [x] **Task 5.1: SSE controller endpoint 제거**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- RED: `disconnectRealtime` 관련 테스트를 제거하거나 WebSocket close/presence 테스트로 이동한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: 제거 대상 method 참조가 남아 있으면 실패한다.
|
|
||||||
- GREEN: `/events`, `/events/disconnect`, `/messages/text` REST endpoint를 제거한다. 텍스트 메시지는 WebSocket 전송만 허용한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 사용하지 않는 `MediaType.TEXT_EVENT_STREAM_VALUE` import를 제거한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: `/events`, `/events/disconnect`, `/messages/text` REST endpoint를 controller에서 제거하고, 제거된 경로가 더 이상 매핑되지 않는 테스트를 추가했다.
|
|
||||||
- 왜: 텍스트 메시지 송수신과 presence lifecycle을 WebSocket으로만 처리하기 위해서다.
|
|
||||||
- RED: `UserCreatorChatControllerMappingTest.shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints` 추가 후 기존 코드에서 매핑이 실행되어 `NestedServletException`으로 실패함을 확인했다.
|
|
||||||
- GREEN: endpoint 제거 후 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`가 `BUILD SUCCESSFUL in 56s`로 통과했다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: `UserCreatorChatRealtimeService` 제거**
|
|
||||||
- Files:
|
|
||||||
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- RED: service 테스트에서 SSE realtime mock 의존성을 제거하고 WebSocket presence/broker mock을 주입하도록 변경한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: constructor signature 불일치 또는 미구현 mock으로 실패한다.
|
|
||||||
- GREEN: `UserCreatorChatService` 생성자와 메시지 전달 로직에서 `UserCreatorChatRealtimeService`를 제거한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: `rg -n "SseEmitter|connectEvents|disconnectRealtime|TEXT_EVENT_STREAM|UserCreatorChatRealtimeService" src/main src/test`로 잔여 참조가 없는지 확인한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: `UserCreatorChatRealtimeService` 파일과 `UserCreatorChatService`의 SSE 의존성, `connect`, `disconnectRealtime`, REST text service method를 제거했다. 음성 REST 전송은 유지하되 상대방 presence가 있으면 WebSocket broker로 `MESSAGE`를 발행하고, presence가 없으면 기존 푸시 이벤트를 발행하도록 정리했다.
|
|
||||||
- 왜: 기존 SSE 구현을 완전히 제거하면서도 유지 대상인 음성 업로드 API의 실시간/푸시 분기 동작을 보존하기 위해서다.
|
|
||||||
- RED: 제거 전 `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test`에서 `UserCreatorChatRealtimeService`, controller SSE method, service/test 참조가 확인되었다.
|
|
||||||
- GREEN: `UserCreatorChatServiceTest`의 WebSocket text, voice REST WebSocket broker, voice REST push 분기 테스트가 포함된 focused 명령이 `BUILD SUCCESSFUL in 56s`로 통과했다.
|
|
||||||
- 정적 확인: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test` 결과가 없음을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 5.3: 클라이언트 연동 문서 갱신**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- TDD 예외 사유: 문서 갱신은 자동화 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환`
|
|
||||||
- Expected: 제거 대상 API와 신규 WebSocket/푸시 계약이 모두 문서에 명시되어야 한다.
|
|
||||||
- GREEN: 클라이언트 연동 순서를 `openRoom` REST 호출 후 WebSocket connect + `JOIN_ROOM`으로 갱신한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew tasks --all`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: `docs/plan-task/20260513_유저크리에이터채팅방개편.md`의 클라이언트 연동 프롬프트를 SSE 연결/해제/REST text 전송에서 WebSocket `JOIN_ROOM`, `SEND_TEXT`, `MESSAGE`, `SEND_ACK`, `LEAVE_ROOM` 기준으로 갱신했다.
|
|
||||||
- 왜: 과거 SSE 기준 연동 프롬프트가 현재 서버 구현과 충돌하지 않도록 클라이언트 안내를 최신 계약으로 맞추기 위해서다.
|
|
||||||
- 어떻게: 유지 REST API는 방 생성/open/messages/voice로 남기고, 제거된 `/events`, `/events/disconnect`, `/messages/text` 호출과 SSE reconnect 처리 코드를 제거 대상 목록에 명시했다.
|
|
||||||
|
|
||||||
- [x] **Task 5.4: iOS/Android 앱 반영 체크리스트 확인**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
|
|
||||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- TDD 예외 사유: 앱 코드가 현재 서버 저장소에 없으므로 자동화 테스트를 작성할 수 없다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "iOS|Android|JOIN_ROOM|SEND_ACK|MESSAGE|LEAVE_ROOM|푸시|room_id|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
|
||||||
- Expected: 클라이언트 변경 사항이 PRD와 plan-task 양쪽에 기록되어야 한다.
|
|
||||||
- GREEN: 앱 담당자가 구현해야 할 진입, 전송, 수신, 이탈, 재연결, 푸시 이동, 제거 대상 API를 문서에 유지한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew tasks --all`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇: PRD와 plan-task에 iOS/Android 진입, 전송, 수신, 이탈, 재연결, 토큰 갱신, 푸시 이동, 제거 대상 API가 유지되는지 확인했다.
|
|
||||||
- 왜: 서버 저장소에는 앱 코드가 없으므로 앱 반영 범위는 문서 계약으로 추적해야 하기 때문이다.
|
|
||||||
- 결과: PRD는 기존 SSE 사용 현황과 WebSocket 전환 요구사항을 유지하고, plan-task는 앱 변경 체크리스트와 제거 대상 호출 목록을 유지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 최종 회귀 검증
|
|
||||||
|
|
||||||
- Run: `rg -n "open-in-view" src/main/resources src/test/resources`
|
|
||||||
- Expected: OSIV 정책이 명시되어 있어야 한다.
|
|
||||||
- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<OSIV off 우선 테스트 목록>'`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.*`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- Run: `./gradlew ktlintCheck`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- Run: `./gradlew test`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test`
|
|
||||||
- Expected: 검색 결과 없음
|
|
||||||
- Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|deep_link|USER_CREATOR" build.gradle.kts src/main src/test`
|
|
||||||
- Expected: WebSocket 의존성, endpoint, 푸시 payload 관련 구현이 확인됨
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 구현 후 검증 기록
|
|
||||||
|
|
||||||
- Phase 4 코드 리뷰 및 전체 검증:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
|
||||||
- Result: `BUILD FAILED in 6m 31s`; 전체 suite 실행 중 `Gradle Test Executor 1`이 `java.lang.OutOfMemoryError: Java heap space`로 실패했다. 리포트 기준 Phase 4 관련 단위/통합 테스트 대부분은 `failures=0`, `errors=0`였으나 `UserCreatorChatWebSocketHandshakeIntegrationTest`는 suite 말미 context 초기화 중 OOM으로 `tests=0` 상태였다.
|
|
||||||
- 조치: `UserCreatorChatWebSocketHandshakeIntegrationTest`를 `RANDOM_PORT` 전체 context/실제 `StandardWebSocketClient` 방식에서 WebSocket config slice + mock `TokenProvider` 방식으로 축소했다.
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`
|
|
||||||
- Result: `BUILD SUCCESSFUL in 1m 11s`; 축소한 handshake slice 테스트 단독 실행이 통과했다.
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
|
||||||
- Result: `BUILD FAILED in 1m 22s`; 이전 `OutOfMemoryError`는 재발하지 않았고 `UserCreatorChatWebSocketHandshakeIntegrationTest`도 OOM 원인으로 나타나지 않았다. 실패는 embedded Redis 시작 중 `127.0.0.1:16379: bind: Address already in use`로 발생했다.
|
|
||||||
- 확인: `lsof -nP -iTCP:16379 -sTCP:LISTEN` 결과 `redis-ser` PID `99457`이 `127.0.0.1:16379`를 점유 중이었다. 외부 프로세스 종료는 작업 범위 밖이라 수행하지 않았다.
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
|
||||||
- Result: `BUILD SUCCESSFUL in 46s`; Phase 4 WebSocket handler/service/presence/broker/Redis/handshake/FCM payload focused 테스트가 통과했다.
|
|
||||||
- Run: `./gradlew --no-daemon ktlintCheck`
|
|
||||||
- Result: `BUILD SUCCESSFUL in 7s`.
|
|
||||||
- 코드 리뷰 메모: Phase 4 기능 경로에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다. 다만 전체 suite OOM으로 인해 `./gradlew test` 전체 통과 상태는 아직 확보하지 못했다.
|
|
||||||
- Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
|
||||||
- Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 36s`; 이번 실행에서는 전체 테스트 suite가 통과했다.
|
|
||||||
- Fresh lint Run: `./gradlew --no-daemon ktlintCheck`
|
|
||||||
- Fresh lint Result: `BUILD SUCCESSFUL in 7s`.
|
|
||||||
- Fresh 정적 확인 Run: `rg -n "open-in-view" src/main/resources src/test/resources`
|
|
||||||
- Fresh 정적 확인 Result: main/test 설정의 `spring.jpa.open-in-view=false` 명시를 확인했다.
|
|
||||||
- Fresh 정적 확인 Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|deep_link|USER_CREATOR" build.gradle.kts src/main src/test`
|
|
||||||
- Fresh 정적 확인 Result: WebSocket 의존성, endpoint, FCM `deep_link` 구현과 테스트를 확인했다. v2 채팅 푸시 payload는 `deep_link` 단일 값이다.
|
|
||||||
- Fresh 코드 리뷰 메모: `JOIN_ROOM`/`SEND_TEXT`/`LEAVE_ROOM`/`PING`, 미JOIN/다른 room 차단, malformed/unknown message error, 상대방 presence 유무에 따른 WebSocket publish/FCM push 분기, handshake slice 테스트 범위를 대조했다. Phase 4 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다.
|
|
||||||
- Phase 5:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`
|
|
||||||
- RED Result: `UserCreatorChatControllerMappingTest.shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints` 추가 후 기존 코드에서는 제거 대상 endpoint가 아직 매핑되어 controller method가 실행되면서 `NestedServletException`으로 실패했다.
|
|
||||||
- GREEN Result: endpoint/service 제거와 voice delivery WebSocket 전환 후 같은 focused 명령이 `BUILD SUCCESSFUL in 56s`로 통과했다.
|
|
||||||
- Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService|SendUserCreatorTextMessageRequest" src/main src/test`
|
|
||||||
- Result: 검색 결과 없음. SSE runtime 구현, REST text request DTO, 관련 테스트 참조가 제거되었음을 확인했다.
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
|
||||||
- Result: `BUILD SUCCESSFUL in 5m 24s`; WebSocket handler/presence/broker/handshake와 FCM payload 인접 회귀가 통과했다.
|
|
||||||
- Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환 docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
|
||||||
- Result: 제거 대상 API와 신규 WebSocket/푸시 계약이 문서에 함께 명시되어 있음을 확인했다.
|
|
||||||
- Run: `./gradlew --no-daemon ktlintCheck`
|
|
||||||
- Result: import 정렬 수정 후 `BUILD SUCCESSFUL in 29s`.
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
|
||||||
- Result: `BUILD SUCCESSFUL in 3m 21s`; 전체 테스트 suite가 통과했다.
|
|
||||||
- Fresh 코드 리뷰 메모: SSE controller endpoint 제거, `UserCreatorChatRealtimeService` 삭제, REST text request DTO 제거, 음성 REST 전송의 WebSocket broker/FCM push 분기, 클라이언트 연동 문서 갱신 범위를 대조했다. Phase 5 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다.
|
|
||||||
- Fresh 정적 확인 Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService|SendUserCreatorTextMessageRequest" src/main src/test`
|
|
||||||
- Fresh 정적 확인 Result: 검색 결과 없음. `src/main`/`src/test`에 SSE runtime 구현, REST text request DTO, 관련 참조가 남아 있지 않다.
|
|
||||||
- Fresh 정적 확인 Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|deep_link|USER_CREATOR" build.gradle.kts src/main src/test`
|
|
||||||
- Fresh 정적 확인 Result: WebSocket 의존성, endpoint, FCM `deep_link` 구현과 테스트를 확인했다. v2 채팅 푸시 payload는 `deep_link` 단일 값이다.
|
|
||||||
- Fresh 문서 확인 Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환 docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
|
||||||
- Fresh 문서 확인 Result: 제거 대상 API와 신규 WebSocket/푸시 계약이 문서에 함께 명시되어 있음을 확인했다.
|
|
||||||
- Fresh focused 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`
|
|
||||||
- Fresh focused 검증 Result: `BUILD SUCCESSFUL in 44s`.
|
|
||||||
- Fresh 인접 회귀 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
|
||||||
- Fresh 인접 회귀 Result: `BUILD SUCCESSFUL in 16s`.
|
|
||||||
- Fresh lint Run: `./gradlew --no-daemon ktlintCheck`
|
|
||||||
- Fresh lint Result: `BUILD SUCCESSFUL in 7s`.
|
|
||||||
- Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
|
||||||
- Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 52s`.
|
|
||||||
- 잔여 리스크 개선:
|
|
||||||
- 대상: `UserCreatorChatService.deliverRealtime`
|
|
||||||
- 무엇: Redis/WebSocket 전달 경계의 fail-open 처리 범위를 전체 `Exception`에서 Redis 접근 예외인 `DataAccessException`으로 좁히고, Redis 오류는 warn 로그를 남긴 뒤 푸시 발송으로 fail-open 하도록 정리했다. Redis 계층이 아닌 broker 예외는 숨기지 않고 전파한다.
|
|
||||||
- 왜: Redis 장애 시 메시지 저장 후 푸시 발송 요구사항은 유지하되, 프로그래밍 오류나 예상하지 못한 런타임 오류까지 푸시 fallback으로 숨기지 않기 위해서다.
|
|
||||||
- RED: `UserCreatorChatServiceTest.shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage`를 추가했다. 기존 `runCatching` 구현에서는 `IllegalStateException`이 전파되지 않아 `AssertionFailedError`로 실패했다.
|
|
||||||
- GREEN: `DataAccessException`만 catch하도록 수정한 뒤 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`가 `BUILD SUCCESSFUL in 3m 33s`로 통과했다.
|
|
||||||
- 인접 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.*'`가 `BUILD SUCCESSFUL in 38s`로 통과했다.
|
|
||||||
- Lint: import 정렬 수정 후 `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 21s`로 통과했다.
|
|
||||||
- 전체 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false`가 `BUILD SUCCESSFUL in 4m 39s`로 통과했다.
|
|
||||||
- Phase 3:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
|
||||||
- RED Result: 테스트 파일 부재 상태에서 `No tests found for given includes`로 실패했다.
|
|
||||||
- GREEN Result: `BUILD SUCCESSFUL in 33s`; embedded Redis 기준 presence key/member session set/room set/TTL, markLeft 정리, stale session pruning, Redis pub/sub listener delivery 테스트 4개가 통과했다.
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`
|
|
||||||
- RED Result: `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `UserCreatorChatRoomPublishedMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN Result: `BUILD SUCCESSFUL in 3m 22s`; presence join/refresh/leave/hasPresence 테스트 4개와 broker publish/pattern subscribe/local target delivery 테스트 3개가 통과했다.
|
|
||||||
- Reviewer 보강 RED: stale session id가 남은 상태에서 마지막 live session이 leave 하는 테스트가 기존 구현에서 `ArgumentsAreDifferent`로 실패했다.
|
|
||||||
- Reviewer 보강 GREEN: `UserCreatorChatPresenceServiceTest`가 `BUILD SUCCESSFUL in 1m 22s`, WebSocket focused 테스트 묶음이 `BUILD SUCCESSFUL in 31s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 50s`로 통과했다.
|
|
||||||
- Broker 보강 RED: broken local session의 `sendMessage`가 `IOException`을 던질 때 같은 member의 healthy session 전송이 중단되어 `UserCreatorChatRoomMessageBrokerTest`가 실패했다.
|
|
||||||
- Broker 보강 GREEN: `UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 21s`, WebSocket focused 테스트 묶음이 `BUILD SUCCESSFUL in 21s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 37s`로 통과했다.
|
|
||||||
- Phase 2:
|
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`
|
|
||||||
- Result: `BUILD SUCCESSFUL in 1m 46s`; message envelope enum/JsonNode 역직렬화 테스트 3개와 local session registry 등록/조회/제거/room 전환/동시 같은 session 전환/session lock map 비유지 테스트 6개가 통과했다.
|
|
||||||
- Phase 0:
|
|
||||||
- Run: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`
|
|
||||||
- Result: main/test 설정의 `spring.jpa.open-in-view=false` 명시를 확인했다.
|
|
||||||
- Run: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests '*CreatorChannelHomeControllerTest' --tests '*CreatorChannelLiveControllerTest' --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
|
|
||||||
- Result: `BUILD SUCCESSFUL in 1m 24s`; XML 기준 `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개, `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개, `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0`.
|
|
||||||
|
|
||||||
## 6. OSIV 점검 기록
|
|
||||||
|
|
||||||
- API/기능:
|
|
||||||
- 파일: `src/main/resources/application.yml`, `src/test/resources/application.yml`
|
|
||||||
- 위험 유형: OSIV 정책 미명시
|
|
||||||
- lazy 접근 대상: 해당 없음
|
|
||||||
- OSIV off 테스트: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`
|
|
||||||
- 수정 방향: main/test `spring.jpa.open-in-view=false` 명시
|
|
||||||
- 처리 상태: 완료
|
|
||||||
- API/기능: 유저-크리에이터 채팅 room open/messages/voice service 경계
|
|
||||||
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
|
||||||
- 위험 유형: service/facade 트랜잭션 밖 DTO 변환 중 lazy 연관 접근 가능성
|
|
||||||
- lazy 접근 대상: `UserCreatorChatParticipant.member`, `UserCreatorChatMessage.participant`
|
|
||||||
- OSIV off 테스트: Phase 0 묶음 검증 명령에 포함
|
|
||||||
- 수정 방향: 현재 범위에서는 DTO 변환이 service 트랜잭션 안에서 수행되어 추가 수정 없음
|
|
||||||
- 처리 상태: XML 기준 `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개 모두 `failures=0`, `errors=0`
|
|
||||||
- API/기능: 홈 추천/크리에이터 랭킹 controller 통합 테스트
|
|
||||||
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
|
|
||||||
- 위험 유형: controller 요청 처리 또는 응답 직렬화 중 lazy 연관 접근 가능성
|
|
||||||
- lazy 접근 대상: `MemberAdapter.member.auth`, 홈/랭킹 조회 응답 DTO 관련 entity 연관
|
|
||||||
- OSIV off 테스트: Phase 0 묶음 검증 명령에 포함
|
|
||||||
- 수정 방향: 확인된 `LazyInitializationException` 없음. 추가 lazy 수정 없음
|
|
||||||
- 처리 상태: XML 기준 `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0`
|
|
||||||
- API/기능: 크리에이터 채널 home/live WebMvc controller 표면
|
|
||||||
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
|
|
||||||
- 위험 유형: controller에서 인증 principal을 facade로 전달하는 표면 회귀 가능성
|
|
||||||
- lazy 접근 대상: `MemberAdapter.member`
|
|
||||||
- OSIV off 테스트: Phase 0 묶음 검증 명령에 포함
|
|
||||||
- 수정 방향: JPA lazy loading 직접 검증은 아니며, WebMvc 표면 회귀만 확인
|
|
||||||
- 처리 상태: XML 기준 `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개 모두 `failures=0`, `errors=0`
|
|
||||||
- API/기능: 캐릭터 상세/홈 크리에이터 랭킹 운영 회귀
|
|
||||||
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt`
|
|
||||||
- 위험 유형: service/repository 반환 후 controller/service DTO 변환 중 nested lazy proxy 접근
|
|
||||||
- lazy 접근 대상: `ChatCharacter.tagMappings.tag`, `Member.tags.tag`
|
|
||||||
- OSIV off 테스트: `OsivLazyLoadingRegressionTest`
|
|
||||||
- 수정 방향: 상세/공유 태그 캐릭터 조회와 크리에이터 랭킹 조회에서 필요한 연관을 fetch join으로 선로딩
|
|
||||||
- 처리 상태: `OsivLazyLoadingRegressionTest` 3개 모두 통과
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# PRD: 유저-크리에이터 채팅 WebSocket 전환
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
유저-크리에이터 1:1 채팅의 실시간 전송 방식을 SSE에서 WebSocket으로 전환해, 네이티브 앱의 채팅방 화면 진입 중에는 실시간 메시지를 받고 푸시는 받지 않으며, 화면 밖에서는 푸시로 채팅방 이동 정보를 받을 수 있게 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 현재 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결은 모바일 네이티브 앱의 HTTP connection pool과 충돌해, SSE 연결 중 다른 API 동작이 지연되거나 막히는 문제가 발생한다.
|
|
||||||
- SSE는 서버에서 클라이언트로만 이벤트를 보내는 단방향 방식이므로, 1:1 채팅의 접속 상태, 방 진입/이탈, 메시지 송수신 생명주기를 표현하기에 부적합하다.
|
|
||||||
- 기존 SSE presence는 서버 메모리와 Redis TTL을 함께 사용하지만, 여러 서버 인스턴스에서 메시지를 받는 사용자 세션이 어느 서버에 있는지 전달하는 구조가 없다.
|
|
||||||
- 클라이언트 요구사항은 "해당 채팅방 화면에 있으면 푸시 미발송, 화면에 없으면 푸시 발송 및 푸시 터치 시 해당 방 이동"이므로, 방 단위의 정확한 presence가 필요하다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 유저-크리에이터 채팅 실시간 연결을 SSE에서 WebSocket으로 전환한다.
|
|
||||||
- 기존 SSE endpoint와 `SseEmitter` 기반 구현은 제거한다.
|
|
||||||
- WebSocket 연결은 앱 로그인 전체 수명이 아니라 채팅방 화면에 들어와 있을 때만 유지한다.
|
|
||||||
- 서버가 여러 대라고 가정하고 Redis를 사용해 방 단위 presence와 서버 간 메시지 전달을 처리한다.
|
|
||||||
- WebSocket 전환과 별도로 `spring.jpa.open-in-view=false` 적용 가능성을 점검하고, 트랜잭션 밖 lazy loading에 의존하는 API를 먼저 식별한다.
|
|
||||||
- 같은 `roomId` 채팅방 화면에 상대방이 접속 중이면 새 메시지를 WebSocket으로 전달하고 푸시는 발송하지 않는다.
|
|
||||||
- 상대방이 해당 `roomId` 채팅방 화면에 접속 중이 아니면 새 메시지 저장 후 푸시를 발송한다.
|
|
||||||
- 푸시 payload에는 앱이 해당 채팅방으로 이동할 수 있는 `deep_link`만 포함한다.
|
|
||||||
- 기존 방 생성, 방 열기, 과거 메시지 조회, 음성 메시지 업로드 API는 유지한다.
|
|
||||||
- 텍스트 메시지는 WebSocket으로 전송하고, 서버는 저장 결과를 sender에게 ack로 돌려준다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 전체 앱 로그인 수명 동안 유지되는 글로벌 WebSocket 연결은 이번 범위에 포함하지 않는다.
|
|
||||||
- AI 캐릭터 채팅, 라이브 채팅, 기존 `/api/chat/room` 기능은 이번 범위에서 변경하지 않는다.
|
|
||||||
- 메시지 읽음 처리, typing indicator, 온라인 사용자 목록 노출은 이번 범위에 포함하지 않는다.
|
|
||||||
- 음성 파일 자체를 WebSocket binary로 전송하지 않는다.
|
|
||||||
- DB 스키마 변경은 이번 범위에 포함하지 않는다.
|
|
||||||
- STOMP broker 도입은 이번 범위에 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- iOS/Android 네이티브 앱 사용자: 채팅방 화면에서 실시간으로 메시지를 주고받는 회원
|
|
||||||
- 모바일 클라이언트: 채팅방 화면 진입/이탈 시 연결 생명주기와 푸시 이동 처리를 구현하는 클라이언트
|
|
||||||
- 운영 서버: 여러 인스턴스에서 동일한 presence와 메시지 전달 정책을 유지해야 하는 서버
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 채팅방 화면에 들어와 있으면 새 메시지를 즉시 보고 싶고 같은 메시지의 푸시는 받고 싶지 않다.
|
|
||||||
- 사용자는 채팅방 화면 밖에 있으면 상대방 메시지를 푸시로 받고 싶다.
|
|
||||||
- 사용자는 푸시 알림을 터치하면 해당 유저-크리에이터 채팅방으로 바로 이동하고 싶다.
|
|
||||||
- 앱은 채팅방 진입 시 초기 메시지를 REST로 조회하고 이후 새 메시지는 WebSocket으로 받고 싶다.
|
|
||||||
- 서버는 여러 인스턴스 중 어느 인스턴스에 상대방 WebSocket session이 붙어 있어도 메시지를 전달하거나 푸시 여부를 올바르게 결정해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. WebSocket 연결과 인증
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- WebSocket endpoint는 `/ws/v2/user-creator-chat`을 기본안으로 한다.
|
|
||||||
- 네이티브 앱은 WebSocket handshake에 기존 `Authorization: Bearer <accessToken>` 헤더를 전달한다.
|
|
||||||
- 서버는 기존 JWT 검증 흐름을 재사용해 인증 회원을 식별한다.
|
|
||||||
- 인증 실패 시 WebSocket 연결을 수락하지 않는다.
|
|
||||||
- 연결은 특정 채팅방 화면에 들어왔을 때만 생성한다.
|
|
||||||
- 연결 직후 클라이언트는 `JOIN_ROOM` 메시지로 `roomId`를 전달한다.
|
|
||||||
- 서버는 `JOIN_ROOM` 처리 시 회원이 해당 방의 활성 참여자인지 검증한다.
|
|
||||||
- 검증 성공 시 서버는 해당 WebSocket session을 `roomId/memberId/sessionId/serverId` 기준으로 등록한다.
|
|
||||||
- 하나의 WebSocket session은 하나의 `roomId`만 활성 방으로 가진다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 인증은 성공했지만 `JOIN_ROOM`의 `roomId` 참여자가 아니면 error 메시지를 보내고 연결을 종료한다.
|
|
||||||
- 같은 회원이 같은 방을 여러 기기에서 열 수 있으므로 presence는 session 단위로 관리한다.
|
|
||||||
- 같은 session에서 다른 `roomId`로 다시 `JOIN_ROOM`을 보내면 기존 방 presence를 제거한 뒤 새 방으로 전환한다.
|
|
||||||
|
|
||||||
### Feature B. WebSocket 메시지 프로토콜
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- WebSocket은 raw JSON message protocol을 사용한다.
|
|
||||||
- 공통 envelope는 다음 필드를 사용한다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "JOIN_ROOM",
|
|
||||||
"requestId": "client-generated-id",
|
|
||||||
"roomId": 10,
|
|
||||||
"payload": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 클라이언트에서 서버로 보내는 메시지 타입은 다음을 기본으로 한다.
|
|
||||||
- `JOIN_ROOM`: 방 입장 및 presence 등록
|
|
||||||
- `SEND_TEXT`: 텍스트 메시지 저장 및 전달
|
|
||||||
- `LEAVE_ROOM`: 방 이탈 및 presence 제거
|
|
||||||
- `PING`: 연결 유지 확인
|
|
||||||
- 서버에서 클라이언트로 보내는 메시지 타입은 다음을 기본으로 한다.
|
|
||||||
- `JOINED`: 방 입장 성공
|
|
||||||
- `MESSAGE`: 새 메시지 수신
|
|
||||||
- `SEND_ACK`: sender에게 메시지 저장 결과 전달
|
|
||||||
- `ERROR`: 처리 실패
|
|
||||||
- `PONG`: `PING` 응답
|
|
||||||
- `SEND_TEXT` payload는 `{ "textMessage": "..." }`를 사용한다.
|
|
||||||
- `MESSAGE`와 `SEND_ACK` payload는 기존 `UserCreatorChatMessageItemDto`와 같은 메시지 item 구조를 사용한다.
|
|
||||||
- 빈 문자열 또는 공백뿐인 텍스트 메시지는 기존 `sendTextMessage`와 동일하게 `common.error.invalid_request` 의미의 error로 처리한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 클라이언트가 `JOIN_ROOM` 전에 `SEND_TEXT`를 보내면 `ERROR`를 반환한다.
|
|
||||||
- 알 수 없는 `type`은 `ERROR`를 반환하되 서버 connection은 유지한다.
|
|
||||||
- 메시지 저장 성공 후 sender ack 전송이 실패해도 DB 저장은 롤백하지 않는다.
|
|
||||||
|
|
||||||
### Feature C. Redis 기반 presence와 다중 서버 메시지 전달
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 서버는 여러 대라고 가정한다.
|
|
||||||
- 각 서버 인스턴스는 고유한 `serverId`를 가진다. 기본값은 application start 시 생성한 UUID이며, 운영 환경에서는 env 기반 지정도 허용한다.
|
|
||||||
- local memory에는 현재 서버에 붙은 WebSocket session만 저장한다.
|
|
||||||
- Redis에는 방 단위 presence를 session 단위로 저장한다.
|
|
||||||
- Redis key 기본안:
|
|
||||||
- session presence: `v2:user-creator-chat:ws:presence:{roomId}:{memberId}:{sessionId}`
|
|
||||||
- room member index: `v2:user-creator-chat:ws:room:{roomId}:member:{memberId}:sessions`
|
|
||||||
- pub/sub channel: `v2:user-creator-chat:ws:room`
|
|
||||||
- presence value에는 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt`을 포함한다.
|
|
||||||
- WebSocket 연결 유지 중 heartbeat 또는 메시지 송수신 시 presence TTL을 갱신한다.
|
|
||||||
- presence TTL 기본값은 90초로 한다.
|
|
||||||
- 서버는 메시지 저장 후 상대방의 해당 `roomId` presence가 Redis에 하나 이상 있는지 확인한다.
|
|
||||||
- 상대방 presence가 있으면 Redis pub/sub으로 고정 channel에 메시지를 발행한다.
|
|
||||||
- Redis pub/sub 메시지 payload에는 `roomId`, `memberId`, `payload`를 포함하고, 수신 서버는 payload의 `roomId/memberId`를 기준으로 local session을 필터링한다.
|
|
||||||
- 운영 Redis는 AWS ElastiCache Serverless Valkey 7.2 또는 Redis OSS 7.1을 사용할 수 있으므로, Redis pattern subscribe가 필요한 `PSUBSCRIBE`/`PatternTopic` 방식은 사용하지 않는다.
|
|
||||||
- Redis listener는 `SUBSCRIBE` 기반의 고정 `ChannelTopic`만 사용한다.
|
|
||||||
- 각 서버는 자신에게 연결된 session 중 대상 `roomId/memberId` session에만 메시지를 전송한다.
|
|
||||||
- 상대방 presence가 없으면 푸시 이벤트를 발행한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- Redis presence는 남아 있지만 실제 WebSocket 전송이 실패하면 해당 local session을 정리한다.
|
|
||||||
- Redis pub/sub 전달 실패 또는 Redis 장애 시에는 presence 판단을 신뢰할 수 없으므로 푸시 발송 쪽으로 fail-open 한다.
|
|
||||||
- presence TTL 만료 전 앱이 비정상 종료되어도 최대 TTL 이후에는 오프라인으로 판단되어 푸시가 발송되어야 한다.
|
|
||||||
|
|
||||||
### Feature D. 푸시 발송과 채팅방 이동 payload
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 상대방이 해당 채팅방 화면에 있지 않으면 기존 FCM/APNs 푸시 발송 흐름을 사용한다.
|
|
||||||
- 푸시 category는 기존 `PushNotificationCategory.MESSAGE`를 사용한다.
|
|
||||||
- 푸시 payload에는 `deep_link`만 포함한다.
|
|
||||||
- 운영: `voiceon://chat/{roomId}`
|
|
||||||
- 개발/테스트: `voiceon-test://chat/{roomId}`
|
|
||||||
- v2 채팅 푸시에서는 기존 `room_id`, `message_id`, `chat_type` data payload를 사용하지 않는다.
|
|
||||||
- 앱은 푸시 터치 시 `deep_link`에서 `{roomId}`를 해석해 `GET /api/v2/user-creator-chat/rooms/{roomId}/open` 호출 후 WebSocket 연결을 시작한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 푸시 발송 대상 push token이 없으면 메시지 저장은 성공하고 `pushSent == false` 의미의 내부 결과를 남긴다.
|
|
||||||
- 상대방이 여러 기기 중 하나에서 같은 방을 열고 있으면 푸시는 발송하지 않는다.
|
|
||||||
- 상대방이 다른 채팅방을 열고 있거나 앱의 다른 화면에 있으면 현재 방 presence가 아니므로 푸시를 발송한다.
|
|
||||||
|
|
||||||
### Feature E. 기존 SSE 제거
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` endpoint를 제거한다.
|
|
||||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` endpoint를 제거한다.
|
|
||||||
- `UserCreatorChatRealtimeService`의 `SseEmitter` 기반 구현을 제거한다.
|
|
||||||
- SSE 관련 DTO, 테스트, 문서 언급은 WebSocket 기준으로 갱신한다.
|
|
||||||
- 클라이언트 연동 문서에는 기존 SSE API가 더 이상 사용되지 않음을 명시한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 제거된 SSE endpoint를 호출하면 Spring MVC 기본 404 또는 security 정책에 따른 기존 오류 흐름을 따른다.
|
|
||||||
- 기존 REST 메시지 조회/방 생성/open API는 제거하지 않는다.
|
|
||||||
|
|
||||||
### Feature F. iOS/Android 클라이언트 변경 사항
|
|
||||||
|
|
||||||
#### Current Native App Usage
|
|
||||||
- 개발 중 테스트 중이던 iOS/Android 네이티브 앱은 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결을 사용하고 있었다.
|
|
||||||
- 해당 앱은 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 등 실시간 수신을 중단해야 하는 시점에 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`를 호출하고 있었다.
|
|
||||||
- WebSocket 전환 후에는 위 두 API가 제거되므로, 서버 배포와 같은 릴리스 범위에서 네이티브 앱의 SSE 연결/해제 코드도 함께 변경되어야 한다.
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 클라이언트는 채팅방 화면 진입 시 기존 SSE 연결을 열지 않는다.
|
|
||||||
- 클라이언트는 채팅방 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`으로 초기 메시지와 상대방 정보를 조회한다.
|
|
||||||
- `openRoom` 성공 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
|
|
||||||
- WebSocket handshake에는 기존 API와 동일한 access token을 `Authorization: Bearer <accessToken>` 헤더로 전달한다.
|
|
||||||
- WebSocket 연결 직후 `JOIN_ROOM` 메시지를 전송한다.
|
|
||||||
- `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다.
|
|
||||||
- 기존 SSE `connected` 이벤트 기반 연결 확인 로직은 WebSocket `JOINED` 수신 기준으로 변경한다.
|
|
||||||
- 기존 SSE `message` 이벤트 수신 로직은 WebSocket `MESSAGE` 수신 로직으로 변경한다.
|
|
||||||
- 텍스트 메시지 전송은 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 대신 WebSocket `SEND_TEXT`를 사용한다.
|
|
||||||
- 텍스트 메시지 전송 UI는 `requestId`를 생성해 pending 메시지와 서버 `SEND_ACK`를 매칭한다.
|
|
||||||
- `SEND_ACK`를 수신하면 pending 메시지를 서버가 내려준 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다.
|
|
||||||
- 상대방 메시지는 `MESSAGE` 이벤트 수신 시 현재 채팅방 메시지 목록에 append한다.
|
|
||||||
- 음성 메시지는 기존 multipart REST API를 유지한다.
|
|
||||||
- 기존 `events/disconnect` 호출 위치는 WebSocket `LEAVE_ROOM` 전송 후 socket close 처리로 대체한다.
|
|
||||||
- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다.
|
|
||||||
- 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
|
|
||||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고, 필요하면 REST `messages` API로 누락 메시지를 동기화한다.
|
|
||||||
- 기존 SSE retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다.
|
|
||||||
- 앱은 heartbeat로 `PING`을 주기적으로 보내고 `PONG`을 수신해 연결 상태를 판단한다.
|
|
||||||
- 푸시 알림을 터치하면 payload의 `deep_link`를 확인해 해당 채팅방 화면으로 이동한다.
|
|
||||||
- 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결/`JOIN_ROOM`을 수행한다.
|
|
||||||
- 클라이언트는 제거된 SSE endpoint와 `events/disconnect` endpoint를 더 이상 호출하지 않는다.
|
|
||||||
- 클라이언트는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 응답의 `deliveredRealtime`/`pushSent`를 텍스트 전송 UI 판단에 사용하지 않는다. 텍스트 전송 성공 여부는 WebSocket `SEND_ACK`/`ERROR`/timeout으로 판단한다.
|
|
||||||
|
|
||||||
#### Native App Migration Checklist
|
|
||||||
- SSE client 또는 `EventSource` wrapper 제거: `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출, `Accept: text/event-stream`, SSE event parser, SSE reconnect/retry timer를 삭제한다.
|
|
||||||
- 연결 확인 기준 변경: SSE `connected` 이벤트 수신 완료를 WebSocket `JOINED` 수신 완료로 대체한다.
|
|
||||||
- 메시지 수신 기준 변경: SSE `message` event payload append를 WebSocket `MESSAGE` envelope payload append로 대체한다.
|
|
||||||
- 연결 해제 기준 변경: `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출을 제거하고, 같은 lifecycle 위치에서 `LEAVE_ROOM` 메시지를 보낸 뒤 WebSocket을 close한다.
|
|
||||||
- 텍스트 전송 변경: `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출을 제거하고, `SEND_TEXT` 메시지와 `SEND_ACK` 매칭 방식으로 pending/성공/실패 상태를 관리한다.
|
|
||||||
- 음성 전송 유지: `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 호출은 유지하되, 음성 전송 후 상대방 실시간 수신 여부는 서버 정책에 따른다.
|
|
||||||
- 토큰 갱신 처리 변경: access token refresh 시 기존 WebSocket을 close하고 새 token의 `Authorization` 헤더로 다시 연결한 뒤 `JOIN_ROOM`을 다시 보낸다.
|
|
||||||
- 푸시 이동 처리 확인: `deep_link`의 `voiceon://chat/{roomId}` 또는 `voiceon-test://chat/{roomId}`를 기준으로 채팅방에 진입하고, 진입 후 `openRoom` 호출과 WebSocket `JOIN_ROOM`을 일반 진입과 동일하게 수행한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- WebSocket 연결은 성공했지만 `JOIN_ROOM`이 실패하면 채팅방 화면에 오류를 표시하고 텍스트 전송을 막는다.
|
|
||||||
- `SEND_TEXT` 후 일정 시간 안에 `SEND_ACK`가 오지 않으면 메시지를 전송 실패 상태로 표시하고 재시도 UI를 제공한다.
|
|
||||||
- 재연결 전 pending 메시지를 자동 재전송할 경우 같은 `requestId`를 재사용해 중복 표시를 방지한다.
|
|
||||||
- 앱이 다른 채팅방으로 이동하면 기존 방 WebSocket session을 종료하고 새 방으로 다시 연결한다.
|
|
||||||
- 푸시 payload에 `deep_link`가 없거나 `voiceon://chat/{roomId}` / `voiceon-test://chat/{roomId}` 형식에서 `roomId`를 해석할 수 없으면 채팅방 직접 이동을 하지 않고 앱 기본 알림 처리 흐름을 따른다.
|
|
||||||
|
|
||||||
### Feature G. OSIV 비활성화 사전 점검
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 현재 `spring.jpa.open-in-view` 설정이 명시되어 있는지 확인한다.
|
|
||||||
- 설정이 명시되어 있지 않으면 Spring Boot 2.7 기본값상 OSIV enabled로 동작할 수 있으므로, 운영 설정에 명시적으로 둘지 여부를 결정한다.
|
|
||||||
- `spring.jpa.open-in-view=false`를 바로 적용하기 전에 트랜잭션 밖 lazy loading 의존 API를 먼저 식별한다.
|
|
||||||
- 점검 대상은 다음 패턴을 포함한다.
|
|
||||||
- controller에서 `@AuthenticationPrincipal`로 받은 `Member`의 LAZY 연관(`auth`, `notification` 등)을 직접 접근하는 코드
|
|
||||||
- controller가 JPA entity 또는 LAZY 연관을 가진 객체를 그대로 `ApiResponse.ok(...)`로 반환하는 코드
|
|
||||||
- `@Transactional`이 없는 service/facade/query method에서 repository 조회 후 LAZY 연관을 DTO 변환에 사용하는 코드
|
|
||||||
- Jackson 직렬화 시점에 JPA entity의 LAZY 연관이 열릴 수 있는 응답
|
|
||||||
- self-invocation 때문에 기대한 `@Transactional`이 적용되지 않는 service 내부 호출
|
|
||||||
- 발견된 항목은 API 경로, 파일 경로, lazy 접근 대상, 권장 수정 방향을 문서에 기록한다.
|
|
||||||
- 권장 수정 방향은 controller에서 lazy 접근을 하지 않고 service/query 계층의 트랜잭션 안에서 DTO projection, fetch join, 명시 조회로 필요한 값을 채우는 것이다.
|
|
||||||
- lazy loading 의존성이 해소된 뒤에만 `spring.jpa.open-in-view=false`를 application 설정에 명시한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 테스트 코드가 `@Transactional`로 감싸져 있으면 OSIV off 문제를 가릴 수 있으므로 controller/MockMvc 또는 실제 HTTP 계층 테스트를 우선한다.
|
|
||||||
- 인증 principal의 `Member`는 JWT filter에서 로드된 엔티티이므로, controller에서 LAZY 연관을 직접 열면 OSIV off 후 실패할 수 있다.
|
|
||||||
- 관리자/크리에이터/사용자 API가 서로 다른 controller 패키지에 흩어져 있으므로 특정 패키지 검색만으로 점검을 끝내지 않는다.
|
|
||||||
- OSIV off 적용 후 일부 API가 실패하면 WebSocket 전환과 섞어 수정하지 않고, lazy loading 제거 task로 분리해 먼저 처리한다.
|
|
||||||
|
|
||||||
### Feature H. OSIV off lazy loading 회귀 보완
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 운영에서 확인된 `LazyInitializationException` 발생 지점을 우선 수정한다.
|
|
||||||
- `ChatCharacterController.getCharacterDetail` 응답 조립에 필요한 `ChatCharacter.tagMappings.tag`는 OSIV off 상태에서도 접근 가능해야 한다.
|
|
||||||
- `HomeService.fetchData`의 크리에이터 랭킹 응답 조립에 필요한 `Member.tags.tag`는 OSIV off 상태에서도 접근 가능해야 한다.
|
|
||||||
- 동일 변환 메서드(`toExplorerSectionCreator`)를 쓰는 기존 랭킹 조회도 같은 쿼리 선로딩 정책을 공유해야 한다.
|
|
||||||
- 공개 API 응답 스키마는 변경하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 컬렉션 크기만 접근하면 nested LAZY proxy(`mapping.tag`)는 초기화되지 않을 수 있다.
|
|
||||||
- 조회 테스트에 `@Transactional`이 붙어 있으면 서비스 반환 후 lazy 접근 실패를 가릴 수 있다.
|
|
||||||
- fetch join으로 one-to-many를 가져오면 중복 row가 생길 수 있으므로 결과 중복 여부를 검증한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. UX / UI Expectations
|
|
||||||
- 채팅방 화면 진입 시 REST `openRoom` 응답으로 초기 화면을 그리고, WebSocket `JOINED` 이후 새 메시지를 실시간으로 append한다.
|
|
||||||
- 채팅방 화면에 있는 동안 같은 방의 메시지 푸시가 나타나지 않아야 한다.
|
|
||||||
- 채팅방 화면 밖에서 푸시를 터치하면 `deep_link`의 `{roomId}`에 해당하는 채팅방으로 이동해야 한다.
|
|
||||||
- WebSocket 재연결 중 사용자가 보낸 메시지는 앱에서 전송 실패 또는 재시도 상태로 표시할 수 있어야 한다.
|
|
||||||
- 앱 백그라운드 진입 또는 화면 이탈 시 `LEAVE_ROOM`을 보내고 WebSocket을 close한다.
|
|
||||||
- 앱은 기존 SSE 연결 코드와 `events/disconnect` 호출 코드를 제거한다.
|
|
||||||
- 앱은 텍스트 메시지 전송 성공 기준을 HTTP 200 응답이 아니라 WebSocket `SEND_ACK` 수신으로 변경한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Technical Constraints
|
|
||||||
- Kotlin + Java 17 + Spring Boot 2.7.14 + Gradle Wrapper 구조를 유지한다.
|
|
||||||
- WebSocket 구현은 `spring-boot-starter-websocket`을 사용한다.
|
|
||||||
- STOMP는 도입하지 않고 raw WebSocket JSON protocol을 사용한다.
|
|
||||||
- Redis는 현재 연결된 인프라를 사용한다.
|
|
||||||
- RedisTemplate 또는 Redisson 중 기존 코드 패턴과 테스트 용이성을 기준으로 선택하되, presence TTL과 pub/sub을 모두 구현해야 한다.
|
|
||||||
- Redis presence TTL, session set 정리, pub/sub listener 전달은 mock 검증만으로 완료하지 않고 embedded Redis 또는 동등한 실제 Redis 테스트 인프라로 통합 검증한다.
|
|
||||||
- `spring.jpa.open-in-view=false`는 lazy loading 의존 API 점검과 수정이 끝난 뒤 명시한다.
|
|
||||||
- 공개 REST API 스키마는 필요한 범위 외에는 변경하지 않는다.
|
|
||||||
- 텍스트 메시지 저장은 기존 `UserCreatorChatMessage` 엔티티와 repository를 사용한다.
|
|
||||||
- 음성 메시지 업로드는 기존 REST multipart API를 유지한다.
|
|
||||||
- 기존 `FcmEvent`/`FcmService` 구조를 우선 재사용한다.
|
|
||||||
- iOS/Android 클라이언트는 WebSocket 전용 연결을 일반 REST API 호출과 분리해 관리한다.
|
|
||||||
- iOS/Android 클라이언트는 access token refresh 시 기존 WebSocket을 닫고 새 token으로 재연결한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Metrics
|
|
||||||
- 채팅방 화면에 접속 중인 수신자에게 같은 방 메시지 푸시가 발송된 건수 0건
|
|
||||||
- 채팅방 화면 밖 수신자에게 메시지 푸시가 누락된 건수 0건
|
|
||||||
- WebSocket `JOIN_ROOM` 성공률
|
|
||||||
- WebSocket 연결 중 메시지 전송 성공률
|
|
||||||
- Redis presence TTL 만료로 정리된 orphan session 수
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Open Questions
|
|
||||||
- 없음. 이번 문서 기준 확정안은 채팅방 화면 진입 중에만 유지하는 raw WebSocket, Redis 기반 다중 서버 presence/pub-sub, 기존 SSE 완전 제거다.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# CDN URL 변환 공통화 구현 계획
|
|
||||||
|
|
||||||
### Phase 1: 공통 함수 동작 고정
|
|
||||||
- [x] **Task 1.1: 공통 CDN URL 변환 테스트 작성**
|
|
||||||
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensionsTest.kt`
|
|
||||||
- RED: `null`, blank, 절대 URL, 상대 path 입력의 기대 동작을 검증하는 실패 테스트를 작성하고 실패를 확인한다.
|
|
||||||
- GREEN: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`에 최소 구현을 추가하고 통과를 확인한다.
|
|
||||||
- REFACTOR: 함수명/패키지/import를 정리하고 단일 테스트를 다시 실행한다.
|
|
||||||
- 검증 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest`
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest` 실행 결과,
|
|
||||||
`Unresolved reference: toCdnUrl`로 실패해 공통 함수 미구현 상태를 확인했다.
|
|
||||||
- GREEN: 같은 명령 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
|
|
||||||
### Phase 2: 서비스 중복 함수 제거
|
|
||||||
- [x] **Task 2.1: 4개 서비스가 공통 함수를 사용하도록 변경**
|
|
||||||
- 파일:
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
|
||||||
- RED: Task 1.1 테스트로 절대 URL 유지 동작을 먼저 고정한다.
|
|
||||||
- GREEN: private `toCdnUrl` 중복 선언을 제거하고 공통 함수를 import해 사용한다.
|
|
||||||
- REFACTOR: 변경 파일의 불필요한 import/중복 코드를 제거하고 회귀 테스트를 실행한다.
|
|
||||||
- 검증 명령:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
|
|
||||||
- 검증 기록:
|
|
||||||
- `rg "fun String\\?\\.toCdnUrl|toCdnUrl\\(\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 -n`
|
|
||||||
실행 결과, 공통 함수 선언 1곳만 남은 것을 확인했다.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과
|
|
||||||
`BUILD SUCCESSFUL`로 ranking 서비스 회귀 테스트가 통과했다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- `./gradlew ktlintCheck` 첫 실행은 private 함수 제거 후 남은 클래스 종료 전 빈 줄로 실패했다.
|
|
||||||
- 지적된 `CreatorChannelAudioQueryService.kt`, `CreatorChannelLiveQueryService.kt`의 빈 줄만 제거한 뒤
|
|
||||||
`./gradlew ktlintCheck`를 재실행했고 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 문서 변경 규칙 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
- 최종 관련 테스트로
|
|
||||||
`./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
|
|
||||||
를 실행했고 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# PRD: CDN URL 변환 공통화
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
v2 서비스에서 중복 선언된 `String?.toCdnUrl()` 확장 함수를 공통 유틸로 분리한다.
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- `CreatorChannelHomeQueryService`, `CreatorChannelLiveQueryService`, `CreatorChannelAudioQueryService`,
|
|
||||||
`CreatorRankingQueryService`에 유사한 CDN URL 변환 로직이 private 함수로 중복되어 있다.
|
|
||||||
- ranking 구현은 절대 URL을 그대로 유지하지 않아 다른 3곳과 동작이 다르다.
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 4개 서비스가 하나의 공통 `toCdnUrl` 함수를 사용한다.
|
|
||||||
- `null` 또는 blank 입력은 `null`을 반환한다.
|
|
||||||
- `http://`, `https://` 절대 URL은 그대로 반환한다.
|
|
||||||
- 상대 path는 `cloudFrontHost/path` 형식으로 반환한다.
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- QueryDSL 조회 로직이나 공개 API 스키마는 변경하지 않는다.
|
|
||||||
- 기존 CDN host 설정 방식은 변경하지 않는다.
|
|
||||||
- 다른 레거시 CDN URL 조합 코드는 이번 범위에서 정리하지 않는다.
|
|
||||||
|
|
||||||
## 5. Core Features
|
|
||||||
|
|
||||||
### Feature A: 공통 CDN URL 변환
|
|
||||||
#### Requirements
|
|
||||||
- `kr.co.vividnext.sodalive.v2` 하위 공통 패키지에 재사용 가능한 함수를 둔다.
|
|
||||||
- 기존 서비스 매핑 흐름은 유지하고 private 중복 함수만 제거한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `null`, `""`, `" "` 입력은 `null`이어야 한다.
|
|
||||||
- `https://...`, `http://...` 입력은 host를 덧붙이지 않아야 한다.
|
|
||||||
- `"profile/a.png"` 입력은 `"https://cdn.test/profile/a.png"`가 되어야 한다.
|
|
||||||
|
|
||||||
## 6. Technical Constraints
|
|
||||||
- Kotlin 확장 함수로 구현한다.
|
|
||||||
- 테스트는 JUnit 5로 작성한다.
|
|
||||||
@@ -1,628 +0,0 @@
|
|||||||
# 크리에이터 채널 오디오 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/audio`로 크리에이터 채널 오디오 탭의 테마 목록, 콘텐츠 개수, 소장률, 오디오 콘텐츠 목록을 조회할 수 있게 한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 조립 계층에 둔다. 오디오 탭 조회 service, 순수 fallback/page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.audio` 하위에 두고 `v2.api.*`에 의존하지 않는다. 크리에이터 채널 오디오 콘텐츠 item domain/response는 홈/라이브/오디오 탭에서 동일하게 쓰도록 채널 공통 패키지에 둔다. 라이브 탭에서 만든 `ContentSort`와 오디오 콘텐츠 응답 의미는 재사용하되, `sort/page/size/themeId` fallback 정책은 오디오 탭 전용 정책으로 명시한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/audio`
|
|
||||||
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
|
||||||
- request:
|
|
||||||
- path variable: `creatorId`
|
|
||||||
- query parameter: `sort`, `required = false`, 기본값/fallback `LATEST`
|
|
||||||
- query parameter: `themeId`, `required = false`, 없거나 비활성/미존재이면 전체 활성 테마 조회
|
|
||||||
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
|
|
||||||
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
|
|
||||||
- controller는 invalid `sort` fallback을 위해 `sort: String?`으로 받고 service/facade 경계에서 `ContentSort`로 보정한다.
|
|
||||||
- response:
|
|
||||||
- `audioContentCount`: 적용된 필터 기준 오디오 콘텐츠 전체 개수
|
|
||||||
- `paidAudioContentCount`: 적용된 필터 기준 `price > 0` 콘텐츠 개수
|
|
||||||
- `purchasedAudioContentCount`: 적용된 필터 기준 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수
|
|
||||||
- `purchasedAudioContentRate`: `paidAudioContentCount == 0`이면 `0.0`, 아니면 `(purchasedAudioContentCount / paidAudioContentCount) * 100`
|
|
||||||
- `themes`: 활성 테마 중 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 1개 이상 있는 테마 목록. 선택한 `themeId`와 무관하게 내려준다.
|
|
||||||
- `audioContents`: 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 가진 item 목록
|
|
||||||
- `sort`: 실제 적용한 `ContentSort`
|
|
||||||
- `themeId`: 실제 적용한 활성 테마 id, 전체 조회 fallback이면 `null`
|
|
||||||
- `page`: fallback 보정 후 실제 적용된 page index
|
|
||||||
- `size`: fallback 보정 후 실제 적용된 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
|
|
||||||
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
|
|
||||||
- 테마명은 `LangContext.lang.code` 기준으로 `ContentThemeTranslation`을 우선하고, 없거나 빈 문자열이면 `AudioContentTheme.theme` 원문으로 fallback한다.
|
|
||||||
- 테마 목록 필터링은 콘텐츠 목록/count와 같은 공개 조건, 예약 공개 제외, 성인 콘텐츠 노출 정책을 적용한다.
|
|
||||||
- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다.
|
|
||||||
- `isFirstContent`는 선택 테마 안의 첫 콘텐츠가 아니라 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
|
|
||||||
- 정렬:
|
|
||||||
- `LATEST`: `releaseDate desc`, `price desc`, `audioContent.id desc`
|
|
||||||
- `POPULAR`: 구매 매출 합계 desc, `releaseDate desc`, `audioContent.id desc`
|
|
||||||
- `OWNED`: 조회자 소장 또는 유효 대여 여부 desc, `releaseDate desc`, `audioContent.id desc`
|
|
||||||
- `PRICE_HIGH`: `price desc`, `releaseDate desc`, `audioContent.id desc`
|
|
||||||
- `PRICE_LOW`: `price asc`, `releaseDate desc`, `audioContent.id desc`
|
|
||||||
- 인기순 매출은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출인 `orders.can` 합계를 사용한다. `orders.point`는 포함하지 않고, `orders.is_active = true`인 주문만 포함한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 오디오 탭 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt`
|
|
||||||
|
|
||||||
### 오디오 탭 도메인 조회 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 크리에이터 채널 공통 오디오 콘텐츠 item
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/common/domain/CreatorChannelAudioContent.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
|
|
||||||
### 기존 파일 확인/재사용
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Create: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
|
|
||||||
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/prd.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioContent
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
|
|
||||||
|
|
||||||
data class CreatorChannelAudioTabResponse(
|
|
||||||
val audioContentCount: Int,
|
|
||||||
val paidAudioContentCount: Int,
|
|
||||||
val purchasedAudioContentCount: Int,
|
|
||||||
val purchasedAudioContentRate: Double,
|
|
||||||
val themes: List<CreatorChannelAudioThemeResponse>,
|
|
||||||
val audioContents: List<CreatorChannelAudioContentResponse>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val themeId: Long?,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(tab: CreatorChannelAudioTab): CreatorChannelAudioTabResponse {
|
|
||||||
return CreatorChannelAudioTabResponse(
|
|
||||||
audioContentCount = tab.audioContentCount,
|
|
||||||
paidAudioContentCount = tab.paidAudioContentCount,
|
|
||||||
purchasedAudioContentCount = tab.purchasedAudioContentCount,
|
|
||||||
purchasedAudioContentRate = tab.purchasedAudioContentRate,
|
|
||||||
themes = tab.themes.map(CreatorChannelAudioThemeResponse::from),
|
|
||||||
audioContents = tab.audioContents.map(CreatorChannelAudioContentResponse::from),
|
|
||||||
sort = tab.sort,
|
|
||||||
themeId = tab.themeId,
|
|
||||||
page = tab.page.page,
|
|
||||||
size = tab.page.size,
|
|
||||||
hasNext = tab.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelAudioThemeResponse(
|
|
||||||
val themeId: Long,
|
|
||||||
val themeName: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(theme: CreatorChannelAudioTheme): CreatorChannelAudioThemeResponse {
|
|
||||||
return CreatorChannelAudioThemeResponse(
|
|
||||||
themeId = theme.themeId,
|
|
||||||
themeName = theme.themeName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelAudioContentResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean,
|
|
||||||
@JsonProperty("isPointAvailable")
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
@JsonProperty("isFirstContent")
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val seriesName: String?,
|
|
||||||
@JsonProperty("isOriginalSeries")
|
|
||||||
val isOriginalSeries: Boolean?,
|
|
||||||
@JsonProperty("isOwned")
|
|
||||||
val isOwned: Boolean,
|
|
||||||
@JsonProperty("isRented")
|
|
||||||
val isRented: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
|
|
||||||
return CreatorChannelAudioContentResponse(
|
|
||||||
audioContentId = content.audioContentId,
|
|
||||||
title = content.title,
|
|
||||||
duration = content.duration,
|
|
||||||
imageUrl = content.imageUrl,
|
|
||||||
price = content.price,
|
|
||||||
isAdult = content.isAdult,
|
|
||||||
isPointAvailable = content.isPointAvailable,
|
|
||||||
isFirstContent = content.isFirstContent,
|
|
||||||
seriesName = content.seriesName,
|
|
||||||
isOriginalSeries = content.isOriginalSeries,
|
|
||||||
isOwned = content.isOwned,
|
|
||||||
isRented = content.isRented
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.audio.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
|
||||||
|
|
||||||
data class CreatorChannelAudioTab(
|
|
||||||
val audioContentCount: Int,
|
|
||||||
val paidAudioContentCount: Int,
|
|
||||||
val purchasedAudioContentCount: Int,
|
|
||||||
val purchasedAudioContentRate: Double,
|
|
||||||
val themes: List<CreatorChannelAudioTheme>,
|
|
||||||
val audioContents: List<CreatorChannelAudioContent>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val themeId: Long?,
|
|
||||||
val page: CreatorChannelPage,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelAudioTheme(
|
|
||||||
val themeId: Long,
|
|
||||||
val themeName: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelAudioContent(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val seriesName: String?,
|
|
||||||
val isOriginalSeries: Boolean?,
|
|
||||||
val isOwned: Boolean,
|
|
||||||
val isRented: Boolean
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface CreatorChannelAudioQueryPort {
|
|
||||||
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord?
|
|
||||||
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
|
||||||
fun findActiveThemeId(themeId: Long): Long?
|
|
||||||
fun findAudioThemes(
|
|
||||||
creatorId: Long,
|
|
||||||
now: LocalDateTime,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
locale: String
|
|
||||||
): List<CreatorChannelAudioThemeRecord>
|
|
||||||
fun countAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int
|
|
||||||
fun countPaidAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int
|
|
||||||
fun countPurchasedAudioContents(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
themeId: Long?,
|
|
||||||
now: LocalDateTime,
|
|
||||||
canViewAdultContent: Boolean
|
|
||||||
): Int
|
|
||||||
fun findAudioContents(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
themeId: Long?,
|
|
||||||
now: LocalDateTime,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
sort: ContentSort,
|
|
||||||
locale: String,
|
|
||||||
offset: Long,
|
|
||||||
limit: Int
|
|
||||||
): List<CreatorChannelAudioContentRecord>
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelAudioCreatorRecord(
|
|
||||||
val creatorId: Long,
|
|
||||||
val role: MemberRole,
|
|
||||||
val nickname: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelAudioThemeRecord(
|
|
||||||
val themeId: Long,
|
|
||||||
val themeName: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelAudioContentRecord(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imagePath: String?,
|
|
||||||
val price: Int,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val seriesName: String?,
|
|
||||||
val isOriginalSeries: Boolean?,
|
|
||||||
val isOwned: Boolean,
|
|
||||||
val isRented: Boolean
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: 오디오 탭 정책과 domain 계약
|
|
||||||
|
|
||||||
- [x] **Task 1.1: `CreatorChannelAudioQueryPolicy` fallback 정책 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicyTest.kt`
|
|
||||||
- RED: `createPage(-1, 10)`이 `page=0`, `size=20`을 반환하고, `createPage(2, 100)`이 `page=2`, `size=50`을 반환하며, `resolveSort(null)`과 `resolveSort("UNKNOWN")`이 `ContentSort.LATEST`를 반환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
|
|
||||||
- Expected: `CreatorChannelAudioQueryPolicy` 미존재 컴파일 실패
|
|
||||||
- GREEN: `resolveSort(sort: String?): ContentSort`, `createPage(page: Int?, size: Int?): CreatorChannelPage`, `limitItems`, `hasNext`, `purchaseRate`를 구현한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 라이브 탭의 `CreatorChannelLiveReplayQueryPolicy`는 변경하지 않는다. 오디오 탭만 fallback 정책을 가진다.
|
|
||||||
- 회귀 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
|
|
||||||
- Expected: 기존 라이브 탭 정책 테스트가 있으면 통과한다. 테스트가 없으면 `No tests found`가 아닌 컴파일 실패가 없는지 확인한다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: 오디오 탭 domain model과 port 계약 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
|
|
||||||
- RED: service 테스트 파일에 `CreatorChannelAudioTab`, `CreatorChannelAudioTheme`, `CreatorChannelAudioContent`, `CreatorChannelAudioQueryPort` import를 추가하고 아직 service가 없어서 컴파일 실패하는 상태를 만든다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
|
|
||||||
- Expected: `CreatorChannelAudioQueryService` 또는 domain/port 미존재 컴파일 실패
|
|
||||||
- GREEN: 위 "Domain / Port 초안"의 타입을 추가한다. `CreatorChannelPage`는 기존 `kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage`를 재사용한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: `rg -n "v2\\.api\\.creator\\.channel\\.audio" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio`로 domain/port가 API 조립 계층에 의존하지 않는지 확인한다.
|
|
||||||
|
|
||||||
- [x] **Task 1.3: 크리에이터 채널 오디오 콘텐츠 item 공통화**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/common/domain/CreatorChannelAudioContent.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
|
|
||||||
- Modify: live/home/audio domain과 DTO import
|
|
||||||
- RED: live/home/audio 테스트 import를 공통 `CreatorChannelAudioContent`와 `CreatorChannelAudioContentResponse` 기준으로 변경하고 기존 타입 미존재/필드 불일치 컴파일 실패를 확인한다.
|
|
||||||
- GREEN: 기존 live/home/audio의 중복 `CreatorChannelAudioContent`와 중복 Response를 제거하고 공통 타입을 사용한다. 실질 사용처가 없는 `publishedAt`은 공통 domain과 live/home mapping에서 제거한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
|
|
||||||
- REFACTOR: `rg -n "data class CreatorChannelAudioContent|data class CreatorChannelAudioContentResponse|publishedAt =|v2\\.creator\\.channel\\.(live|home|audio)\\.domain\\.CreatorChannelAudioContent|v2\\.api\\.creator\\.channel\\.(live|home)\\.dto\\.CreatorChannelAudioContentResponse" src/main/kotlin src/test/kotlin`로 중복 타입, 기존 패키지 import, 불필요한 domain field mapping이 남지 않았는지 확인한다.
|
|
||||||
|
|
||||||
### Phase 2: 오디오 탭 service와 API DTO 변환
|
|
||||||
|
|
||||||
- [x] **Task 2.1: `CreatorChannelAudioQueryService` orchestration 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
|
|
||||||
- RED: fake port 기반 service 테스트를 작성한다.
|
|
||||||
- `getAudioTab(creatorId=1, viewer, sort="UNKNOWN", themeId=999, page=-1, size=100)` 호출 시 실제 `sort=LATEST`, `themeId=null`, `page=0`, `size=50`, `offset=0`, `limit=51`이 port에 전달되어야 한다.
|
|
||||||
- `paidAudioContentCount=4`, `purchasedAudioContentCount=3`이면 `purchasedAudioContentRate=75.0`이어야 한다.
|
|
||||||
- `paidAudioContentCount=0`이면 `purchasedAudioContentRate=0.0`이어야 한다.
|
|
||||||
- `creator`가 없으면 `member.validation.user_not_found`, role이 `CREATOR`가 아니면 `member.validation.creator_not_found`, 차단 관계면 기존 차단 메시지 예외를 던져야 한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
|
|
||||||
- Expected: `CreatorChannelAudioQueryService` 미존재 컴파일 실패
|
|
||||||
- GREEN: 라이브 탭 service의 인증/차단/성인 콘텐츠 정책을 참고해 최소 구현한다. `LangContext.lang.code`를 theme/series 번역 조회 locale로 전달하고, `String?.toCdnUrl()`은 라이브 탭 service와 같은 규칙으로 구현한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: service가 QueryDSL/Q타입을 직접 import하지 않는지 확인한다.
|
|
||||||
- Run: `rg -n "Q[A-Z]|queryFactory|javax\\.persistence" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application`
|
|
||||||
- Expected: 검색 결과 없음
|
|
||||||
|
|
||||||
- [x] **Task 2.2: 오디오 탭 API response DTO와 facade 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt`
|
|
||||||
- RED: facade가 service 결과를 `CreatorChannelAudioTabResponse`로 변환하고 `isOwned`, `isRented`, `hasNext`의 JSON property 의미를 보존하는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
|
|
||||||
- Expected: facade/DTO 미존재 컴파일 실패
|
|
||||||
- GREEN: 위 "Response data class 초안"에 맞춰 DTO를 추가하고 facade에서 `CreatorChannelAudioQueryService.getAudioTab(creatorId, viewer, sort, themeId, page, size, now)` 결과를 `CreatorChannelAudioTabResponse.from(tab)`으로 변환한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 기존 라이브 탭 DTO를 이동하거나 수정하지 않는다.
|
|
||||||
- Run: `rg -n "package kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\.creator\\.channel\\.live\\.dto" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
|
|
||||||
- Expected: 기존 라이브 탭 DTO package 유지
|
|
||||||
|
|
||||||
### Phase 3: QueryDSL repository 구현
|
|
||||||
|
|
||||||
- [x] **Task 3.1: repository skeleton과 creator/block/theme 조회 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
|
|
||||||
- RED: `@DataJpaTest(properties = ["spring.cache.type=none"])` 기반으로 `findCreator`, `existsBlockedBetween`, `findActiveThemeId`, `findAudioThemes(creatorId, now, canViewAdultContent, locale="en")` 테스트를 작성한다. `ContentThemeTranslation`이 있으면 번역명, 없으면 원문명을 반환해야 하며, 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마는 제외해야 한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
|
|
||||||
- Expected: repository 미존재 컴파일 실패
|
|
||||||
- GREEN: 라이브 탭 repository의 `findCreator`, `existsBlockedBetween`을 오디오 패키지로 필요한 만큼 복사하고, `findActiveThemeId`, `findAudioThemes`를 QueryDSL로 구현한다. `findAudioThemes`는 `audioContentCondition(creatorId, themeId = null, now, canViewAdultContent)`를 공유해 콘텐츠 목록/count와 같은 공개 조건을 적용한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: `ContentThemeTranslation.theme`이 blank인 경우 원문 fallback을 repository 또는 domain mapping 중 한 곳에서만 처리한다.
|
|
||||||
- 후속 수정 검증 기록:
|
|
||||||
- 무엇: 테마 목록에서 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마를 제외하는 RED 테스트를 추가했다.
|
|
||||||
- 왜: 오디오 탭에서 선택 가능한 테마가 실제 콘텐츠가 없는 빈 필터로 노출되지 않아야 한다.
|
|
||||||
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`를 실행했다.
|
|
||||||
- 결과: 현재 구현은 활성 테마 전체를 반환해 `DefaultCreatorChannelAudioQueryRepositoryTest.kt:71`, `DefaultCreatorChannelAudioQueryRepositoryTest.kt:96`에서 실패함을 확인했다.
|
|
||||||
- 무엇: `findAudioThemes`가 `creatorId`, `now`, `canViewAdultContent`, `locale`를 받아 콘텐츠 목록/count와 같은 공개 조건으로 테마를 조회하도록 수정했다.
|
|
||||||
- 왜: 조회 가능한 아이템이 없는 테마와 성인 노출 정책상 볼 수 없는 테마를 응답에서 제외해야 한다.
|
|
||||||
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`를 실행했다.
|
|
||||||
- 결과: `BUILD SUCCESSFUL`로 repository 필터링과 service 컨텍스트 전달을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 오디오 콘텐츠 count와 소장률 count 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
|
|
||||||
- RED: 아래 조건을 검증하는 repository 테스트를 추가한다.
|
|
||||||
- `countAudioContents`는 공개/활성/예약 공개/성인 콘텐츠 정책과 활성 `themeId` 필터를 적용한다.
|
|
||||||
- `countPaidAudioContents`는 같은 필터에서 `price > 0`만 계산한다.
|
|
||||||
- `countPurchasedAudioContents`는 유료 콘텐츠 중 `OrderType.KEEP` 또는 유효한 `OrderType.RENTAL` 주문을 가진 콘텐츠만 계산한다.
|
|
||||||
- 무료 콘텐츠는 구매 count와 소장률 count에서 제외한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
|
|
||||||
- Expected: 신규 count method 미구현 실패
|
|
||||||
- GREEN: 공통 `audioContentCondition(creatorId, themeId, now, canViewAdultContent)` private helper를 만들고 count query들이 같은 조건을 공유하게 구현한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 목록 query와 count query의 조건이 어긋나지 않도록 helper 사용 여부를 확인한다.
|
|
||||||
- Run: `rg -n "audioContentCondition|countAudioContents|countPaidAudioContents|countPurchasedAudioContents" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Expected: 세 count method가 공통 조건 helper를 사용한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: 오디오 콘텐츠 목록, 정렬, 시리즈 번역, 소장/대여 상태 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
|
|
||||||
- RED: 아래 조건을 검증하는 repository 테스트를 추가한다.
|
|
||||||
- `findAudioContents`는 `size + 1`개 조회가 가능하도록 전달받은 `limit`을 그대로 사용한다.
|
|
||||||
- `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬이 PRD 기준으로 동작한다.
|
|
||||||
- `POPULAR`은 `orders.can` 합계 기준으로 정렬하고 비활성 주문은 제외한다.
|
|
||||||
- `OWNED`는 소장 또는 유효 대여 중인 콘텐츠를 먼저 노출한다.
|
|
||||||
- 시리즈에 속한 콘텐츠는 `SeriesTranslation(locale)`이 있으면 번역명을, 없으면 원문명을 `seriesName`으로 반환한다.
|
|
||||||
- `isFirstContent`는 테마 필터와 무관하게 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠 기준이다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
|
|
||||||
- Expected: 목록/정렬 method 미구현 실패
|
|
||||||
- GREEN: 라이브 탭 repository의 `findLiveReplayAudioRows`, `audioSeriesByContentIds`, `orderStatesByContentIds`, `firstAudioContentId` 구조를 오디오 탭 범위에 맞춰 구현한다. `themeId == null`이면 전체 활성 테마를 대상으로 한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: QueryDSL 중복이 커지면 오디오 탭 repository 내부 private helper로만 정리하고, 라이브 탭 repository까지 건드리는 공용화는 이번 범위에서 하지 않는다.
|
|
||||||
|
|
||||||
### Phase 4: Controller와 공개 API 계약
|
|
||||||
|
|
||||||
- [x] **Task 4.1: `CreatorChannelAudioController` 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt`
|
|
||||||
- RED: MockMvc 테스트를 작성한다.
|
|
||||||
- 비회원 `GET /api/v2/creator-channels/1/audio`는 401을 반환한다.
|
|
||||||
- 인증 회원 기본 요청은 facade에 `sort=null`, `themeId=null`, `page=null`, `size=null`을 전달하고 성공 응답을 반환한다.
|
|
||||||
- `sort=INVALID&page=-1&size=100&themeId=999` 요청은 controller에서 400을 내지 않고 facade까지 원 요청값을 전달한다.
|
|
||||||
- 응답 JSON에는 `audioContentCount`, `paidAudioContentCount`, `purchasedAudioContentCount`, `purchasedAudioContentRate`, `themes`, `audioContents`, `sort`, `themeId`, `page`, `size`, `hasNext`, `audioContents[0].isOwned`, `audioContents[0].isRented`가 있어야 한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
|
|
||||||
- Expected: controller 미존재 컴파일 실패
|
|
||||||
- GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/audio")` controller를 추가한다. query parameter는 `@RequestParam(required = false) sort: String?`, `themeId: Long?`, `page: Int?`, `size: Int?`로 받는다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 기존 `/live`, `/home` mapping과 충돌하지 않는지 확인한다.
|
|
||||||
- Run: `rg -n "@GetMapping\\(\"/\\{creatorId\\}/(home|live|audio)\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel`
|
|
||||||
- Expected: home/live/audio 각각 1건
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 오디오 탭 통합 흐름 테스트 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt`
|
|
||||||
- RED: `@SpringBootTest + MockMvc` 기반으로 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/audio?sort=INVALID&page=-1&size=100&themeId=999`를 호출했을 때 200 성공과 fallback 적용 응답(`sort=LATEST`, `themeId=null`, `page=0`, `size=50`)을 받는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest`
|
|
||||||
- Expected: endpoint 또는 fixture 미구현으로 실패
|
|
||||||
- GREEN: 필요한 최소 fixture만 추가하고 controller, facade, service, repository wiring이 동작하도록 구현을 보완한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: E2E fixture가 기존 테스트 데이터를 과도하게 공유하지 않는지 확인하고, 불필요한 데이터 생성 helper는 추가하지 않는다.
|
|
||||||
|
|
||||||
### Phase 5: 회귀 검증과 문서 기록
|
|
||||||
|
|
||||||
- [x] **Task 5.1: 관련 단위/슬라이스 테스트 회귀 실행**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 코드 구현이 아니라 구현 완료 후 검증 기록을 누적하는 문서 작업이다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
|
|
||||||
- Expected: 모두 `BUILD SUCCESSFUL`
|
|
||||||
- 검증 기록: 구현 완료 후 실행 명령, 결과, 실패 시 원인과 수정 내역을 이 task 아래에 누적한다.
|
|
||||||
- 2026-06-19 실행: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → `BUILD SUCCESSFUL`.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: 전체 회귀와 포맷 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 전체 회귀/포맷 검증 기록 task다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `./gradlew test`
|
|
||||||
- Run: `./gradlew ktlintCheck`
|
|
||||||
- Run: `git diff --check`
|
|
||||||
- Run: `rg -n "미완성 표시|후속 처리 표시" docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio`
|
|
||||||
- Expected: Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없음, placeholder 검색은 의도하지 않은 결과 없음
|
|
||||||
- 검증 기록: 구현 완료 후 전체 검증 결과를 이 task 아래와 문서 하단의 검증 기록에 누적한다.
|
|
||||||
- 2026-06-19 실행: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test` → `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-19 실행: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck` → `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-19 실행: `git diff --check` → 출력 없음.
|
|
||||||
- 2026-06-19 실행: `rg -n "미완성 표시|후속 처리 표시" docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` → 계획 문서의 검증 명령/기록 자체만 매칭되어 의도하지 않은 placeholder 없음.
|
|
||||||
- 2026-06-19 코드 리뷰: controller/facade/service/repository/test 경로를 PRD 요구사항 기준으로 검토했고, 공개 조건, fallback, count, 정렬, 시리즈/테마 번역 fallback, 소장/대여 상태 관련 차단 이슈 없음.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 구현 순서 요약
|
|
||||||
|
|
||||||
1. 오디오 탭 fallback/page/sort/rate 정책을 테스트로 고정한다.
|
|
||||||
2. domain model과 port 계약을 추가한다.
|
|
||||||
3. service orchestration을 fake port 테스트로 고정한다.
|
|
||||||
4. API DTO와 facade 변환을 고정한다.
|
|
||||||
5. QueryDSL repository를 creator/block/theme/count/list 순서로 구현한다.
|
|
||||||
6. controller 공개 계약을 MockMvc로 고정한다.
|
|
||||||
7. E2E 테스트와 전체 회귀 검증을 실행하고 결과를 이 문서에 누적한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. PRD 요구사항 추적
|
|
||||||
|
|
||||||
- API endpoint와 공개 API 패키지: Phase 4 Task 4.1
|
|
||||||
- 재사용 가능한 조회 책임을 API 밖 도메인 패키지에 배치: Phase 1, Phase 2, Phase 3
|
|
||||||
- `creatorId`, `sort`, `themeId`, `page`, `size` 요청 처리: Phase 1 Task 1.1, Phase 4 Task 4.1
|
|
||||||
- invalid `sort` -> `LATEST` fallback: Phase 1 Task 1.1, Phase 4 Task 4.1, Phase 4 Task 4.2
|
|
||||||
- page/size fallback: Phase 1 Task 1.1, Phase 2 Task 2.1, Phase 4 Task 4.2
|
|
||||||
- 비활성/미존재 `themeId` 전체 조회 fallback: Phase 2 Task 2.1, Phase 3 Task 3.1, Phase 4 Task 4.2
|
|
||||||
- 테마 다국어 목록: Phase 3 Task 3.1
|
|
||||||
- 오디오/유료/구매 count와 퍼센트 소장률: Phase 2 Task 2.1, Phase 3 Task 3.2
|
|
||||||
- 오디오 콘텐츠 목록과 `CreatorChannelAudioContentResponse` 의미 보존: Phase 2 Task 2.2, Phase 3 Task 3.3
|
|
||||||
- 시리즈 이름 다국어 표시: Phase 3 Task 3.3
|
|
||||||
- 정렬 정책: Phase 3 Task 3.3
|
|
||||||
- 기존 API endpoint/응답 의미 보존: Phase 4 Task 4.1, Phase 5 Task 5.2
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 검증 기록
|
|
||||||
|
|
||||||
- 2026-06-19: plan-task 문서 작성 단계. 구현 코드는 아직 변경하지 않았다.
|
|
||||||
- 2026-06-19: Phase 1 완료.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` 실행 시 `CreatorChannelAudioQueryPolicy`, `CreatorChannelAudioTab`, `CreatorChannelAudioQueryPort` 미존재 컴파일 실패 확인.
|
|
||||||
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 의존성 확인: `rg -n "v2\\.api\\.creator\\.channel\\.audio" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` → 출력 없음.
|
|
||||||
- 2026-06-19: Phase 1 보강 범위 추가.
|
|
||||||
- 크리에이터 채널 오디오 콘텐츠 item은 홈/라이브/오디오 탭에서 공통 domain/response를 사용한다.
|
|
||||||
- live/home domain model의 `publishedAt`은 공개 응답에 사용하지 않고 오디오 item 공통 계약에도 필요하지 않아 제거 대상으로 확정했다.
|
|
||||||
- 2026-06-19: Task 1.3 완료.
|
|
||||||
- RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` → 공통 `CreatorChannelAudioContent`, `CreatorChannelAudioContentResponse` 미존재와 `publishedAt` 필드 불일치 컴파일 실패 확인.
|
|
||||||
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest` → 단독 재실행 시 `BUILD SUCCESSFUL`.
|
|
||||||
- 참고: live/home 회귀 테스트를 동시에 실행했을 때 home 테스트 결과 XML 파일 쓰기 실패가 1회 발생했다. 단독 재실행에서 통과해 Gradle 병렬 실행 중 `build/test-results/test` 쓰기 충돌로 판단했다.
|
|
||||||
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck` → `BUILD SUCCESSFUL`.
|
|
||||||
- 공백: `git diff --check` → 출력 없음.
|
|
||||||
- 중복 확인: `rg -n "data class CreatorChannelAudioContent|data class CreatorChannelAudioContentResponse|publishedAt =|v2\\.creator\\.channel\\.(live|home|audio)\\.domain\\.CreatorChannelAudioContent|v2\\.api\\.creator\\.channel\\.(live|home)\\.dto\\.CreatorChannelAudioContentResponse" src/main/kotlin src/test/kotlin` → 공통 domain/response 1건씩, 각 탭 port record, 홈 시리즈 집계 local 변수만 확인.
|
|
||||||
- 2026-06-19: Phase 2 완료.
|
|
||||||
- Task 2.1 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` → `CreatorChannelAudioQueryService` 미존재 컴파일 실패 확인.
|
|
||||||
- Task 2.2 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest` → `CreatorChannelAudioFacade` 미존재 컴파일 실패 확인.
|
|
||||||
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 리뷰 보강: Phase 3 port 구현 전 Spring bean 생성 실패를 피하기 위해 live 탭과 동일하게 `ObjectProvider<CreatorChannelAudioQueryPort>` 주입으로 조정했다.
|
|
||||||
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck` → `BUILD SUCCESSFUL`.
|
|
||||||
- 의존성 확인: `rg -n "Q[A-Z]|queryFactory|javax\\.persistence" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application` → 출력 없음.
|
|
||||||
- 공백: `git diff --check` → 출력 없음.
|
|
||||||
|
|
||||||
- 2026-06-19: Phase 3 완료.
|
|
||||||
- Task 3.1~3.3 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → 테스트 미존재/구현 전 실패 확인.
|
|
||||||
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck` → `BUILD SUCCESSFUL`.
|
|
||||||
- 공백: `git diff --check` → 출력 없음.
|
|
||||||
- 참고: 검증 중 Gradle 명령을 병렬 실행했을 때 QueryDSL generated source 참조 오류가 1회 발생했다. 단독 순차 재실행에서 컴파일과 테스트가 통과해 병렬 Gradle 실행 중 generated source 작업 충돌로 판단했다.
|
|
||||||
|
|
||||||
- 리뷰 보강: `OWNED` 정렬이 주문 수가 아니라 소장 또는 유효 대여 여부 boolean 기준이 되도록 `CaseBuilder` 정렬로 수정했다.
|
|
||||||
- 보강 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest.shouldSortAudioContentsByOwnedAndReturnOrderStatesWithSeriesFallback` → 중복 주문 콘텐츠가 더 최신 소장 콘텐츠보다 앞서는 assertion 실패 확인.
|
|
||||||
- 보강 GREEN: 같은 테스트 재실행 → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck` → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강 공백: `git diff --check` → 출력 없음.
|
|
||||||
- 2026-06-19: Phase 4 완료.
|
|
||||||
- Task 4.1 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest` → `CreatorChannelAudioController` 미존재 컴파일 실패 확인.
|
|
||||||
- Task 4.1 GREEN: 같은 테스트 재실행 → `BUILD SUCCESSFUL`.
|
|
||||||
- Task 4.2: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강: 전체 suite 실행 중 SpringBootTest context 추가로 heap 사용량이 증가해 `OutOfMemoryError`가 발생했다. 오디오 E2E가 기존 라이브 E2E와 Spring TestContext cache를 재사용하도록 datasource property를 동일하게 맞추고, 공유 DB에서 theme 정렬에 의존하지 않도록 assertion을 조정했다.
|
|
||||||
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck` → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강 전체 회귀: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test` → `BUILD SUCCESSFUL`.
|
|
||||||
- 보강 매핑 확인: `rg -n "@GetMapping\\(\\\"/\\{creatorId\\}/(home|live|audio)\\\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel` → home/live/audio 각각 1건.
|
|
||||||
- 보강 공백: `git diff --check` → 출력 없음.
|
|
||||||
- 2026-06-19: Phase 5 완료.
|
|
||||||
- 대상 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 전체 회귀: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test` → `BUILD SUCCESSFUL`.
|
|
||||||
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck` → `BUILD SUCCESSFUL`.
|
|
||||||
- 공백: `git diff --check` → 출력 없음.
|
|
||||||
- placeholder 확인: `rg -n "미완성 표시|후속 처리 표시" docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` → 계획 문서의 검증 명령/기록 자체만 매칭되어 의도하지 않은 placeholder 없음.
|
|
||||||
- 코드 리뷰: controller/facade/service/repository/test 경로를 PRD 요구사항 기준으로 검토했고, 공개 조건, fallback, count, 정렬, 시리즈/테마 번역 fallback, 소장/대여 상태 관련 차단 이슈 없음.
|
|
||||||
- 2026-06-19: 후속 수정 완료.
|
|
||||||
- 요구사항: 오디오 탭 `themes` 응답에서 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마를 제외한다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → 활성 테마 전체를 반환해 신규 assertion 실패 확인.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` → `BUILD SUCCESSFUL`.
|
|
||||||
- 문서 명령 확인: `./gradlew tasks --all` → `BUILD SUCCESSFUL`.
|
|
||||||
- 포맷: `./gradlew ktlintCheck` → `BUILD SUCCESSFUL`.
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
# PRD: 크리에이터 채널 오디오 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
크리에이터 채널의 오디오 탭에서 테마 목록, 정렬별 오디오 콘텐츠 개수, 소장률, 오디오 콘텐츠 목록을 한 번에 조회하는 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 크리에이터 채널 오디오 탭은 테마 필터, 정렬 상태, 콘텐츠 개수, 소장률, 콘텐츠 목록을 함께 표시해야 한다.
|
|
||||||
- 기존 라이브 탭 API는 `다시듣기` 콘텐츠에 한정되어 있고, 오디오 탭은 전체 오디오 콘텐츠와 선택한 테마별 콘텐츠를 조회해야 한다.
|
|
||||||
- 클라이언트는 오디오 탭 진입 시 테마 리스트와 콘텐츠 목록을 별도 API 조합 없이 일관된 계약으로 받아야 한다.
|
|
||||||
- 테마명은 호출하는 유저의 언어코드에 따라 한글, 영문, 일본어로 표시되어야 한다.
|
|
||||||
- 기존 API endpoint와 응답 필드의 의미는 변경하지 않아야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 크리에이터 채널 오디오 탭 조회 API를 제공한다.
|
|
||||||
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 하위 조립 계층에 둔다.
|
|
||||||
- 오디오 리스트, 오디오 개수, 소장률 계산, 테마 조회처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
|
|
||||||
- 요청은 `creatorId`, 정렬 순서, 테마를 받는다.
|
|
||||||
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
|
|
||||||
- 테마를 보내지 않으면 전체 테마의 오디오 콘텐츠를 조회한다.
|
|
||||||
- 응답에는 오디오 콘텐츠 개수, 유료 콘텐츠 개수, 호출자가 구매한 콘텐츠 개수, 호출자가 구매한 콘텐츠 개수의 비율, 크리에이터의 콘텐츠 목록, 실제 적용된 정렬 순서, 테마 목록을 포함한다.
|
|
||||||
- 콘텐츠 목록 item은 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 사용한다.
|
|
||||||
- 오디오 콘텐츠 목록은 라이브 탭의 `다시듣기` 목록과 같은 조회/정렬/소장 상태 의미를 따르되, 시리즈 이름이 표시되어야 한다.
|
|
||||||
- 테마 목록은 테마 id와 호출 유저 언어코드에 맞는 테마명을 내려준다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 이번 범위는 크리에이터 채널 `오디오` 탭 조회 API만 포함한다.
|
|
||||||
- 기존 크리에이터 채널 홈 API, 라이브 탭 API endpoint와 응답 필드의 의미는 변경하지 않는다.
|
|
||||||
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
|
|
||||||
- 오디오 콘텐츠 생성/수정/삭제 API는 포함하지 않는다.
|
|
||||||
- 테마 관리 화면, 테마 생성/수정/삭제 API, 테마 번역 관리 API는 포함하지 않는다.
|
|
||||||
- 테마 번역 데이터가 없는 항목을 새로 번역하거나 생성하는 배치 작업은 포함하지 않는다.
|
|
||||||
- 앱 표시용 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 크리에이터 채널 오디오 탭에서 크리에이터의 오디오 콘텐츠를 테마별로 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 오디오 탭 구성에 필요한 테마/개수/소장률/목록 데이터를 단일 API 응답으로 표시하려는 클라이언트
|
|
||||||
- 크리에이터: 자신의 오디오 콘텐츠가 테마와 정렬 기준에 따라 적절히 노출되기를 원하는 사용자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 크리에이터 채널 오디오 탭에 들어가면 전체 오디오 콘텐츠 개수를 확인하고 싶다.
|
|
||||||
- 사용자는 테마를 선택해 특정 테마의 오디오 콘텐츠만 보고 싶다.
|
|
||||||
- 사용자는 최신순, 인기순, 소장순, 높은 가격순, 낮은 가격순으로 오디오 콘텐츠를 바꿔 보고 싶다.
|
|
||||||
- 사용자는 유료 콘텐츠 중 자신이 구매한 콘텐츠 비율을 확인하고 싶다.
|
|
||||||
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
|
|
||||||
- 앱 클라이언트는 호출 유저 언어코드에 맞는 테마명을 받아 화면에 표시하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 크리에이터 채널 오디오 탭 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/audio`를 기본안으로 한다.
|
|
||||||
- `creatorId`는 path variable로 받는다.
|
|
||||||
- 정렬 순서는 query parameter로 받는다.
|
|
||||||
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
|
|
||||||
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
|
|
||||||
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
|
|
||||||
- 테마는 query parameter로 받는다.
|
|
||||||
- 테마 query parameter 이름은 `themeId`를 기본안으로 한다.
|
|
||||||
- `themeId`를 보내지 않으면 전체 테마의 오디오 콘텐츠를 조회한다.
|
|
||||||
- 오디오 콘텐츠 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
|
||||||
- `page`는 0부터 시작하는 page index로 처리한다.
|
|
||||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
|
||||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
|
||||||
- `page`가 0보다 작으면 `0`으로 fallback한다.
|
|
||||||
- `size`가 20보다 작으면 `20`으로 fallback한다.
|
|
||||||
- `size`가 50보다 크면 `50`으로 fallback한다.
|
|
||||||
- API는 인증 회원만 조회할 수 있어야 한다.
|
|
||||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
|
||||||
- 공개된 오디오 콘텐츠가 없어도 전체 API는 성공 처리한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
|
||||||
- 알 수 없는 `sort` 값은 400 오류를 반환하지 않고 `LATEST`로 fallback한다.
|
|
||||||
- `themeId`가 존재하지 않거나 비활성 테마이면 오류를 반환하지 않고 전체 테마 조회로 fallback한다.
|
|
||||||
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
|
||||||
|
|
||||||
### Feature B. 응답 스키마
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
|
||||||
- 응답 최상위 DTO 이름은 `CreatorChannelAudioTabResponse`를 기본안으로 한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- `audioContentCount`: 선택한 테마 필터를 적용한 오디오 콘텐츠 전체 개수
|
|
||||||
- `paidAudioContentCount`: 선택한 테마 필터를 적용한 유료 오디오 콘텐츠 전체 개수
|
|
||||||
- `purchasedAudioContentCount`: 선택한 테마 필터를 적용한 유료 오디오 콘텐츠 중 호출자가 구매한 콘텐츠 개수
|
|
||||||
- `purchasedAudioContentRate`: `paidAudioContentCount` 대비 `purchasedAudioContentCount`의 퍼센트 값
|
|
||||||
- `themes`: 활성 테마 목록
|
|
||||||
- `audioContents`: 오디오 콘텐츠 목록
|
|
||||||
- `sort`: 콘텐츠 조회에 실제 적용한 정렬 순서
|
|
||||||
- `themeId`: 콘텐츠 조회에 실제 적용한 테마 id, 전체 조회이면 `null`
|
|
||||||
- `page`: 현재 응답의 page index
|
|
||||||
- `size`: 현재 응답의 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- `audioContents`의 각 item은 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 사용한다.
|
|
||||||
- `sort`는 요청값이 없거나 알 수 없는 값이면 실제 적용값인 `LATEST`를 내려준다.
|
|
||||||
- `themeId`는 요청값이 없거나 비활성/미존재 테마라 전체 조회로 fallback하면 `null`을 내려준다.
|
|
||||||
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
|
||||||
- `hasNext`는 같은 필터/정렬 조건에서 다음 page에 노출할 오디오 콘텐츠가 있으면 `true`로 내려준다.
|
|
||||||
- `purchasedAudioContentRate`는 `paidAudioContentCount == 0`이면 `0.0`으로 내려준다.
|
|
||||||
- `purchasedAudioContentRate`는 `(purchasedAudioContentCount / paidAudioContentCount) * 100`을 기준으로 계산한 퍼센트 값으로 내려준다.
|
|
||||||
- 응답 스키마 예시는 다음과 같다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class CreatorChannelAudioTabResponse(
|
|
||||||
val audioContentCount: Int,
|
|
||||||
val paidAudioContentCount: Int,
|
|
||||||
val purchasedAudioContentCount: Int,
|
|
||||||
val purchasedAudioContentRate: Double,
|
|
||||||
val themes: List<CreatorChannelAudioThemeResponse>,
|
|
||||||
val audioContents: List<CreatorChannelAudioContentResponse>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val themeId: Long?,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelAudioThemeResponse(
|
|
||||||
val themeId: Long,
|
|
||||||
val themeName: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelAudioContentResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val seriesName: String?,
|
|
||||||
val isOriginalSeries: Boolean?,
|
|
||||||
val isOwned: Boolean,
|
|
||||||
val isRented: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class ContentSort {
|
|
||||||
LATEST,
|
|
||||||
POPULAR,
|
|
||||||
OWNED,
|
|
||||||
PRICE_HIGH,
|
|
||||||
PRICE_LOW
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 공개된 오디오 콘텐츠가 없으면 `audioContentCount`, `paidAudioContentCount`, `purchasedAudioContentCount`는 `0`, `purchasedAudioContentRate`는 `0.0`, `audioContents`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
|
||||||
- 요청한 page 범위에 콘텐츠가 없으면 `audioContents`는 빈 배열, `hasNext`는 `false`로 내려주되 개수 필드는 전체 개수를 유지한다.
|
|
||||||
|
|
||||||
### Feature C. 테마 목록
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 테마 목록은 `AudioContentTheme.isActive == true`인 테마만 내려준다.
|
|
||||||
- 테마 목록은 기존 테마 정렬 정책인 `AudioContentTheme.orders`를 따른다.
|
|
||||||
- 테마 응답은 테마 id와 테마명을 포함한다.
|
|
||||||
- 테마명은 호출하는 유저의 언어코드에 따라 한글, 영문, 일본어로 반환한다.
|
|
||||||
- 호출 유저 언어코드는 기존 `LangContext.lang.code` 값을 사용한다.
|
|
||||||
- 지원 언어코드는 `ko`, `en`, `ja`를 기준으로 한다.
|
|
||||||
- `ko`는 `AudioContentTheme.theme` 원문을 기본으로 사용한다.
|
|
||||||
- `en`, `ja`는 `ContentThemeTranslation.locale`에 해당하는 번역값이 있으면 해당 `theme`을 사용한다.
|
|
||||||
- 요청 언어의 번역값이 없으면 `AudioContentTheme.theme` 원문을 fallback으로 사용한다.
|
|
||||||
- 테마 목록은 선택한 `themeId`와 무관하게 활성 테마 중 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 1개 이상 있는 테마만 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 활성 테마가 없으면 `themes`는 빈 배열로 내려준다.
|
|
||||||
- 활성 테마가 있어도 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마는 `themes`에서 제외한다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책이 false이고 특정 테마의 조회 가능한 콘텐츠가 성인 콘텐츠뿐이면 해당 테마는 `themes`에서 제외한다.
|
|
||||||
- 번역 데이터는 있지만 빈 문자열이면 원문 테마명을 fallback으로 사용한다.
|
|
||||||
|
|
||||||
### Feature D. 오디오 콘텐츠 목록과 개수
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 조회 대상은 지정한 `creatorId`의 오디오 콘텐츠로 제한한다.
|
|
||||||
- 공개된 콘텐츠만 조회한다.
|
|
||||||
- 예약 공개 전 콘텐츠는 포함하지 않는다.
|
|
||||||
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 조회에서 제외한다.
|
|
||||||
- `themeId`가 있고 활성 테마이면 해당 테마의 오디오 콘텐츠만 조회한다.
|
|
||||||
- `themeId`가 없거나 비활성/미존재 테마이면 전체 활성 테마의 오디오 콘텐츠를 조회한다.
|
|
||||||
- 콘텐츠 목록은 `page`, `size` 기준으로 페이징 조회한다.
|
|
||||||
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
|
||||||
- 오디오 콘텐츠 개수는 목록 조회와 같은 공개 여부, 예약 공개, 성인 콘텐츠, 차단 정책, 테마 필터를 적용해 계산한다.
|
|
||||||
- 유료 콘텐츠 개수는 같은 필터를 적용한 콘텐츠 중 `price > 0`인 콘텐츠 개수로 계산한다.
|
|
||||||
- 호출자가 구매한 콘텐츠 개수는 같은 필터를 적용한 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수로 계산한다.
|
|
||||||
- 대여 중인 콘텐츠는 호출자가 구매한 콘텐츠 개수와 `purchasedAudioContentRate` 계산에 포함한다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책이 false이면 성인 콘텐츠는 목록과 개수에서 제외한다.
|
|
||||||
- 응답 item 필드는 기존 `CreatorChannelAudioContentResponse`와 같은 의미를 유지한다.
|
|
||||||
- `seriesName`은 콘텐츠가 속한 시리즈 이름을 내려준다.
|
|
||||||
- 시리즈 이름은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
|
|
||||||
- `isFirstContent`, `seriesName`, `isOriginalSeries`, 구매/대여/포인트 사용 가능 여부의 의미는 기존 오디오 콘텐츠/시리즈 콘텐츠 응답과 동일하게 유지한다.
|
|
||||||
- `isFirstContent`는 선택한 테마 안에서 첫 콘텐츠인지가 아니라, 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 시리즈에 속하지 않은 콘텐츠는 `seriesName`, `isOriginalSeries`를 `null`로 내려준다.
|
|
||||||
- 무료 콘텐츠는 구매한 콘텐츠 개수와 구매 비율 계산에서 제외한다.
|
|
||||||
- 일반적으로 `isOwned == true`와 `isRented == true`가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
|
|
||||||
|
|
||||||
### Feature E. 콘텐츠 정렬
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
|
|
||||||
- 공개 요청/응답 값은 다음을 사용한다.
|
|
||||||
- `LATEST`: 최신순, 기본값
|
|
||||||
- `POPULAR`: 인기순
|
|
||||||
- `OWNED`: 소장순
|
|
||||||
- `PRICE_HIGH`: 높은 가격순
|
|
||||||
- `PRICE_LOW`: 낮은 가격순
|
|
||||||
- `LATEST`는 공개일 최신순을 1차 정렬로 사용한다.
|
|
||||||
- `LATEST`의 2차 정렬은 높은 가격순이다.
|
|
||||||
- `LATEST`의 3차 정렬은 `audioContent.id desc`다.
|
|
||||||
- `POPULAR`은 매출이 많은 콘텐츠를 먼저 노출한다.
|
|
||||||
- `OWNED`는 조회자가 소장 또는 대여 중인 콘텐츠를 먼저 노출한다.
|
|
||||||
- `PRICE_HIGH`는 가격이 높은 콘텐츠를 먼저 노출한다.
|
|
||||||
- `PRICE_LOW`는 가격이 낮은 콘텐츠를 먼저 노출한다.
|
|
||||||
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 2차 정렬은 최신순이다.
|
|
||||||
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 3차 정렬은 `audioContent.id desc`다.
|
|
||||||
- 최신순 기준에 사용하는 날짜는 기존 `CreatorChannelAudioContentResponse` 목록 정책과 동일하게 공개 시각(`releaseDate`)을 기준으로 한다.
|
|
||||||
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 하며, 포인트 사용액은 매출 기준에 포함하지 않는다.
|
|
||||||
- 환불되었거나 비활성 처리된 구매 내역은 기존 콘텐츠 구매/매출 정책과 동일하게 제외한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 매출이 없는 콘텐츠의 인기순 매출값은 0으로 처리한다.
|
|
||||||
- 조회자가 소장 또는 대여 중인 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `audioContent.id desc` 보조 정렬과 같은 결과가 될 수 있다.
|
|
||||||
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
|
||||||
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
|
|
||||||
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
|
|
||||||
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 하위에 둔다.
|
|
||||||
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
|
|
||||||
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.audio` 또는 재사용 범위가 더 넓은 기존 도메인 패키지 하위에 둔다.
|
|
||||||
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
|
|
||||||
- 기존 라이브 탭의 `CreatorChannelAudioContentResponse`와 필드/의미가 어긋나지 않도록 오디오 탭 응답 DTO를 작성한다.
|
|
||||||
- 기존 `ContentSort` enum을 재사용하고, API binding, service 정책, 테스트에서 같은 타입을 사용한다.
|
|
||||||
- 기존 크리에이터 채널 홈/라이브 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용한다.
|
|
||||||
- 페이징 응답은 기존 라이브 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
|
|
||||||
- 테마명 다국어 처리는 기존 `LangContext`, `ContentThemeTranslation` 구조를 따른다.
|
|
||||||
- 시리즈명 다국어 처리는 기존 `SeriesTranslation` 구조를 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Metrics
|
|
||||||
- 오디오 탭 API 성공/실패 건수
|
|
||||||
- 오디오 탭 API 응답 시간
|
|
||||||
- 테마별 조회 건수
|
|
||||||
- 정렬 기준별 조회 건수
|
|
||||||
- 오디오 탭에서 콘텐츠 추가 로딩 요청 건수
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
# 크리에이터 채널 시리즈 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/series`로 크리에이터 채널 시리즈 탭의 전체 시리즈 개수와 정렬/페이징된 시리즈 목록을 조회할 수 있게 한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 조립 계층에 둔다. 시리즈 탭 조회 service, 순수 fallback/page/rate/day-of-week 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 오디오 탭의 `ContentSort`, `CreatorChannelPage`, 인증/차단/성인 콘텐츠 노출 정책 흐름을 재사용하되, 홈 API의 `CreatorChannelSeries`는 확장하지 않는다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/series`
|
|
||||||
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
|
||||||
- request:
|
|
||||||
- path variable: `creatorId`
|
|
||||||
- query parameter: `sort`, `required = false`, 기본값/fallback `LATEST`
|
|
||||||
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
|
|
||||||
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
|
|
||||||
- controller는 invalid `sort` fallback을 위해 `sort: String?`으로 받고 service/facade 경계에서 `ContentSort`로 보정한다.
|
|
||||||
- response:
|
|
||||||
- `seriesCount`: sort-bar에 표시할 조회 가능한 전체 시리즈 개수
|
|
||||||
- `series`: 시리즈 목록
|
|
||||||
- `sort`: 실제 적용한 `ContentSort`
|
|
||||||
- `page`: fallback 보정 후 실제 적용된 page index
|
|
||||||
- `size`: fallback 보정 후 실제 적용된 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- series item:
|
|
||||||
- `seriesId`, `title`, `coverImageUrl`, `publishedDaysOfWeek`, `isOriginal`, `isAdult`, `isProceeding`, `contentCount`
|
|
||||||
- 조회자가 해당 시리즈의 크리에이터가 아니면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산한다.
|
|
||||||
- 조회자가 해당 시리즈의 크리에이터이면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`는 `null`이다.
|
|
||||||
- `purchasedPaidContentRate`: `Int?`, 비크리에이터 조회 시 `paidContentCount == 0`이면 `0`, 그 외 `(purchasedContentCount * 100) / paidContentCount`로 계산하고 소수점 이하는 버린다.
|
|
||||||
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
|
|
||||||
- 공개 시리즈 기준: `Series.isActive == true`, `Series.member.id == creatorId`.
|
|
||||||
- `coverImageUrl`은 `Series.coverImage`를 `String?.toCdnUrl(cloudFrontHost)`로 변환한 값이다. 커버 이미지 경로가 없거나 blank이면 `null`로 내려준다.
|
|
||||||
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
|
|
||||||
- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다.
|
|
||||||
- 연재 요일:
|
|
||||||
- `RANDOM`이 포함되면 다른 요일을 무시하고 랜덤 문구만 반환한다.
|
|
||||||
- 랜덤 문구: `ko=랜덤`, `en=Random`, `ja=ランダム`
|
|
||||||
- 7개 요일이 모두 있으면 `ko=매일`, `en=Every day`, `ja=毎日`
|
|
||||||
- 그 외 `ko=매주 월, 목, 토`, `en=Every Mon, Thu, Sat`, `ja=毎週 月, 木, 土` 형식
|
|
||||||
- 정렬:
|
|
||||||
- `LATEST`: 대표 `releaseDate desc`, 대표 `price desc`, `series.id desc`
|
|
||||||
- `POPULAR`: 시리즈 콘텐츠의 `orders.can` 합계 desc, 대표 `releaseDate desc`, `series.id desc`; `orders.is_active = true`만 포함
|
|
||||||
- `OWNED`: 조회자가 유효하게 소장/대여 중인 시리즈 콘텐츠 개수 desc, 대표 `releaseDate desc`, `series.id desc`
|
|
||||||
- `PRICE_HIGH`: 대표 `price desc`, 대표 `releaseDate desc`, `series.id desc`
|
|
||||||
- `PRICE_LOW`: 대표 `price asc`, 대표 `releaseDate desc`, `series.id desc`
|
|
||||||
- 대표값:
|
|
||||||
- 대표 `releaseDate`: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 최근 `releaseDate`
|
|
||||||
- `price desc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 높은 가격
|
|
||||||
- `price asc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 낮은 가격
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 시리즈 탭 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
|
|
||||||
|
|
||||||
### 시리즈 탭 도메인 조회 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 기존 파일 확인/재사용
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/SeriesContent.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Create: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md`
|
|
||||||
- Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab
|
|
||||||
|
|
||||||
data class CreatorChannelSeriesTabResponse(
|
|
||||||
val seriesCount: Int,
|
|
||||||
val series: List<CreatorChannelSeriesResponse>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(tab: CreatorChannelSeriesTab): CreatorChannelSeriesTabResponse {
|
|
||||||
return CreatorChannelSeriesTabResponse(
|
|
||||||
seriesCount = tab.seriesCount,
|
|
||||||
series = tab.series.map(CreatorChannelSeriesResponse::from),
|
|
||||||
sort = tab.sort,
|
|
||||||
page = tab.page.page,
|
|
||||||
size = tab.page.size,
|
|
||||||
hasNext = tab.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelSeriesResponse(
|
|
||||||
val seriesId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val publishedDaysOfWeek: String,
|
|
||||||
@JsonProperty("isOriginal")
|
|
||||||
val isOriginal: Boolean,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean,
|
|
||||||
@JsonProperty("isProceeding")
|
|
||||||
val isProceeding: Boolean,
|
|
||||||
val contentCount: Int,
|
|
||||||
val purchasedContentCount: Int?,
|
|
||||||
val paidContentCount: Int?,
|
|
||||||
val purchasedPaidContentRate: Int?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse {
|
|
||||||
return CreatorChannelSeriesResponse(
|
|
||||||
seriesId = series.seriesId,
|
|
||||||
title = series.title,
|
|
||||||
coverImageUrl = series.coverImageUrl,
|
|
||||||
publishedDaysOfWeek = series.publishedDaysOfWeek,
|
|
||||||
isOriginal = series.isOriginal,
|
|
||||||
isAdult = series.isAdult,
|
|
||||||
isProceeding = series.isProceeding,
|
|
||||||
contentCount = series.contentCount,
|
|
||||||
purchasedContentCount = series.purchasedContentCount,
|
|
||||||
paidContentCount = series.paidContentCount,
|
|
||||||
purchasedPaidContentRate = series.purchasedPaidContentRate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
|
||||||
|
|
||||||
data class CreatorChannelSeriesTab(
|
|
||||||
val seriesCount: Int,
|
|
||||||
val series: List<CreatorChannelSeries>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val page: CreatorChannelPage,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelSeries(
|
|
||||||
val seriesId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val publishedDaysOfWeek: String,
|
|
||||||
val isOriginal: Boolean,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isProceeding: Boolean,
|
|
||||||
val contentCount: Int,
|
|
||||||
val purchasedContentCount: Int?,
|
|
||||||
val paidContentCount: Int?,
|
|
||||||
val purchasedPaidContentRate: Int?
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface CreatorChannelSeriesQueryPort {
|
|
||||||
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord?
|
|
||||||
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
|
||||||
fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int
|
|
||||||
fun findSeries(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
now: LocalDateTime,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
sort: ContentSort,
|
|
||||||
locale: String,
|
|
||||||
offset: Long,
|
|
||||||
limit: Int
|
|
||||||
): List<CreatorChannelSeriesRecord>
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelSeriesCreatorRecord(
|
|
||||||
val creatorId: Long,
|
|
||||||
val role: MemberRole,
|
|
||||||
val nickname: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelSeriesRecord(
|
|
||||||
val seriesId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImagePath: String?,
|
|
||||||
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>,
|
|
||||||
val isOriginal: Boolean,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val state: SeriesState,
|
|
||||||
val contentCount: Int,
|
|
||||||
val purchasedContentCount: Int?,
|
|
||||||
val paidContentCount: Int?
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 작업 계획
|
|
||||||
|
|
||||||
### Phase 1: 순수 정책과 도메인 모델 추가
|
|
||||||
|
|
||||||
- [x] **Task 1.1: `CreatorChannelSeriesQueryPolicy` 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt`
|
|
||||||
- RED: 아래 케이스를 테스트로 먼저 작성한다.
|
|
||||||
- `sort == null`, `UNKNOWN`은 `ContentSort.LATEST`로 fallback한다.
|
|
||||||
- `page = -1`, `size = 10`은 `page=0`, `size=20`, `fetchLimit=21`이 된다.
|
|
||||||
- `page = 2`, `size = 100`은 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이 된다.
|
|
||||||
- `limitItems`는 `size`만큼만 남기고 `hasNext`는 `fetched.size > size`로 계산한다.
|
|
||||||
- 구매율은 `paidContentCount == 0`이면 `0`, `paid=4`, `purchased=3`이면 `75`, `paid=3`, `purchased=2`이면 `66`이다.
|
|
||||||
- `publishedDaysOfWeek`는 `RANDOM` 포함 시 다른 요일을 무시하고 locale별 랜덤 문구를 반환한다.
|
|
||||||
- 7개 요일은 locale별 매일 문구를 반환한다.
|
|
||||||
- 일부 요일은 `SUN`부터 `SAT` 순서로 locale별 `매주/Every/毎週` 문구를 반환한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
|
||||||
- GREEN: `CreatorChannelAudioQueryPolicy`와 같은 page/sort/list 정책을 구현하되 `purchaseRate`는 `Int`를 반환한다. `publishedDaysOfWeekText(days, locale)`는 `ko`, `en`, `ja` 명시 매핑으로 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
|
||||||
- REFACTOR: `CreatorChannelAudioQueryPolicy`를 수정하지 않는다. 중복 제거는 이번 범위에서 하지 않는다.
|
|
||||||
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryPolicyTest`를 추가해 sort/page/list/구매율/연재 요일 정책을 문서 명세대로 고정했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 시 신규 `CreatorChannelSeriesQueryPolicy`, domain, port 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: `CreatorChannelSeriesQueryPolicy`를 추가하고 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: 시리즈 탭 domain model과 port record 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
|
|
||||||
- RED: Task 1.1 테스트 컴파일이 새 domain/port 타입 부재로 실패하는 상태를 확인한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
|
||||||
- GREEN: 문서의 Domain / Port 초안 그대로 타입을 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
|
||||||
- REFACTOR: domain/port가 `kr.co.vividnext.sodalive.v2.api.*` 패키지를 import하지 않는지 확인한다.
|
|
||||||
- 구현 기록(2026-06-20): 문서의 Domain / Port 초안 기준으로 `CreatorChannelSeriesTab`, `CreatorChannelSeriesQueryPort`와 관련 record를 추가했다.
|
|
||||||
- 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 의존성 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series` 실행 결과 출력이 없어 domain/port의 API 패키지 의존이 없음을 확인했다.
|
|
||||||
|
|
||||||
### Phase 2: API 조립 계층 추가
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 응답 DTO mapper 테스트와 DTO 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
|
|
||||||
- RED: facade 테스트 또는 DTO mapper 테스트에서 `CreatorChannelSeriesTabResponse.from` 결과가 `seriesCount`, `series`, `sort`, `page`, `size`, `hasNext`, `coverImageUrl`, `purchasedPaidContentRate: Int?`를 그대로 매핑하는지 기대한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
|
|
||||||
- GREEN: Response data class 초안대로 DTO와 mapper를 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
|
|
||||||
- REFACTOR: Jackson boolean property는 `@JsonProperty("isOriginal")`, `@JsonProperty("isAdult")`, `@JsonProperty("isProceeding")`, `@JsonProperty("hasNext")`로 명시한다.
|
|
||||||
- 구현 기록(2026-06-20): `CreatorChannelSeriesFacadeTest`에 DTO mapper 검증을 추가하고 `CreatorChannelSeriesTabResponse`, `CreatorChannelSeriesResponse`를 초안대로 추가했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` 실행 시 DTO/facade/query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: Facade 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
|
|
||||||
- RED: `CreatorChannelSeriesFacade.getSeriesTab(creatorId, viewer, sort, page, size, now)`가 query service 호출 결과를 `CreatorChannelSeriesTabResponse`로 변환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
|
|
||||||
- GREEN: `CreatorChannelAudioFacade`와 같은 형태로 read-only service를 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
|
|
||||||
- REFACTOR: facade는 HTTP 예외 처리와 repository 세부 사항을 알지 않도록 query service에 위임한다.
|
|
||||||
- 구현 기록(2026-06-20): `CreatorChannelSeriesFacade.getSeriesTab`을 추가해 query service 결과를 공개 DTO로 변환하도록 했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` 실행 시 facade/query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: Controller 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
|
|
||||||
- RED: MockMvc 테스트를 작성한다.
|
|
||||||
- `GET /api/v2/creator-channels/{creatorId}/series?sort=POPULAR&page=1&size=20` 요청이 facade에 `sort="POPULAR"`, `page=1`, `size=20`을 전달한다.
|
|
||||||
- 응답 JSON에 `seriesCount`, `series[0].seriesId`, `series[0].coverImageUrl`, `series[0].publishedDaysOfWeek`, `series[0].purchasedPaidContentRate`, `sort`, `page`, `size`, `hasNext`가 있다.
|
|
||||||
- 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
|
|
||||||
- GREEN: `CreatorChannelAudioController`와 같은 `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/series")`, `requireMember` 구조로 controller를 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
|
|
||||||
- REFACTOR: `sort`는 `String?`으로 받고 `ContentSort` enum binding 오류가 발생하지 않게 한다.
|
|
||||||
- 구현 기록(2026-06-20): `CreatorChannelSeriesController`와 MockMvc 테스트를 추가해 인증 회원 요청, query parameter 전달, invalid sort 전달, 비회원 거부를 검증했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 시 Kotlin incremental cache 손상(`Malformed input`)으로 중단되어 controller 부재 메시지까지 도달하지 못했다.
|
|
||||||
- GREEN: `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
### Phase 3: 도메인 조회 서비스 추가
|
|
||||||
|
|
||||||
- [x] **Task 3.1: QueryService 인증/차단/creator 검증 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
|
|
||||||
- RED: 아래 서비스 테스트를 작성한다.
|
|
||||||
- `findCreator`가 `null`이면 `member.validation.user_not_found` 예외를 던진다.
|
|
||||||
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다.
|
|
||||||
- 차단 관계가 있으면 기존 크리에이터 채널과 같은 blocked access 예외를 던진다.
|
|
||||||
- 정상 조회 시 policy가 보정한 sort/page를 사용해 port를 호출한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
|
||||||
- GREEN: `CreatorChannelAudioQueryService` 흐름을 기준으로 `ObjectProvider<CreatorChannelSeriesQueryPort>`, `MemberContentPreferenceService`, `SodaMessageSource`, `LangContext`, `cloud.aws.cloud-front.host`를 주입받는 service를 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
|
||||||
- REFACTOR: 서비스는 repository record의 `coverImagePath`를 `String?.toCdnUrl(cloudFrontHost)`로 변환해 domain의 `coverImageUrl`에 채운다.
|
|
||||||
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryServiceTest`에 creator 조회 실패, creator role 검증, 차단 예외, sort/page fallback과 port 호출 검증을 추가하고 service를 구현했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 시 query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: QueryService 응답 조립 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
|
|
||||||
- RED: 아래 조립 테스트를 추가한다.
|
|
||||||
- 조회자가 creator 본인이면 각 series item의 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`가 `null`이다.
|
|
||||||
- 조회자가 creator가 아니면 `paidContentCount`, `purchasedContentCount`로 `purchasedPaidContentRate` 정수값을 계산한다.
|
|
||||||
- `coverImagePath`가 상대 경로이면 `cloudFrontHost`가 붙은 `coverImageUrl`로 변환되고, blank이면 `coverImageUrl == null`이다.
|
|
||||||
- `fetched.size == size + 1`이면 `hasNext == true`이고 응답 목록은 `size`개만 남는다.
|
|
||||||
- `publishedDaysOfWeek`는 policy의 locale별 문자열로 변환된다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
|
||||||
- GREEN: service에서 `countSeries`, `findSeries` 결과를 조립하고 creator 본인 여부에 따라 구매 통계 필드를 null 처리한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
|
||||||
- REFACTOR: 구매율 계산은 service에 직접 두지 않고 `CreatorChannelSeriesQueryPolicy.purchaseRate`를 사용한다.
|
|
||||||
- 구현 기록(2026-06-20): service에서 `countSeries`, `findSeries`, CDN URL, 연재 요일 문자열, hasNext/list limit, creator 본인 구매 통계 null 처리, 비크리에이터 구매율 계산을 조립하도록 했다.
|
|
||||||
- RED: 신규 조립 테스트 작성 후 query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
### Phase 4: QueryDSL repository 추가
|
|
||||||
|
|
||||||
- [x] **Task 4.1: Repository creator/차단/count 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
|
|
||||||
- RED: repository 테스트를 작성한다.
|
|
||||||
- active creator를 `CreatorChannelSeriesCreatorRecord`로 조회한다.
|
|
||||||
- viewer와 creator 사이 차단 관계가 있으면 `existsBlockedBetween == true`다.
|
|
||||||
- `countSeries`는 `series.isActive == true`, `series.member.id == creatorId`, 성인 콘텐츠 노출 정책을 반영한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
|
||||||
- GREEN: 오디오 탭 repository의 creator/차단 조회 패턴을 복사해 series 패키지용 repository를 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
|
||||||
- REFACTOR: count 쿼리는 목록 쿼리와 같은 공개 시리즈/성인 정책을 공유하는 private condition을 사용한다.
|
|
||||||
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryRepository`, `DefaultCreatorChannelSeriesQueryRepository`, repository 테스트를 추가해 creator 조회, 양방향 차단, series count의 활성/creator/성인 정책을 검증했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 시 `DefaultCreatorChannelSeriesQueryRepository` 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: Repository 목록 필드/번역/통계 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
|
|
||||||
- RED: `findSeries` 테스트를 작성한다.
|
|
||||||
- locale에 맞는 `SeriesTranslation` title이 있으면 번역명을 반환하고, 없거나 빈 문자열이면 원문 title을 반환한다.
|
|
||||||
- `coverImagePath`는 `Series.coverImage` 값을 반환한다.
|
|
||||||
- `contentCount`는 공개 콘텐츠 기준으로 계산한다.
|
|
||||||
- `paidContentCount`는 공개 콘텐츠 중 `price > 0`만 계산한다.
|
|
||||||
- `purchasedContentCount`는 viewer의 active 소장 주문과 만료되지 않은 active 대여 주문을 중복 없이 계산한다.
|
|
||||||
- 예약 공개 전 콘텐츠와 `releaseDate == null` 콘텐츠는 통계에서 제외한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
|
||||||
- GREEN: `seriesContent`와 `audioContent`를 기준으로 시리즈 목록을 조회하고, 목록의 series id 묶음에 대해 콘텐츠 통계를 bulk 조회해 record에 채운다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
|
||||||
- REFACTOR: N+1 조회가 생기지 않도록 `seriesIds` 기반 bulk map을 사용한다.
|
|
||||||
- 구현 기록(2026-06-20): `findSeries`가 시리즈 필드, `SeriesTranslation` title fallback, 공개 콘텐츠 기준 `contentCount`/`paidContentCount`, 유효 KEEP/RENTAL 기반 distinct `purchasedContentCount`를 반환하도록 구현했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 시 `findSeries` 빈 목록으로 `NoSuchElementException` 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: Repository 정렬 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
|
|
||||||
- RED: 각 정렬별 순서 테스트를 작성한다.
|
|
||||||
- `LATEST`: 시리즈별 `max(audioContent.releaseDate) desc`, `max(audioContent.price) desc`, `series.id desc`
|
|
||||||
- `POPULAR`: `sum(orders.can) desc`, `max(audioContent.releaseDate) desc`, `series.id desc`; inactive order 제외
|
|
||||||
- `OWNED`: viewer의 유효 소장/대여 콘텐츠 개수 desc, `max(audioContent.releaseDate) desc`, `series.id desc`
|
|
||||||
- `PRICE_HIGH`: `max(audioContent.price) desc`, `max(audioContent.releaseDate) desc`, `series.id desc`
|
|
||||||
- `PRICE_LOW`: `min(audioContent.price) asc`, `max(audioContent.releaseDate) desc`, `series.id desc`
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
|
||||||
- GREEN: `groupBy(series.id)` 기반 QueryDSL 정렬을 구현한다. 정렬 대표값은 공개 콘텐츠 조건을 적용한 조인 결과에서 계산한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
|
||||||
- REFACTOR: 콘텐츠가 없는 시리즈는 대표값이 없는 항목으로 같은 정렬 내 마지막에 오도록 null 정렬 처리를 테스트와 구현에 고정한다.
|
|
||||||
- 구현 기록(2026-06-20): `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 추가하고 공개 콘텐츠 대표값 및 주문 조건 기반 QueryDSL group 정렬을 구현했다.
|
|
||||||
- RED/GREEN: 정렬 테스트 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 결과 기존 구현이 정렬 계약을 만족해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 리뷰 보완: `OWNED` 정렬이 구매 개수가 아닌 공개 콘텐츠 개수로 정렬될 수 있는 문제를 발견해, 미구매 공개 콘텐츠가 더 많은 시리즈 fixture를 추가했다. RED로 `AssertionFailedError`를 확인한 뒤 `ownedOrder.audioContent.id.countDistinct()` 기준으로 수정하고 동일 명령 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
### Phase 5: API 통합 검증
|
|
||||||
|
|
||||||
- [x] **Task 5.1: End-to-End 테스트 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
|
||||||
- RED: 실제 Spring context 기반 테스트를 작성한다.
|
|
||||||
- `GET /api/v2/creator-channels/{creatorId}/series`가 성공하고 PRD의 전체 응답 필드를 반환한다.
|
|
||||||
- invalid `sort`, 음수 `page`, 작은 `size`가 fallback되어 응답의 `sort/page/size`에 반영된다.
|
|
||||||
- 비크리에이터 viewer는 구매 통계 정수 비율을 받는다.
|
|
||||||
- creator 본인은 구매 통계 필드가 `null`이다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
|
|
||||||
- GREEN: controller, facade, service, repository wiring 누락을 보완한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
|
|
||||||
- REFACTOR: API 응답 필드명이 PRD와 다르면 PRD 또는 코드를 먼저 맞춘 뒤 테스트를 갱신한다.
|
|
||||||
- 구현 기록(2026-06-20): `CreatorChannelSeriesEndToEndTest`를 추가해 실제 Spring context에서 controller-service-repository 경로를 검증했다.
|
|
||||||
- 검증 시나리오: 인증 회원의 전체 응답 필드, invalid `sort`/음수 `page`/작은 `size` fallback, 비크리에이터 구매 통계 정수 비율, creator 본인 구매 통계 `null` 응답을 확인했다.
|
|
||||||
- RED/GREEN: 신규 E2E 테스트 파일 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` 실행 결과 기존 wiring이 계약을 만족해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 보완: controller/facade/service/repository production code 수정은 필요하지 않았다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: 회귀 검증과 문서 검증 기록**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md`
|
|
||||||
- Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md`
|
|
||||||
- RED: 문서와 코드 계약 차이를 확인한다.
|
|
||||||
- `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API`
|
|
||||||
- 실패 확인: 문서와 구현 계약이 불일치하면 해당 task를 완료하지 않는다.
|
|
||||||
- GREEN: 단일 테스트와 관련 회귀 테스트를 실행한다.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
|
|
||||||
- 통과 확인: `./gradlew test`
|
|
||||||
- REFACTOR: Kotlin 포맷 검증은 `./gradlew ktlintCheck`로 확인한다.
|
|
||||||
- 문서 기록: 구현 완료 시 각 task 아래에 실행 명령, 성공/실패 결과, 수정 내용을 한국어로 누적 기록한다.
|
|
||||||
- 검증 기록(2026-06-20): 문서 계약 검색과 Phase 5 focused 회귀를 실행했다.
|
|
||||||
- 문서 계약 검색: `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API` 실행으로 PRD/plan의 endpoint, 구매 통계, `PRICE_LOW`, `RANDOM` 계약 기재를 확인했다.
|
|
||||||
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`는 병렬 실행 중 XML 결과 파일 동시 쓰기 실패가 발생했으나, 동일 명령 순차 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- OOM 원인 보완: 기본 `./gradlew test`에서 test worker가 `-Xmx512m`로 실행되어 full Spring context 누적 시 `Gradle Test Executor`의 `Java heap space` 실패가 발생했다. `build.gradle.kts`의 `tasks.withType<Test>`에 `maxHeapSize = "1536m"`를 명시해 test worker heap을 1.5g로 고정했다.
|
|
||||||
- context 재사용 보완: `CreatorChannelSeriesEndToEndTest`의 H2 datasource URL을 기존 creator-channel E2E와 같은 `creator-channel-live-e2e`로 맞춰 `audio/live/series` E2E가 Spring context를 공유하도록 했다.
|
|
||||||
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest --info` 실행 결과 test worker가 `-Xmx1536m`로 실행되고 `HikariPool-1`만 생성되는 것을 확인했으며 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 통과: 기본 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 통과: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 전체 검증 명령
|
|
||||||
|
|
||||||
구현 완료 후 아래 순서로 실행한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest
|
|
||||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest
|
|
||||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest
|
|
||||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest
|
|
||||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest
|
|
||||||
./gradlew test
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 계획 자체 검토
|
|
||||||
|
|
||||||
- PRD의 endpoint, request, response data class, 커버 이미지 URL, 정렬, 페이징, 구매 통계, 연재 요일 다국어, creator 본인/비본인 분기 요구사항을 task에 반영했다.
|
|
||||||
- 공개 API 조립 계층과 도메인 조회 계층을 분리했다.
|
|
||||||
- 기존 홈 API의 `CreatorChannelSeries` 확장은 계획에 포함하지 않았다.
|
|
||||||
- `purchasedPaidContentRate`는 `Int?`로 고정했다.
|
|
||||||
- `RANDOM` 포함 시 다른 요일을 무시하는 정책을 테스트 task에 포함했다.
|
|
||||||
- 시리즈별 정렬 대표값은 `max(releaseDate)`, `max(price)`, `min(price)`로 명시했다.
|
|
||||||
- Open Questions는 PRD 기준 없음.
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
# PRD: 크리에이터 채널 시리즈 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
크리에이터 채널의 시리즈 탭에서 정렬별 시리즈 개수와 시리즈 목록을 페이징 조회하는 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 크리에이터 채널 시리즈 탭은 전체 시리즈 개수, 정렬 상태, 시리즈 목록을 함께 표시해야 한다.
|
|
||||||
- 기존 홈 API의 `CreatorChannelSeries`는 홈 화면용 요약 모델이라 시리즈 탭에서 필요한 연재 요일, 연재 상태, 콘텐츠 개수, 구매/유료 콘텐츠 통계를 모두 표현하지 못한다.
|
|
||||||
- 클라이언트는 시리즈 탭 진입과 추가 로딩 시 별도 API 조합 없이 일관된 계약으로 시리즈 목록을 받아야 한다.
|
|
||||||
- 연재 요일 문구는 서버에서 조합하고, 호출 유저의 언어에 맞게 반환해야 한다.
|
|
||||||
- 기존 크리에이터 채널 홈/라이브/오디오 탭 API endpoint와 응답 필드의 의미는 변경하지 않아야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 크리에이터 채널 시리즈 탭 조회 API를 제공한다.
|
|
||||||
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 하위 조립 계층에 둔다.
|
|
||||||
- 시리즈 목록, 시리즈 개수, 구매/유료 콘텐츠 통계, 연재 요일 조합처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
|
|
||||||
- 기존 홈 API의 `CreatorChannelSeries`는 확장하지 않고, 시리즈 탭 전용 도메인 모델과 응답 DTO를 새로 둔다.
|
|
||||||
- 요청은 `creatorId`, 정렬 순서, 페이징 값을 받는다.
|
|
||||||
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
|
|
||||||
- 페이징 동작은 크리에이터 채널 오디오 탭 API와 같은 방식으로 처리한다.
|
|
||||||
- 응답에는 전체 시리즈 개수, 시리즈 목록, 실제 적용된 정렬 순서, page, size, hasNext를 포함한다.
|
|
||||||
- 시리즈 목록 item에는 시리즈 id, 제목, 커버 이미지 URL, 연재 요일 문구, 오리지널 여부, 19금 여부, 연재 중 여부, 전체 콘텐츠 개수를 포함한다.
|
|
||||||
- 조회자가 해당 시리즈의 크리에이터가 아닌 경우에는 구매한 콘텐츠 개수, 유료 콘텐츠 개수, 유료 콘텐츠 중 구매한 콘텐츠 비율도 포함한다.
|
|
||||||
- 시리즈 제목과 연재 요일 문구는 호출 유저 언어코드에 맞게 반환한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 이번 범위는 크리에이터 채널 `시리즈` 탭 조회 API만 포함한다.
|
|
||||||
- 기존 크리에이터 채널 홈 API, 라이브 탭 API, 오디오 탭 API endpoint와 응답 필드의 의미는 변경하지 않는다.
|
|
||||||
- 시리즈 상세 조회 API는 포함하지 않는다.
|
|
||||||
- 시리즈 생성/수정/삭제 API는 포함하지 않는다.
|
|
||||||
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
|
|
||||||
- 시리즈 번역 데이터가 없는 항목을 새로 번역하거나 생성하는 배치 작업은 포함하지 않는다.
|
|
||||||
- 앱 표시용 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 크리에이터 채널 시리즈 탭에서 크리에이터의 시리즈를 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 시리즈 탭 구성에 필요한 개수/목록/구매 통계를 단일 API 응답으로 표시하려는 클라이언트
|
|
||||||
- 크리에이터: 자신의 시리즈가 정렬 기준에 따라 적절히 노출되기를 원하는 사용자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 크리에이터 채널 시리즈 탭에 들어가면 전체 시리즈 개수를 확인하고 싶다.
|
|
||||||
- 사용자는 최신순, 인기순, 소장순, 높은 가격순, 낮은 가격순으로 시리즈 목록을 바꿔 보고 싶다.
|
|
||||||
- 사용자는 시리즈의 연재 요일과 연재 중 여부를 확인하고 싶다.
|
|
||||||
- 사용자는 유료 콘텐츠 중 자신이 구매한 콘텐츠 비율을 확인하고 싶다.
|
|
||||||
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
|
|
||||||
- 앱 클라이언트는 호출 유저 언어코드에 맞는 시리즈 제목과 연재 요일 문구를 받아 화면에 표시하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 크리에이터 채널 시리즈 탭 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/series`를 기본안으로 한다.
|
|
||||||
- `creatorId`는 path variable로 받는다.
|
|
||||||
- 정렬 순서는 query parameter로 받는다.
|
|
||||||
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
|
|
||||||
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
|
|
||||||
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
|
|
||||||
- 시리즈 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
|
||||||
- `page`는 0부터 시작하는 page index로 처리한다.
|
|
||||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
|
||||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
|
||||||
- `page`가 0보다 작으면 `0`으로 fallback한다.
|
|
||||||
- `size`가 20보다 작으면 `20`으로 fallback한다.
|
|
||||||
- `size`가 50보다 크면 `50`으로 fallback한다.
|
|
||||||
- API는 인증 회원만 조회할 수 있어야 한다.
|
|
||||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
|
||||||
- 공개된 시리즈가 없어도 전체 API는 성공 처리한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
|
||||||
- 알 수 없는 `sort` 값은 400 오류를 반환하지 않고 `LATEST`로 fallback한다.
|
|
||||||
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
|
||||||
|
|
||||||
### Feature B. 응답 스키마
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
|
||||||
- 응답 최상위 DTO 이름은 `CreatorChannelSeriesTabResponse`를 기본안으로 한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- `seriesCount`: 조회 가능한 전체 시리즈 개수
|
|
||||||
- `series`: 시리즈 목록
|
|
||||||
- `sort`: 시리즈 조회에 실제 적용한 정렬 순서
|
|
||||||
- `page`: 현재 응답의 page index
|
|
||||||
- `size`: 현재 응답의 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- `seriesCount`는 sort-bar에 표시할 전체 개수이며, 콘텐츠 개수가 아니라 시리즈 개수다.
|
|
||||||
- `sort`는 요청값이 없거나 알 수 없는 값이면 실제 적용값인 `LATEST`를 내려준다.
|
|
||||||
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
|
||||||
- `hasNext`는 같은 정렬 조건에서 다음 page에 노출할 시리즈가 있으면 `true`로 내려준다.
|
|
||||||
- 조회자가 해당 시리즈의 크리에이터인 경우 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`는 `null`로 내려준다.
|
|
||||||
- 조회자가 해당 시리즈의 크리에이터가 아닌 경우 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산해 내려준다.
|
|
||||||
- `purchasedPaidContentRate`는 정수 퍼센트 값으로 내려준다.
|
|
||||||
- `purchasedPaidContentRate`는 `paidContentCount == 0`이면 `0`으로 내려준다.
|
|
||||||
- `purchasedPaidContentRate`는 `(purchasedContentCount * 100) / paidContentCount`를 기준으로 계산하고 소수점 이하는 버린다.
|
|
||||||
- 응답 스키마 예시는 다음과 같다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class CreatorChannelSeriesTabResponse(
|
|
||||||
val seriesCount: Int,
|
|
||||||
val series: List<CreatorChannelSeriesResponse>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelSeriesResponse(
|
|
||||||
val seriesId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val publishedDaysOfWeek: String,
|
|
||||||
val isOriginal: Boolean,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isProceeding: Boolean,
|
|
||||||
val contentCount: Int,
|
|
||||||
val purchasedContentCount: Int?,
|
|
||||||
val paidContentCount: Int?,
|
|
||||||
val purchasedPaidContentRate: Int?
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class ContentSort {
|
|
||||||
LATEST,
|
|
||||||
POPULAR,
|
|
||||||
OWNED,
|
|
||||||
PRICE_HIGH,
|
|
||||||
PRICE_LOW
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 공개된 시리즈가 없으면 `seriesCount`는 `0`, `series`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
|
||||||
- 요청한 page 범위에 시리즈가 없으면 `series`는 빈 배열, `hasNext`는 `false`로 내려주되 `seriesCount`는 전체 개수를 유지한다.
|
|
||||||
- 무료 콘텐츠만 포함한 시리즈는 비크리에이터 조회 시 `paidContentCount`를 `0`, `purchasedContentCount`를 `0`, `purchasedPaidContentRate`를 `0`으로 내려준다.
|
|
||||||
|
|
||||||
### Feature C. 시리즈 목록과 필드
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 조회 대상은 지정한 `creatorId`의 시리즈로 제한한다.
|
|
||||||
- 공개 가능한 활성 시리즈만 조회한다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 시리즈는 목록과 개수에서 제외한다.
|
|
||||||
- 시리즈에 속한 콘텐츠 통계는 공개된 오디오 콘텐츠만 기준으로 계산한다.
|
|
||||||
- 예약 공개 전 콘텐츠는 콘텐츠 통계에서 제외한다.
|
|
||||||
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 콘텐츠 통계에서 제외한다.
|
|
||||||
- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
|
|
||||||
- `coverImageUrl`은 시리즈의 커버 이미지 경로를 CDN URL로 변환해 내려준다.
|
|
||||||
- 시리즈 커버 이미지 경로가 없거나 빈 문자열이면 `coverImageUrl`은 `null`로 내려준다.
|
|
||||||
- 호출 유저 언어코드는 기존 `LangContext.lang.code` 값을 사용한다.
|
|
||||||
- 지원 언어코드는 `ko`, `en`, `ja`를 기준으로 한다.
|
|
||||||
- `isProceeding`은 `SeriesState.PROCEEDING`이면 `true`, 그 외 상태이면 `false`로 내려준다.
|
|
||||||
- `contentCount`는 조회 가능한 공개 콘텐츠 개수다.
|
|
||||||
- `paidContentCount`는 같은 필터를 적용한 콘텐츠 중 `price > 0`인 콘텐츠 개수다.
|
|
||||||
- `purchasedContentCount`는 같은 필터를 적용한 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수다.
|
|
||||||
- 대여 중인 콘텐츠는 구매한 콘텐츠 개수와 `purchasedPaidContentRate` 계산에 포함한다.
|
|
||||||
- 유효 구매/대여 조건은 기존 오디오 탭과 동일하게 `orders.is_active = true`이며, 대여는 만료되지 않은 주문만 포함한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 제목 번역 데이터는 있지만 빈 문자열이면 원문 시리즈명을 fallback으로 사용한다.
|
|
||||||
- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함하되 `contentCount`, `paidContentCount`, `purchasedContentCount`를 `0`으로 계산한다.
|
|
||||||
- 일반적으로 동일 콘텐츠의 소장과 대여가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 콘텐츠 1개로 중복 없이 계산한다.
|
|
||||||
|
|
||||||
### Feature D. 연재 요일 문구
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `publishedDaysOfWeek`는 서버에서 조합한 문자열로 내려준다.
|
|
||||||
- 일요일부터 토요일까지 7개 요일이 모두 있으면 호출 유저 언어에 맞는 `매일` 문구를 내려준다.
|
|
||||||
- 7개 요일이 모두 있지 않으면 호출 유저 언어에 맞는 `매주 {요일 목록}` 문구를 내려준다.
|
|
||||||
- 요일 목록은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT` 순서로 정렬한다.
|
|
||||||
- 한국어 예시는 `매일`, `매주 월, 목, 토`다.
|
|
||||||
- 영어 예시는 `Every day`, `Every Mon, Thu, Sat`다.
|
|
||||||
- 일본어 예시는 `毎日`, `毎週 月, 木, 土`다.
|
|
||||||
- `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 경우에는 다른 요일 값을 모두 무시하고 호출 유저 언어에 맞는 랜덤 문구만 내려준다.
|
|
||||||
- 랜덤 문구도 다국어 처리한다.
|
|
||||||
- 랜덤 문구는 한국어 `랜덤`, 영어 `Random`, 일본어 `ランダム`을 기본안으로 한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 연재 요일이 비어 있으면 빈 문자열 대신 호출 유저 언어에 맞는 랜덤 문구를 fallback으로 내려준다.
|
|
||||||
- `RANDOM`과 다른 요일이 동시에 저장된 데이터는 `RANDOM`을 우선해 다른 요일을 제거한 것과 같은 결과로 랜덤 문구만 내려준다.
|
|
||||||
|
|
||||||
### Feature E. 시리즈 정렬
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
|
|
||||||
- 공개 요청/응답 값은 다음을 사용한다.
|
|
||||||
- `LATEST`: 최신순, 기본값
|
|
||||||
- `POPULAR`: 인기순
|
|
||||||
- `OWNED`: 소장순
|
|
||||||
- `PRICE_HIGH`: 높은 가격순
|
|
||||||
- `PRICE_LOW`: 낮은 가격순
|
|
||||||
- `LATEST`는 시리즈에 속한 콘텐츠의 `releaseDate desc`를 1차 정렬로 사용한다.
|
|
||||||
- `LATEST`의 2차 정렬은 시리즈에 속한 콘텐츠의 `price desc`다.
|
|
||||||
- `LATEST`의 3차 정렬은 `series.id desc`다.
|
|
||||||
- `POPULAR`은 시리즈에 속한 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)가 높은 시리즈를 먼저 노출한다.
|
|
||||||
- `POPULAR`의 매출 합계에는 `orders.is_active = true`인 주문만 포함한다.
|
|
||||||
- `POPULAR`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
|
|
||||||
- `POPULAR`의 3차 정렬은 `series.id desc`다.
|
|
||||||
- `OWNED`는 조회자가 시리즈에 속한 콘텐츠 중 유효하게 소장하거나 대여 중인 콘텐츠 개수가 많은 시리즈를 먼저 노출한다.
|
|
||||||
- `OWNED`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
|
|
||||||
- `OWNED`의 3차 정렬은 `series.id desc`다.
|
|
||||||
- `PRICE_HIGH`는 시리즈에 속한 콘텐츠의 `price desc`를 1차 정렬로 사용한다.
|
|
||||||
- `PRICE_HIGH`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
|
|
||||||
- `PRICE_HIGH`의 3차 정렬은 `series.id desc`다.
|
|
||||||
- `PRICE_LOW`는 시리즈에 속한 콘텐츠의 `price asc`를 1차 정렬로 사용한다.
|
|
||||||
- `PRICE_LOW`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
|
|
||||||
- `PRICE_LOW`의 3차 정렬은 `series.id desc`다.
|
|
||||||
- 시리즈에 여러 콘텐츠가 속한 경우 정렬은 시리즈 단위 집계 대표값을 사용한다.
|
|
||||||
- 정렬용 `releaseDate`는 항상 내림차순 정렬에만 사용하므로 각 시리즈에 속한 공개 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다.
|
|
||||||
- `price desc` 정렬에 사용하는 가격 대표값은 각 시리즈에 속한 공개 콘텐츠 중 가장 높은 가격이다.
|
|
||||||
- `price asc` 정렬에 사용하는 가격 대표값은 각 시리즈에 속한 공개 콘텐츠 중 가장 낮은 가격이다.
|
|
||||||
- 따라서 `LATEST`의 2차 정렬과 `PRICE_HIGH`의 1차 정렬은 시리즈별 최고 가격을 사용하고, `PRICE_LOW`의 1차 정렬은 시리즈별 최저 가격을 사용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 매출이 없는 시리즈의 인기순 매출값은 0으로 처리한다.
|
|
||||||
- 조회자가 유효하게 소장하거나 대여 중인 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `series.id desc` 보조 정렬과 같은 결과가 될 수 있다.
|
|
||||||
- 콘텐츠가 없는 시리즈는 정렬 대표값이 없는 항목으로 처리해 같은 정렬 내 마지막에 노출한다.
|
|
||||||
- 가격이 같은 시리즈는 각 정렬의 2차/3차 기준을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
|
||||||
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
|
|
||||||
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
|
|
||||||
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 하위에 둔다.
|
|
||||||
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
|
|
||||||
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 또는 재사용 범위가 더 넓은 기존 도메인 패키지 하위에 둔다.
|
|
||||||
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
|
|
||||||
- 기존 홈 API의 `CreatorChannelSeries`는 홈 응답 전용 요약 모델로 유지하고, 시리즈 탭 API에서는 별도 `CreatorChannelSeriesTab`, `CreatorChannelSeries` 계열 모델을 둔다.
|
|
||||||
- 기존 `ContentSort` enum을 재사용하고, API binding, service 정책, 테스트에서 같은 타입을 사용한다.
|
|
||||||
- 기존 크리에이터 채널 홈/라이브/오디오 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용한다.
|
|
||||||
- 페이징 응답은 기존 오디오 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
|
|
||||||
- 시리즈명 다국어 처리는 기존 `SeriesTranslation` 구조를 따른다.
|
|
||||||
- 연재 요일 문구 다국어 처리는 서버 코드의 명시적 매핑으로 처리한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Metrics
|
|
||||||
- 시리즈 탭 API 성공/실패 건수
|
|
||||||
- 시리즈 탭 API 응답 시간
|
|
||||||
- 정렬 기준별 조회 건수
|
|
||||||
- 시리즈 탭에서 추가 로딩 요청 건수
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions
|
|
||||||
- 없음.
|
|
||||||
@@ -1,580 +0,0 @@
|
|||||||
# 크리에이터 채널 커뮤니티 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/community`로 크리에이터 채널 커뮤니티 탭의 조회 가능한 전체 게시글 개수와 페이징된 게시글 목록을 조회할 수 있게 한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 조립 계층에 둔다. 커뮤니티 게시글 조회 service, page/content masking 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 두고 `v2.api.*`에 의존하지 않는다. 홈 API는 홈 repository에 커뮤니티 조회 쿼리를 직접 두지 않고, 분리된 커뮤니티 조회 도메인의 홈 요약 조회 메서드를 호출해 기존 `notices`, `communities` 응답 계약을 유지한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/community`
|
|
||||||
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
|
||||||
- request:
|
|
||||||
- path variable: `creatorId`
|
|
||||||
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
|
|
||||||
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
|
|
||||||
- response:
|
|
||||||
- `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수
|
|
||||||
- `communityPosts`: 커뮤니티 게시글 목록
|
|
||||||
- `page`: fallback 보정 후 실제 적용된 page index
|
|
||||||
- `size`: fallback 보정 후 실제 적용된 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- community post item:
|
|
||||||
- `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `existOrdered`, `likeCount`, `commentCount`, `isPinned`
|
|
||||||
- 공개 게시글 기준: `CreatorCommunity.isActive == true`, `CreatorCommunity.member.id == creatorId`, `CreatorCommunity.member.isActive == true`.
|
|
||||||
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
|
|
||||||
- 성인 콘텐츠 필터는 구매 여부보다 우선한다. 조회자가 19금 게시글을 구매했거나 작성자여도 성인 콘텐츠 노출 정책이 false이면 제외한다.
|
|
||||||
- 목록 정렬:
|
|
||||||
- 고정 게시글을 먼저 노출한다.
|
|
||||||
- 고정 게시글 사이의 정렬은 `fixedAt desc`, `id desc`다.
|
|
||||||
- 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`다.
|
|
||||||
- 고정 게시글과 일반 게시글은 하나의 목록으로 페이징한다.
|
|
||||||
- `communityPostCount`는 고정 게시글과 일반 게시글을 모두 포함한 전체 개수다.
|
|
||||||
- `createdAtUtc`는 게시글 생성 시각을 UTC 기준 ISO-8601 문자열로 내려준다. 구현 전 재사용 가능한 `toUtcIso` 확장함수를 검색하고, public 확장함수가 있으면 신규 생성 없이 import해서 사용한다.
|
|
||||||
- 문서 작성 시점 확인 결과 `toUtcIso`는 일부 DTO의 private/internal 확장함수로만 존재하고, 공용 확장 파일인 `kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensions.kt`에는 없다. 구현 시점에도 public 확장함수가 없으면 이 공용 확장 파일에 `fun LocalDateTime.toUtcIso(): String`을 추가하고 커뮤니티 DTO에서 import한다.
|
|
||||||
- `creatorProfileUrl`은 `CreatorCommunity.member.profileImage`를 `String?.toCdnUrl(cloudFrontHost)`로 변환하고, 없으면 기존 홈 API의 기본 프로필 이미지 URL을 내려준다.
|
|
||||||
- `existOrdered`는 조회자가 게시글 작성자이면 `true`, 조회자가 유효 구매 내역을 가지고 있으면 `true`, 그 외에는 `false`로 내려준다.
|
|
||||||
- `imageUrl`은 `CreatorCommunity.imagePath`가 있고 이미지 접근 권한이 있을 때만 `String?.toCdnUrl(cloudFrontHost)`로 변환한다. 경로가 없거나 blank이면 `null`이다.
|
|
||||||
- legacy 커뮤니티 목록은 유료 미구매 게시글의 이미지를 노출했지만, 커뮤니티 탭 API는 유료 미구매 게시글의 `imageUrl`도 `audioUrl`과 동일하게 `null`로 내려준다.
|
|
||||||
- 이미지는 signed URL을 생성하지 않고 기존 CDN URL 조합 정책만 사용한다.
|
|
||||||
- `audioUrl`은 `CreatorCommunity.audioPath`가 있고 접근 권한이 있을 때만 `AudioContentCloudFront.generateSignedURL(resourcePath, 1000 * 60 * 30)` 결과를 내려준다.
|
|
||||||
- 이미지/오디오 접근 권한:
|
|
||||||
- 무료 게시글이면 접근 가능
|
|
||||||
- 유료 게시글이고 조회자가 게시글 작성자이면 접근 가능
|
|
||||||
- 유료 게시글이고 조회자가 `CanUsage.PAID_COMMUNITY_POST`, `isRefund == false` 구매 내역을 가지면 접근 가능
|
|
||||||
- 그 외에는 `imageUrl == null`, `audioUrl == null`
|
|
||||||
- 유료 게시글 본문은 기존 홈 API/legacy 커뮤니티 정책과 같은 마스킹을 적용한다.
|
|
||||||
- 접근 가능하면 원문
|
|
||||||
- 접근 불가이고 길이가 15 code point 초과이면 앞 15 code point + `...`
|
|
||||||
- 접근 불가이고 길이가 15 code point 이하이면 앞 절반 code point + `...`
|
|
||||||
- `commentCount`는 `isCommentAvailable == false`이면 `0`이다.
|
|
||||||
- `commentCount`는 활성 최상위 댓글만 세고, 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다.
|
|
||||||
- `likeCount`는 활성 좋아요 수만 센다.
|
|
||||||
- legacy `/creator-community` 공개 endpoint는 변경하지 않는다.
|
|
||||||
- 홈 API 공개 응답 스키마는 변경하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 커뮤니티 탭 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
|
|
||||||
|
|
||||||
### 커뮤니티 도메인 조회 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 홈 API 커뮤니티 조회 분리 대상
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
|
|
||||||
### 기존 파일 확인/재사용
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
|
||||||
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/like/CreatorCommunityLikeRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Create: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`
|
|
||||||
- Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
|
|
||||||
|
|
||||||
data class CreatorChannelCommunityTabResponse(
|
|
||||||
val communityPostCount: Int,
|
|
||||||
val communityPosts: List<CreatorChannelCommunityPostResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(tab: CreatorChannelCommunityTab): CreatorChannelCommunityTabResponse {
|
|
||||||
return CreatorChannelCommunityTabResponse(
|
|
||||||
communityPostCount = tab.communityPostCount,
|
|
||||||
communityPosts = tab.communityPosts.map(CreatorChannelCommunityPostResponse::from),
|
|
||||||
page = tab.page.page,
|
|
||||||
size = tab.page.size,
|
|
||||||
hasNext = tab.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelCommunityPostResponse(
|
|
||||||
val postId: Long,
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileUrl: String,
|
|
||||||
val createdAtUtc: String,
|
|
||||||
val content: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val audioUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isCommentAvailable")
|
|
||||||
val isCommentAvailable: Boolean,
|
|
||||||
val existOrdered: Boolean,
|
|
||||||
val likeCount: Int,
|
|
||||||
val commentCount: Int,
|
|
||||||
@JsonProperty("isPinned")
|
|
||||||
val isPinned: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
|
|
||||||
return CreatorChannelCommunityPostResponse(
|
|
||||||
postId = post.postId,
|
|
||||||
creatorId = post.creatorId,
|
|
||||||
creatorNickname = post.creatorNickname,
|
|
||||||
creatorProfileUrl = post.creatorProfileUrl,
|
|
||||||
createdAtUtc = post.createdAt.toUtcIso(),
|
|
||||||
content = post.content,
|
|
||||||
imageUrl = post.imageUrl,
|
|
||||||
audioUrl = post.audioUrl,
|
|
||||||
price = post.price,
|
|
||||||
isCommentAvailable = post.isCommentAvailable,
|
|
||||||
existOrdered = post.existOrdered,
|
|
||||||
likeCount = post.likeCount,
|
|
||||||
commentCount = post.commentCount,
|
|
||||||
isPinned = post.isPinned
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
data class CreatorChannelCommunityTab(
|
|
||||||
val communityPostCount: Int,
|
|
||||||
val communityPosts: List<CreatorChannelCommunityPost>,
|
|
||||||
val page: CreatorChannelPage,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelCommunityPost(
|
|
||||||
val postId: Long,
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileUrl: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val audioUrl: String?,
|
|
||||||
val content: String,
|
|
||||||
val price: Int,
|
|
||||||
val createdAt: LocalDateTime,
|
|
||||||
val existOrdered: Boolean,
|
|
||||||
val isCommentAvailable: Boolean,
|
|
||||||
val likeCount: Int,
|
|
||||||
val commentCount: Int,
|
|
||||||
val isPinned: Boolean
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface CreatorChannelCommunityQueryPort {
|
|
||||||
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord?
|
|
||||||
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
|
||||||
fun countCommunityPosts(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
canViewAdultContent: Boolean
|
|
||||||
): Int
|
|
||||||
fun findCommunityPosts(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
offset: Long,
|
|
||||||
limit: Int
|
|
||||||
): List<CreatorChannelCommunityPostRecord>
|
|
||||||
fun findHomeCommunityPosts(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
isPinned: Boolean,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
limit: Int
|
|
||||||
): List<CreatorChannelCommunityPostRecord>
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelCommunityCreatorRecord(
|
|
||||||
val creatorId: Long,
|
|
||||||
val role: MemberRole,
|
|
||||||
val nickname: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelCommunityPostRecord(
|
|
||||||
val postId: Long,
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfilePath: String?,
|
|
||||||
val imagePath: String?,
|
|
||||||
val audioPath: String?,
|
|
||||||
val content: String,
|
|
||||||
val price: Int,
|
|
||||||
val createdAt: LocalDateTime,
|
|
||||||
val existOrdered: Boolean,
|
|
||||||
val isCommentAvailable: Boolean,
|
|
||||||
val likeCount: Int,
|
|
||||||
val commentCount: Int,
|
|
||||||
val isPinned: Boolean
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 작업 계획
|
|
||||||
|
|
||||||
### Phase 1: 커뮤니티 도메인 계약과 순수 정책 추가
|
|
||||||
|
|
||||||
- [x] **Task 1.1: `CreatorChannelCommunityQueryPolicy`와 domain/port 계약 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
|
|
||||||
- RED: 아래 케이스를 테스트로 먼저 작성한다.
|
|
||||||
- `page = null`, `size = null`이면 `page=0`, `size=20`, `offset=0`, `fetchLimit=21`이다.
|
|
||||||
- `page = -1`, `size = 10`이면 `page=0`, `size=20`, `fetchLimit=21`이다.
|
|
||||||
- `page = 2`, `size = 100`이면 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이다.
|
|
||||||
- `limitItems`는 `size`만큼만 남기고 `hasNext`는 `fetched.size > size`로 계산한다.
|
|
||||||
- 유료 본문 마스킹은 15 code point 초과면 앞 15자 + `...`, 15자 이하면 앞 절반 + `...`로 계산한다.
|
|
||||||
- 무료 게시글, 작성자 본인, 구매자는 유료 본문 원문을 볼 수 있다.
|
|
||||||
- domain model과 port record가 Phase 1 계약 필드를 유지한다.
|
|
||||||
- RED 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"`
|
|
||||||
- 기대 결과: `CreatorChannelCommunityQueryPolicy`, domain, port 미구현으로 컴파일 실패 또는 테스트 실패
|
|
||||||
- GREEN: `CreatorChannelPage`를 재사용해 page 정책을 만들고, `maskPaidContent(content, price, isCreatorSelf, existOrdered)` 순수 함수를 추가한다.
|
|
||||||
- GREEN 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 정책 클래스에는 DB, Spring MVC, API DTO 의존성을 넣지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"` 실행 결과 `CreatorChannelCommunityQueryPolicy`, domain, port 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: 같은 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 범위: Phase 1의 순수 정책, domain model, port 계약, 계약 테스트만 추가했고 DB/Spring MVC/API DTO 의존성은 넣지 않았다.
|
|
||||||
|
|
||||||
### Phase 2: QueryDSL repository 분리와 조회 정책 구현
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 커뮤니티 repository의 creator/차단/count/list 조회 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
|
|
||||||
- RED: `@DataJpaTest(properties = ["spring.cache.type=none", "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"])`, `@Import(QueryDslConfig::class)` 패턴으로 아래 케이스를 작성한다.
|
|
||||||
- 활성 creator는 `findCreator`로 조회되고 비활성 creator는 `null`이다.
|
|
||||||
- viewer와 creator 사이 양방향 활성 차단 관계는 `existsBlockedBetween`에서 `true`다.
|
|
||||||
- `countCommunityPosts`는 creator의 활성 게시글만 세고 다른 creator, 비활성 게시글은 제외한다.
|
|
||||||
- `canViewAdultContent=false`이면 19금 게시글은 count와 list에서 제외된다.
|
|
||||||
- `canViewAdultContent=false`이고 viewer가 19금 게시글을 구매했어도 count와 list에서 제외된다.
|
|
||||||
- list는 고정 게시글을 먼저 반환하고, 고정 게시글은 `fixedAt desc`, 일반 게시글은 `createdAt desc` 순서를 따른다.
|
|
||||||
- `offset`, `limit`으로 하나의 통합 목록을 페이징한다.
|
|
||||||
- `likeCount`는 활성 좋아요만 센다.
|
|
||||||
- `isCommentAvailable=false`인 게시글의 `commentCount`는 `0`이다.
|
|
||||||
- `commentCount`는 활성 최상위 댓글만 세고, 비밀 댓글은 작성자 본인 또는 콘텐츠 creator에게만 포함한다.
|
|
||||||
- 차단 관계에 걸린 댓글 작성자의 댓글은 `commentCount`에서 제외된다.
|
|
||||||
- 유효 구매 내역은 `CanUsage.PAID_COMMUNITY_POST`, `UseCan.member.id == viewerId`, `UseCan.communityPost.id == postId`, `UseCan.isRefund == false`다.
|
|
||||||
- 같은 게시글에 구매 내역이 중복되어도 list item은 중복되지 않는다.
|
|
||||||
- 조회자가 게시글 작성자이면 구매 내역이 없어도 `existOrdered == true`다.
|
|
||||||
- RED 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"`
|
|
||||||
- 기대 결과: repository 미구현으로 컴파일 실패 또는 테스트 실패
|
|
||||||
- GREEN: 기존 `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 차단 sub query, adult condition을 커뮤니티 repository로 옮기되 탭용 통합 정렬과 count를 추가한다.
|
|
||||||
- GREEN 구현 기준:
|
|
||||||
- tab list where는 `isActive == true`, `member.id == creatorId`, `member.isActive == true`, adult condition을 먼저 적용한다.
|
|
||||||
- 구매 내역 exists/join은 접근 권한 계산에만 사용하고 adult condition을 우회하지 않는다.
|
|
||||||
- 정렬은 `isFixed desc`, `fixedAt desc nullsLast`, `createdAt desc`, `id desc`를 사용한다.
|
|
||||||
- home summary 조회는 `findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`로 제공하고, 기존 홈과 동일하게 고정글/일반글을 분리 조회한다.
|
|
||||||
- GREEN 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: `v2.creator.channel.community.adapter.out.persistence`는 `v2.api.*`를 import하지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: focused test 실행 결과 `DefaultCreatorChannelCommunityQueryRepository` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: repository 구현 추가 후 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 계약 보정: block fixture와 구현을 양방향 활성 차단 정책에 맞춘 뒤 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Review follow-up RED: raw `createdAt`, 같은 `fixedAt` 고정글 `id desc`, 홈 구매자 비활성 게시글 비노출 테스트 추가 후 focused test 2건 실패를 확인했다.
|
|
||||||
- Review follow-up GREEN: repository 보정 후 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 전체 테스트: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- ktlint: `./gradlew --no-daemon ktlintCheck`는 `DefaultCreatorChannelCommunityQueryRepository.kt` 1개 줄에서 처음 실패했고, formatting 후 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 범위: Phase 2 repository/test 파일만 추가했고 service/API/home refactor는 건드리지 않았다.
|
|
||||||
|
|
||||||
### Phase 3: 커뮤니티 조회 service 구현
|
|
||||||
|
|
||||||
- [x] **Task 3.1: `CreatorChannelCommunityQueryService` 단위 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
|
||||||
- RED: fake `CreatorChannelCommunityQueryPort`, mock `MemberContentPreferenceService`, mock `AudioContentCloudFront`, `LangContext`, `SodaMessageSource`를 사용해 아래 케이스를 작성한다.
|
|
||||||
- 요청 page/size fallback 결과를 port의 `offset`, `limit`에 전달하고 `hasNext`와 응답 목록 size를 조립한다.
|
|
||||||
- creator가 없으면 `member.validation.user_not_found` 예외를 던진다.
|
|
||||||
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다.
|
|
||||||
- 차단 관계가 있으면 기존 `explorer.creator.blocked_access` 메시지 예외를 던진다.
|
|
||||||
- `MemberContentPreferenceService`와 `isAdultVisibleByPolicy` 결과를 port의 `canViewAdultContent`로 전달한다.
|
|
||||||
- 이미지 path는 `toCdnUrl(cloudFrontHost)`로 변환하고 blank path는 `null`이다.
|
|
||||||
- 작성자 프로필 path가 없으면 기존 홈 API의 기본 프로필 URL 정책을 적용한다.
|
|
||||||
- 조회자가 게시글 작성자이면 구매 내역이 없어도 `existOrdered == true`로 조립한다.
|
|
||||||
- 무료 이미지, 구매한 유료 이미지, 작성자 본인 유료 이미지는 CDN URL을 사용하고 signed URL을 생성하지 않는다.
|
|
||||||
- 미구매 유료 이미지는 `imageUrl == null`이다.
|
|
||||||
- 무료 오디오, 구매한 유료 오디오, 작성자 본인 유료 오디오는 `AudioContentCloudFront.generateSignedURL(audioPath, 1000 * 60 * 30)` 결과를 사용한다.
|
|
||||||
- 미구매 유료 오디오는 signed URL을 생성하지 않고 `audioUrl == null`이다.
|
|
||||||
- 유료 미구매 본문은 policy의 마스킹 결과를 사용한다.
|
|
||||||
- `findHomeCommunityPosts`는 탭 전체 검증 없이 받은 `viewerId`, `canViewAdultContent`, `isPinned`, `limit`로 홈 요약 목록을 조립한다.
|
|
||||||
- RED 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"`
|
|
||||||
- 기대 결과: service 미구현으로 컴파일 실패 또는 테스트 실패
|
|
||||||
- GREEN: `getCommunityTab(creatorId, viewer, page, size, now)`와 `findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`를 구현한다.
|
|
||||||
- GREEN 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: signed URL 생성은 접근 가능한 오디오 path가 있을 때만 호출하고, 이미지는 signed URL 생성 대상에서 제외한다. service는 API DTO를 반환하지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"` 실행 결과 `CreatorChannelCommunityQueryService` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: service 구현 추가 후 같은 focused test 실행 중 Phase 1 마스킹 정책 기대값(`15 code point 이하이면 앞 절반 + ...`)과 테스트 기대값 불일치 1건을 확인했고, 테스트 기대값을 정책에 맞춘 뒤 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 범위: Phase 3 service/test 파일만 추가했고 API DTO/controller/facade와 홈 API 연결은 건드리지 않았다.
|
|
||||||
|
|
||||||
### Phase 4: 홈 API 커뮤니티 조회 로직을 새 도메인으로 연결
|
|
||||||
|
|
||||||
- [x] **Task 4.1: 홈 service 회귀 테스트를 새 커뮤니티 service 의존으로 갱신**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
|
|
||||||
- RED: 기존 홈 service 테스트에서 home query port의 `findCommunityPosts` stub 대신 `CreatorChannelCommunityQueryService.findHomeCommunityPosts` 결과를 사용하도록 테스트를 먼저 바꾼다.
|
|
||||||
- `notices`는 `isPinned=true`, `limit=3`으로 조회한다.
|
|
||||||
- `communities`는 `isPinned=false`, `limit=3`으로 조회한다.
|
|
||||||
- 홈 응답의 커뮤니티 필드명은 유지하되, 커뮤니티 도메인 정책에 맞춰 유료 미구매 게시글의 `imageUrl`/`audioUrl`은 `null`이고 `dateUtc`는 게시글 작성 시각(`createdAt`) 기준이다.
|
|
||||||
- RED 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"`
|
|
||||||
- 기대 결과: service 생성자/호출부 미구현으로 컴파일 실패 또는 테스트 실패
|
|
||||||
- GREEN: `CreatorChannelHomeQueryService`에 `CreatorChannelCommunityQueryService`를 주입하고, 기존 `queryPort.findCommunityPosts` 호출 2곳을 새 community service 호출로 교체한다.
|
|
||||||
- GREEN 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 홈 domain의 기존 `CreatorChannelCommunityPost` data class를 제거하고, 홈의 `notices`, `communities` 타입은 `kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost`를 사용한다. 홈 API response DTO 변환 결과가 바뀌지 않는지 테스트로 확인한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"` 실행 결과 `communityQueryService` 생성자 파라미터 미구현, 홈/커뮤니티 post domain 타입 불일치, 기존 home port `findCommunityPosts` 잔존으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: 홈 service가 `CreatorChannelCommunityQueryService.findHomeCommunityPosts`를 `isPinned=true/false`, `limit=3`으로 호출하도록 교체하고, 홈 domain/DTO/test fixture를 커뮤니티 domain post 기준으로 보정한 뒤 같은 focused test `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 홈 API 회귀: 유료 미구매 홈 커뮤니티 게시글의 `imageUrl`/`audioUrl == null`, 고정글 `dateUtc == createdAt` 응답을 `CreatorChannelHomeControllerTest`, `CreatorChannelHomeFacadeTest`에 고정했고, 포함 회귀 focused test 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 홈 port/repository에서 커뮤니티 조회 책임 제거**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 홈 repository 테스트에서 커뮤니티 게시글 조회 전용 테스트가 있으면 동일한 케이스가 `DefaultCreatorChannelCommunityQueryRepositoryTest`로 이동되어야 함을 먼저 확인한다.
|
|
||||||
- RED 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"`
|
|
||||||
- 기대 결과: 기존 home port method 제거 전에는 테스트/컴파일이 아직 기존 구조를 기대해 실패할 수 있다.
|
|
||||||
- GREEN:
|
|
||||||
- `CreatorChannelHomeQueryPort.findCommunityPosts`와 `CreatorChannelCommunityPostRecord`를 제거한다.
|
|
||||||
- `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 커뮤니티 전용 차단 sub query, `canAccessPaidCommunityContent`, `maskPaidCommunityContent`, `adultCommunityCondition`, `fixedNoticeCondition`, `visibleCommunityPostCondition` 중 홈 repository에서 더 이상 쓰지 않는 커뮤니티 전용 helper를 제거한다.
|
|
||||||
- 같은 로직은 `DefaultCreatorChannelCommunityQueryRepository`에만 남긴다.
|
|
||||||
- GREEN 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 홈 repository에서 `creatorCommunity`, `creatorCommunityLike`, `creatorCommunityComment`, `useCan` import가 더 이상 필요 없으면 제거한다. 다른 홈 조회에서 쓰는 import는 유지한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- GREEN: `CreatorChannelHomeQueryPort.findCommunityPosts`, home 전용 `CreatorChannelCommunityPostRecord`, `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`와 커뮤니티 전용 helper/import 및 home repository의 직접 커뮤니티 조회 테스트를 제거했다.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- `rg -n "CreatorChannelHomeQueryPort\.findCommunityPosts|CreatorChannelCommunityPostRecord|findCommunityPosts\(" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence` 결과 home port/repository의 커뮤니티 조회 책임 잔존 0건을 확인했다.
|
|
||||||
|
|
||||||
### Phase 5: 커뮤니티 탭 API 조립 계층 추가
|
|
||||||
|
|
||||||
- [x] **Task 5.1: response DTO와 facade 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
|
|
||||||
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
|
|
||||||
- RED: 아래 케이스를 테스트로 먼저 작성한다.
|
|
||||||
- facade는 `CreatorChannelCommunityQueryService.getCommunityTab` 결과를 `CreatorChannelCommunityTabResponse`로 변환한다.
|
|
||||||
- `createdAtUtc`는 UTC ISO-8601 문자열이다.
|
|
||||||
- `createdAtUtc` 변환은 재사용 가능한 `toUtcIso` 확장함수가 있으면 기존 확장함수를 import해서 사용하고, 없으면 `LocalDateTimeExtensions.kt`에 공용 확장함수를 추가해 사용한다.
|
|
||||||
- `creatorProfileUrl`, `existOrdered`가 응답에 포함된다.
|
|
||||||
- `imageUrl == null`, `audioUrl == null`이 그대로 응답된다.
|
|
||||||
- `@JsonProperty`로 `isCommentAvailable`, `isPinned`, `hasNext` 필드명이 유지된다.
|
|
||||||
- RED 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"`
|
|
||||||
- 기대 결과: DTO/facade 미구현으로 컴파일 실패 또는 테스트 실패
|
|
||||||
- GREEN: PRD와 이 문서의 response data class 초안을 기준으로 DTO와 facade를 구현한다.
|
|
||||||
- GREEN 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: DTO는 공개 API 필드 변환만 담당하고, 구매/성인/정렬 정책을 포함하지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"` 실행 결과 `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityFacade` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: DTO/facade와 공용 `LocalDateTime.toUtcIso()` 확장함수를 추가한 뒤 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 범위: 공개 API 응답 DTO 변환과 facade 위임만 추가했고 구매/성인/정렬 정책은 DTO에 넣지 않았다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: controller 테스트와 endpoint 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
|
||||||
- RED: `@WebMvcTest(CreatorChannelCommunityController::class)`와 기존 시리즈/오디오 controller test의 `TestSecurityConfig` 패턴으로 아래 케이스를 작성한다.
|
|
||||||
- 비회원 요청은 `401 Unauthorized`다.
|
|
||||||
- 인증 회원 요청은 `GET /api/v2/creator-channels/{creatorId}/community`를 호출하고 `creatorId`, `page`, `size`, `viewer`를 facade에 전달한다.
|
|
||||||
- `page=-1`, `size=100` 같은 값은 controller에서 거부하지 않고 facade로 전달한다.
|
|
||||||
- 성공 응답은 `success=true`, `data.communityPostCount`, `data.communityPosts[0].postId`, `data.communityPosts[0].creatorProfileUrl`, `data.communityPosts[0].existOrdered`, `data.communityPosts[0].isCommentAvailable`, `data.communityPosts[0].isPinned`, `data.page`, `data.size`, `data.hasNext`를 포함한다.
|
|
||||||
- RED 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"`
|
|
||||||
- 기대 결과: controller 미구현으로 컴파일 실패 또는 테스트 실패
|
|
||||||
- GREEN: `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/community")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 패턴으로 구현한다.
|
|
||||||
- GREEN 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 인증 null guard는 기존 탭 controller와 같은 `requireMember` private 함수로 둔다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"` 실행 결과 `CreatorChannelCommunityController` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: `GET /api/v2/creator-channels/{creatorId}/community` controller 구현 후 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Phase 5 focused 회귀: facade/controller focused tests 동시 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- ktlint: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 의존 방향: `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 출력 없음으로 domain/query 계층의 API 의존 0건을 확인했다.
|
|
||||||
- 코드 리뷰 및 fresh 검증: controller는 기존 v2 탭 API와 같은 인증/`requireMember` 패턴으로 facade에 `creatorId`, `viewer`, raw `page`, raw `size`만 전달하고, facade/DTO는 query service 결과를 공개 응답 DTO로 변환만 하는 것을 확인했다. `LocalDateTime.toUtcIso()` 공용 확장함수는 기존 v2 DTO private 확장함수와 동일한 UTC offset 직렬화 방식임을 확인했다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`, `git diff --check` 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했고, 커뮤니티 domain/query 계층의 `v2.api.*` import 검색도 출력 없음으로 확인했다.
|
|
||||||
|
|
||||||
### Phase 6: E2E와 회귀 검증
|
|
||||||
|
|
||||||
- [x] **Task 6.1: 커뮤니티 탭 end-to-end 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
|
|
||||||
- RED: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`, `TransactionTemplate` 패턴으로 아래 케이스를 작성한다.
|
|
||||||
- controller-service-repository를 거쳐 전체 응답 필드를 반환한다.
|
|
||||||
- 고정 게시글이 일반 게시글보다 먼저 반환된다.
|
|
||||||
- `page=-1`, `size=10` 요청은 `page=0`, `size=20`으로 fallback된다.
|
|
||||||
- 성인 콘텐츠 노출 정책이 false인 조회자는 19금 게시글을 count와 list에서 받지 않는다.
|
|
||||||
- 성인 콘텐츠 노출 정책이 false인 조회자가 19금 게시글을 구매했더라도 count와 list에서 받지 않는다.
|
|
||||||
- 구매한 유료 게시글의 `imageUrl`은 CDN URL이고 signed URL이 아니며, 미구매 유료 게시글의 `imageUrl`은 `null`이다.
|
|
||||||
- 구매한 유료 게시글의 `audioUrl`은 signed URL 형태이고, 미구매 유료 게시글의 `audioUrl`은 `null`이다.
|
|
||||||
- 이미지가 없는 게시글의 `imageUrl`은 `null`이다.
|
|
||||||
- RED 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`
|
|
||||||
- 기대 결과: API 미구현 또는 fixture 미연결로 실패
|
|
||||||
- GREEN: 필요한 fixture helper를 테스트 내부에 추가하고, `@MockBean AudioContentCloudFront`로 signed URL 결과를 `https://signed.test/community-audio`처럼 고정한다. E2E 테스트에서 실제 CloudFront private key 파일을 요구하지 않게 한다.
|
|
||||||
- GREEN 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: E2E fixture는 테스트 내부 helper로 유지하고 운영 코드에 테스트 전용 분기를 넣지 않는다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED/GREEN: `CreatorChannelCommunityEndToEndTest`를 추가한 뒤 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. 별도 production 수정 없이 즉시 GREEN이었으며, Phase 1-5 구현이 이미 endpoint 동작을 충족했기 때문으로 확인했다.
|
|
||||||
- 범위: `@SpringBootTest`, `@AutoConfigureMockMvc`, `EmbeddedRedisInitializer`, `TransactionTemplate`, `@MockBean AudioContentCloudFront` 패턴으로 controller-service-repository 실제 경로를 검증했다. 고정글 우선 정렬, `page=-1`/`size=10` fallback, 성인 콘텐츠 비노출, 구매/미구매 유료 이미지·오디오 접근, 이미지 없는 게시글 `imageUrl == null`을 E2E 응답으로 고정했고 운영 코드는 변경하지 않았다. 리뷰 후 기존 v2 E2E와 같은 shared H2 datasource를 사용하도록 보정하고, 미구매 오디오 signed URL 생성이 발생하면 실패하도록 `AudioContentCloudFront` interaction 검증을 추가한 뒤 focused E2E와 ktlint를 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 6.2: 홈 API 회귀와 의존 방향 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
|
||||||
- RED: 홈 API 응답 스키마가 변경되지 않아야 하므로 기존 테스트가 실패하면 변경 원인을 확인한다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- 의존 방향 검색:
|
|
||||||
- `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`
|
|
||||||
- 기대 결과: 검색 결과 0건
|
|
||||||
- REFACTOR: 홈 API response DTO의 필드명, `dateUtc`, `existOrdered`, `likeCount`, `commentCount` 의미가 바뀌지 않도록 API DTO 변경을 피한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 홈 회귀: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 의존 방향: `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 출력 없음으로 community domain/query 계층의 API 의존 0건을 확인했다.
|
|
||||||
- 코드 리뷰 및 fresh 검증: 신규 E2E가 Phase 6 범위인 controller-service-repository 실제 경로, page/size fallback, 고정글 우선 정렬, 성인 콘텐츠 비노출, 구매/미구매 유료 미디어 접근, 홈 API 회귀, 의존 방향을 검증하는지 확인했고 추가 코드 이슈는 발견하지 않았다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`, `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL`; `git diff --check`는 출력 없음, 커뮤니티 domain/query 계층의 `v2.api.*` import 검색도 출력 없음으로 확인했다.
|
|
||||||
|
|
||||||
### Phase 7: 전체 검증과 문서 갱신
|
|
||||||
|
|
||||||
- [x] **Task 7.1: 전체 테스트와 ktlint 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew --no-daemon test`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- `./gradlew --no-daemon ktlintCheck`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
- 문서 검증:
|
|
||||||
- 각 완료 task의 체크박스를 `- [x]`로 갱신한다.
|
|
||||||
- 각 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
|
|
||||||
- 전체 검증 결과는 아래 `전체 검증 기록` 섹션에 누적한다.
|
|
||||||
- REFACTOR: 검증 실패가 구현 범위 변경을 요구하면 먼저 이 문서의 task를 갱신한 뒤 코드를 수정한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 전체 테스트: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- ktlint: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 범위: Phase 7은 검증과 문서 갱신만 수행했고 production/test code는 변경하지 않았다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 구현 순서 요약
|
|
||||||
|
|
||||||
1. Phase 1에서 순수 정책과 domain/port 계약을 먼저 고정한다.
|
|
||||||
2. Phase 2에서 QueryDSL repository를 새 커뮤니티 도메인으로 분리한다.
|
|
||||||
3. Phase 3에서 service가 인증/성인/차단/CDN URL/오디오 signed URL/마스킹 정책을 조립하게 한다.
|
|
||||||
4. Phase 4에서 홈 API의 커뮤니티 조회를 새 도메인으로 연결하고 홈 repository의 커뮤니티 쿼리를 제거한다.
|
|
||||||
5. Phase 5에서 공개 API DTO/facade/controller를 추가한다.
|
|
||||||
6. Phase 6에서 커뮤니티 탭 E2E와 홈 API 회귀를 확인한다.
|
|
||||||
7. Phase 7에서 전체 테스트, ktlint, 의존 방향 검색 결과를 누적 기록한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 전체 검증 기록
|
|
||||||
|
|
||||||
- 구현 전 문서 작성 단계에서는 코드 검증을 수행하지 않는다. 구현 단계에서 각 task 완료 즉시 실행 명령과 결과를 이 섹션에 누적한다.
|
|
||||||
- 2026-06-21: 문서 작성 검증 - placeholder와 모호한 문구 검색 결과 0건.
|
|
||||||
- 2026-06-21: 문서 변경 whitespace 검증 - `git diff --check` 실행 결과 출력 없음, exit code 0.
|
|
||||||
- 2026-06-21: 문서 유지보수 규칙 검증 - sandbox 내부 `./gradlew tasks --all`은 `~/.gradle` lock 파일 접근 제한으로 실패했고, 승인 실행으로 재시도한 `./gradlew tasks --all`은 `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 2026-06-21: Phase 1 Task 1.1 검증 - RED focused test는 미구현 심볼로 `compileTestKotlin` 실패, GREEN focused test는 `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 2026-06-21: Phase 2 Task 2.1 검증 - focused repository test는 RED에서 미구현 repository로 `compileTestKotlin` 실패, GREEN과 양방향 활성 차단 계약 보정 후 `BUILD SUCCESSFUL` 확인. Review follow-up에서 raw `createdAt`, 같은 `fixedAt` 고정글 `id desc`, 홈 구매자 비활성 게시글 비노출 테스트 추가 후 2건 실패를 확인했고 repository 보정 후 focused test `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test`는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon ktlintCheck`는 formatting 후 `BUILD SUCCESSFUL`, `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`는 출력 없음 확인.
|
|
||||||
- 2026-06-21: Phase 3 Task 3.1 검증 - RED focused service test는 `CreatorChannelCommunityQueryService` 미구현 심볼로 `compileTestKotlin` 실패, GREEN focused service test는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew --no-daemon test`, `./gradlew --no-daemon ktlintCheck` 모두 `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 2026-06-21: Phase 4 Task 4.1 검증 - RED focused home service test는 `communityQueryService` 생성자 파라미터 미구현, 홈/커뮤니티 post domain 타입 불일치, 기존 home port `findCommunityPosts` 잔존으로 `compileTestKotlin` 실패 확인. GREEN focused home service test는 `BUILD SUCCESSFUL` 확인. 리뷰 후 홈 커뮤니티 정책을 유료 미구매 `imageUrl`/`audioUrl == null`, `dateUtc == createdAt`으로 명시하고 테스트에 고정했다.
|
|
||||||
- 2026-06-21: Phase 4 Task 4.2 검증 - focused home repository test는 `BUILD SUCCESSFUL` 확인. 홈/커뮤니티 회귀 focused test(`CreatorChannelHomeControllerTest`, `CreatorChannelHomeFacadeTest`, `CreatorChannelCommunityQueryServiceTest`, `DefaultCreatorChannelCommunityQueryRepositoryTest`)는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon ktlintCheck`는 import 정렬로 1회 실패 후 `./gradlew --no-daemon ktlintFormat` 적용 및 재실행 결과 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test` 전체 테스트 `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 2026-06-21: Phase 5 Task 5.1 검증 - RED focused facade test는 `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityFacade` 미구현 심볼로 `compileTestKotlin` 실패 확인. GREEN focused facade test는 `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 2026-06-21: Phase 5 Task 5.2 검증 - RED focused controller test는 `CreatorChannelCommunityController` 미구현 심볼로 `compileTestKotlin` 실패 확인. GREEN focused controller test는 `BUILD SUCCESSFUL` 확인. Phase 5 facade/controller focused tests 동시 실행, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL` 확인. 커뮤니티 domain/query 계층의 `v2.api.*` import 검색은 출력 없음 확인.
|
|
||||||
- 2026-06-21: Phase 5 코드 리뷰 및 fresh 검증 - controller/facade/DTO 구현이 Phase 5 범위인 인증 사용자 전달, raw page/size 전달, query service 위임, 공개 응답 변환에 머무는지 확인했고 추가 코드 이슈는 발견하지 않았다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL`; `git diff --check`와 `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`는 출력 없음 확인.
|
|
||||||
- 2026-06-22: Phase 6 Task 6.1/6.2 검증 - 커뮤니티 탭 E2E focused test, 홈 API 회귀 focused test, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL` 확인. `git diff --check`와 커뮤니티 domain/query 계층의 `v2.api.*` import 검색은 출력 없음 확인. 리뷰 후 shared H2 datasource와 오디오 signed URL interaction 검증을 보정했고, focused E2E와 ktlint 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-22: Phase 6 코드 리뷰 및 fresh 검증 - 신규 E2E와 Phase 6 문서 기록을 재검토했고 추가 코드 이슈는 발견하지 않았다. focused 커뮤니티 E2E, 홈 API 회귀, `ktlintCheck`, 전체 테스트는 모두 `BUILD SUCCESSFUL`; `git diff --check`와 community domain/query 계층의 API 의존 검색은 출력 없음으로 확인했다.
|
|
||||||
- 2026-06-22: Phase 7 Task 7.1 검증 - `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`, `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. Phase 7은 전체 검증과 문서 갱신만 수행했고 production/test code는 변경하지 않았다.
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
# PRD: 크리에이터 채널 커뮤니티 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
크리에이터 채널의 커뮤니티 탭에서 조회자가 볼 수 있는 커뮤니티 게시글 전체 개수와 게시글 목록을 페이징 조회하는 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 크리에이터 채널 홈 API는 커뮤니티 게시글 일부를 홈 화면 요약용으로 조회하지만, 커뮤니티 탭은 전체 개수와 페이징 목록이 필요하다.
|
|
||||||
- 기존 홈 API의 커뮤니티 조회 로직이 `home` 도메인 repository 안에 포함되어 있어, 커뮤니티 탭 API에서 그대로 재사용하려면 홈 도메인에 의존하게 된다.
|
|
||||||
- 커뮤니티 게시글 조회 로직은 홈 화면과 커뮤니티 탭에서 모두 쓰일 수 있으므로, 하나의 커뮤니티 조회 도메인으로 분리되어야 한다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 커뮤니티 게시글은 전체 개수와 목록에서 모두 제외되어야 한다.
|
|
||||||
- legacy `/creator-community` 목록 조회는 구매 내역 조건과 성인 필터 조건이 섞일 수 있으므로, `isAdult=false` 조회에서 구매한 19금 게시글이 개수나 목록에 포함되지 않도록 새 v2 조회 정책에서 명확히 보장해야 한다.
|
|
||||||
- legacy 커뮤니티 목록은 유료 게시글을 구매하지 않은 조회자에게도 게시글 이미지를 노출했지만, 커뮤니티 탭 API는 유료 미구매 게시글의 이미지도 오디오와 동일하게 `null`로 내려줘야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 크리에이터 채널 커뮤니티 탭 조회 API를 제공한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/community`로 한다.
|
|
||||||
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위 조립 계층에 둔다.
|
|
||||||
- 커뮤니티 게시글 목록, 전체 개수, 구매 여부, 좋아요 수, 댓글 수, 성인 콘텐츠 노출, 유료 이미지/오디오 접근 정책은 API 패키지 밖 커뮤니티 도메인 패키지에 둔다.
|
|
||||||
- 크리에이터 채널 홈 API와 커뮤니티 탭 API는 동일한 커뮤니티 조회 도메인을 사용한다.
|
|
||||||
- 응답에는 조회 가능한 커뮤니티 게시글 전체 개수, 게시글 목록, page, size, hasNext를 포함한다.
|
|
||||||
- 게시글 목록 item에는 게시글 id, 크리에이터 id, 크리에이터 닉네임, 크리에이터 프로필 이미지 URL, 작성 시간 UTC, 게시글 본문, 이미지 URL, 오디오 URL, 가격, 댓글 쓰기 가능 여부, 구매 여부, 좋아요 개수, 댓글 개수, pin 여부를 포함한다.
|
|
||||||
- 유료 게시글의 이미지와 오디오 콘텐츠는 조회자가 구매했거나 게시글 작성자인 경우에만 내려준다.
|
|
||||||
- 유료 게시글을 구매하지 않은 조회자에게는 이미지 URL과 오디오 콘텐츠 URL을 `null`로 내려준다.
|
|
||||||
- 이미지가 없는 게시글은 `imageUrl`을 `null`로 내려준다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 전체 개수와 목록에서 제외한다.
|
|
||||||
- 페이징 요청값은 기존 오디오/시리즈 탭 API와 같은 보정 규칙을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 커뮤니티 게시글 작성, 수정, 삭제 API는 포함하지 않는다.
|
|
||||||
- 커뮤니티 게시글 구매 API는 포함하지 않는다.
|
|
||||||
- 커뮤니티 댓글 작성, 수정, 삭제, 목록 조회 API는 포함하지 않는다.
|
|
||||||
- 커뮤니티 좋아요 생성/취소 API는 포함하지 않는다.
|
|
||||||
- legacy `/creator-community` API의 공개 endpoint 변경은 포함하지 않는다.
|
|
||||||
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
|
|
||||||
- 홈 API의 커뮤니티 노출 개수나 홈 화면 구성 정책 변경은 포함하지 않는다.
|
|
||||||
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
|
||||||
- 앱 표시용 상대 시간 문구는 서버에서 새로 조합하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 크리에이터 채널 커뮤니티 탭에서 크리에이터의 커뮤니티 게시글을 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 커뮤니티 탭 구성에 필요한 전체 개수와 게시글 목록을 단일 API 응답으로 표시하려는 클라이언트
|
|
||||||
- 서버 개발자: 홈 API와 커뮤니티 탭 API에서 커뮤니티 조회 정책을 중복 없이 재사용하려는 개발자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 크리에이터 채널 커뮤니티 탭에 들어가면 자신이 조회 가능한 게시글 전체 개수를 확인하고 싶다.
|
|
||||||
- 사용자는 커뮤니티 게시글을 최신순으로 추가 로딩하고 싶다.
|
|
||||||
- 성인 콘텐츠 노출이 꺼진 사용자는 19금 게시글이 개수와 목록에 포함되지 않기를 원한다.
|
|
||||||
- 사용자는 이미지가 없는 게시글도 정상적으로 목록에서 확인하고 싶다.
|
|
||||||
- 사용자는 구매한 유료 게시글의 오디오 콘텐츠를 재생할 수 있어야 한다.
|
|
||||||
- 구매하지 않은 사용자는 유료 게시글의 이미지 URL과 오디오 콘텐츠 URL을 받지 않아야 한다.
|
|
||||||
- 앱 클라이언트는 크리에이터 프로필 이미지, 댓글 작성 가능 여부, 구매 여부, 좋아요 개수, 댓글 개수, pin 여부를 게시글 item에서 바로 확인하고 싶다.
|
|
||||||
- 서버 개발자는 홈 API와 커뮤니티 탭 API가 동일한 커뮤니티 조회 도메인을 사용한다는 것을 패키지 의존 방향으로 확인하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 크리에이터 채널 커뮤니티 탭 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/community`로 한다.
|
|
||||||
- `creatorId`는 path variable로 받는다.
|
|
||||||
- 커뮤니티 게시글 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
|
||||||
- `page`는 0부터 시작하는 page index로 처리한다.
|
|
||||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
|
||||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
|
||||||
- `page`가 0보다 작으면 `0`으로 보정한다.
|
|
||||||
- `size`가 20보다 작으면 `20`으로 보정한다.
|
|
||||||
- `size`가 50보다 크면 `50`으로 보정한다.
|
|
||||||
- API는 인증 회원만 조회할 수 있어야 한다.
|
|
||||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
|
||||||
- 공개된 커뮤니티 게시글이 없어도 전체 API는 성공 처리한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
|
||||||
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
|
||||||
- 요청한 page 범위에 게시글이 없으면 `communityPosts`는 빈 배열, `hasNext`는 `false`로 내려주되 `communityPostCount`는 전체 개수를 유지한다.
|
|
||||||
|
|
||||||
### Feature B. 응답 스키마
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
|
||||||
- 응답 최상위 DTO 이름은 `CreatorChannelCommunityTabResponse`로 한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수
|
|
||||||
- `communityPosts`: 커뮤니티 게시글 목록
|
|
||||||
- `page`: 현재 응답의 page index
|
|
||||||
- `size`: 현재 응답의 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- `communityPostCount`는 목록 조회와 같은 공개 여부, 작성자, 성인 콘텐츠 노출, 차단 정책을 적용해 계산한다.
|
|
||||||
- `communityPostCount`에는 현재 page에 포함되지 않은 게시글도 포함한다.
|
|
||||||
- `communityPostCount`는 pinned 게시글과 일반 게시글을 모두 포함한 전체 개수다.
|
|
||||||
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
|
||||||
- `hasNext`는 같은 조건에서 다음 page에 노출할 게시글이 있으면 `true`로 내려준다.
|
|
||||||
- 응답 스키마 예시는 다음과 같다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class CreatorChannelCommunityTabResponse(
|
|
||||||
val communityPostCount: Int,
|
|
||||||
val communityPosts: List<CreatorChannelCommunityPostResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelCommunityPostResponse(
|
|
||||||
val postId: Long,
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileUrl: String,
|
|
||||||
val createdAtUtc: String,
|
|
||||||
val content: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val audioUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isCommentAvailable")
|
|
||||||
val isCommentAvailable: Boolean,
|
|
||||||
val existOrdered: Boolean,
|
|
||||||
val likeCount: Int,
|
|
||||||
val commentCount: Int,
|
|
||||||
@JsonProperty("isPinned")
|
|
||||||
val isPinned: Boolean
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회 가능한 커뮤니티 게시글이 없으면 `communityPostCount`는 `0`, `communityPosts`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
|
||||||
- 이미지가 없는 게시글은 `imageUrl`을 `null`로 내려준다.
|
|
||||||
- 유료 게시글을 구매하지 않았고 게시글 작성자도 아닌 조회자에게는 이미지가 있는 게시글이어도 `imageUrl`을 `null`로 내려준다.
|
|
||||||
- 오디오가 없는 게시글은 `audioUrl`을 `null`로 내려준다.
|
|
||||||
- `isCommentAvailable == false`인 게시글의 `commentCount`는 기존 커뮤니티 목록 정책과 동일하게 `0`으로 내려준다.
|
|
||||||
- Boolean 응답 필드는 Jackson 직렬화 시 `commentAvailable`, `pinned`로 바뀌지 않고 `isCommentAvailable`, `isPinned`로 내려가야 한다.
|
|
||||||
|
|
||||||
### Feature C. 커뮤니티 게시글 목록과 개수
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 조회 대상은 지정한 `creatorId`가 작성한 커뮤니티 게시글로 제한한다.
|
|
||||||
- 활성 게시글만 조회한다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 목록에서 제외한다.
|
|
||||||
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 `communityPostCount`에서도 제외한다.
|
|
||||||
- 성인 콘텐츠 필터는 구매 여부보다 우선 적용한다.
|
|
||||||
- 조회자가 19금 게시글을 구매했더라도 성인 콘텐츠 노출 정책이 false이면 해당 게시글은 목록과 전체 개수에 포함하지 않는다.
|
|
||||||
- 목록은 pinned 게시글을 먼저 노출하고, 그 다음 일반 게시글을 노출한다.
|
|
||||||
- pinned 게시글 사이의 정렬은 `fixedAt desc`, `id desc`를 따른다.
|
|
||||||
- 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`를 따른다.
|
|
||||||
- 목록은 `page`, `size` 기준으로 페이징 조회한다.
|
|
||||||
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
|
||||||
- `createdAtUtc`는 게시글 생성 시간을 UTC 기준 ISO-8601 문자열로 내려준다.
|
|
||||||
- `creatorProfileUrl`은 크리에이터 프로필 이미지 path가 있으면 기존 CDN URL 조합 정책으로 내려주고, 없으면 기본 프로필 이미지 URL을 내려준다.
|
|
||||||
- `existOrdered`는 조회자가 게시글 작성자이면 `true`, 조회자가 유효 구매 내역을 가지고 있으면 `true`, 그 외에는 `false`로 내려준다.
|
|
||||||
- `imageUrl`은 커뮤니티 게시글 이미지 path가 있고 조회자가 해당 게시글의 유료 미디어에 접근할 수 있을 때만 기존 CDN URL 조합 정책으로 내려준다.
|
|
||||||
- `likeCount`는 활성 좋아요 수를 기준으로 계산한다.
|
|
||||||
- `commentCount`는 조회자가 볼 수 있는 활성 최상위 댓글 수를 기준으로 계산한다.
|
|
||||||
- 댓글 수 계산에는 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- pinned 게시글과 일반 게시글이 섞여 있어도 전체 목록은 하나의 페이징 결과로 내려준다.
|
|
||||||
- pinned 게시글 개수가 page size를 초과하면 첫 page는 pinned 게시글만 포함될 수 있다.
|
|
||||||
- 게시글 작성자가 조회자인 경우에도 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 제외한다.
|
|
||||||
- 좋아요나 댓글이 없는 게시글은 `likeCount`, `commentCount`를 `0`으로 내려준다.
|
|
||||||
|
|
||||||
### Feature D. 유료 이미지와 오디오 콘텐츠 접근 정책
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 커뮤니티 게시글에 이미지 path가 없으면 `imageUrl`은 `null`이다.
|
|
||||||
- 커뮤니티 게시글에 오디오 path가 없으면 `audioUrl`은 `null`이다.
|
|
||||||
- 무료 게시글에 이미지 path가 있으면 CDN URL을 내려준다.
|
|
||||||
- 무료 게시글에 오디오 path가 있으면 signed URL을 내려준다.
|
|
||||||
- 유료 게시글에 이미지 path가 있고 조회자가 해당 게시글을 구매했으면 CDN URL을 내려준다.
|
|
||||||
- 유료 게시글에 오디오 path가 있고 조회자가 해당 게시글을 구매했으면 signed URL을 내려준다.
|
|
||||||
- 유료 게시글에 이미지 path가 있고 조회자가 게시글 작성자이면 CDN URL을 내려준다.
|
|
||||||
- 유료 게시글에 오디오 path가 있고 조회자가 게시글 작성자이면 signed URL을 내려준다.
|
|
||||||
- 유료 게시글에 이미지 path가 있지만 조회자가 구매하지 않았고 게시글 작성자도 아니면 `imageUrl`은 `null`이다.
|
|
||||||
- 유료 게시글에 오디오 path가 있지만 조회자가 구매하지 않았고 게시글 작성자도 아니면 `audioUrl`은 `null`이다.
|
|
||||||
- 이 이미지 제한 정책은 legacy `/creator-community` 목록의 기존 이미지 노출 동작과 다르며, 커뮤니티 탭 API에서는 오디오 접근 정책과 동일하게 적용한다.
|
|
||||||
- 이미지 URL은 signed URL로 만들지 않고 기존 CDN URL 조합 정책만 사용한다.
|
|
||||||
- 오디오 signed URL 생성은 기존 `AudioContentCloudFront.generateSignedURL` 방식을 재사용한다.
|
|
||||||
- 오디오 signed URL 만료 시간은 legacy 커뮤니티 목록 정책과 동일하게 30분을 기본으로 한다.
|
|
||||||
- 유료 게시글 본문은 기존 크리에이터 채널 홈 API의 유료 커뮤니티 본문 마스킹 정책을 따른다.
|
|
||||||
- 유료 게시글 이미지/오디오 접근 여부는 `CanUsage.PAID_COMMUNITY_POST`의 유효 구매 내역을 기준으로 판단한다.
|
|
||||||
- 환불된 구매 내역은 접근 가능 구매로 보지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회자가 구매했더라도 성인 콘텐츠 노출 정책이 false인 19금 게시글은 목록에 포함되지 않으므로 이미지 URL과 오디오 signed URL도 내려주지 않는다.
|
|
||||||
- 구매 내역이 중복으로 있어도 응답 item은 게시글 1개로 중복 없이 내려준다.
|
|
||||||
- 이미지 path가 blank이면 `imageUrl`은 `null`로 내려준다.
|
|
||||||
- 오디오 signed URL 생성 대상 path가 blank이면 `audioUrl`은 `null`로 내려준다.
|
|
||||||
|
|
||||||
### Feature E. 커뮤니티 조회 도메인 분리
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 커뮤니티 탭 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다.
|
|
||||||
- 커뮤니티 게시글 조회 service, 순수 정책, domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다.
|
|
||||||
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
|
|
||||||
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
|
|
||||||
- 의존 방향은 항상 `v2.api.creator.channel.community -> v2.creator.channel.community`이다.
|
|
||||||
- 크리에이터 채널 홈 API는 홈 도메인 내부에 커뮤니티 조회 쿼리를 직접 보유하지 않고, 분리된 커뮤니티 조회 도메인을 사용한다.
|
|
||||||
- 홈 API의 공개 응답 필드명과 필드 의미는 변경하지 않는다.
|
|
||||||
- 홈 API의 커뮤니티 요약 조회 limit와 notice 조회 정책은 기존 동작을 유지한다.
|
|
||||||
- legacy `kr.co.vividnext.sodalive.explorer.profile.creatorCommunity` 쓰기/상세/댓글/좋아요/구매 기능은 이번 분리 대상에 포함하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 홈 API와 커뮤니티 탭 API가 같은 domain model을 사용하더라도 각 API response DTO는 각 API 패키지에서 따로 소유한다.
|
|
||||||
- 커뮤니티 도메인 분리 과정에서 기존 홈 API controller mapping과 신규 커뮤니티 탭 controller mapping이 충돌하면 안 된다.
|
|
||||||
- 도메인 분리 후 `v2.creator.channel.community` 하위에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
|
||||||
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
|
|
||||||
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
|
|
||||||
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다.
|
|
||||||
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
|
|
||||||
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다.
|
|
||||||
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
|
|
||||||
- 기존 크리에이터 채널 홈/라이브/오디오/시리즈 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책을 재사용한다.
|
|
||||||
- 성인 콘텐츠 노출 여부는 기존 v2 탭 API와 동일하게 `MemberContentPreferenceService`와 `isAdultVisibleByPolicy`를 기준으로 계산한다.
|
|
||||||
- 페이징 응답은 기존 오디오/시리즈 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
|
|
||||||
- 이미지 URL은 기존 `String?.toCdnUrl(cloudFrontHost)` 방식과 같은 CDN URL 조합 정책을 따른다.
|
|
||||||
- 오디오 URL은 콘텐츠 CloudFront signed URL 생성 정책을 따른다.
|
|
||||||
- `createdAtUtc` 변환은 기존에 재사용 가능한 `toUtcIso` 확장함수가 있으면 신규 private 확장함수를 만들지 않고 기존 확장함수를 사용한다.
|
|
||||||
- 날짜 응답은 UTC 기준 ISO-8601 문자열로 내려준다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Metrics
|
|
||||||
- 커뮤니티 탭 API 성공/실패 건수
|
|
||||||
- 커뮤니티 탭 API 응답 시간
|
|
||||||
- 커뮤니티 탭 추가 로딩 요청 건수
|
|
||||||
- 성인 콘텐츠 노출 정책이 false인 조회에서 19금 게시글이 개수와 목록에 포함되지 않는 테스트 통과 여부
|
|
||||||
- 유료 게시글 이미지 CDN URL/null 처리와 오디오 signed URL/null 처리 테스트 통과 여부
|
|
||||||
- 홈 API 커뮤니티 요약 조회 회귀 테스트 통과 여부
|
|
||||||
- `v2.creator.channel.community` 도메인 패키지의 `v2.api.*` import 검색 결과 0건 여부
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions
|
|
||||||
- 없음. 구현 중 새 정책 결정이 필요하면 구현 전에 이 PRD와 `plan-task.md`를 먼저 갱신한다.
|
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
# 크리에이터 채널 FanTalk 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 크리에이터 채널 FanTalk 탭의 전체 FanTalk 개수와 페이징된 FanTalk 글 목록, 크리에이터 답글을 조회할 수 있게 한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 조립 계층에 둔다. FanTalk 조회 service, page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 두고 `v2.api.*`에 의존하지 않는다. 저장 엔티티는 legacy `CreatorCheers`를 그대로 사용하되, legacy timezone 기반 cheers 응답은 재사용하지 않고 V2 탭 전용 UTC 응답을 만든다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/fan-talks`
|
|
||||||
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
|
||||||
- request:
|
|
||||||
- path variable: `creatorId`
|
|
||||||
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
|
|
||||||
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
|
|
||||||
- page 기준: 기존 크리에이터 채널 V2 탭 API와 동일한 0 기반 page index
|
|
||||||
- response:
|
|
||||||
- `fanTalkCount`: 조회자가 조회 가능한 최상위 FanTalk 전체 개수
|
|
||||||
- `fanTalks`: FanTalk 글 목록
|
|
||||||
- `page`: fallback 보정 후 실제 적용된 page index
|
|
||||||
- `size`: fallback 보정 후 실제 적용된 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- FanTalk item:
|
|
||||||
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`, `creatorReplies`
|
|
||||||
- creator reply item:
|
|
||||||
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`
|
|
||||||
- 저장 엔티티: `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`
|
|
||||||
- 최상위 FanTalk 기준: `creatorCheers.creator.id == creatorId`, `creatorCheers.isActive == true`, `creatorCheers.parent is null`
|
|
||||||
- 크리에이터 답글 기준: `creatorCheers.parent.id in parentFanTalkIds`, `creatorCheers.creator.id == creatorId`, `creatorCheers.member.id == creatorId`, `creatorCheers.isActive == true`
|
|
||||||
- 팬끼리 답글 작성은 현재 불가능하므로 응답 대상에 포함하지 않는다. 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 있어도 제외한다.
|
|
||||||
- 목록 정렬:
|
|
||||||
- 최상위 FanTalk: `createdAt desc`, `id desc`
|
|
||||||
- 크리에이터 답글: `createdAt asc`, `id asc`
|
|
||||||
- `fanTalkCount`는 최상위 FanTalk만 계산한다. 답글은 count에 포함하지 않는다.
|
|
||||||
- `hasNext`는 `size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
|
|
||||||
- 차단 필터:
|
|
||||||
- 조회자와 FanTalk 작성자가 서로 차단 관계이면 해당 최상위 FanTalk는 목록과 count에서 제외한다.
|
|
||||||
- 차단으로 제외된 최상위 FanTalk의 답글도 응답에 포함하지 않는다.
|
|
||||||
- 조회자와 조회 대상 크리에이터 사이 차단 관계는 기존 크리에이터 채널 접근 정책과 동일하게 API 접근 자체를 거부한다.
|
|
||||||
- creator 검증:
|
|
||||||
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
|
|
||||||
- `createdAtUtc`는 `CreatorCheers.createdAt`을 `kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
|
|
||||||
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
|
|
||||||
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답처럼 `removeDeletedNicknamePrefix()`를 적용한다.
|
|
||||||
- `languageCode`는 FanTalk 탭 응답에 포함하지 않는다.
|
|
||||||
- legacy `/profile/{id}/cheers` 공개 endpoint와 응답 스키마는 변경하지 않는다.
|
|
||||||
- 크리에이터 채널 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
|
|
||||||
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### FanTalk 탭 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
|
||||||
|
|
||||||
### FanTalk 도메인 조회 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 기존 파일 확인/재사용
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRole.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Create: `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md`
|
|
||||||
- Verify: `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkTabResponse(
|
|
||||||
val fanTalkCount: Int,
|
|
||||||
val fanTalks: List<CreatorChannelFanTalkResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse {
|
|
||||||
return CreatorChannelFanTalkTabResponse(
|
|
||||||
fanTalkCount = tab.fanTalkCount,
|
|
||||||
fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from),
|
|
||||||
page = tab.page.page,
|
|
||||||
size = tab.page.size,
|
|
||||||
hasNext = tab.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkResponse(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val writerId: Long,
|
|
||||||
val writerNickname: String,
|
|
||||||
val writerProfileImageUrl: String,
|
|
||||||
val content: String,
|
|
||||||
val createdAtUtc: String,
|
|
||||||
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
|
|
||||||
return CreatorChannelFanTalkResponse(
|
|
||||||
fanTalkId = fanTalk.fanTalkId,
|
|
||||||
writerId = fanTalk.writerId,
|
|
||||||
writerNickname = fanTalk.writerNickname,
|
|
||||||
writerProfileImageUrl = fanTalk.writerProfileImageUrl,
|
|
||||||
content = fanTalk.content,
|
|
||||||
createdAtUtc = fanTalk.createdAt.toUtcIso(),
|
|
||||||
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkReplyResponse(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val writerId: Long,
|
|
||||||
val writerNickname: String,
|
|
||||||
val writerProfileImageUrl: String,
|
|
||||||
val content: String,
|
|
||||||
val createdAtUtc: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse {
|
|
||||||
return CreatorChannelFanTalkReplyResponse(
|
|
||||||
fanTalkId = reply.fanTalkId,
|
|
||||||
writerId = reply.writerId,
|
|
||||||
writerNickname = reply.writerNickname,
|
|
||||||
writerProfileImageUrl = reply.writerProfileImageUrl,
|
|
||||||
content = reply.content,
|
|
||||||
createdAtUtc = reply.createdAt.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkTab(
|
|
||||||
val fanTalkCount: Int,
|
|
||||||
val fanTalks: List<CreatorChannelFanTalk>,
|
|
||||||
val page: CreatorChannelPage,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalk(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val writerId: Long,
|
|
||||||
val writerNickname: String,
|
|
||||||
val writerProfileImageUrl: String,
|
|
||||||
val content: String,
|
|
||||||
val createdAt: LocalDateTime,
|
|
||||||
val creatorReplies: List<CreatorChannelFanTalkReply>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkReply(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val writerId: Long,
|
|
||||||
val writerNickname: String,
|
|
||||||
val writerProfileImageUrl: String,
|
|
||||||
val content: String,
|
|
||||||
val createdAt: LocalDateTime
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface CreatorChannelFanTalkQueryPort {
|
|
||||||
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord?
|
|
||||||
|
|
||||||
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
|
||||||
|
|
||||||
fun countFanTalks(creatorId: Long, viewerId: Long): Int
|
|
||||||
|
|
||||||
fun findFanTalks(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
offset: Long,
|
|
||||||
limit: Int
|
|
||||||
): List<CreatorChannelFanTalkRecord>
|
|
||||||
|
|
||||||
fun findCreatorReplies(
|
|
||||||
creatorId: Long,
|
|
||||||
parentFanTalkIds: List<Long>
|
|
||||||
): List<CreatorChannelFanTalkReplyRecord>
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkCreatorRecord(
|
|
||||||
val creatorId: Long,
|
|
||||||
val role: MemberRole,
|
|
||||||
val nickname: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkRecord(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val writerId: Long,
|
|
||||||
val writerNickname: String,
|
|
||||||
val writerProfileImagePath: String?,
|
|
||||||
val content: String,
|
|
||||||
val createdAt: LocalDateTime
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkReplyRecord(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val parentFanTalkId: Long,
|
|
||||||
val writerId: Long,
|
|
||||||
val writerNickname: String,
|
|
||||||
val writerProfileImagePath: String?,
|
|
||||||
val content: String,
|
|
||||||
val createdAt: LocalDateTime
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Query policy 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`에 아래 정책을 둔다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class CreatorChannelFanTalkQueryPolicy {
|
|
||||||
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
|
|
||||||
return CreatorChannelPage(
|
|
||||||
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
|
|
||||||
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
|
|
||||||
return fetched.take(page.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
|
|
||||||
return fetched.size > page.size
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val DEFAULT_PAGE = 0
|
|
||||||
private const val DEFAULT_PAGE_SIZE = 20
|
|
||||||
private const val MIN_PAGE = 0
|
|
||||||
private const val MIN_PAGE_SIZE = 20
|
|
||||||
private const val MAX_PAGE_SIZE = 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 구현 TASK
|
|
||||||
|
|
||||||
### Phase 1: FanTalk 도메인 모델과 페이징 정책
|
|
||||||
|
|
||||||
- [x] **Task 1.1: FanTalk 페이징 정책 테스트와 구현**
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
|
||||||
- RED: `page`, `size` 보정과 `hasNext`, `limitItems` 동작 테스트를 먼저 작성한다.
|
|
||||||
- 테스트 케이스:
|
|
||||||
- `page == null`, `size == null`이면 `page=0`, `size=20`
|
|
||||||
- `page < 0`이면 `0`
|
|
||||||
- `size < 20`이면 `20`
|
|
||||||
- `size > 50`이면 `50`
|
|
||||||
- fetched size가 `size + 1`이면 `hasNext == true`
|
|
||||||
- fetched size가 `size` 이하이면 `hasNext == false`
|
|
||||||
- `limitItems`는 최대 `size`개만 반환
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
|
||||||
- GREEN: `CreatorChannelFanTalkQueryPolicy`를 `CreatorChannelCommunityQueryPolicy`와 같은 보정 규칙으로 최소 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
|
||||||
- REFACTOR: 상수와 메서드명이 커뮤니티/시리즈 탭 정책과 일관되는지 확인한다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: FanTalk domain model과 port 계약 추가**
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
|
||||||
- RED: Task 1.1 테스트에 domain/port 타입 import를 추가해 타입 부재 컴파일 실패를 확인한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
|
||||||
- GREEN: `CreatorChannelFanTalkTab`, `CreatorChannelFanTalk`, `CreatorChannelFanTalkReply`, `CreatorChannelFanTalkQueryPort`, record data class를 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
|
||||||
- REFACTOR: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain`과 `port/out`에서 `v2.api` import가 없는지 확인한다.
|
|
||||||
- 확인 명령: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
|
||||||
|
|
||||||
### Phase 2: API 응답 DTO와 조립 계층
|
|
||||||
|
|
||||||
- [x] **Task 2.1: FanTalk 응답 DTO와 UTC 변환 테스트**
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
|
||||||
- RED: facade 테스트에서 domain tab을 response로 변환했을 때 필드명과 UTC 문자열이 PRD와 일치하는지 검증한다.
|
|
||||||
- 검증 값:
|
|
||||||
- `fanTalkCount`
|
|
||||||
- `fanTalks[0].writerId`
|
|
||||||
- `fanTalks[0].writerNickname`
|
|
||||||
- `fanTalks[0].writerProfileImageUrl`
|
|
||||||
- `fanTalks[0].content`
|
|
||||||
- `fanTalks[0].createdAtUtc`
|
|
||||||
- `fanTalks[0].creatorReplies[0].writerId`
|
|
||||||
- `page`
|
|
||||||
- `size`
|
|
||||||
- JSON 직렬화 필드명 `hasNext`
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
|
||||||
- GREEN: DTO를 추가하고 `createdAt.toUtcIso()`를 사용해 UTC ISO 문자열을 내려준다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
|
||||||
- REFACTOR: `languageCode`가 응답 DTO에 포함되지 않았는지 확인한다.
|
|
||||||
- 확인 명령: `rg -n "languageCode" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
|
||||||
|
|
||||||
- [x] **Task 2.2: FanTalk facade 추가**
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
|
||||||
- RED: facade가 query service의 `getFanTalkTab(creatorId, viewer, page, size, now)` 결과를 `CreatorChannelFanTalkTabResponse`로 변환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
|
||||||
- GREEN: `CreatorChannelFanTalkFacade`를 `@Service`, `@Transactional(readOnly = true)`로 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
|
||||||
- REFACTOR: facade가 API DTO와 domain query service 조립 외 책임을 갖지 않는지 확인한다.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: FanTalk controller 추가**
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
|
||||||
- RED: MockMvc 테스트를 작성한다.
|
|
||||||
- `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=1&size=20` 요청이 facade에 `creatorId`, `page=1`, `size=20`을 전달한다.
|
|
||||||
- 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
|
||||||
- GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/fan-talks")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 구조로 controller를 추가한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
|
||||||
- REFACTOR: controller가 `ApiResponse.ok(...)`와 `requireMember` 외 응답 가공 책임을 갖지 않는지 확인한다.
|
|
||||||
|
|
||||||
### Phase 3: FanTalk 조회 서비스
|
|
||||||
|
|
||||||
- [x] **Task 3.1: query service의 creator 검증과 접근 차단 처리**
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
|
||||||
- RED: query service 테스트를 작성한다.
|
|
||||||
- creator가 없으면 `SodaException(messageKey = "member.validation.user_not_found")`
|
|
||||||
- creator role이 `MemberRole.CREATOR`가 아니면 `SodaException(messageKey = "member.validation.creator_not_found")`
|
|
||||||
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 채널 접근 차단 오류
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
|
||||||
- GREEN: `CreatorChannelFanTalkQueryService`를 추가하고 `findCreator`, `existsBlockedBetween`, role 검증을 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
|
||||||
- REFACTOR: 에러 키와 차단 메시지 흐름이 커뮤니티/홈 query service와 같은지 확인한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: query service의 page/count/list/reply 조립**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
|
||||||
- RED: query service 테스트를 추가한다.
|
|
||||||
- `page=-1`, `size=10` 요청 시 port에는 `offset=0`, `limit=21`이 전달되고 응답 `page=0`, `size=20`
|
|
||||||
- fetched FanTalk가 `size + 1`개이면 응답 목록은 `size`개이고 `hasNext=true`
|
|
||||||
- fetched FanTalk가 비어 있으면 `fanTalks=[]`, `hasNext=false`
|
|
||||||
- `countFanTalks` 결과가 `fanTalkCount`로 내려간다.
|
|
||||||
- `findCreatorReplies` 결과는 parent id 기준으로 각 FanTalk의 `creatorReplies`에 묶인다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
|
||||||
- GREEN: `CreatorChannelFanTalkQueryPolicy`로 page를 만들고, `countFanTalks`, `findFanTalks`, `findCreatorReplies`를 호출해 `CreatorChannelFanTalkTab`을 조립한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
|
||||||
- REFACTOR: reply 조회는 page에 포함된 parent FanTalk id만 대상으로 호출하는지 확인한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: query service의 URL/닉네임 변환**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
|
||||||
- RED: query service 테스트를 추가한다.
|
|
||||||
- writer profile path가 있으면 CDN URL로 변환한다.
|
|
||||||
- writer profile path가 없으면 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
|
|
||||||
- writer nickname은 `removeDeletedNicknamePrefix()` 결과를 내려준다.
|
|
||||||
- reply writer도 같은 URL/닉네임 변환을 적용한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
|
||||||
- GREEN: `String?.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()`와 `removeDeletedNicknamePrefix()`를 적용한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
|
||||||
- REFACTOR: default profile URL 생성 방식이 홈/커뮤니티 query service와 일관되는지 확인한다.
|
|
||||||
|
|
||||||
### Phase 4: QueryDSL repository
|
|
||||||
|
|
||||||
- [x] **Task 4.1: FanTalk repository 기본 creator/차단 조회**
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- RED: repository 테스트를 작성한다.
|
|
||||||
- `findCreator`가 creator id, role, nickname을 조회한다.
|
|
||||||
- `existsBlockedBetween`가 양방향 활성 차단 관계를 감지한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
|
||||||
- GREEN: `JPAQueryFactory` 기반 repository를 추가하고 홈/커뮤니티 repository와 같은 creator/차단 조건을 구현한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
|
||||||
- REFACTOR: repository class 이름은 `Default...Repository` 접두사 규칙을 따른다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 최상위 FanTalk count/list 조회**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
|
||||||
- RED: repository 테스트를 추가한다.
|
|
||||||
- `countFanTalks`는 `creator.id`, `isActive=true`, `parent is null` 조건만 count한다.
|
|
||||||
- 비활성 FanTalk는 count/list에서 제외한다.
|
|
||||||
- 답글 FanTalk는 count/list에서 제외한다.
|
|
||||||
- 조회자와 작성자 사이 차단 관계가 있으면 count/list에서 제외한다.
|
|
||||||
- 목록 정렬은 `createdAt desc`, `id desc`다.
|
|
||||||
- `offset`, `limit`이 적용된다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
|
||||||
- GREEN: `countFanTalks`, `findFanTalks`를 구현한다. projection은 `CreatorChannelFanTalkRecord`를 사용한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
|
||||||
- REFACTOR: 홈 API의 `fanTalkSummaryCondition`과 조건 의미가 일치하는지 확인한다.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: 크리에이터 답글 조회**
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
|
||||||
- RED: repository 테스트를 추가한다.
|
|
||||||
- `findCreatorReplies`는 parent id 목록에 속한 활성 답글만 조회한다.
|
|
||||||
- 답글 작성자가 조회 대상 크리에이터인 데이터만 조회한다.
|
|
||||||
- 크리에이터가 아닌 회원의 답글은 제외한다.
|
|
||||||
- 비활성 답글은 제외한다.
|
|
||||||
- 답글 정렬은 `createdAt asc`, `id asc`다.
|
|
||||||
- `parentFanTalkIds`가 빈 목록이면 빈 목록을 반환하고 DB 조회 결과가 없어야 한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
|
||||||
- GREEN: `findCreatorReplies`를 구현한다. projection은 `CreatorChannelFanTalkReplyRecord`를 사용한다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
|
||||||
- REFACTOR: reply 조회가 최상위 FanTalk page 결과 외 parent를 가져오지 않는지 확인한다.
|
|
||||||
|
|
||||||
### Phase 5: API 통합과 회귀 검증
|
|
||||||
|
|
||||||
- [x] **Task 5.1: FanTalk End-to-End 테스트**
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
|
|
||||||
- RED: E2E 테스트를 작성한다.
|
|
||||||
- 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=0&size=20` 호출 시 200 OK
|
|
||||||
- 응답 JSON에 `fanTalkCount`, `fanTalks`, `page`, `size`, `hasNext`가 포함된다.
|
|
||||||
- 최상위 FanTalk의 `createdAtUtc`는 UTC ISO 문자열이다.
|
|
||||||
- 크리에이터 답글은 `creatorReplies`에 포함된다.
|
|
||||||
- 팬이 작성한 비정상 답글 데이터는 응답에 포함되지 않는다.
|
|
||||||
- page 범위를 벗어나면 빈 목록과 `hasNext=false`를 반환하되 count는 유지한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
|
||||||
- GREEN: Phase 1~4 구현을 연결해 E2E 테스트를 통과시킨다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
|
||||||
- REFACTOR: 테스트 데이터가 다른 크리에이터 채널 탭 테스트와 충돌하지 않도록 독립 fixture를 사용한다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: 패키지 의존 방향과 기존 API 회귀 확인**
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
|
|
||||||
- RED: 신규 테스트 추가는 없다. 이 task는 문서화된 구조 검증 task다.
|
|
||||||
- TDD 예외 사유: 패키지 의존 방향과 기존 endpoint 비변경 여부는 정적 검색과 기존 회귀 테스트가 더 직접적인 검증이다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
|
||||||
- `rg -n "fan-talks|/profile/\\{id\\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer`
|
|
||||||
- GREEN: `v2.creator.channel.fantalk` 하위에서 `v2.api.*` import 검색 결과가 0건인지 확인한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
|
||||||
- REFACTOR: 홈 API와 legacy cheers endpoint의 공개 응답 스키마를 변경한 파일 diff가 없는지 확인한다.
|
|
||||||
|
|
||||||
- [x] **Task 5.3: 전체 FanTalk 관련 테스트와 ktlint 검증**
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
|
||||||
- RED: 신규 테스트 추가는 없다. 이 task는 구현 완료 후 회귀 검증 task다.
|
|
||||||
- TDD 예외 사유: 전체 회귀와 ktlint는 구현 완료 상태를 검증하는 명령 실행 task다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- GREEN: 실패하는 FanTalk 관련 테스트나 ktlint 오류가 있으면 해당 task의 구현 단계로 돌아가 수정한다.
|
|
||||||
- 통과 확인:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- REFACTOR: 필요한 경우 `./gradlew test`를 추가 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 구현 시 주의사항
|
|
||||||
|
|
||||||
- 구현 전에 이 문서와 `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`가 같은 endpoint, page 기준, response field를 말하는지 다시 확인한다.
|
|
||||||
- 신규 공개 API 스키마 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 수정한다.
|
|
||||||
- `CreatorCheers` 엔티티 자체 구조는 변경하지 않는다.
|
|
||||||
- legacy `ExplorerQueryRepository.getCheersList`는 timezone 표시 문자열을 만들기 때문에 신규 V2 응답 DTO에 재사용하지 않는다.
|
|
||||||
- FanTalk 탭 query service는 홈 API query service에 의존하지 않는다.
|
|
||||||
- 홈 API의 `findFanTalkSummary`는 이번 작업에서 수정하지 않는 것을 기본으로 한다. 수정이 필요해지면 PRD와 이 문서를 먼저 갱신한다.
|
|
||||||
- controller/facade/DTO 조립 계층은 `v2.api.creator.channel.fantalk`에만 둔다.
|
|
||||||
- domain/application/port/repository 조회 계층은 `v2.creator.channel.fantalk`에만 둔다.
|
|
||||||
- 테스트 작성 시 Redis가 필요 없는 JPA/QueryDSL slice 테스트는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 관례를 따른다.
|
|
||||||
- 테스트 완료 후 각 task 아래에 실행 명령과 결과를 한국어로 누적 기록한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 검증 기록
|
|
||||||
|
|
||||||
- 문서 생성 시점에는 구현 코드를 작성하지 않았으므로 신규 테스트는 실행하지 않았다.
|
|
||||||
- 문서 변경 검증으로 `./gradlew tasks --all`을 실행했다.
|
|
||||||
- sandbox 일반 실행은 Gradle wrapper가 `/Users/klaus/.gradle/wrapper/dists/gradle-8.1.1-bin/9wiye5v2saajue4irfo8ybqfp/gradle-8.1.1-bin.zip.lck`에 접근하지 못해 `Operation not permitted`로 실패했다.
|
|
||||||
- 권한 승인 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Phase 1 Task 1.1/1.2 구현 검증을 진행했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` 실행 시 `CreatorChannelFanTalkQueryPolicy`, FanTalk domain model, FanTalk port record 미존재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: FanTalk 페이징 정책, domain model, port 계약 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 의존 방향 확인: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
|
||||||
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
|
||||||
- Phase 2 Task 2.1/2.2 구현 검증을 진행했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` 실행 시 FanTalk 응답 DTO, FanTalk facade, FanTalk query service 타입 미존재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: FanTalk 응답 DTO, FanTalk facade, Phase 3 구현 전 facade 컴파일을 위한 `CreatorChannelFanTalkQueryService` 최소 shell 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Phase 2 범위 준수: `CreatorChannelFanTalkQueryService`는 최종 public method signature만 두고 조회/검증/DB/port 구현은 추가하지 않았다.
|
|
||||||
- Phase 2 Task 2.3 구현 검증을 진행했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 시 `CreatorChannelFanTalkController` 미존재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: FanTalk controller 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
|
||||||
- Phase 3 Task 3.1/3.2/3.3 구현 검증을 진행했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` 실행 시 `CreatorChannelFanTalkQueryService` 생성자 의존성 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: FanTalk query service의 creator 검증, 접근 차단, page/count/list/reply 조립, CDN URL/default profile URL, 탈퇴 닉네임 prefix 제거 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
|
||||||
- ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 assertion 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
|
||||||
|
|
||||||
- Phase 4 Task 4.1/4.2/4.3 구현 검증을 진행했다.
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 시 `DefaultCreatorChannelFanTalkQueryRepository` 미존재로 `compileTestKotlin` 실패를 확인했다.
|
|
||||||
- GREEN: FanTalk QueryDSL repository의 creator 조회, 양방향 차단 조회, 최상위 FanTalk count/list, 크리에이터 답글 조회 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
|
||||||
- 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
|
||||||
- ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 fixture 호출 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Phase 4 코드 리뷰 및 재검증을 진행했다.
|
|
||||||
- 리뷰 범위: `DefaultCreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryPort`, `DefaultCreatorChannelFanTalkQueryRepositoryTest`, `CreatorChannelFanTalkQueryService` 연동부를 PRD/plan의 Phase 4 요구사항과 대조했다.
|
|
||||||
- 리뷰 결과: creator/차단 조회, 최상위 FanTalk count/list 조건, 정렬, offset/limit, 크리에이터 답글 필터와 빈 parent 목록 처리에서 수정이 필요한 결함을 발견하지 않았다.
|
|
||||||
- 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 관련 회귀 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- FanTalk 전체 재검증: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 의존 방향 재확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
|
||||||
- ktlint 재확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Phase 5 Task 5.1 구현 검증을 진행했다.
|
|
||||||
- GREEN: `CreatorChannelFanTalkEndToEndTest`를 추가해 인증 회원의 FanTalk 탭 200 OK, 응답 필드, UTC ISO 문자열, 크리에이터 답글 포함, 팬 작성 답글 제외, 범위 밖 page의 빈 목록/count 유지 동작을 검증했다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
|
||||||
- Phase 5 Task 5.2 회귀 검증을 진행했다.
|
|
||||||
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
|
||||||
- API 참조 확인: `rg -n "fan-talks|/profile/\{id\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer` 실행 결과 신규 `fan-talks` controller 매핑과 기존 legacy cheers/home latestFanTalk 참조만 확인했다.
|
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- Phase 5 Task 5.3 전체 FanTalk 관련 테스트와 ktlint 검증을 진행했다.
|
|
||||||
- FanTalk 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- ktlint 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# PRD: 크리에이터 채널 FanTalk 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
크리에이터 채널의 FanTalk 탭에서 전체 FanTalk 개수와 FanTalk 글 목록을 페이징 조회하는 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 크리에이터 채널 홈 API는 FanTalk 전체 개수와 최신 FanTalk 1건만 요약으로 제공한다.
|
|
||||||
- FanTalk 탭은 전체 개수, 페이징된 글 목록, 각 글에 달린 크리에이터 답글을 함께 표시해야 한다.
|
|
||||||
- legacy `/profile/{id}/cheers` API는 FanTalk를 조회하지만 날짜를 timezone 기반 표시 문자열로 내려주므로, V2 크리에이터 채널 탭 API에서 요구하는 UTC 기반 응답 계약과 맞지 않는다.
|
|
||||||
- FanTalk 엔티티는 legacy `CreatorCheers`를 사용하되, 신규 API 조립 계층과 도메인 조회 계층은 기존 V2 크리에이터 채널 탭 패턴처럼 분리해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 크리에이터 채널 FanTalk 탭 조회 API를 제공한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
|
|
||||||
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위 조립 계층에 둔다.
|
|
||||||
- FanTalk 목록, 전체 개수, 답글 조회, 페이징 보정, 차단 필터링 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 도메인 조회 계층에 둔다.
|
|
||||||
- FanTalk 저장 엔티티는 기존 `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`를 사용한다.
|
|
||||||
- 응답에는 조회 가능한 전체 FanTalk 개수, FanTalk 글 목록, page, size, hasNext를 포함한다.
|
|
||||||
- FanTalk 글 item에는 글쓴이 닉네임, 글쓴이 ID, 글쓴이 프로필 이미지, 글쓴이가 쓴 글, 글 쓴 시간 UTC, 크리에이터가 쓴 답글 목록을 포함한다.
|
|
||||||
- 크리에이터 답글 item도 FanTalk 글과 동일한 작성자/본문/시간 필드 구조를 사용한다.
|
|
||||||
- 페이징 요청값은 기존 V2 크리에이터 채널 커뮤니티/시리즈 탭 API와 같은 보정 규칙을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- FanTalk 작성, 수정, 삭제 API는 포함하지 않는다.
|
|
||||||
- FanTalk 답글 작성, 수정, 삭제 API는 포함하지 않는다.
|
|
||||||
- 팬 회원 간 답글 작성/조회 기능은 포함하지 않는다. 현재 팬끼리 답글을 작성할 수 없으므로 FanTalk 탭 응답에서도 팬 간 답글을 고려하지 않는다.
|
|
||||||
- legacy `/profile/{id}/cheers` API의 공개 endpoint나 응답 스키마 변경은 포함하지 않는다.
|
|
||||||
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
|
|
||||||
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
|
||||||
- 앱 표시용 상대 시간 문구나 timezone 변환 문자열은 서버에서 새로 조합하지 않는다.
|
|
||||||
- 신고, 언어 감지, 푸시 알림 정책 변경은 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 크리에이터 채널 FanTalk 탭에서 다른 팬들의 FanTalk 글과 크리에이터 답글을 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: FanTalk 탭 구성에 필요한 전체 개수와 페이징 목록을 단일 API 응답으로 표시하려는 클라이언트
|
|
||||||
- 서버 개발자: 기존 `CreatorCheers` 저장 구조를 유지하면서 V2 조회 계층을 분리하려는 개발자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 크리에이터 채널 FanTalk 탭에 들어가면 전체 FanTalk 개수를 확인하고 싶다.
|
|
||||||
- 사용자는 FanTalk 글을 최신순으로 추가 로딩하고 싶다.
|
|
||||||
- 사용자는 각 FanTalk 글에 크리에이터가 남긴 답글을 같은 화면에서 확인하고 싶다.
|
|
||||||
- 사용자는 글쓴이 닉네임, ID, 프로필 이미지, 본문, 작성 시간을 목록 item에서 바로 확인하고 싶다.
|
|
||||||
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
|
|
||||||
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 크리에이터 채널 FanTalk 탭 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
|
|
||||||
- `creatorId`는 path variable로 받는다.
|
|
||||||
- FanTalk 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
|
||||||
- `page`는 기존 V2 탭 API와 동일하게 0부터 시작하는 page index로 처리한다.
|
|
||||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
|
||||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
|
||||||
- `page`가 0보다 작으면 `0`으로 보정한다.
|
|
||||||
- `size`가 20보다 작으면 `20`으로 보정한다.
|
|
||||||
- `size`가 50보다 크면 `50`으로 보정한다.
|
|
||||||
- API는 인증 회원만 조회할 수 있어야 한다.
|
|
||||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
|
||||||
- 조회 가능한 FanTalk가 없어도 전체 API는 성공 처리한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
|
||||||
- 요청한 page 범위에 FanTalk가 없으면 `fanTalks`는 빈 배열, `hasNext`는 `false`로 내려주되 `fanTalkCount`는 전체 개수를 유지한다.
|
|
||||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
|
||||||
|
|
||||||
### Feature B. 응답 스키마
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
|
||||||
- 응답 최상위 DTO 이름은 `CreatorChannelFanTalkTabResponse`로 한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- `fanTalkCount`: 조회자가 조회 가능한 전체 FanTalk 개수
|
|
||||||
- `fanTalks`: FanTalk 글 목록
|
|
||||||
- `page`: 현재 응답의 page index
|
|
||||||
- `size`: 현재 응답의 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- `fanTalkCount`는 최상위 FanTalk 글만 계산한다.
|
|
||||||
- `fanTalkCount`에는 현재 page에 포함되지 않은 FanTalk 글도 포함한다.
|
|
||||||
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
|
||||||
- `hasNext`는 같은 조건에서 다음 page에 노출할 FanTalk 글이 있으면 `true`로 내려준다.
|
|
||||||
- 응답 스키마 예시는 다음과 같다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class CreatorChannelFanTalkTabResponse(
|
|
||||||
val fanTalkCount: Int,
|
|
||||||
val fanTalks: List<CreatorChannelFanTalkResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkResponse(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val writerId: Long,
|
|
||||||
val writerNickname: String,
|
|
||||||
val writerProfileImageUrl: String,
|
|
||||||
val content: String,
|
|
||||||
val createdAtUtc: String,
|
|
||||||
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelFanTalkReplyResponse(
|
|
||||||
val fanTalkId: Long,
|
|
||||||
val writerId: Long,
|
|
||||||
val writerNickname: String,
|
|
||||||
val writerProfileImageUrl: String,
|
|
||||||
val content: String,
|
|
||||||
val createdAtUtc: String
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회 가능한 FanTalk가 없으면 `fanTalkCount`는 `0`, `fanTalks`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
|
||||||
- FanTalk 글에 크리에이터 답글이 없으면 `creatorReplies`는 빈 배열로 내려준다.
|
|
||||||
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
|
|
||||||
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답 정책을 따른다.
|
|
||||||
- `createdAtUtc`는 `CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
|
|
||||||
- Boolean 응답 필드는 현재 스키마에 없지만, 추후 추가 시 Jackson 직렬화 필드명을 명시해야 한다.
|
|
||||||
|
|
||||||
### Feature C. FanTalk 목록과 개수
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 조회 대상은 지정한 `creatorId`의 FanTalk로 제한한다.
|
|
||||||
- 저장 엔티티는 `CreatorCheers`를 사용한다.
|
|
||||||
- 최상위 FanTalk 글은 `CreatorCheers.parent is null`인 활성 데이터로 정의한다.
|
|
||||||
- 활성 데이터는 `CreatorCheers.isActive == true`인 데이터로 정의한다.
|
|
||||||
- 목록은 최상위 FanTalk 글만 페이징한다.
|
|
||||||
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
|
|
||||||
- 전체 개수는 목록과 같은 creator, active, parent, 차단 필터 조건을 적용해 계산한다.
|
|
||||||
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
|
||||||
- 글쓴이 ID는 `CreatorCheers.member.id`를 사용한다.
|
|
||||||
- 글쓴이 닉네임은 `CreatorCheers.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
|
|
||||||
- 글쓴이 프로필 이미지는 `CreatorCheers.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
|
|
||||||
- 글쓴이가 쓴 글은 `CreatorCheers.cheers`를 사용한다.
|
|
||||||
- 글 쓴 시간은 `CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
|
|
||||||
- `languageCode`는 이번 FanTalk 탭 응답에 포함하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `CreatorCheers.createdAt`이 nullable 기반 엔티티 필드에서 온 경우에도 조회 결과 응답에는 null이 나오지 않아야 한다.
|
|
||||||
- FanTalk 작성자가 조회자와 차단 관계이면 해당 최상위 글은 목록과 개수에서 제외한다.
|
|
||||||
- 차단으로 제외된 최상위 글의 답글도 응답에 포함하지 않는다.
|
|
||||||
- 같은 작성자의 FanTalk가 여러 건 있어도 각각 별도 item으로 내려준다.
|
|
||||||
|
|
||||||
### Feature D. 크리에이터 답글 포함
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 각 FanTalk 글에는 크리에이터가 쓴 활성 답글 목록을 `creatorReplies`로 포함한다.
|
|
||||||
- 답글은 `CreatorCheers.parent`가 해당 최상위 FanTalk 글인 데이터로 조회한다.
|
|
||||||
- 답글 작성자가 조회 대상 크리에이터인 데이터만 포함한다.
|
|
||||||
- 답글도 `CreatorCheers.isActive == true`인 데이터만 포함한다.
|
|
||||||
- 답글 item의 필드 구조는 최상위 FanTalk 글과 동일한 작성자 ID, 닉네임, 프로필 이미지, 본문, UTC 작성 시간을 사용한다.
|
|
||||||
- 답글 정렬은 오래된 답글부터 확인할 수 있도록 `createdAt asc`, `id asc`를 따른다.
|
|
||||||
- 현재 팬끼리 답글을 작성할 수 없으므로 크리에이터가 아닌 회원의 답글은 정상 응답 대상이 아니다.
|
|
||||||
- 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 존재하더라도 응답에 포함하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 크리에이터 답글이 여러 개면 모두 `creatorReplies`에 포함한다.
|
|
||||||
- 크리에이터가 작성했지만 비활성 처리된 답글은 포함하지 않는다.
|
|
||||||
- 답글 작성자인 크리에이터 프로필 이미지가 없으면 기본 프로필 이미지 URL을 내려준다.
|
|
||||||
- 답글 작성자인 크리에이터가 조회자와 차단 관계인 경우는 이미 채널 접근 차단 조건에서 처리된다.
|
|
||||||
|
|
||||||
### Feature E. V2 재사용 범위와 계층 분리
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위에 둔다.
|
|
||||||
- FanTalk 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 둔다.
|
|
||||||
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
|
|
||||||
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
|
|
||||||
- 의존 방향은 항상 `v2.api.creator.channel.fantalk -> v2.creator.channel.fantalk`이다.
|
|
||||||
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
|
|
||||||
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
|
|
||||||
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
|
|
||||||
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
|
|
||||||
- 기존 홈 API의 FanTalk 요약 조회 로직은 참고하되, 홈 도메인 repository에 신규 탭 페이징 책임을 추가하지 않는다.
|
|
||||||
- legacy `ExplorerQueryRepository.getCheersList`의 timezone 기반 날짜 포맷 응답은 신규 V2 API에서 재사용하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 신규 `fantalk` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
|
|
||||||
- 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
|
|
||||||
- legacy FanTalk 작성/수정/삭제 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
|
||||||
- 언어/런타임은 Kotlin + Java 17을 따른다.
|
|
||||||
- 프레임워크는 Spring Boot 2.7.14를 따른다.
|
|
||||||
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
|
|
||||||
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
|
|
||||||
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Decisions
|
|
||||||
- endpoint 이름은 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 확정한다.
|
|
||||||
- `page`는 기존 크리에이터 채널 V2 탭 API와 동일하게 0 기반 page index로 처리한다.
|
|
||||||
@@ -1,544 +0,0 @@
|
|||||||
# 크리에이터 채널 후원 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/donations`로 크리에이터 채널 후원 탭의 전체 채널 후원 개수, 후원 순위 Top 8, 페이징된 채널 후원 목록을 조회할 수 있게 한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 조립 계층에 둔다. 후원 탭 조회 service, page/month 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 하위에 두고 `v2.api.*`에 의존하지 않는다. 채널 후원 목록은 기존 `ChannelDonationMessage`와 홈 API 후원 섹션 조건을 따르고, 후원 순위는 legacy `CreatorDonationRankingService`를 통해 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일하게 재사용한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/donations`
|
|
||||||
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
|
||||||
- request:
|
|
||||||
- path variable: `creatorId`
|
|
||||||
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 보정
|
|
||||||
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 보정
|
|
||||||
- response:
|
|
||||||
- `donationCount`: 조회자가 조회 가능한 현재 KST 월 범위의 전체 채널 후원 개수
|
|
||||||
- `rankings`: 후원 순위 Top 8 목록
|
|
||||||
- `donations`: 채널 후원 목록
|
|
||||||
- `page`: 보정 후 실제 적용된 page index
|
|
||||||
- `size`: 보정 후 실제 적용된 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- channel donation item:
|
|
||||||
- `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`
|
|
||||||
- ranking item:
|
|
||||||
- `userId`, `nickname`, `profileImage`, `donationCan`
|
|
||||||
- 채널 후원 목록 기준:
|
|
||||||
- 저장 엔티티는 `kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage`를 사용한다.
|
|
||||||
- 기간은 홈 후원 섹션과 동일하게 현재 KST 월 시작 이상, 다음 달 KST 월 시작 미만을 UTC `LocalDateTime`으로 변환해 사용한다.
|
|
||||||
- 정렬은 `createdAt desc`, `id desc`를 따른다.
|
|
||||||
- `hasNext`는 `size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
|
|
||||||
- 비공개 후원 노출:
|
|
||||||
- 조회자가 크리에이터 본인이면 해당 크리에이터의 비공개 후원까지 목록과 개수에 포함한다.
|
|
||||||
- 조회자가 크리에이터 본인이 아니면 공개 후원과 조회자 본인의 비공개 후원만 목록과 개수에 포함한다.
|
|
||||||
- 후원 순위 기준:
|
|
||||||
- `CreatorDonationRankingService.getMemberDonationRanking(...)`를 통해 legacy `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과를 재사용한다.
|
|
||||||
- Top 8 조회는 `offset = 0`, `limit = 8`을 사용한다.
|
|
||||||
- 기간은 크리에이터의 `donationRankingPeriod`를 따르고, 값이 없으면 `DonationRankingPeriod.CUMULATIVE`를 사용한다.
|
|
||||||
- `DonationRankingPeriod.WEEKLY`이면 legacy service의 주간 범위를 그대로 사용한다.
|
|
||||||
- `DonationRankingPeriod.CUMULATIVE`이면 legacy service의 전체 누적 범위를 그대로 사용한다.
|
|
||||||
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank`가 `true`이면 `rankings`를 내려준다.
|
|
||||||
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank`가 `false`이면 `rankings`는 빈 배열이다.
|
|
||||||
- `rankings`가 빈 배열이어도 `donationCount`, `donations`, `page`, `size`, `hasNext`는 후원 목록 조건대로 조회한다.
|
|
||||||
- `donationCan`은 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
|
|
||||||
- creator 검증:
|
|
||||||
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
|
|
||||||
- `createdAtUtc`는 `ChannelDonationMessage.createdAt`을 `kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
|
|
||||||
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
|
|
||||||
- 후원자 닉네임은 `removeDeletedNicknamePrefix()`를 적용한다.
|
|
||||||
- 후원 메시지는 홈 API와 동일하게 `additionalMessage`가 없으면 빈 문자열로 내려준다. 레거시 `ChannelDonationService.buildMessage` 기본 문구 조합은 사용하지 않는다.
|
|
||||||
- legacy `/explorer/profile/channel-donation` 공개 endpoint와 응답 스키마는 변경하지 않는다.
|
|
||||||
- 크리에이터 채널 홈 API의 `channelDonations` 공개 응답 의미는 변경하지 않는다.
|
|
||||||
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 후원 탭 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
|
|
||||||
|
|
||||||
### 후원 도메인 조회 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
|
|
||||||
|
|
||||||
### 기존 파일 확인/재사용
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingQueryRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
|
||||||
|
|
||||||
### 문서 산출물
|
|
||||||
- Create: `docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md`
|
|
||||||
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/prd.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
|
|
||||||
|
|
||||||
data class CreatorChannelDonationTabResponse(
|
|
||||||
val donationCount: Int,
|
|
||||||
val rankings: List<MemberDonationRankingResponse>,
|
|
||||||
val donations: List<CreatorChannelDonationResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(tab: CreatorChannelDonationTab): CreatorChannelDonationTabResponse {
|
|
||||||
return CreatorChannelDonationTabResponse(
|
|
||||||
donationCount = tab.donationCount,
|
|
||||||
rankings = tab.rankings.map(MemberDonationRankingResponse::from),
|
|
||||||
donations = tab.donations.map(CreatorChannelDonationResponse::from),
|
|
||||||
page = tab.page.page,
|
|
||||||
size = tab.page.size,
|
|
||||||
hasNext = tab.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class MemberDonationRankingResponse(
|
|
||||||
@JsonProperty("userId") val userId: Long,
|
|
||||||
@JsonProperty("nickname") val nickname: String,
|
|
||||||
@JsonProperty("profileImage") val profileImage: String,
|
|
||||||
@JsonProperty("donationCan") val donationCan: Int
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(ranking: CreatorChannelDonationRanking): MemberDonationRankingResponse {
|
|
||||||
return MemberDonationRankingResponse(
|
|
||||||
userId = ranking.userId,
|
|
||||||
nickname = ranking.nickname,
|
|
||||||
profileImage = ranking.profileImage,
|
|
||||||
donationCan = ranking.donationCan
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelDonationResponse(
|
|
||||||
val nickname: String,
|
|
||||||
val profileImageUrl: String,
|
|
||||||
val can: Int,
|
|
||||||
val message: String,
|
|
||||||
val createdAtUtc: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
|
|
||||||
return CreatorChannelDonationResponse(
|
|
||||||
nickname = donation.nickname,
|
|
||||||
profileImageUrl = donation.profileImageUrl,
|
|
||||||
can = donation.can,
|
|
||||||
message = donation.message,
|
|
||||||
createdAtUtc = donation.createdAt.toUtcIso()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
data class CreatorChannelDonationTab(
|
|
||||||
val donationCount: Int,
|
|
||||||
val rankings: List<CreatorChannelDonationRanking>,
|
|
||||||
val donations: List<CreatorChannelDonation>,
|
|
||||||
val page: CreatorChannelPage,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelDonationRanking(
|
|
||||||
val userId: Long,
|
|
||||||
val nickname: String,
|
|
||||||
val profileImage: String,
|
|
||||||
val donationCan: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelDonation(
|
|
||||||
val nickname: String,
|
|
||||||
val profileImageUrl: String,
|
|
||||||
val can: Int,
|
|
||||||
val message: String,
|
|
||||||
val createdAt: LocalDateTime
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface CreatorChannelDonationQueryPort {
|
|
||||||
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord?
|
|
||||||
|
|
||||||
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
|
||||||
|
|
||||||
fun countChannelDonations(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
now: LocalDateTime
|
|
||||||
): Int
|
|
||||||
|
|
||||||
fun findChannelDonations(
|
|
||||||
creatorId: Long,
|
|
||||||
viewerId: Long,
|
|
||||||
now: LocalDateTime,
|
|
||||||
offset: Long,
|
|
||||||
limit: Int
|
|
||||||
): List<CreatorChannelDonationRecord>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatorChannelDonationRankingPort {
|
|
||||||
fun findTopRankings(
|
|
||||||
creatorId: Long,
|
|
||||||
period: DonationRankingPeriod,
|
|
||||||
withDonationCan: Boolean
|
|
||||||
): List<CreatorChannelDonationRankingRecord>
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatorChannelDonationCreatorRecord(
|
|
||||||
val creatorId: Long,
|
|
||||||
val role: MemberRole,
|
|
||||||
val nickname: String,
|
|
||||||
val isVisibleDonationRank: Boolean,
|
|
||||||
val donationRankingPeriod: DonationRankingPeriod?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelDonationRecord(
|
|
||||||
val nickname: String,
|
|
||||||
val profileImagePath: String?,
|
|
||||||
val can: Int,
|
|
||||||
val message: String,
|
|
||||||
val createdAt: LocalDateTime
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelDonationRankingRecord(
|
|
||||||
val userId: Long,
|
|
||||||
val nickname: String,
|
|
||||||
val profileImage: String,
|
|
||||||
val donationCan: Int
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 구현 Tasks
|
|
||||||
|
|
||||||
### Phase 1: 공개 계약과 순수 정책 추가
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 후원 탭 domain model, port, page/month 정책 추가**
|
|
||||||
- 파일:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt`
|
|
||||||
- RED: `CreatorChannelDonationQueryPolicyTest`를 먼저 작성한다.
|
|
||||||
- null page/size가 `0/20`, fetchLimit `21`로 보정되는지 검증한다.
|
|
||||||
- `page = -1`, `size = 10`이 `0/20`으로 보정되는지 검증한다.
|
|
||||||
- `page = 2`, `size = 100`이 `2/50`, offset `100`, fetchLimit `51`로 보정되는지 검증한다.
|
|
||||||
- fetched 21개에서 응답 item 20개와 `hasNext = true`가 계산되는지 검증한다.
|
|
||||||
- `now = 2026-06-22T03:00:00` 기준 KST 월 범위가 `2026-05-31T15:00:00` 이상, `2026-06-30T15:00:00` 미만 UTC로 계산되는지 검증한다.
|
|
||||||
- domain/port record가 PRD 필드를 보존하는지 생성 테스트로 검증한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
|
|
||||||
- Expected: 신규 클래스가 없어 컴파일 실패한다.
|
|
||||||
- GREEN: domain model, port, `CreatorChannelDonationQueryPolicy`를 최소 구현한다.
|
|
||||||
- `createPage(page, size)`는 기존 FanTalk 정책과 같은 보정값을 사용한다.
|
|
||||||
- `limitItems(fetched, page)`는 `fetched.take(page.size)`를 반환한다.
|
|
||||||
- `hasNext(fetched, page)`는 `fetched.size > page.size`를 반환한다.
|
|
||||||
- `currentKstMonthRange(now)`는 홈 후원 섹션과 동일한 KST 월 범위 UTC 변환을 반환한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 중복 상수와 월 범위 계산을 읽기 쉽게 정리하되 기존 `CreatorChannelPage`를 재사용한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- 실행 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest` 실행, 신규 domain/port/policy 타입 부재로 `compileTestKotlin` 실패 확인.
|
|
||||||
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: response DTO와 facade 매핑 추가**
|
|
||||||
- 파일:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
|
||||||
- RED: `CreatorChannelDonationFacadeTest`를 먼저 작성한다.
|
|
||||||
- `CreatorChannelDonationTabResponse.from(...)`이 `donationCount`, `rankings`, `donations`, `page`, `size`, `hasNext`를 공개 필드로 매핑하는지 검증한다.
|
|
||||||
- `rankings[0]`의 JSON 필드가 `userId`, `nickname`, `profileImage`, `donationCan`인지 검증한다.
|
|
||||||
- `donations[0]`의 JSON 필드가 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`인지 검증한다.
|
|
||||||
- `hasNext`가 JSON에서 `hasNext`로 직렬화되는지 검증한다.
|
|
||||||
- facade가 query service 결과를 `CreatorChannelDonationTabResponse`로 변환하는지 검증한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest`
|
|
||||||
- Expected: DTO/facade가 없어 컴파일 실패한다.
|
|
||||||
- GREEN: DTO와 facade를 최소 구현한다.
|
|
||||||
- `CreatorChannelDonationFacade.getDonationTab(creatorId, viewer, page, size, now)`는 query service를 호출하고 `CreatorChannelDonationTabResponse.from(...)`을 반환한다.
|
|
||||||
- DTO의 `createdAtUtc` 변환은 기존 `toUtcIso`를 사용한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: DTO가 도메인 model만 import하고 persistence/legacy 타입을 import하지 않는지 확인한다.
|
|
||||||
- Run: `rg -n "adapter\\.out|explorer\\.profile" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
|
|
||||||
- Expected: 검색 결과 0건
|
|
||||||
- 실행 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest` 실행, DTO/facade/query service 경계 부재로 `compileTestKotlin` 실패 확인.
|
|
||||||
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 보완: Phase 2 전 공개 endpoint가 내부 `UnsupportedOperationException`으로 실패하지 않도록 query service placeholder를 `SodaException(messageKey = "common.error.invalid_request")`로 고정하고 `CreatorChannelDonationQueryServiceTest`를 추가했다.
|
|
||||||
- 보완 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest` 실행, RED에서 `UnsupportedOperationException` 실패 확인 후 GREEN에서 `BUILD SUCCESSFUL` 확인.
|
|
||||||
- REFACTOR: `rg -n "adapter\\.out|explorer\\.profile" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
|
||||||
|
|
||||||
- [x] **Task 1.3: controller와 인증/API 계약 추가**
|
|
||||||
- 파일:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
|
|
||||||
- RED: `CreatorChannelDonationControllerTest`를 먼저 작성한다.
|
|
||||||
- 비회원 요청 `GET /api/v2/creator-channels/1/donations`는 401 또는 기존 테스트 보안 설정 기준 인증 실패로 거부되는지 검증한다.
|
|
||||||
- 인증 회원 요청은 `page`, `size`, `creatorId`, `viewer`를 facade에 전달하는지 검증한다.
|
|
||||||
- 성공 응답 JSON에 `data.donationCount`, `data.rankings[0].userId`, `data.donations[0].createdAtUtc`, `data.page`, `data.size`, `data.hasNext`가 포함되는지 검증한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest`
|
|
||||||
- Expected: controller가 없어 컴파일 실패한다.
|
|
||||||
- GREEN: controller를 최소 구현한다.
|
|
||||||
- `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/donations")`를 사용한다.
|
|
||||||
- `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")`로 회원을 받고, null이면 `SodaException(messageKey = "common.error.bad_credentials")`를 던진다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 기존 FanTalk/커뮤니티 controller와 request mapping 스타일이 같은지 확인한다.
|
|
||||||
- Run: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
|
|
||||||
- Expected: controller class와 endpoint mapping 각 1건 확인
|
|
||||||
- 실행 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest` 실행, controller 부재로 `compileTestKotlin` 실패 확인.
|
|
||||||
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 보완: Phase 2 전 미완성 endpoint가 기본 운영 컨텍스트에 노출되지 않도록 `@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")`를 추가했다.
|
|
||||||
- 보완 검증: controller annotation 계약 테스트를 추가하고 RED에서 조건부 등록 annotation 부재 실패 확인 후 GREEN에서 `BUILD SUCCESSFUL` 확인.
|
|
||||||
- REFACTOR: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, controller class와 endpoint mapping 각 1건 확인.
|
|
||||||
|
|
||||||
### Phase 2: 도메인 조회 서비스와 legacy ranking 재사용 추가
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 후원 탭 query service 추가**
|
|
||||||
- 파일:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
|
|
||||||
- RED: fake `CreatorChannelDonationQueryPort`, fake `CreatorChannelDonationRankingPort`를 사용해 query service 테스트를 먼저 작성한다.
|
|
||||||
- creator가 없으면 `member.validation.user_not_found` 예외를 던지는지 검증한다.
|
|
||||||
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던지는지 검증한다.
|
|
||||||
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 차단 메시지 예외를 던지는지 검증한다.
|
|
||||||
- `page = -1`, `size = 10` 요청이 `offset = 0`, `limit = 21`로 port에 전달되고 응답은 size 20으로 잘리는지 검증한다.
|
|
||||||
- 조회자 본인이 크리에이터이면 `isVisibleDonationRank = false`여도 ranking port를 호출하고 `withDonationCan = true`가 전달되는지 검증한다.
|
|
||||||
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = WEEKLY`이면 ranking port에 `period = WEEKLY`, `withDonationCan = false`가 전달되고 `rankings`가 반환되는지 검증한다.
|
|
||||||
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = CUMULATIVE`이면 ranking port에 `period = CUMULATIVE`, `withDonationCan = false`가 전달되는지 검증한다.
|
|
||||||
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = null`이면 ranking port에 `period = CUMULATIVE`가 전달되는지 검증한다.
|
|
||||||
- 조회자 본인이 아니고 `isVisibleDonationRank = false`이면 ranking port를 호출하지 않고 `rankings`가 빈 배열인지 검증한다.
|
|
||||||
- 후원 순위가 비공개라 `rankings`가 빈 배열이어도 `donationCount`, `donations`, `page`, `size`, `hasNext`가 정상 조립되는지 검증한다.
|
|
||||||
- donation 작성자 닉네임의 삭제 prefix 제거, profileImagePath CDN 변환, 기본 프로필 이미지 fallback, null message의 빈 문자열 변환을 검증한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest`
|
|
||||||
- Expected: query service가 없어 컴파일 실패한다.
|
|
||||||
- GREEN: query service를 최소 구현한다.
|
|
||||||
- `ObjectProvider<CreatorChannelDonationQueryPort>` 패턴을 사용해 기존 FanTalk query service와 같은 순환 의존 회피 스타일을 따른다.
|
|
||||||
- `CreatorChannelDonationRankingPort`는 생성자 주입한다.
|
|
||||||
- `DonationRankingPeriod`는 creator record 값이 null이면 `DonationRankingPeriod.CUMULATIVE`로 보정한다.
|
|
||||||
- `isVisibleDonationRank`가 false이고 조회자가 크리에이터 본인이 아니면 ranking port를 호출하지 않는다.
|
|
||||||
- `isVisibleDonationRank`가 true이거나 조회자가 크리에이터 본인이면 ranking port를 호출하고 creator의 ranking period를 그대로 전달한다.
|
|
||||||
- `findChannelDonations(...)` 결과는 `limitItems` 적용 후 domain으로 변환한다.
|
|
||||||
- `hasNext`는 fetch 결과 크기로 계산한다.
|
|
||||||
- Phase 1 임시 보호장치를 함께 정리한다.
|
|
||||||
- `CreatorChannelDonationQueryService.getDonationTab(...)`의 placeholder `SodaException(messageKey = "common.error.invalid_request")`를 실제 구현으로 대체한다.
|
|
||||||
- placeholder 전용 `CreatorChannelDonationQueryServiceTest`는 실제 query service 동작 테스트로 교체하고, placeholder 오류 검증은 제거한다.
|
|
||||||
- `CreatorChannelDonationController`의 `@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")`와 관련 import를 제거해 endpoint가 기본 Spring context에 등록되도록 한다.
|
|
||||||
- `CreatorChannelDonationControllerTest`의 `@TestPropertySource(properties = ["creator-channel.donation-tab.enabled=true"])`와 conditional annotation 검증 테스트를 제거한다.
|
|
||||||
- 별도 feature flag rollout 정책을 유지하기로 결정한 경우에만 위 controller 조건부 등록을 남기고, 그 결정 사유와 활성화 설정 위치를 이 문서에 추가한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: query service가 API DTO를 import하지 않는지 확인한다.
|
|
||||||
- Run: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
|
|
||||||
- Expected: 검색 결과 0건
|
|
||||||
- 실행 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest` 실행, 새 fake port 기반 테스트가 기존 placeholder service 생성자/동작과 맞지 않아 `compileTestKotlin` 실패 확인. 같은 실행에서 당시 존재하던 Phase 2.2 repository 테스트의 미구현 repository 참조도 함께 컴파일 실패로 노출됨.
|
|
||||||
- GREEN 보정 전: 동일 명령 실행, service 구현 후 테스트 실행까지 진행됐고 차단 메시지 기대값이 실제 `explorer.creator.blocked_access` 한국어 템플릿과 달라 1건 실패 확인.
|
|
||||||
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- Controller regression: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- REFACTOR: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
|
||||||
- REFACTOR: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: 채널 후원 QueryDSL repository 추가**
|
|
||||||
- 파일:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
|
|
||||||
- RED: `@DataJpaTest`로 repository 테스트를 먼저 작성한다.
|
|
||||||
- 활성 creator는 role, nickname, `isVisibleDonationRank`, `donationRankingPeriod`를 조회하고 비활성 회원은 조회하지 않는지 검증한다.
|
|
||||||
- 조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회하는지 검증한다.
|
|
||||||
- 현재 KST 월 범위의 채널 후원만 count/list에 포함되는지 검증한다.
|
|
||||||
- 크리에이터 본인은 비공개 후원까지 count/list에 포함되는지 검증한다.
|
|
||||||
- 일반 조회자는 공개 후원과 본인의 비공개 후원만 count/list에 포함되는지 검증한다.
|
|
||||||
- 목록 정렬이 `createdAt desc`, `id desc`인지 검증한다.
|
|
||||||
- `offset`, `limit`이 적용되는지 검증한다.
|
|
||||||
- projection이 `selectFrom(channelDonationMessage)`가 아니라 필요한 컬럼 projection을 사용하는지 소스 문자열 또는 동작 테스트로 확인한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest`
|
|
||||||
- Expected: repository가 없어 컴파일 실패한다.
|
|
||||||
- GREEN: QueryDSL repository를 최소 구현한다.
|
|
||||||
- `findCreator(...)`는 활성 회원만 조회하고 role이 USER인 회원도 record로 반환해 service에서 `creator_not_found`를 판단하게 한다.
|
|
||||||
- `existsBlockedBetween(...)`은 기존 홈/FanTalk repository의 차단 조건과 동일하게 구현한다.
|
|
||||||
- `countChannelDonations(...)`와 `findChannelDonations(...)`는 `CreatorChannelDonationQueryPolicy.currentKstMonthRange(now)` 결과와 같은 월 범위 조건을 적용한다.
|
|
||||||
- `donationVisibilityCondition(creatorId, viewerId)`는 홈 API의 조건과 동일하게 구현한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 홈 repository의 기존 `findChannelDonations` 공개 동작이 변경되지 않았는지 관련 테스트를 실행한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- 실행 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest` 실행, 신규 repository 부재로 `Unresolved reference: DefaultCreatorChannelDonationQueryRepository` 실패 확인.
|
|
||||||
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 보완: `ktlintCheck`에서 repository 테스트의 긴 `saveDonation(...)` 호출 1곳이 실패해 줄바꿈만 수정했다.
|
|
||||||
- 재검증: Phase 2 focused 테스트 묶음 재실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: legacy 후원 랭킹 adapter 추가**
|
|
||||||
- 파일:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt`
|
|
||||||
- RED: mock `CreatorDonationRankingService`를 사용해 adapter 테스트를 먼저 작성한다.
|
|
||||||
- `findTopRankings(creatorId = 1, period = CUMULATIVE, withDonationCan = false)` 호출 시 legacy service에 `offset = 0`, `limit = 8`, `withDonationCan = false`, 같은 period가 전달되는지 검증한다.
|
|
||||||
- `findTopRankings(creatorId = 1, period = WEEKLY, withDonationCan = true)` 호출 시 legacy service에 `offset = 0`, `limit = 8`, `withDonationCan = true`, `period = WEEKLY`가 전달되는지 검증한다.
|
|
||||||
- legacy `MemberDonationRankingResponse` 결과가 `CreatorChannelDonationRankingRecord`로 필드 손실 없이 변환되는지 검증한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest`
|
|
||||||
- Expected: adapter가 없어 컴파일 실패한다.
|
|
||||||
- GREEN: `CreatorChannelDonationRankingPort` 구현체를 최소 구현한다.
|
|
||||||
- `CreatorDonationRankingService.getMemberDonationRanking(creatorId, offset = 0, limit = 8, withDonationCan, period)`를 호출한다.
|
|
||||||
- 반환값의 `userId`, `nickname`, `profileImage`, `donationCan`을 record로 복사한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 랭킹 산식이나 기간 계산을 V2 코드에 중복 구현하지 않았는지 확인한다.
|
|
||||||
- Run: `rg -n "previousOrSame|SPIN_ROULETTE|CanUsage\\.DONATION|creator_donation_ranking" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
|
|
||||||
- Expected: 검색 결과 0건
|
|
||||||
- 실행 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest` 실행, 신규 adapter 부재로 `Unresolved reference: LegacyCreatorChannelDonationRankingAdapter` 실패 확인.
|
|
||||||
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- REFACTOR: `rg -n "previousOrSame|SPIN_ROULETTE|CanUsage\.DONATION|creator_donation_ranking" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
|
||||||
- 재검증: Phase 2 focused 테스트 묶음 재실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
### Phase 3: 통합 검증과 회귀 확인
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 후원 탭 End-to-End 테스트 추가**
|
|
||||||
- 파일:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
|
|
||||||
- RED: `@SpringBootTest` + `MockMvc` 통합 테스트를 먼저 작성한다.
|
|
||||||
- 별도 `creator-channel.donation-tab.enabled` 테스트 property 없이 기본 Spring context에서 후원 탭 endpoint가 등록되는지 검증한다.
|
|
||||||
- controller-service-repository를 거쳐 후원 탭 API가 `donationCount`, `donations`, `page`, `size`, `hasNext`를 반환하는지 검증한다.
|
|
||||||
- `page` 범위 밖 요청은 빈 `donations`, 유지된 `donationCount`, `hasNext = false`를 반환하는지 검증한다.
|
|
||||||
- `page = -1`, `size = 100` 요청은 응답의 `page = 0`, `size = 50`으로 보정되는지 검증한다.
|
|
||||||
- 일반 조회자에게 크리에이터의 비공개 후원은 숨기고 조회자 본인의 비공개 후원은 노출하는지 검증한다.
|
|
||||||
- 일반 조회자가 `isVisibleDonationRank = false`인 크리에이터 채널을 조회하면 `rankings`는 빈 배열이고 `donationCount`, `donations`, `page`, `size`, `hasNext`는 정상 반환되는지 검증한다.
|
|
||||||
- 크리에이터 본인 조회 시 비공개 후원과 `donationCan` 값이 포함된 ranking이 내려오는지 검증한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
|
||||||
- Expected: 통합 wiring 또는 신규 API가 없어 실패한다.
|
|
||||||
- GREEN: 누락된 Spring bean wiring, package scan, constructor 주입 문제를 최소 수정한다.
|
|
||||||
- 신규 repository/adapter/service/controller가 component scan 대상 package에 들어가야 한다.
|
|
||||||
- 테스트 데이터는 `ChannelDonationMessage`, `UseCan`, `UseCanCalculate` 등 기존 엔티티 저장 방식에 맞춰 생성한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: End-to-End 테스트 fixture helper 중복을 줄이되 테스트 의도를 흐리지 않는 범위에서만 정리한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- 실행 기록:
|
|
||||||
- E2E: `CreatorChannelDonationEndToEndTest`를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, 기존 Phase 2 wiring으로 `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 검증 범위: 기본 Spring context endpoint 등록, controller-service-repository-legacy ranking 통합, page 범위 밖 응답, page/size 보정, 일반 조회자 비공개 후원/랭킹 숨김, 크리에이터 본인 비공개 후원 및 `donationCan` 노출을 확인.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 관련 테스트와 아키텍처 의존 방향 검증**
|
|
||||||
- 파일:
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
|
|
||||||
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/prd.md`
|
|
||||||
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md`
|
|
||||||
- RED: 이 task는 신규 실패 테스트 작성 대상이 아니라 구현 완료 후 회귀/아키텍처 검증 task다.
|
|
||||||
- TDD 예외 사유: 개별 동작 실패 테스트는 Task 1.1부터 Task 3.1까지 작성한다. 이 task는 전체 검증과 문서 상태 확인만 담당한다.
|
|
||||||
- 대체 검증 방법: 관련 단일 테스트 묶음, import 검색, ktlint를 실행한다.
|
|
||||||
- GREEN: 관련 테스트를 묶어서 실행한다.
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- REFACTOR: 의존 방향과 포맷을 검증한다.
|
|
||||||
- Run: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
|
|
||||||
- Expected: 검색 결과 0건
|
|
||||||
- Run: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2`
|
|
||||||
- Expected: 후원 탭 controller와 endpoint mapping 각 1건 확인
|
|
||||||
- Run: `rg -n "ConditionalOnProperty|creator-channel\\.donation-tab\\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
|
|
||||||
- Expected: 별도 feature flag rollout 정책을 유지하기로 문서화한 경우가 아니라면 검색 결과 0건
|
|
||||||
- Run: `./gradlew ktlintCheck`
|
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
|
||||||
- 실행 기록:
|
|
||||||
- 관련 테스트 묶음: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 의존 방향: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
|
||||||
- endpoint mapping: `rg -n "class CreatorChannelDonationController|/\{creatorId\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2` 실행, controller class와 endpoint mapping 각 1건 확인.
|
|
||||||
- feature flag: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
|
||||||
- format: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 구현 순서
|
|
||||||
|
|
||||||
1. Phase 1에서 공개 계약, domain/port, page/month 정책, facade/controller를 먼저 고정한다.
|
|
||||||
2. Phase 2에서 query service, QueryDSL repository, legacy ranking adapter를 TDD로 추가한다.
|
|
||||||
3. Phase 3에서 End-to-End 테스트와 아키텍처/포맷 검증을 수행한다.
|
|
||||||
4. 각 task 완료 즉시 해당 체크박스를 `- [x]`로 변경하고, 실행한 명령과 결과를 task 아래에 한국어로 누적 기록한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 전체 검증 기록
|
|
||||||
|
|
||||||
- Phase 1 검증은 각 Task 실행 기록에 누적했다.
|
|
||||||
- Phase 2 검증은 각 Task 실행 기록에 누적했다.
|
|
||||||
- Phase 3 검증은 Task 3.1, Task 3.2 실행 기록에 누적했다. 단일 E2E, 관련 테스트 묶음, 의존 방향 검색, endpoint mapping 검색, feature flag 검색, `ktlintCheck` 모두 성공했다.
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
# PRD: 크리에이터 채널 후원 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
크리에이터 채널의 후원 탭에서 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록을 페이징 조회하는 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 크리에이터 채널 홈 API는 후원 섹션에 최신 채널 후원 일부만 제공한다.
|
|
||||||
- 후원 탭은 홈 요약보다 더 많은 채널 후원 목록을 추가 로딩해야 하고, 전체 채널 후원 개수와 후원 순위 Top 8을 함께 표시해야 한다.
|
|
||||||
- 레거시 채널 후원 목록 API는 `/explorer/profile/channel-donation`에 있고, V2 크리에이터 채널 탭 API의 패키지 분리 구조와 맞지 않는다.
|
|
||||||
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 하므로 새 집계 기준을 임의로 만들면 안 된다.
|
|
||||||
- 레거시 프로필의 후원 순위는 크리에이터 설정에 따라 비공개, 주간 공개, 전체 공개가 가능하므로 후원 탭 API도 같은 공개 범위와 기간 정책을 따라야 한다.
|
|
||||||
- 신규 API는 기존 V2 크리에이터 채널 탭과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 크리에이터 채널 후원 탭 조회 API를 제공한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 한다.
|
|
||||||
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 하위 조립 계층에 둔다.
|
|
||||||
- 후원 개수, 후원 순위, 후원 목록, 페이징 보정, 비공개 후원 노출 조건 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 도메인 조회 계층에 둔다.
|
|
||||||
- 응답에는 조회 가능한 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록, page, size, hasNext를 포함한다.
|
|
||||||
- 채널 후원 목록 item의 내용은 크리에이터 채널 홈 API의 `channelDonations` 섹션과 동일한 필드 의미를 사용한다.
|
|
||||||
- 후원 순위 Top 8 item은 기존 `MemberDonationRankingResponse`와 동일한 결과 리스트 구조를 사용한다.
|
|
||||||
- 페이징 요청값은 page 기본값 `0`, size 기본값 `20`, size 허용 범위 `20..50`으로 보정한다.
|
|
||||||
- V2 패키지에 있는 기존 크리에이터 채널 탭 패턴과 홈 후원 섹션 조회 로직 중 재사용 가능한 것을 확인하고 재사용한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 채널 후원 생성 API는 포함하지 않는다.
|
|
||||||
- 채널 후원 수정, 삭제, 환불 API는 포함하지 않는다.
|
|
||||||
- 후원 순위 산식, 포함 `CanUsage`, 정렬 기준 변경은 포함하지 않는다.
|
|
||||||
- 크리에이터의 후원 순위 노출 설정 변경 API는 포함하지 않는다.
|
|
||||||
- 레거시 `/explorer/profile/channel-donation` endpoint나 응답 스키마 변경은 포함하지 않는다.
|
|
||||||
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
|
|
||||||
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
|
||||||
- 후원 메시지 기본 문구 조합 정책을 새로 만들지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 크리에이터 채널 후원 탭에서 다른 팬들의 채널 후원 내역과 후원 순위를 확인하는 사용자
|
|
||||||
- 크리에이터: 자신의 채널 후원 내역과 후원 순위를 확인하는 사용자
|
|
||||||
- 앱 클라이언트: 후원 탭 구성에 필요한 개수, 랭킹, 목록, 추가 로딩 상태를 단일 API 응답으로 표시하려는 클라이언트
|
|
||||||
- 서버 개발자: 레거시 후원 저장 구조와 랭킹 쿼리를 보존하면서 V2 조회 계층을 분리하려는 개발자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 크리에이터 채널 후원 탭에 들어가면 전체 채널 후원 개수를 확인하고 싶다.
|
|
||||||
- 사용자는 해당 크리에이터의 후원 순위 Top 8을 확인하고 싶다.
|
|
||||||
- 사용자는 크리에이터가 후원 순위를 공개하지 않은 채널에서는 후원 순위 없이 채널 후원 목록만 확인한다.
|
|
||||||
- 크리에이터는 후원 순위를 공개하지 않은 경우에도 본인 채널에서 자신의 후원 순위를 확인하고 싶다.
|
|
||||||
- 사용자는 채널 후원 목록을 최신순으로 추가 로딩하고 싶다.
|
|
||||||
- 사용자는 후원자 닉네임, 프로필 이미지, 후원 캔 수, 메시지, 후원 시간을 목록 item에서 바로 확인하고 싶다.
|
|
||||||
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
|
|
||||||
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 크리에이터 채널 후원 탭 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
|
|
||||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 한다.
|
|
||||||
- `creatorId`는 path variable로 받는다.
|
|
||||||
- 채널 후원 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
|
||||||
- `page`는 0부터 시작하는 page index로 처리한다.
|
|
||||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
|
||||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
|
||||||
- `page`가 0보다 작으면 `0`으로 보정한다.
|
|
||||||
- `size`가 20보다 작으면 `20`으로 보정한다.
|
|
||||||
- `size`가 50보다 크면 `50`으로 보정한다.
|
|
||||||
- API는 인증 회원만 조회할 수 있어야 한다.
|
|
||||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
|
||||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
|
||||||
- 조회 가능한 채널 후원이 없어도 전체 API는 성공 처리한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
|
||||||
- 요청한 page 범위에 채널 후원이 없으면 `donations`는 빈 배열, `hasNext`는 `false`로 내려주되 `donationCount`는 전체 개수를 유지한다.
|
|
||||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
|
||||||
|
|
||||||
### Feature B. 응답 스키마
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
|
||||||
- 응답 최상위 DTO 이름은 `CreatorChannelDonationTabResponse`로 한다.
|
|
||||||
- 응답에는 다음 값을 포함한다.
|
|
||||||
- `donationCount`: 조회자가 조회 가능한 전체 채널 후원 개수
|
|
||||||
- `rankings`: 후원 순위 Top 8 목록
|
|
||||||
- `donations`: 채널 후원 목록
|
|
||||||
- `page`: 현재 응답의 page index
|
|
||||||
- `size`: 현재 응답의 page size
|
|
||||||
- `hasNext`: 다음 page 존재 여부
|
|
||||||
- `donationCount`는 현재 page에 포함되지 않은 채널 후원도 포함한다.
|
|
||||||
- `rankings`는 최대 8개만 내려준다.
|
|
||||||
- `rankings` item은 기존 `MemberDonationRankingResponse`와 동일하게 `userId`, `nickname`, `profileImage`, `donationCan`을 포함한다.
|
|
||||||
- `donations` item은 크리에이터 채널 홈 API의 `CreatorChannelDonationResponse`와 동일하게 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`를 포함한다.
|
|
||||||
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
|
||||||
- `hasNext`는 같은 조건에서 다음 page에 노출할 채널 후원이 있으면 `true`로 내려준다.
|
|
||||||
- 응답 스키마 예시는 다음과 같다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class CreatorChannelDonationTabResponse(
|
|
||||||
val donationCount: Int,
|
|
||||||
val rankings: List<MemberDonationRankingResponse>,
|
|
||||||
val donations: List<CreatorChannelDonationResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MemberDonationRankingResponse(
|
|
||||||
@JsonProperty("userId") val userId: Long,
|
|
||||||
@JsonProperty("nickname") val nickname: String,
|
|
||||||
@JsonProperty("profileImage") val profileImage: String,
|
|
||||||
@JsonProperty("donationCan") val donationCan: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorChannelDonationResponse(
|
|
||||||
val nickname: String,
|
|
||||||
val profileImageUrl: String,
|
|
||||||
val can: Int,
|
|
||||||
val message: String,
|
|
||||||
val createdAtUtc: String
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회 가능한 채널 후원이 없으면 `donationCount`는 `0`, `donations`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
|
||||||
- 노출 가능한 후원 순위가 없으면 `rankings`는 빈 배열로 내려준다.
|
|
||||||
- 크리에이터가 후원 순위를 공개하지 않았고 조회자가 크리에이터 본인이 아니면 채널 후원 목록은 정상 조회하되 `rankings`만 빈 배열로 내려준다.
|
|
||||||
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
|
|
||||||
- `createdAtUtc`는 `ChannelDonationMessage.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
|
|
||||||
- Boolean 응답 필드는 Jackson 직렬화 필드명을 명시한다.
|
|
||||||
|
|
||||||
### Feature C. 전체 채널 후원 개수와 목록
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 조회 대상은 지정한 `creatorId`의 채널 후원 메시지로 제한한다.
|
|
||||||
- 저장 엔티티는 기존 `ChannelDonationMessage`를 사용한다.
|
|
||||||
- 채널 후원 목록은 크리에이터 채널 홈 API의 후원 섹션과 동일하게 현재 KST 월 범위의 후원 메시지를 대상으로 한다.
|
|
||||||
- 현재 KST 월 범위는 `now`를 UTC로 받은 뒤 Asia/Seoul 기준 월 시작 이상, 다음 달 월 시작 미만으로 변환해 계산한다.
|
|
||||||
- 전체 채널 후원 개수는 목록과 같은 creator, 월 범위, 비공개 후원 노출 조건을 적용해 계산한다.
|
|
||||||
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
|
|
||||||
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
|
||||||
- 후원자 닉네임은 `ChannelDonationMessage.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
|
|
||||||
- 후원자 프로필 이미지는 `ChannelDonationMessage.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
|
|
||||||
- 후원 캔 수는 `ChannelDonationMessage.can`을 사용한다.
|
|
||||||
- 후원 메시지는 크리에이터 채널 홈 API와 동일하게 `ChannelDonationMessage.additionalMessage`가 없으면 빈 문자열로 내려준다.
|
|
||||||
- 후원 시간은 `ChannelDonationMessage.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회자가 크리에이터 본인이면 해당 크리에이터의 비공개 후원까지 목록과 개수에 포함한다.
|
|
||||||
- 조회자가 크리에이터 본인이 아니면 공개 후원과 조회자 본인의 비공개 후원만 목록과 개수에 포함한다.
|
|
||||||
- 비회원 조회는 허용하지 않으므로 비회원 기준 비공개 후원 필터는 별도로 만들지 않는다.
|
|
||||||
- 같은 회원이 여러 번 후원한 경우 목록에서는 각각 별도 item으로 내려준다.
|
|
||||||
|
|
||||||
### Feature D. 후원 순위 Top 8
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 한다.
|
|
||||||
- API 응답에는 Top 8만 내려준다.
|
|
||||||
- 호출 offset은 `0`, limit은 `8`을 사용한다.
|
|
||||||
- 순위 산식과 포함 후원 유형은 레거시 쿼리 기준을 따른다.
|
|
||||||
- `CanUsage.DONATION`
|
|
||||||
- `CanUsage.SPIN_ROULETTE`
|
|
||||||
- `CanUsage.LIVE`
|
|
||||||
- `CanUsage.CHANNEL_DONATION`
|
|
||||||
- 환불된 사용 내역은 제외한다.
|
|
||||||
- 비활성 회원은 제외한다.
|
|
||||||
- 정렬은 레거시 쿼리와 동일하게 `donationCan desc`, `member.id desc`를 따른다.
|
|
||||||
- 기간은 크리에이터의 `donationRankingPeriod` 설정을 따른다.
|
|
||||||
- `donationRankingPeriod`가 없으면 `DonationRankingPeriod.CUMULATIVE`를 사용한다.
|
|
||||||
- `DonationRankingPeriod.WEEKLY`는 기존 레거시 서비스의 주간 범위 계산을 따른다.
|
|
||||||
- `DonationRankingPeriod.CUMULATIVE`는 기존 레거시 서비스의 전체 누적 범위 계산을 따른다.
|
|
||||||
- 후원 순위 노출 정책은 기존 프로필 정책과 동일하게 유지한다.
|
|
||||||
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank`가 `true`이면 `rankings`를 내려준다.
|
|
||||||
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank`가 `false`이면 `rankings`는 빈 배열로 내려준다.
|
|
||||||
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank`가 `false`인 경우에도 `donationCount`, `donations`, `page`, `size`, `hasNext`는 후원 목록 조건대로 정상 조회한다.
|
|
||||||
- `donationCan` 노출 여부는 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 순위 대상 회원이 8명보다 적으면 있는 만큼만 내려준다.
|
|
||||||
- 같은 후원 캔 금액이면 레거시 쿼리와 동일하게 회원 ID 내림차순으로 정렬한다.
|
|
||||||
- 순위 조회 결과가 없어도 후원 탭 API는 성공 처리한다.
|
|
||||||
- 후원 순위 비공개로 `rankings`가 빈 배열인 경우와 실제 순위 결과가 없어 `rankings`가 빈 배열인 경우 모두 같은 응답 스키마를 사용한다.
|
|
||||||
|
|
||||||
### Feature E. V2 재사용 범위와 계층 분리
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 하위에 둔다.
|
|
||||||
- 후원 탭 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 하위에 둔다.
|
|
||||||
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
|
|
||||||
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
|
|
||||||
- 의존 방향은 항상 `v2.api.creator.channel.donation -> v2.creator.channel.donation`이다.
|
|
||||||
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
|
|
||||||
- `page`, `size`, `hasNext`, `limitItems` 정책은 기존 FanTalk/커뮤니티/시리즈 탭의 query policy 패턴을 재사용한다.
|
|
||||||
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
|
|
||||||
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
|
|
||||||
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
|
|
||||||
- 홈 API의 `findChannelDonations` 조회 조건과 응답 필드는 참고하되, 홈 도메인 repository에 후원 탭 페이징 책임을 추가하지 않는다.
|
|
||||||
- 후원 순위는 레거시 repository 또는 같은 쿼리 기준을 감싼 V2 port를 통해 재사용한다.
|
|
||||||
- 레거시 채널 후원 목록 API의 기본 메시지 조합(`buildMessage`)은 이번 V2 후원 탭 목록 응답에 재사용하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 신규 `donation` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
|
|
||||||
- 홈 API의 `channelDonations` 공개 응답 의미는 변경하지 않는다.
|
|
||||||
- legacy 후원 생성/목록 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technical Constraints
|
|
||||||
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
|
||||||
- 언어/런타임은 Kotlin + Java 17을 따른다.
|
|
||||||
- 프레임워크는 Spring Boot 2.7.14를 따른다.
|
|
||||||
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
|
|
||||||
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
|
|
||||||
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Decisions
|
|
||||||
- endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 확정한다.
|
|
||||||
- page는 0 기반 page index로 처리한다.
|
|
||||||
- page 기본값은 `0`, size 기본값은 `20`으로 한다.
|
|
||||||
- page가 0 미만이면 `0`으로 보정한다.
|
|
||||||
- size가 20 미만이면 `20`, 50 초과이면 `50`으로 보정한다.
|
|
||||||
- 채널 후원 목록 item은 크리에이터 채널 홈 API의 `CreatorChannelDonationResponse`와 같은 필드 의미를 사용한다.
|
|
||||||
- 후원 순위 Top 8 item은 기존 `MemberDonationRankingResponse`와 같은 필드 의미를 사용한다.
|
|
||||||
- 후원 순위 산식은 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 기준을 변경하지 않는다.
|
|
||||||
- 후원 순위 공개 여부는 `isVisibleDonationRank`, 기간은 `donationRankingPeriod` 기준으로 판단한다.
|
|
||||||
- 채널 후원 목록과 개수의 기간은 홈 후원 섹션과 동일하게 현재 KST 월 범위로 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions
|
|
||||||
- 없음. 구현 중 공개 응답 필드 추가나 기간 정책 변경이 필요하면 이 PRD를 먼저 갱신한다.
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
-- MySQL 메인 콘텐츠 랭킹 탭 스냅샷 테이블
|
|
||||||
-- 날짜/시간 표시 컬럼은 TIMESTAMP를 사용한다.
|
|
||||||
-- 같은 랭킹 타입/기간 재생성 시 삭제 기준:
|
|
||||||
-- delete from content_ranking_snapshot
|
|
||||||
-- where ranking_type = :rankingType
|
|
||||||
-- and aggregation_start_at_utc = :aggregationStartAtUtc
|
|
||||||
-- and aggregation_end_at_utc = :aggregationEndAtUtc;
|
|
||||||
|
|
||||||
create table content_ranking_snapshot (
|
|
||||||
id bigint not null auto_increment comment '콘텐츠 랭킹 스냅샷 ID',
|
|
||||||
ranking_type varchar(30) not null comment '랭킹 타입(WEEKLY_POPULAR, RISING, REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT)',
|
|
||||||
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
|
|
||||||
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
|
|
||||||
visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)',
|
|
||||||
content_id bigint not null comment '오디오 콘텐츠 ID',
|
|
||||||
title varchar(255) not null comment '스냅샷 생성 시점 콘텐츠 제목',
|
|
||||||
creator_member_id bigint not null comment '크리에이터 회원 ID(member.id)',
|
|
||||||
creator_nickname varchar(100) not null comment '스냅샷 생성 시점 크리에이터 닉네임',
|
|
||||||
cover_image_url varchar(500) null comment '스냅샷 생성 시점 콘텐츠 커버 이미지 URL',
|
|
||||||
release_date timestamp not null comment '콘텐츠 공개 시각',
|
|
||||||
is_adult tinyint(1) not null default 0 comment '스냅샷 생성 시점 성인 콘텐츠 여부',
|
|
||||||
rank_no int not null comment '스냅샷 생성 시점 순위',
|
|
||||||
final_score double not null comment '최종 랭킹 점수 또는 정렬 지표',
|
|
||||||
normalized_score double null comment '유료/무료 그룹 정규화 점수',
|
|
||||||
raw_score double null comment '정규화 전 원점수',
|
|
||||||
revenue_can_amount bigint null comment '집계 기간 매출 캔 합계',
|
|
||||||
sales_count bigint null comment '집계 기간 판매량',
|
|
||||||
view_count bigint null comment '집계 기간 상세 페이지 조회수',
|
|
||||||
like_count bigint null comment '집계 기간 좋아요 수',
|
|
||||||
comment_count bigint null comment '집계 기간 댓글 수',
|
|
||||||
previous_sales_count bigint null comment '직전 비교 기간 판매량',
|
|
||||||
previous_view_count bigint null comment '직전 비교 기간 상세 페이지 조회수',
|
|
||||||
previous_like_count bigint null comment '직전 비교 기간 좋아요 수',
|
|
||||||
previous_comment_count bigint null comment '직전 비교 기간 댓글 수',
|
|
||||||
sales_growth_rate double null comment '판매 증가율',
|
|
||||||
view_growth_rate double null comment '조회수 증가율',
|
|
||||||
like_growth_rate double null comment '좋아요 증가율',
|
|
||||||
comment_growth_rate double null comment '댓글 증가율',
|
|
||||||
content_growth_score double null comment '지금 뜨는 중 콘텐츠 성장 점수',
|
|
||||||
boost_multiplier double 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)
|
|
||||||
) engine=InnoDB default charset=utf8mb4 comment='메인 콘텐츠 랭킹 탭 주간 스냅샷';
|
|
||||||
|
|
||||||
create unique index uk_content_ranking_snapshot_period_content
|
|
||||||
on content_ranking_snapshot (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, content_id);
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_period_rank
|
|
||||||
on content_ranking_snapshot (ranking_type, aggregation_end_at_utc, rank_no);
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_visible_rank
|
|
||||||
on content_ranking_snapshot (ranking_type, visible_from_at desc, rank_no);
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_visible_adult_rank
|
|
||||||
on content_ranking_snapshot (ranking_type, visible_from_at desc, is_adult, rank_no);
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_period_score
|
|
||||||
on content_ranking_snapshot (ranking_type, aggregation_end_at_utc, final_score desc, release_date desc, content_id desc);
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_content
|
|
||||||
on content_ranking_snapshot (content_id);
|
|
||||||
|
|
||||||
create table content_ranking_snapshot_job (
|
|
||||||
id bigint not null auto_increment comment '콘텐츠 랭킹 스냅샷 생성 job ID',
|
|
||||||
ranking_type varchar(30) not null comment '랭킹 타입(WEEKLY_POPULAR, RISING, REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT)',
|
|
||||||
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
|
|
||||||
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
|
|
||||||
visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)',
|
|
||||||
trigger_type varchar(20) not null comment '실행 트리거(SCHEDULED, MANUAL, FALLBACK)',
|
|
||||||
status varchar(20) not null comment 'job 상태(PENDING, PROCESSING, DONE, FAILED)',
|
|
||||||
last_error text null comment '마지막 실패 사유',
|
|
||||||
processing_started_at timestamp null comment '처리 시작 시각',
|
|
||||||
processed_at timestamp 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)
|
|
||||||
) engine=InnoDB default charset=utf8mb4 comment='메인 콘텐츠 랭킹 탭 스냅샷 생성 job 이력';
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_job_period_status
|
|
||||||
on content_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, status);
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_job_visible_status
|
|
||||||
on content_ranking_snapshot_job (ranking_type, visible_from_at, status);
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_job_trigger_period
|
|
||||||
on content_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, trigger_type, created_at);
|
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_job_status_created_at
|
|
||||||
on content_ranking_snapshot_job (status, created_at);
|
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
# 메인 콘텐츠 랭킹 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** `GET /api/v2/audio/rankings`로 메인 콘텐츠 랭킹 탭의 6개 랭킹 타입을 스냅샷 기반으로 조회하고, 순위/순위 변화/신규 진입 여부를 안정적으로 제공한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.ranking` 조립 계층에 둔다. 콘텐츠 랭킹 계산, 스냅샷 조회/생성, fallback, scheduler는 `kr.co.vividnext.sodalive.v2.content.ranking` 하위에 두고 `v2.api.*`에 의존하지 않는다. 스냅샷은 `rankingType + aggregation period + visibleFromAt`을 기준으로 저장하고, 조회 API는 `visibleFromAt <= now`인 생성 완료 스냅샷만 공개 응답에 사용한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL/native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/audio/rankings`
|
|
||||||
- 요청 query parameter: `type`, 기본값 `WEEKLY_POPULAR`
|
|
||||||
- 랭킹 타입:
|
|
||||||
- `WEEKLY_POPULAR`: 주간 인기
|
|
||||||
- `RISING`: 지금 뜨는 중
|
|
||||||
- `REVENUE`: 매출
|
|
||||||
- `SALES_COUNT`: 판매량
|
|
||||||
- `COMMENT_COUNT`: 댓글 수
|
|
||||||
- `LIKE_COUNT`: 좋아요
|
|
||||||
- 모든 랭킹 타입은 완료된 지난 주 데이터를 기준으로 한다.
|
|
||||||
- 집계 기준 시각: 매주 월요일 `00:00:00 KST`
|
|
||||||
- 스냅샷 생성 시간대: 매주 월요일 `01:00:00 ~ 07:30:00 KST` 사이 랭킹 타입별 분산 실행
|
|
||||||
- 새 스냅샷 노출 전환 시각: 매주 월요일 `09:00:00 KST`
|
|
||||||
- 조회 API는 `visibleFromAt <= now`인 최신 완료 스냅샷만 응답한다.
|
|
||||||
- 09:00 전에는 새 스냅샷이 생성되어도 직전 공개 스냅샷을 응답한다.
|
|
||||||
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
|
|
||||||
- fallback은 요청한 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 실행한다.
|
|
||||||
- 이번 범위는 콘텐츠 랭킹만 수정한다.
|
|
||||||
- 크리에이터 랭킹의 생성 시간/표시 시간 분리와 다중 랭킹 타입 대응은 다음 범위에서 별도 PRD 문서 수정부터 시작한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`
|
|
||||||
|
|
||||||
### 신규 콘텐츠 랭킹 도메인 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingType.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicyTest.kt`
|
|
||||||
|
|
||||||
### 신규 콘텐츠 랭킹 application/port
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotJobPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingBlockPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
|
|
||||||
|
|
||||||
### 신규 persistence/scheduler
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJobRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingBlockRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt`
|
|
||||||
|
|
||||||
### 문서/DDL
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.content.ranking.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
|
||||||
|
|
||||||
data class AudioRankingResponse(
|
|
||||||
val showRankChange: Boolean,
|
|
||||||
val type: AudioRankingType,
|
|
||||||
val items: List<AudioRankingItemResponse>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(ranking: AudioRanking): AudioRankingResponse {
|
|
||||||
return AudioRankingResponse(
|
|
||||||
showRankChange = ranking.showRankChange,
|
|
||||||
type = ranking.type,
|
|
||||||
items = ranking.items.map(AudioRankingItemResponse::from)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AudioRankingItemResponse(
|
|
||||||
val contentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val rank: Int,
|
|
||||||
val rankChange: Int?,
|
|
||||||
@JsonProperty("isNew")
|
|
||||||
val isNew: Boolean,
|
|
||||||
val coverImageUrl: String?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(item: AudioRankingItem): AudioRankingItemResponse {
|
|
||||||
return AudioRankingItemResponse(
|
|
||||||
contentId = item.contentId,
|
|
||||||
title = item.title,
|
|
||||||
creatorNickname = item.creatorNickname,
|
|
||||||
rank = item.rank,
|
|
||||||
rankChange = item.rankChange,
|
|
||||||
isNew = item.isNew,
|
|
||||||
coverImageUrl = item.coverImageUrl
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.content.ranking.domain
|
|
||||||
|
|
||||||
enum class AudioRankingType {
|
|
||||||
WEEKLY_POPULAR,
|
|
||||||
RISING,
|
|
||||||
REVENUE,
|
|
||||||
SALES_COUNT,
|
|
||||||
COMMENT_COUNT,
|
|
||||||
LIKE_COUNT
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AudioRanking(
|
|
||||||
val showRankChange: Boolean,
|
|
||||||
val type: AudioRankingType,
|
|
||||||
val items: List<AudioRankingItem>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AudioRankingItem(
|
|
||||||
val contentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val rank: Int,
|
|
||||||
val rankChange: Int?,
|
|
||||||
val isNew: Boolean,
|
|
||||||
val coverImageUrl: String?
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.content.ranking.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface AudioRankingSnapshotPort {
|
|
||||||
fun findLatestVisibleSnapshots(
|
|
||||||
rankingType: AudioRankingType,
|
|
||||||
nowUtc: LocalDateTime
|
|
||||||
): List<AudioRankingSnapshotRecord>
|
|
||||||
|
|
||||||
fun findPreviousVisibleSnapshots(
|
|
||||||
rankingType: AudioRankingType,
|
|
||||||
currentAggregationStartAtUtc: LocalDateTime,
|
|
||||||
nowUtc: LocalDateTime
|
|
||||||
): List<AudioRankingSnapshotRecord>
|
|
||||||
|
|
||||||
fun replaceSnapshots(
|
|
||||||
rankingType: AudioRankingType,
|
|
||||||
aggregationStartAtUtc: LocalDateTime,
|
|
||||||
aggregationEndAtUtc: LocalDateTime,
|
|
||||||
visibleFromAtUtc: LocalDateTime,
|
|
||||||
newSnapshots: List<AudioRankingSnapshotRecord>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AudioRankingSnapshotRecord(
|
|
||||||
val rankingType: AudioRankingType,
|
|
||||||
val aggregationStartAtUtc: LocalDateTime,
|
|
||||||
val aggregationEndAtUtc: LocalDateTime,
|
|
||||||
val visibleFromAtUtc: LocalDateTime,
|
|
||||||
val contentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val creatorMemberId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val releaseDate: LocalDateTime,
|
|
||||||
val rank: Int,
|
|
||||||
val finalScore: Double
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: API 계약과 DTO
|
|
||||||
|
|
||||||
- [x] **Task 1.1: `AudioRankingType`과 응답 DTO 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingType.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponseTest.kt`
|
|
||||||
- RED: `AudioRankingResponse.from(...)`이 `showRankChange`, `type`, `contentId`, `title`, `creatorNickname`, `rank`, `rankChange`, `isNew`, `coverImageUrl`을 변환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponseTest`
|
|
||||||
- GREEN: DTO와 domain model을 최소 구현한다.
|
|
||||||
- REFACTOR: 공개 DTO가 persistence/entity를 import하지 않도록 확인한다.
|
|
||||||
- 기대 결과: PRD의 Response data class 계약이 테스트로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: facade 변환 계층 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`
|
|
||||||
- RED: facade가 `AudioRankingQueryService.getRankings(type, member)` 결과를 `AudioRankingResponse`로 변환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacadeTest`
|
|
||||||
- GREEN: facade는 query service 호출과 DTO 변환만 담당한다.
|
|
||||||
- REFACTOR: facade에 점수 계산, 스냅샷 조회, fallback 로직을 두지 않는다.
|
|
||||||
- 기대 결과: API 조립 계층과 도메인 조회 계층 의존 방향이 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 1.3: 비회원 허용 controller 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
|
|
||||||
- RED: `GET /api/v2/audio/rankings`가 비회원과 인증 회원 모두 `200 OK`를 반환하고, `type` 미지정 시 `WEEKLY_POPULAR`로 facade를 호출하는 MockMvc 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`
|
|
||||||
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴과 `@RequestParam` 기본값을 적용한다.
|
|
||||||
- REFACTOR: controller에는 인증/요청/응답 경계만 남긴다.
|
|
||||||
- 기대 결과: endpoint 경로, 기본 type, wrapper 응답 계약이 controller 테스트로 고정된다.
|
|
||||||
|
|
||||||
### Phase 2: 기간/노출/점수 정책
|
|
||||||
|
|
||||||
- [x] **Task 2.1: KST 주간 집계 기간과 UTC 변환 정책 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt`
|
|
||||||
- RED: 임의의 KST 수요일 기준으로 지난 주 월요일 00:00 KST 이상, 이번 주 월요일 00:00 KST 미만 기간을 산출하고 UTC `LocalDateTime`으로 변환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicyTest`
|
|
||||||
- GREEN: `resolveLastCompletedWeek(now)`와 `toUtcRange(period)`를 구현한다.
|
|
||||||
- REFACTOR: 서버 기본 timezone에 의존하지 않고 `ZoneId.of("Asia/Seoul")`을 명시한다.
|
|
||||||
- 기대 결과: 모든 랭킹 타입의 집계 기준 기간이 동일하게 계산된다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: 09:00 노출 전환 정책 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicyTest.kt`
|
|
||||||
- RED: 집계 종료일 월요일 기준 `visibleFromAt`이 같은 날 09:00 KST의 UTC 시각으로 계산되고, 09:00 전에는 새 스냅샷이 공개되지 않는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicyTest`
|
|
||||||
- GREEN: `resolveVisibleFromAt(aggregationEndAtKst)`와 `isVisible(visibleFromAtUtc, nowUtc)`를 구현한다.
|
|
||||||
- REFACTOR: scheduler 실행 시각과 공개 노출 시각을 별도 함수로 분리한다.
|
|
||||||
- 기대 결과: 계산 완료와 공개 노출 전환이 분리된다.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: 주간 인기/지금 뜨는 중 점수 정책 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt`
|
|
||||||
- RED: 유료/무료 주간 인기 원점수, 0~100 정규화, 지금 뜨는 중 증가율, 최소 반영 기준, 신규 콘텐츠 부스트를 검증하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicyTest`
|
|
||||||
- GREEN: `calculateWeeklyPopularScore`, `normalizeScore`, `calculateRisingScore`, `applyMinimumThreshold`, `releaseBoost`를 구현한다.
|
|
||||||
- REFACTOR: 가중치와 최소 기준은 `companion object` 상수로 모은다.
|
|
||||||
- 기대 결과: PRD 산식과 “기준 미달 지표만 0점 처리” 정책이 순수 단위 테스트로 고정된다.
|
|
||||||
|
|
||||||
### Phase 3: 스냅샷 Entity/Port/DDL
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 스냅샷 Entity와 port 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`
|
|
||||||
- RED: `visibleFromAtUtc <= nowUtc`인 최신 스냅샷만 조회하고, 09:00 전에는 이전 visible 스냅샷을 반환하는 persistence adapter 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest`
|
|
||||||
- GREEN: `AudioRankingSnapshot`, `AudioRankingSnapshotRepository`, `DefaultAudioRankingSnapshotPersistenceAdapter`를 구현한다.
|
|
||||||
- REFACTOR: `rankingType`, `aggregationStartAtUtc`, `aggregationEndAtUtc`, `visibleFromAtUtc` 필드명을 DDL과 맞춘다.
|
|
||||||
- 기대 결과: 공개 조회 기준이 `latest generated`가 아니라 `latest visible`로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 스냅샷 job Entity와 port 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotJobPort.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
|
|
||||||
- RED: `SCHEDULED`, `MANUAL`, `FALLBACK` trigger와 `PENDING`, `PROCESSING`, `DONE`, `FAILED` 상태를 저장/변경할 수 있는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`
|
|
||||||
- GREEN: job entity, repository, port adapter를 구현한다.
|
|
||||||
- REFACTOR: fallback 3회 제한 조회에 필요한 `rankingType + aggregation period + triggerType` 조건을 port에 둔다.
|
|
||||||
- 기대 결과: 스케줄 실행과 fallback 실행이 모두 job 이력으로 추적된다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: DDL 문서와 Entity 필드 정합성 확인**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
|
|
||||||
- TDD 예외 사유: DDL 문서와 JPA Entity 필드 정합성 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "visible_from_at|content_ranking_snapshot|content_ranking_snapshot_job" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking`
|
|
||||||
- Run: `./gradlew tasks --all`
|
|
||||||
- 기대 결과: 신규 Entity에 대응하는 운영 DB DDL이 같은 작업 디렉터리에 기록되어 있다.
|
|
||||||
|
|
||||||
### Phase 4: 랭킹 후보 집계와 스냅샷 후보 생성
|
|
||||||
|
|
||||||
- [x] **Task 4.1: 기존 4종 지표의 v2 전용 집계 작성**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
|
|
||||||
- RED: `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`가 v2 집계 지표를 그대로 `finalScore`로 전달하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest`
|
|
||||||
- GREEN: legacy `RankingService` 호출 없이 v2 집계 repository에서 매출, 판매량, 댓글 수, 좋아요 후보를 만든다.
|
|
||||||
- REFACTOR: 기존 랭킹 조회 조건과 v2 스냅샷 공개/제외 조건이 섞이지 않도록 snapshot 생성 경로에서 legacy 의존성을 제거한다.
|
|
||||||
- 기대 결과: 6개 랭킹 타입 모두 v2 집계/스냅샷 경로로 생성된다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 주간 인기/지금 뜨는 중 후보 집계 repository 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
|
|
||||||
- RED: 상세 조회수, 매출, 판매량, 좋아요, 댓글 수를 집계하고 비활성/공개 전/비활성 크리에이터 콘텐츠를 제외하는 repository 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest`
|
|
||||||
- GREEN: QueryDSL 또는 native SQL로 주간 인기와 지금 뜨는 중 후보 원천 지표를 조회한다.
|
|
||||||
- REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
|
|
||||||
- 기대 결과: 신규 산식 2종의 원천 지표가 application service에 전달된다.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: 동점 정렬과 Top 20 스냅샷 후보 생성**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: 최종 점수 동점이면 `releaseDate desc`, `contentId desc` 순으로 최대 20개를 저장하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`
|
|
||||||
- GREEN: 후보별 점수 계산, 정규화, 정렬, rank 부여, snapshot record 변환을 구현한다.
|
|
||||||
- REFACTOR: 점수 계산은 `AudioRankingScorePolicy`, 기간/노출 시각 계산은 policy에 위임한다.
|
|
||||||
- 기대 결과: 스냅샷 생성 결과가 조회 시 재정렬되지 않아도 안정적인 순위를 가진다.
|
|
||||||
|
|
||||||
### Phase 5: 스냅샷 생성 job과 분산 scheduler
|
|
||||||
|
|
||||||
- [x] **Task 5.1: 랭킹 타입별 refresh service 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: 각 `AudioRankingType`에 대해 집계 기간, `visibleFromAt`, 후보 목록을 계산해 기존 스냅샷을 replace하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`
|
|
||||||
- GREEN: `refreshLastCompletedWeek(type, now)`를 구현하고 `AudioRankingSnapshotPort.replaceSnapshots(...)`를 호출한다.
|
|
||||||
- REFACTOR: 특정 타입 실패가 다른 타입 refresh를 막지 않도록 job service에서 타입 단위 실행을 분리한다.
|
|
||||||
- 기대 결과: 랭킹 타입별 독립 스냅샷 생성이 가능하다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: job service와 fallback 3회 제한 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
|
|
||||||
- RED: scheduled job이 `PENDING -> PROCESSING -> DONE/FAILED`로 상태 변경되고, 같은 타입/기간 fallback이 3회 이상이면 refresh를 호출하지 않는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`
|
|
||||||
- GREEN: job 생성, 상태 변경, fallback 제한, 기간 기반 Redisson lock 경계를 구현한다.
|
|
||||||
- REFACTOR: job 이력 저장은 `REQUIRES_NEW` 트랜잭션 패턴을 검토해 크리에이터 랭킹 job service와 맞춘다.
|
|
||||||
- 기대 결과: fallback과 scheduled refresh가 같은 job 이력 구조를 사용한다.
|
|
||||||
|
|
||||||
- [x] **Task 5.3: 01:00~07:30 분산 scheduler 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt`
|
|
||||||
- RED: 랭킹 타입별 scheduler method가 `Asia/Seoul` zone과 서로 다른 cron을 가지고, lock 획득 성공 시에만 job service를 호출하는 reflection/Mockito 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler.AudioRankingSnapshotSchedulerTest`
|
|
||||||
- GREEN: 예시 배치로 `WEEKLY_POPULAR 02:00`, `RISING 03:00`, `REVENUE 04:00`, `SALES_COUNT 05:00`, `COMMENT_COUNT 06:00`, `LIKE_COUNT 07:00` KST scheduler를 구현한다.
|
|
||||||
- REFACTOR: lock key는 `lock:content-ranking-snapshot-refresh:{rankingType}` 형태로 목적과 타입이 드러나게 한다.
|
|
||||||
- 기대 결과: 콘텐츠 랭킹 스냅샷 생성이 01:00~07:30 범위 안에서 타입별로 분산된다.
|
|
||||||
|
|
||||||
### Phase 6: 조회 서비스와 순위 변화 계산
|
|
||||||
|
|
||||||
- [x] **Task 6.1: 최신/직전 visible 스냅샷 조회와 rankChange 계산**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
|
||||||
- RED: 직전 공개 스냅샷이 있으면 `rankChange = previousRank - currentRank`, 신규 진입은 `isNew=true`, 직전 스냅샷이 없으면 `showRankChange=false`가 되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
|
|
||||||
- GREEN: `getRankings(type, member)`에서 최신 visible 스냅샷과 직전 visible 스냅샷을 조회해 `AudioRanking`을 조립한다.
|
|
||||||
- REFACTOR: 순위 변화 계산은 별도 private 함수로 분리한다.
|
|
||||||
- 기대 결과: 크리에이터 랭킹과 같은 의미의 `rank`, `rankChange`, `isNew`가 콘텐츠 랭킹에도 적용된다.
|
|
||||||
|
|
||||||
- [x] **Task 6.2: 차단/성인 콘텐츠 정책 반영**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingBlockPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingBlockRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
|
||||||
- RED: 비회원은 성인 콘텐츠를 제외하고, 회원 차단 관계가 있는 콘텐츠는 기존 콘텐츠 랭킹/추천 정책에 맞게 제외 또는 마스킹되는 테스트를 작성한다. 성인 콘텐츠 제외는 스냅샷의 `isAdult`와 `global top 20 ∪ non-adult top 20` 후보 보존으로 보충 가능해야 한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
|
|
||||||
- GREEN: 조회 조건 또는 응답 조립 단계에서 성인 콘텐츠/차단 관계 정책을 적용한다.
|
|
||||||
- REFACTOR: 기존 콘텐츠 추천 탭의 성인 콘텐츠 조회 가능 여부 계산 경로를 재사용한다.
|
|
||||||
- 기대 결과: 공개 조회 정책이 기존 v2 콘텐츠 추천/랭킹 정책과 어긋나지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 6.3: 스냅샷 없음 fallback 조회 보강**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
|
||||||
- RED: 요청 타입의 최신 visible 스냅샷이 없으면 fallback job을 최대 3회까지 실행하고, 생성 후에도 `visibleFromAt > now`이면 직전 공개 스냅샷 또는 빈 배열을 응답하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
|
|
||||||
- GREEN: query service가 snapshot job service에 fallback을 위임하고 공개 응답 스키마를 유지한다.
|
|
||||||
- REFACTOR: fallback 실패는 구조화 로그/job 이력으로 추적하고 공개 응답에 fallback 여부를 추가하지 않는다.
|
|
||||||
- 기대 결과: 테스트 환경 초기 스냅샷 공백을 보강하되, 09:00 노출 정책은 깨지 않는다.
|
|
||||||
|
|
||||||
### Phase 7: 통합 검증과 문서 정리
|
|
||||||
|
|
||||||
- [x] **Task 7.1: controller/facade/query 통합 테스트**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
|
|
||||||
- RED: `GET /api/v2/audio/rankings?type=RISING`이 `showRankChange`, `type`, `items[].contentId`, `rank`, `rankChange`, `isNew`를 반환하는 MockMvc 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`
|
|
||||||
- GREEN: controller, facade, query service wiring을 완성한다.
|
|
||||||
- REFACTOR: 공개 response에 점수, 집계 기간, fallback 여부가 노출되지 않는지 확인한다.
|
|
||||||
- 기대 결과: 공개 API 계약이 end-to-end로 검증된다.
|
|
||||||
|
|
||||||
- [x] **Task 7.2: 문서와 DDL 최종 정합성 확인**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
|
|
||||||
- TDD 예외 사유: 구현 완료 후 문서/DDL 정합성 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "visibleFromAt|visible_from_at|09:00:00|01:00:00|07:30:00|AudioRankingType" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin src/test/kotlin`
|
|
||||||
- Run: `./gradlew tasks --all`
|
|
||||||
- 기대 결과: PRD, 계획 문서, DDL, 코드가 같은 정책을 설명한다.
|
|
||||||
|
|
||||||
- [x] **Task 7.3: 전체 회귀 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: `build.gradle.kts`
|
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 전체 회귀 검증과 검증 기록 누적 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.*`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.*`
|
|
||||||
- Run: `./gradlew ktlintCheck`
|
|
||||||
- 기대 결과: 콘텐츠 랭킹 신규 테스트와 ktlint가 통과하고, 검증 결과가 이 문서 하단에 누적된다.
|
|
||||||
|
|
||||||
### Phase 8: 다음 범위 크리에이터 랭킹 시간 정책 문서 시작점
|
|
||||||
|
|
||||||
- [x] **Task 8.1: 크리에이터 랭킹 시간 정책 변경을 별도 PRD 문서 수정으로 시작하도록 기록**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
|
||||||
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 이번 구현 범위 밖의 후속 작업 진입점을 문서화하는 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "07:30|visibleFromAt|visible_from_at|ranking_type|크리에이터 랭킹" docs/20260608_크리에이터_랭킹 docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
|
||||||
- Run: `./gradlew tasks --all`
|
|
||||||
- 후속 작업 시작 지침:
|
|
||||||
- 다음 범위는 크리에이터 랭킹 PRD 문서 수정부터 시작한다.
|
|
||||||
- 현재 크리에이터 랭킹 스냅샷 생성 시간은 `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")` 기준 매주 월요일 KST 07:30이다.
|
|
||||||
- 다음 범위에서는 크리에이터 랭킹도 집계 기준 시각 `월요일 00:00:00 KST`, 생성 시간 `월요일 01:00:00 KST` 후보, 노출 전환 시각 `월요일 09:00:00 KST`로 분리하는 정책을 PRD에 먼저 반영한다.
|
|
||||||
- 크리에이터 랭킹도 향후 다중 랭킹 타입 3개가 추가될 예정이므로 `creator_ranking_snapshot`과 `creator_ranking_snapshot_job`에 `ranking_type`, `visible_from_at` 추가가 필요한지 DDL 영향부터 검토한다.
|
|
||||||
- 크리에이터 랭킹 코드 변경은 별도 PRD와 별도 plan-task 문서가 준비된 뒤 진행한다.
|
|
||||||
- 기대 결과: 이번 콘텐츠 랭킹 구현 범위를 넘지 않으면서, 다음 범위의 첫 작업이 문서 수정부터 시작되도록 명확한 기록이 남는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 작성 시점: PRD 기반 구현 계획 문서를 신규 생성했다. 아직 구현 전이므로 task별 검증 기록은 없다.
|
|
||||||
- 2026-06-24 Phase 1, 2 구현: `AudioRankingType`, 응답 DTO, facade, 비회원 허용 controller, KST 주간 기간 정책, 09:00 KST 노출 전환 정책, 주간 인기/지금 뜨는 중 점수 정책을 추가했다.
|
|
||||||
- 2026-06-24 RED/GREEN: 각 task는 대상 테스트를 먼저 추가한 뒤 미구현 참조 또는 컨트롤러 미존재 실패를 확인하고 최소 구현으로 GREEN 전환했다.
|
|
||||||
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponseTest --tests kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicyTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicyTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicyTest` 통과.
|
|
||||||
- 2026-06-24 검증: `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-24 Phase 3, 4 구현: `content_ranking_snapshot`, `content_ranking_snapshot_job` Entity/Repository/Port/Adapter, 6개 타입 v2 집계 repository, 스냅샷 refresh service를 추가했다.
|
|
||||||
- 2026-06-24 RED/GREEN: `DefaultAudioRankingAggregationRepositoryTest`에서 H2 native query의 `release_date`가 `Timestamp`로 반환되어 `LocalDateTime` cast 실패를 확인했고, `Timestamp.toLocalDateTime()` 변환을 추가해 GREEN 전환했다.
|
|
||||||
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotJobRepositoryTest` 통과.
|
|
||||||
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest` 통과.
|
|
||||||
- 2026-06-24 검증: `rg -n "visible_from_at|ranking_type|content_ranking_snapshot|content_ranking_snapshot_job" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking`으로 DDL/Entity 핵심 컬럼 정합성을 확인했다.
|
|
||||||
- 2026-06-24 최종 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck`, `./gradlew tasks --all` 통과.
|
|
||||||
- 2026-06-24 리뷰 반영: `RISING` 점수도 유료/무료 그룹별 0~100 정규화를 거치도록 보강했고, `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT` 스냅샷 후보 산정은 기존 랭킹 조회 재사용이 아닌 v2 전용 집계 repository 책임으로 변경했다.
|
|
||||||
- 2026-06-24 리뷰 반영 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-24 리뷰 재반영: `AudioRankingQueryService`가 최신/직전 visible snapshot을 조회해 `rankChange`, `isNew`, `showRankChange`를 계산하도록 구현했다.
|
|
||||||
- 2026-06-24 리뷰 재반영: 비회원/비성인 조회자를 위해 스냅샷에 `isAdult`를 저장하고, snapshot refresh는 전체 상위 20개와 비성인 상위 20개 후보의 합집합을 보존하도록 변경했다.
|
|
||||||
- 2026-06-24 Phase 7 구현: `AudioRankingControllerTest`를 Spring context 기반 MockMvc 통합 테스트로 전환해 `Controller -> Facade -> QueryService -> SnapshotRepository` 경로로 `GET /api/v2/audio/rankings?type=RISING` 응답의 `showRankChange`, `type`, `contentId`, `rank`, `rankChange`, `isNew`를 검증했다.
|
|
||||||
- 2026-06-24 Phase 7 검증: 공개 response에 `finalScore`, `aggregationStartAtUtc`, `aggregationEndAtUtc`, `visibleFromAtUtc`, `fallback`이 노출되지 않음을 통합 테스트로 확인했다.
|
|
||||||
- 2026-06-24 Phase 7 문서/DDL 정합성 검증: `rg -n "visibleFromAt|visible_from_at|09:00:00|01:00:00|07:30:00|AudioRankingType" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin src/test/kotlin`, `./gradlew tasks --all` 통과.
|
|
||||||
- 2026-06-24 Phase 7 회귀 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-24 Phase 5 구현: `AudioRankingSnapshotJobService`와 `AudioRankingSnapshotScheduler`를 추가해 타입별 scheduled/fallback job 이력, fallback 3회 제한, 타입/기간 기반 Redisson lock, 02:00~07:00 KST 분산 스케줄을 구현했다.
|
|
||||||
- 2026-06-24 Phase 6 구현: `AudioRankingBlockPort`, `DefaultAudioRankingBlockRepository`를 추가하고 `AudioRankingQueryService`가 회원 차단 관계 콘텐츠를 제외하며 최신 visible snapshot 공백 시 fallback job 실행 후 재조회하도록 보강했다.
|
|
||||||
- 2026-06-24 RED/GREEN: `AudioRankingSnapshotJobServiceTest`는 service 미존재 컴파일 실패를 확인한 뒤 GREEN 전환했고, `AudioRankingSnapshotSchedulerTest`는 scheduler 미존재 컴파일 실패를 확인한 뒤 GREEN 전환했다. `AudioRankingQueryServiceTest`는 `AudioRankingBlockPort`와 query service 의존성 미구현 컴파일 실패를 확인한 뒤 차단/fallback 구현으로 GREEN 전환했다.
|
|
||||||
- 2026-06-24 Phase 5/6 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler.AudioRankingSnapshotSchedulerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest` 통과.
|
|
||||||
- 2026-06-24 Phase 5/6 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과. 병렬 실행 중 `kaptTestKotlin`에서 `StreamCorruptedException: unexpected EOF in middle of data block`이 1회 발생했으나, 동일 content ranking 테스트 단독 재실행은 통과했다.
|
|
||||||
- 2026-06-24 Phase 5/6 리뷰 반영: snapshot refresh 실패 시 `FAILED` job 이력이 rollback되지 않도록 job 생성/상태 변경을 각각 `REQUIRES_NEW` 트랜잭션으로 분리했다. 공개 조회 fallback 실행 중 예외가 발생해도 응답 스키마를 유지하도록 보강했고, 차단 creator가 직전 스냅샷에만 있는 경우도 `rankChange` 계산에서 제외되도록 latest/previous creator 합집합 기준으로 차단 관계를 조회한다.
|
|
||||||
- 2026-06-24 Phase 5/6 리뷰 반영 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-24 Phase 5/6 코드 리뷰 추가 반영: class-level `@Transactional(readOnly = true)` 경계에서 snapshot replace write가 실행되지 않도록 `refreshService.refreshLastCompletedWeek(...)` 호출 자체를 `REQUIRES_NEW` 트랜잭션으로 감쌌다. fallback job 생성 이전 또는 lock/transaction 단계 예외도 추적 가능하도록 `AudioRankingQueryService`에 `event=audio_ranking_query_fallback_failure` warn 로그를 추가했다.
|
|
||||||
- 2026-06-24 Phase 5/6 코드 리뷰 추가 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-24 Phase 6 잔여 리스크 반영: `DefaultAudioRankingBlockRepositoryTest` DB slice 테스트를 추가해 실제 QueryDSL 양방향 활성 차단 조회, 비활성 차단 제외, 입력 목록 외 차단 제외, 빈 입력 반환을 검증하도록 보강했다.
|
|
||||||
- 2026-06-24 Phase 6 잔여 리스크 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingBlockRepositoryTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'` 통과.
|
|
||||||
- 2026-06-24 Phase 6 트랜잭션 가시성 리뷰 반영: MySQL `REPEATABLE READ`에서 fallback `REQUIRES_NEW` 커밋 후 같은 read-only 트랜잭션 재조회가 새 스냅샷을 보지 못할 수 있어, `AudioRankingQueryService.getRankings()`의 외부 `@Transactional(readOnly = true)` 경계를 제거했다.
|
|
||||||
- 2026-06-24 Phase 6 트랜잭션 가시성 검증: `getRankings()`에 `@Transactional`이 다시 붙지 않도록 회귀 테스트를 추가했다. RED 확인 후 수정했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
|
|
||||||
- 2026-06-24 Phase 8 문서 확인: `rg -n "07:30|visibleFromAt|visible_from_at|ranking_type|크리에이터 랭킹" docs/20260608_크리에이터_랭킹 docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`로 크리에이터 랭킹 현재 07:30 스케줄과 다음 범위의 `visible_from_at`, `ranking_type` DDL 검토 시작점을 확인했다.
|
|
||||||
- 2026-06-24 Phase 8 범위 확정: 크리에이터 랭킹 코드/DDL은 수정하지 않고, 다음 범위가 별도 PRD 문서 수정부터 시작되도록 Task 8.1 완료 상태와 검증 기록만 갱신했다.
|
|
||||||
|
|
||||||
### Phase 9: `coverImageUrl` CDN host 누락 버그 수정
|
|
||||||
|
|
||||||
- [x] **Task 9.1: `AudioRankingQueryService` 응답 변환 지점에서 CDN URL 정책 고정**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
|
|
||||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
|
||||||
- 버그 내용: 메인 콘텐츠 랭킹 탭 API는 스냅샷의 `coverImageUrl` 값을 `AudioRankingQueryService.toItem(...)`에서 그대로 `AudioRankingItem.coverImageUrl`로 옮기고 있었다. 스냅샷 생성 과정의 원천 값은 `audio_content.cover_image` 계열의 저장 path이므로, 공개 API 응답도 `cover-1.png`처럼 host 없는 path만 내려갔다.
|
|
||||||
- 영향 범위: `GET /api/v2/audio/rankings`의 item `coverImageUrl`만 대상이다. 순위 계산, 최신/직전 visible snapshot 조회, 19금 필터, 차단 필터, fallback job 실행, 스냅샷 저장 구조와 DDL은 변경하지 않는다.
|
|
||||||
- 원인: 콘텐츠 랭킹 조회 서비스가 크리에이터 랭킹의 `profileImageUrl.toCdnUrl(cloudFrontHost)` 패턴이나 v2 콘텐츠/크리에이터 조회 계층의 `toCdnUrl` 패턴을 적용하지 않았다. DTO 변환 계층은 domain item 값을 그대로 응답으로 내보내므로, domain item 조립 시점에 URL 변환이 누락되면 Response에서도 그대로 노출된다.
|
|
||||||
- RED: `AudioRankingQueryServiceTest.shouldReturnLatestVisibleSnapshotsWithRankChangesAndNewFlags`에 스냅샷 fixture의 `coverImageUrl = "cover-N.png"`가 응답 item에서는 `https://cdn.test/cover-N.png`로 변환되어야 한다는 assertion을 추가한다. 기존 구현에서는 path만 반환하므로 이 assertion이 실패해야 한다.
|
|
||||||
- GREEN: `AudioRankingQueryService` 생성자에 `@Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String`을 주입하고, `AudioRankingSnapshotRecord.toItem(...)`에서 `coverImageUrl.toCdnUrl(cloudFrontHost)`를 사용한다. 이 방식은 `null`/blank는 `null`, 이미 `http://` 또는 `https://`로 시작하는 값은 그대로 유지하는 기존 공통 확장 함수를 재사용한다.
|
|
||||||
- REFACTOR: 별도 URL helper를 새로 만들지 않는다. 스냅샷 저장 데이터를 full URL로 마이그레이션하지 않고, 공개 응답 조립 지점에서만 변환해 기존 데이터와 신규 데이터 모두 동일하게 처리한다.
|
|
||||||
- 기대 결과: `GET /api/v2/audio/rankings` 응답의 `items[*].coverImageUrl`은 path가 아니라 `cloud.aws.cloud-front.host`가 포함된 이미지 URL로 내려간다.
|
|
||||||
|
|
||||||
## Phase 9 검증 기록
|
|
||||||
|
|
||||||
- 2026-06-25 문서 갱신: 사용자 후속 요청에 따라 `prd.md`에 `coverImageUrl` host 누락 버그, 공개 응답 URL 정책, `toCdnUrl` 기반 변환 규칙을 추가했다. `plan-task.md`에는 버그 내용, 영향 범위, 원인, RED/GREEN/REFACTOR 기준을 Phase 9로 누적 기록했다.
|
|
||||||
- 2026-06-25 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL in 30s`를 확인했다. 이 테스트는 스냅샷 fixture의 path 값(`cover-N.png`)이 응답 item에서는 `https://cdn.test/cover-N.png`로 변환되는지 검증한다.
|
|
||||||
- 2026-06-25 문서 명령 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 8s`를 확인했고, `rg -n "coverImageUrl|Phase 9|cdn|cloud-front|toCdnUrl|host 없는 path|CDN host" docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`로 PRD와 plan-task에 버그 내용 및 수정 정책이 반영된 위치를 확인했다.
|
|
||||||
- 2026-06-25 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 32s`를 확인했다.
|
|
||||||
- 2026-06-25 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'` 실행 결과 `BUILD SUCCESSFUL in 1m 4s`를 확인했다. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`를 API 테스트와 병렬 실행했을 때는 `build/test-results/test/TEST-*.xml` 파일 쓰기 충돌로 실패했으나, 동일 명령을 단독 재실행해 `BUILD SUCCESSFUL in 19s`를 확인했다.
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
# PRD: 메인 콘텐츠 랭킹 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
메인 콘텐츠 탭의 내부 랭킹 탭에서 사용할 콘텐츠 랭킹을 조회하는 v2 API를 제공한다.
|
|
||||||
|
|
||||||
랭킹 구분은 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글 수`, `좋아요`이며, 각 랭킹은 최대 20위까지 표시한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 기존 콘텐츠 랭킹 조회는 `RankingService.getContentRanking` 기반의 정렬 조회를 제공하지만, 신규 랭킹 탭은 v2 스냅샷 기준으로 `rank`, `rankChange`, `isNew`를 크리에이터 랭킹과 같은 의미로 내려줘야 한다.
|
|
||||||
- `주간 인기`와 `지금 뜨는 중`은 신규 점수 산식, 유료/무료 콘텐츠별 정규화, 주간 스냅샷 갱신, fallback 실행 기록이 필요하다.
|
|
||||||
- `매출`, `판매량`, `댓글 수`, `좋아요`는 기존 랭킹과 동일한 원천 지표를 사용하되, 순위 변화와 신규 진입 여부를 안정적으로 계산하려면 v2 스냅샷 생성 시점에 완료 주차 기준으로 직접 집계해야 한다.
|
|
||||||
- 조회 시마다 모든 랭킹 타입의 원천 데이터를 집계하면 응답 지연과 계산 중복이 커지고, 운영 서버와 테스트 환경에서 같은 기준의 결과를 재현하기 어렵다.
|
|
||||||
- 기존 v2 패키지에 크리에이터 랭킹 스냅샷/작업 이력/fallback 패턴과 콘텐츠 추천 탭의 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재사용해야 한다.
|
|
||||||
- 2026-06-25 후속 확인 결과, 메인 콘텐츠 랭킹 탭 API의 `coverImageUrl` 응답이 `cloud.aws.cloud-front.host`가 포함된 완성 URL이 아니라 `cover-*.png` 같은 저장 path만 내려가는 버그가 확인되었다. 앱 클라이언트는 공개 API의 이미지 필드를 직접 렌더링 가능한 URL로 기대하므로, 다른 v2 콘텐츠/크리에이터 조회 API와 동일하게 CDN host를 포함해 반환해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 메인 콘텐츠 랭킹 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
|
||||||
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
|
||||||
- 모든 랭킹 타입은 최대 20개 콘텐츠를 응답한다.
|
|
||||||
- 모든 랭킹 타입의 동점자는 `releaseDate desc`, `contentId desc` 순으로 2차, 3차 정렬한다.
|
|
||||||
- `rank`, `rankChange`, `isNew`의 의미는 크리에이터 랭킹과 동일하게 정의한다.
|
|
||||||
- `주간 인기`와 `지금 뜨는 중`은 매주 월요일 00:00 KST 기준으로 지난 주 데이터를 계산한다.
|
|
||||||
- `매출`, `판매량`, `댓글 수`, `좋아요`도 완료된 지난 주 데이터를 기준으로 계산한다.
|
|
||||||
- 스냅샷 생성은 부하 분산을 위해 매주 월요일 01:00:00 KST부터 07:30:00 KST 사이에 랭킹 타입별로 분산 실행한다.
|
|
||||||
- 새로 생성된 스냅샷은 매주 월요일 09:00:00 KST부터 조회 API에 노출한다.
|
|
||||||
- 스냅샷이 없어 조회할 수 없는 경우 스케줄러로 예약된 랭킹 계산 로직을 fallback으로 직접 실행한다.
|
|
||||||
- fallback 실행은 스케줄 실행 기록처럼 저장하며, 동일 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 시도한다.
|
|
||||||
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
|
||||||
- 신규 Entity가 생성되는 경우 같은 작업 디렉터리에 대응 DB table 생성/수정 DDL을 기록한다.
|
|
||||||
- `coverImageUrl`은 스냅샷 또는 DB에 저장된 path를 그대로 공개하지 않고, 공개 Response를 만들기 전에 `cloud.aws.cloud-front.host`를 포함한 URL로 변환한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 기존 공개 API 스키마를 임의 변경하지 않는다.
|
|
||||||
- 기존 공개 API의 `RankingService.getContentRanking` 동작과 정렬 산식은 이번 작업에서 변경하지 않는다.
|
|
||||||
- 관리자 화면, 수동 보정 기능, 랭킹 결과 고정/제외 기능은 포함하지 않는다.
|
|
||||||
- 개인화 랭킹, A/B 테스트, 머신러닝 기반 점수 산정은 포함하지 않는다.
|
|
||||||
- 20위 이후 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
|
|
||||||
- 실시간 랭킹은 포함하지 않는다. 모든 랭킹 타입은 완료된 지난 주 데이터를 기준으로 한다.
|
|
||||||
- 기존 크리에이터 랭킹의 다중 랭킹 타입 전환, 스냅샷 테이블 구조 변경, 계산 스케줄 분산 처리는 이번 PRD에 포함하지 않고 별도 PRD에서 다룬다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 콘텐츠 메인 탭에서 인기 콘텐츠와 상승 중인 콘텐츠를 탐색하는 사용자
|
|
||||||
- 비회원: 인증 없이 조회 가능한 랭킹 콘텐츠를 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 내부 랭킹 탭의 랭킹 타입별 목록과 순위 변화 UI를 구성하는 클라이언트
|
|
||||||
- 운영자: 주간 콘텐츠 랭킹 계산 결과와 fallback 실행 이력을 확인하는 내부 사용자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 주간 인기 콘텐츠 상위 20개를 보고 싶다.
|
|
||||||
- 사용자는 지난 주 대비 지금 뜨는 중인 콘텐츠를 보고 싶다.
|
|
||||||
- 사용자는 매출, 판매량, 댓글 수, 좋아요 기준의 콘텐츠 랭킹을 보고 싶다.
|
|
||||||
- 사용자는 각 콘텐츠의 현재 순위, 순위 변화, 신규 진입 여부를 보고 싶다.
|
|
||||||
- 앱 클라이언트는 하나의 API endpoint에서 랭킹 타입만 바꿔 동일한 응답 구조로 화면을 구성하고 싶다.
|
|
||||||
- 테스트 환경에서는 스냅샷이 비어 있어도 조회 API 호출만으로 fallback 랭킹 계산이 실행되기를 원한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 메인 콘텐츠 랭킹 탭 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API endpoint는 `GET /api/v2/audio/rankings`로 정의한다.
|
|
||||||
- 요청 query parameter는 `type`을 사용한다.
|
|
||||||
- `type` 값은 아래 enum으로 정의한다.
|
|
||||||
- `WEEKLY_POPULAR`: 주간 인기
|
|
||||||
- `RISING`: 지금 뜨는 중
|
|
||||||
- `REVENUE`: 매출
|
|
||||||
- `SALES_COUNT`: 판매량
|
|
||||||
- `COMMENT_COUNT`: 댓글 수
|
|
||||||
- `LIKE_COUNT`: 좋아요
|
|
||||||
- `type`이 없으면 `WEEKLY_POPULAR`를 기본값으로 사용한다.
|
|
||||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
|
||||||
- 조회 API는 `visibleFromAt <= now`이고 생성이 완료된 최신 스냅샷만 응답한다.
|
|
||||||
- 월요일 09:00:00 KST 전에는 새 주차 스냅샷이 이미 생성되어 있어도 직전 공개 스냅샷을 응답한다.
|
|
||||||
- 인증 회원이면 기존 콘텐츠 랭킹/추천 조회와 같은 방식으로 회원의 19금 노출 가능 여부와 차단 관계를 반영한다.
|
|
||||||
- 비회원이면 19금 콘텐츠를 노출하지 않는다.
|
|
||||||
- 비활성 콘텐츠, 공개 전 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.
|
|
||||||
- 각 랭킹 타입은 최대 20개를 응답한다.
|
|
||||||
- 정렬은 랭킹 점수 또는 정렬 지표 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 랭킹 결과가 없으면 빈 배열로 성공 응답한다.
|
|
||||||
- 후보가 20개 미만이면 가능한 개수만 내려준다.
|
|
||||||
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
|
|
||||||
- 콘텐츠 제목, 크리에이터 닉네임, 커버 이미지가 기존 정책상 마스킹되어야 하는 경우 기존 콘텐츠 랭킹/추천 조회 정책을 따른다.
|
|
||||||
- `coverImageUrl`은 스냅샷 저장값이 path 형태여도 공개 응답에서는 `https://...` 또는 `http://...`로 시작하는 완성 URL이어야 한다. 이미 완성 URL인 값은 중복 prefix를 붙이지 않는다.
|
|
||||||
|
|
||||||
### Feature B. rank, rankChange, isNew 의미
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `rank`는 최신 완료 주차 스냅샷에서 해당 랭킹 타입의 정렬 결과 순위다.
|
|
||||||
- `rank`는 1부터 시작한다.
|
|
||||||
- `rankChange`는 `직전 완료 주차 rank - 최신 완료 주차 rank`로 계산한다.
|
|
||||||
- 순위가 올라갔으면 양수, 순위가 내려갔으면 음수, 동일하면 `0`을 내려준다.
|
|
||||||
- 예를 들어 직전 완료 주차 10위, 최신 완료 주차 5위이면 `rankChange`는 `5`다.
|
|
||||||
- 예를 들어 직전 완료 주차 1위, 최신 완료 주차 10위이면 `rankChange`는 `-9`다.
|
|
||||||
- 직전 완료 주차에는 없고 최신 완료 주차에 진입한 콘텐츠는 `isNew == true`로 내려준다.
|
|
||||||
- 신규 진입 콘텐츠의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다.
|
|
||||||
- 직전 완료 주차 스냅샷이 없으면 `showRankChange == false`로 내려주고, 각 item의 `rankChange`는 `null`, `isNew`는 `false`로 내려준다.
|
|
||||||
- 직전 완료 주차 스냅샷이 있으면 `showRankChange == true`로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- fallback으로 최신 주차 스냅샷을 생성했지만 직전 완료 주차 스냅샷이 없으면 `showRankChange == false`를 유지한다.
|
|
||||||
- 동점자는 `releaseDate desc`, `contentId desc`로 결정되므로 같은 스냅샷을 조회할 때 순위가 랜덤하게 바뀌지 않는다.
|
|
||||||
|
|
||||||
### Feature C. 주간 인기 랭킹
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 갱신 기준은 매주 월요일 00:00 KST다.
|
|
||||||
- 집계 대상 기간은 지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만이다.
|
|
||||||
- DB 조회 조건은 KST 집계 기간을 UTC로 변환해 사용한다.
|
|
||||||
- 유료 콘텐츠와 무료 콘텐츠는 서로 다른 원천 지표와 가중치로 1차 점수를 산출한다.
|
|
||||||
- 유료 콘텐츠 점수는 `매출 45% + 판매량 35% + 좋아요 수 10% + 댓글 수 10%`로 계산한다.
|
|
||||||
- 무료 콘텐츠 점수는 `조회수 50% + 좋아요 수 25% + 댓글 수 25%`로 계산한다.
|
|
||||||
- 조회수는 상세 페이지 조회 이력인 `creator_content_view_history` 기준으로 집계한다.
|
|
||||||
- 유료 콘텐츠와 무료 콘텐츠는 각 그룹의 최고 점수를 기준으로 0~100 정규화한 뒤 비교한다.
|
|
||||||
- 유료 정규화 점수는 `(현재 유료 콘텐츠 점수 / 유료 콘텐츠 최고 점수) * 100`으로 계산한다.
|
|
||||||
- 무료 정규화 점수는 `(현재 무료 콘텐츠 점수 / 무료 콘텐츠 최고 점수) * 100`으로 계산한다.
|
|
||||||
- 각 그룹의 최고 점수가 0 이하이면 해당 그룹의 정규화 점수는 0으로 처리한다.
|
|
||||||
- 최종 정렬은 정규화 점수 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
|
|
||||||
|
|
||||||
#### 정규화 판단
|
|
||||||
- `(최고 점수 + 현재 콘텐츠 점수) * 100`은 최고점 대비 상대 위치를 0~100 범위로 맞추지 못하므로 정규화 산식으로 부적절하다.
|
|
||||||
- 유료/무료 콘텐츠를 별도 산식으로 계산한 뒤 한 목록에서 비교하려면 `(현재 점수 / 그룹 최고 점수) * 100` 방식이 더 적절하다.
|
|
||||||
- 이 방식은 각 그룹의 1위 콘텐츠를 100점으로 맞추고 나머지 콘텐츠를 상대 점수로 비교한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 유료 콘텐츠 후보가 없으면 유료 정규화는 수행하지 않고 무료 콘텐츠만 비교한다.
|
|
||||||
- 무료 콘텐츠 후보가 없으면 무료 정규화는 수행하지 않고 유료 콘텐츠만 비교한다.
|
|
||||||
- 원천 지표가 없으면 0으로 계산한다.
|
|
||||||
|
|
||||||
### Feature D. 지금 뜨는 중 랭킹
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 갱신 기준은 매주 월요일 00:00 KST다.
|
|
||||||
- 집계 대상 기간은 최근 7일과 직전 7일을 비교한다.
|
|
||||||
- 기준 시점은 완료된 지난 주의 종료 시점으로 한다.
|
|
||||||
- 최근 7일: 지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만
|
|
||||||
- 직전 7일: 2주 전 월요일 00:00:00 KST 이상, 지난 주 월요일 00:00:00 KST 미만
|
|
||||||
- 콘텐츠 지금 뜨는 중 점수는 `((0.5 * 콘텐츠 성장 점수) + (0.25 * 좋아요 증가율) + (0.25 * 댓글 증가율)) * 신규 콘텐츠 부스트`로 계산한다.
|
|
||||||
- 유료 콘텐츠 성장 점수는 `(0.6 * 판매 증가율) + (0.4 * 조회수 증가율)`로 계산한다.
|
|
||||||
- 무료 콘텐츠 성장 점수는 `(0.5 * 조회수 증가율) + (0.25 * 좋아요 증가율) + (0.25 * 댓글 증가율)`로 계산한다.
|
|
||||||
- 판매 증가율은 `(최근 7일 판매량 - 직전 7일 판매량) / max(직전 7일 판매량, 1)`로 계산한다.
|
|
||||||
- 조회수 증가율은 `(최근 7일 조회수 - 직전 7일 조회수) / max(직전 7일 조회수, 1)`로 계산한다.
|
|
||||||
- 좋아요 증가율은 `(최근 7일 좋아요 수 - 직전 7일 좋아요 수) / max(직전 7일 좋아요 수, 1)`로 계산한다.
|
|
||||||
- 댓글 증가율은 `(최근 7일 댓글 수 - 직전 7일 댓글 수) / max(직전 7일 댓글 수, 1)`로 계산한다.
|
|
||||||
- 최근 7일 조회수 10회 미만이면 조회수 증가율 반영값은 0으로 처리한다.
|
|
||||||
- 최근 7일 좋아요 수 3개 미만이면 좋아요 증가율 반영값은 0으로 처리한다.
|
|
||||||
- 최근 7일 댓글 수 3개 미만이면 댓글 증가율 반영값은 0으로 처리한다.
|
|
||||||
- 최근 7일 판매량 3건 미만이면 판매 증가율 반영값은 0으로 처리한다.
|
|
||||||
- 유료 콘텐츠와 무료 콘텐츠는 각 그룹의 최고 점수를 기준으로 0~100 정규화한 뒤 비교한다.
|
|
||||||
- 유료 정규화 점수는 `(현재 유료 콘텐츠 점수 / 유료 콘텐츠 최고 점수) * 100`으로 계산한다.
|
|
||||||
- 무료 정규화 점수는 `(현재 무료 콘텐츠 점수 / 무료 콘텐츠 최고 점수) * 100`으로 계산한다.
|
|
||||||
- 각 그룹의 최고 점수가 0 이하이면 해당 그룹의 정규화 점수는 0으로 처리한다.
|
|
||||||
- 신규 콘텐츠 부스트는 집계 종료일 기준 `releaseDate` 경과 일수로 적용한다.
|
|
||||||
- Release 3일 이내: `1.5`
|
|
||||||
- Release 7일 이내: `1.3`
|
|
||||||
- Release 14일 이내: `1.15`
|
|
||||||
- Release 14일 초과: `1.0`
|
|
||||||
- 최종 정렬은 정규화 점수 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 모든 증가율 반영값이 0이면 지금 뜨는 중 원점수는 0으로 계산한다.
|
|
||||||
- 증가율은 음수가 될 수 있으며, 음수 원점수는 정규화 전 후보 점수에 그대로 반영한다.
|
|
||||||
- 그룹 최고 점수가 0 이하인 경우 해당 그룹 콘텐츠의 정규화 점수는 0으로 처리해 음수 최고점으로 인한 역전 현상을 피한다.
|
|
||||||
|
|
||||||
### Feature E. 매출, 판매량, 댓글 수, 좋아요 랭킹
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`는 v2 스냅샷 생성 계층에서 완료 주차 기준으로 직접 집계한다.
|
|
||||||
- 각 타입의 최종 점수는 원천 지표를 그대로 사용한다. `REVENUE`는 매출 can 합계, `SALES_COUNT`는 판매 건수, `COMMENT_COUNT`는 활성 댓글 수, `LIKE_COUNT`는 활성 좋아요 수다.
|
|
||||||
- 각 랭킹 타입도 스냅샷으로 저장한다.
|
|
||||||
- 스냅샷 생성 시 v2 집계 결과를 기반으로 전체 기준 상위 20개와 비성인 콘텐츠 기준 상위 20개의 합집합 후보를 저장한다.
|
|
||||||
- 공개 응답은 조회자의 성인 콘텐츠 열람 가능 여부를 적용한 뒤 최대 20개 콘텐츠를 반환한다.
|
|
||||||
- 동점자는 `releaseDate desc`, `contentId desc` 순으로 정렬한다.
|
|
||||||
- 최종 응답의 `rank`, `rankChange`, `isNew` 계산은 `주간 인기`, `지금 뜨는 중`과 동일한 공통 로직을 사용한다.
|
|
||||||
|
|
||||||
#### 스냅샷 저장 판단
|
|
||||||
- 이번 PRD의 기준안은 모든 랭킹 타입을 스냅샷으로 저장하는 것이다.
|
|
||||||
- 이유는 `rankChange`와 `isNew`가 모든 응답 item에 필요하고, 이 값은 최신 완료 주차와 직전 완료 주차의 같은 랭킹 타입 결과를 비교해야 안정적으로 계산할 수 있기 때문이다.
|
|
||||||
- `주간 인기`와 `지금 뜨는 중`만 스냅샷으로 저장하면 `매출`, `판매량`, `댓글 수`, `좋아요`는 조회 때마다 최신/직전 주차를 동적으로 재계산해야 하며, 계산 비용과 late update에 따른 순위 흔들림이 생긴다.
|
|
||||||
- 기존 랭킹과 같은 원천 지표를 사용하는 4개 랭킹도 v2 스냅샷 생성 시점에 직접 집계해, legacy 조회 조건과 v2 공개/제외 조건이 섞이지 않도록 한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- v2 집계 결과 또는 조회자에게 노출 가능한 후보가 20개 미만이면 해당 개수만 저장/응답한다.
|
|
||||||
- legacy 랭킹 조회의 최소 개수 확보용 기간 확장 로직은 v2 스냅샷 생성에 적용하지 않는다.
|
|
||||||
|
|
||||||
### Feature F. 랭킹 스냅샷 및 작업 이력
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 콘텐츠 랭킹 스냅샷은 랭킹 타입, 집계 시작/종료 시각, 콘텐츠 id, 순위, 점수 또는 정렬 지표, 표시용 콘텐츠 정보를 저장한다.
|
|
||||||
- 신규 스냅샷 Entity와 작업 이력 Entity의 DB table DDL은 `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`에 기록한다.
|
|
||||||
- 스냅샷에는 `visibleFromAt`을 저장하며, 공개 조회는 이 시각이 지난 스냅샷만 대상으로 한다.
|
|
||||||
- 스냅샷은 최신 완료 주차와 직전 완료 주차를 조회할 수 있어야 한다.
|
|
||||||
- 같은 랭킹 타입과 같은 집계 기간의 스냅샷은 중복 저장하지 않는다.
|
|
||||||
- 스냅샷 생성은 기존 크리에이터 랭킹과 동일하게 job service, refresh service, scheduler 책임으로 분리한다.
|
|
||||||
- 집계 기준 시각은 매주 월요일 00:00:00 KST다.
|
|
||||||
- 스냅샷 생성은 원천 데이터 적재 지연과 운영 부하를 고려해 매주 월요일 01:00:00 KST부터 07:30:00 KST 사이에 랭킹 타입별로 분산 실행한다.
|
|
||||||
- 새 스냅샷의 기본 노출 전환 시각은 매주 월요일 09:00:00 KST다.
|
|
||||||
- 스케줄러는 `Asia/Seoul` zone을 명시한다.
|
|
||||||
- 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 Redisson lock으로 동일 기간/랭킹 타입은 한 번만 계산한다.
|
|
||||||
- 작업 이력에는 trigger, status, 집계 시작/종료 시각, 랭킹 타입, 오류 메시지, 처리 시작/종료 시각을 저장한다.
|
|
||||||
- trigger 값은 최소 `SCHEDULED`, `MANUAL`, `FALLBACK`을 지원한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 특정 랭킹 타입 스냅샷 생성이 실패해도 다른 랭킹 타입 생성이 가능한 구조로 분리한다.
|
|
||||||
- 일부 랭킹 타입만 스냅샷이 있으면 요청한 `type` 기준으로만 fallback 여부를 판단한다.
|
|
||||||
- 월요일 09:00:00 KST 전에 새 스냅샷이 일부만 생성되어도 공개 조회에는 반영하지 않는다.
|
|
||||||
- 월요일 09:00:00 KST 이후 특정 랭킹 타입의 새 스냅샷이 없거나 생성 실패 상태이면 해당 타입은 직전 공개 스냅샷을 응답한다.
|
|
||||||
|
|
||||||
### Feature G. 크리에이터 랭킹과의 범위 경계
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 현재 크리에이터 랭킹 스냅샷 생성 스케줄은 매주 월요일 KST 07:30이다.
|
|
||||||
- 크리에이터 랭킹도 향후 다중 랭킹 타입이 추가될 예정이므로, 콘텐츠 랭킹의 스냅샷/작업 이력 구조는 향후 크리에이터 랭킹에도 같은 운영 모델을 적용할 수 있도록 `rankingType`, 집계 기간, `visibleFromAt`, job trigger/status 축을 기준으로 설계한다.
|
|
||||||
- 이번 PRD는 콘텐츠 랭킹 API와 콘텐츠 랭킹 스냅샷 생성만 구현한다.
|
|
||||||
- 기존 크리에이터 랭킹의 스냅샷 테이블에 `rankingType`을 추가하거나, 크리에이터 랭킹 계산 스케줄을 01:00~07:30 분산 방식으로 변경하는 작업은 이번 PRD에 포함하지 않는다.
|
|
||||||
- 크리에이터 랭킹 다중 타입 전환과 스케줄 분산 처리는 별도 PRD에서 기존 크리에이터 랭킹 PRD/DDL/구현 계획을 갱신해 다룬다.
|
|
||||||
|
|
||||||
### Feature H. fallback 랭킹 계산
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 조회 시 요청한 랭킹 타입의 최신 완료 주차 스냅샷이 없으면 fallback 실행 가능 여부를 확인한다.
|
|
||||||
- fallback은 스케줄러가 호출하는 랭킹 계산 로직과 동일한 refresh service를 직접 실행한다.
|
|
||||||
- fallback 실행 전 `FALLBACK` trigger의 작업 이력을 `PENDING` 또는 `PROCESSING` 상태로 기록한다.
|
|
||||||
- fallback 성공 시 `DONE`, 실패 시 `FAILED`로 작업 이력을 기록한다.
|
|
||||||
- 동일 랭킹 타입과 동일 집계 기간의 fallback 실행 이력이 3회 이상이면 추가 fallback을 실행하지 않는다.
|
|
||||||
- fallback으로 스냅샷이 생성되면 해당 스냅샷을 다시 조회해 응답한다.
|
|
||||||
- fallback으로 생성된 스냅샷도 `visibleFromAt <= now` 조건을 만족해야 공개 조회에 노출한다.
|
|
||||||
- fallback 실행 후에도 스냅샷이 없으면 빈 배열로 성공 응답한다.
|
|
||||||
- fallback 여부는 공개 API response schema에 포함하지 않는다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 다른 요청이 같은 랭킹 타입/기간 fallback을 처리 중이면 lock 획득 실패를 정상 skip으로 간주하고, 현재 요청은 재조회 후 없으면 빈 배열로 응답한다.
|
|
||||||
- fallback 계산 중 예외가 발생해도 공개 API는 내부 오류를 그대로 노출하지 않고 기존 예외/응답 정책을 따른다.
|
|
||||||
- fallback 작업 이력 저장 실패와 랭킹 계산 실패의 트랜잭션 경계는 구현 계획 단계에서 크리에이터 랭킹 작업 이력 패턴을 따른다.
|
|
||||||
|
|
||||||
### Feature I. v2 재사용 후보
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- API 조립 계층은 `v2/api/content/recommendation`의 `AudioRecommendationController`, `AudioRecommendationFacade`, DTO 변환 패턴을 참고한다.
|
|
||||||
- 도메인 조회 계층은 `v2/content/recommendation/application/AudioRecommendationQueryService`처럼 응답 조립에 필요한 도메인 모델을 반환한다.
|
|
||||||
- `rankChange`, `isNew`, `showRankChange`, fallback 로그/작업 이력 패턴은 `v2/ranking/application/CreatorRankingQueryService`와 `CreatorRankingSnapshotJobService`를 참고한다.
|
|
||||||
- 주간 기간 계산, UTC 변환, Redisson lock은 `v2/ranking/domain/CreatorRankingPeriodPolicy`와 크리에이터 랭킹 스냅샷 job 구조를 재사용하거나 콘텐츠 랭킹용으로 동일 패턴을 만든다.
|
|
||||||
- 상세 페이지 조회수는 `v2/recommendation/adapter/out/persistence/CreatorContentViewHistory`와 관련 port/repository를 재사용 후보로 검토한다.
|
|
||||||
- CDN URL 조립은 `v2/common/domain/CdnUrlExtensions.kt`의 `toCdnUrl` 패턴을 우선 사용한다.
|
|
||||||
- `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`는 legacy adapter를 사용하지 않고 v2 집계 repository에서 직접 후보를 만든다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. API Endpoint
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/v2/audio/rankings?type=WEEKLY_POPULAR
|
|
||||||
Authorization: Bearer {accessToken} (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
- 비회원 조회를 허용한다.
|
|
||||||
- 회원 조회 시 기존 v2 controller 패턴과 동일하게 anonymous user를 `null` member로 처리한다.
|
|
||||||
- `type`이 없으면 `WEEKLY_POPULAR`를 기본값으로 사용한다.
|
|
||||||
- 잘못된 `type` 값에 대한 오류 응답은 기존 enum request parameter 오류 처리 정책을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Response Data Class
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class AudioRankingResponse(
|
|
||||||
val showRankChange: Boolean,
|
|
||||||
val type: AudioRankingType,
|
|
||||||
val items: List<AudioRankingItemResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class AudioRankingType {
|
|
||||||
WEEKLY_POPULAR,
|
|
||||||
RISING,
|
|
||||||
REVENUE,
|
|
||||||
SALES_COUNT,
|
|
||||||
COMMENT_COUNT,
|
|
||||||
LIKE_COUNT
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AudioRankingItemResponse(
|
|
||||||
val contentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val rank: Int,
|
|
||||||
val rankChange: Int?,
|
|
||||||
@JsonProperty("isNew")
|
|
||||||
val isNew: Boolean,
|
|
||||||
val coverImageUrl: String?
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`coverImageUrl` 응답 정책은 다음과 같다.
|
|
||||||
|
|
||||||
- 스냅샷 테이블의 표시용 커버 이미지 값은 원천 `audio_content.cover_image`와 같은 path 형태로 저장될 수 있다.
|
|
||||||
- 공개 API 응답의 `coverImageUrl`은 클라이언트가 바로 이미지 로딩에 사용할 수 있도록 `cloud.aws.cloud-front.host`를 prefix로 포함한다.
|
|
||||||
- 변환은 `v2/common/domain/CdnUrlExtensions.kt`의 `toCdnUrl` 정책을 따른다.
|
|
||||||
- `null`, 빈 문자열, blank 값은 `null`로 유지한다.
|
|
||||||
- 이미 `https://` 또는 `http://`로 시작하는 값은 외부/완성 URL로 보고 그대로 유지한다.
|
|
||||||
- 이 정책은 스냅샷 생성, 정렬, `rankChange`, `isNew`, fallback 여부와 무관한 Response 조립 정책이며, 기존 스냅샷 데이터의 재생성이나 DDL 변경을 요구하지 않는다.
|
|
||||||
|
|
||||||
응답 예시는 다음과 같다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"showRankChange": true,
|
|
||||||
"type": "WEEKLY_POPULAR",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"contentId": 123,
|
|
||||||
"title": "Audio title",
|
|
||||||
"creatorNickname": "creator",
|
|
||||||
"rank": 1,
|
|
||||||
"rankChange": 5,
|
|
||||||
"isNew": false,
|
|
||||||
"coverImageUrl": "https://cdn.example.com/audio-cover.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contentId": 456,
|
|
||||||
"title": "New audio",
|
|
||||||
"creatorNickname": "new creator",
|
|
||||||
"rank": 2,
|
|
||||||
"rankChange": null,
|
|
||||||
"isNew": true,
|
|
||||||
"coverImageUrl": "https://cdn.example.com/audio-cover-new.png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Technical Constraints
|
|
||||||
- Kotlin + Spring Boot 2.7.14 기준으로 작성한다.
|
|
||||||
- Java 17 런타임을 기준으로 한다.
|
|
||||||
- 신규 코드는 `kr.co.vividnext.sodalive.v2` 하위에 배치한다.
|
|
||||||
- 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
|
|
||||||
- 스냅샷과 작업 이력 저장은 MySQL 기준 DDL을 별도 문서 또는 구현 계획에서 작성한다.
|
|
||||||
- 이번 PRD에서 예상하는 신규 Entity는 `content_ranking_snapshot`, `content_ranking_snapshot_job` 테이블에 대응하며, 초안 DDL은 `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`에 둔다.
|
|
||||||
- 시간 기준은 `Asia/Seoul`을 명시하고 DB 조회는 UTC 변환 범위를 사용한다.
|
|
||||||
- 테스트는 순위 변화 계산, 정규화 산식, 동점 정렬, fallback 최대 3회 제한, 작업 이력 기록을 포함해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Metrics
|
|
||||||
- 랭킹 조회 API 응답 시간
|
|
||||||
- 랭킹 타입별 스냅샷 생성 성공/실패 횟수
|
|
||||||
- fallback 실행 횟수와 성공/실패 횟수
|
|
||||||
- fallback 최대 3회 초과로 빈 응답한 횟수
|
|
||||||
- 랭킹 타입별 응답 item 수
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Open Questions
|
|
||||||
- fallback 최대 3회 제한은 동일 랭킹 타입과 동일 집계 기간 기준으로 가정한다.
|
|
||||||
- 지금 뜨는 중의 최소 반영 기준은 콘텐츠 전체 후보 제외가 아니라 지표별 점수 반영 제외로 해석한다. 예를 들어 최근 7일 조회수는 10회 미만이지만 좋아요 3개 이상, 댓글 3개 이상이면 조회수 증가율만 0으로 계산하고 좋아요/댓글 증가율은 점수에 반영한다. 콘텐츠 전체 후보 제외가 의도라면 구현 전에 수정해야 한다.
|
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
# 메인 콘텐츠 추천 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** `GET /api/v2/audio/recommendations`로 메인 콘텐츠 추천 탭의 배너, 오리지널 시리즈, 최신/무료/포인트/추천 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회할 수 있게 한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 조립 계층에 둔다. 추천 조회 service, 점수 정책, 조회 domain model, port, QueryDSL/native SQL repository, scheduler는 `kr.co.vividnext.sodalive.v2.content.recommendation` 하위에 두고 `v2.api.*`에 의존하지 않는다. `content` 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다. 배너 값 모델은 `v2.common.domain`, 배너 응답 DTO는 `v2.api.common.dto`로 분리하고, 기존 `recommendation_snapshot`은 section enum 확장 방식으로 재사용한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/audio/recommendations`
|
|
||||||
- 최종 패키지 구조: 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.recommendation`, 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.recommendation`을 사용한다.
|
|
||||||
- 기존 Phase 1-5 구현 산출물이 `audio.recommendation` 패키지에 있으면 Phase 6에서 `content.recommendation` 패키지로 이동한다.
|
|
||||||
- 인증 정책: 비회원 조회 가능. 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
|
|
||||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
|
||||||
- 기본 노출 수:
|
|
||||||
- `banners`: 메인 홈 추천 배너와 동일
|
|
||||||
- `originalSeries`: 최신순 12개
|
|
||||||
- `latestAudios`: 최신순 12개
|
|
||||||
- `newAndHotAudios`: 최대 12개
|
|
||||||
- `freeAudios`: 최대 10개 랜덤
|
|
||||||
- `pointAudios`: 최대 10개 랜덤
|
|
||||||
- `mostCommentedAudios`: 최대 5개
|
|
||||||
- `recommendedAudios`: 최대 10개
|
|
||||||
- 공개 오디오 공통 조건: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`, 크리에이터 회원 활성.
|
|
||||||
- 비회원과 19금 노출 불가 회원은 성인 콘텐츠를 제외하고 `SAFE` 스냅샷을 조회한다.
|
|
||||||
- 19금 노출 가능 회원은 성인/비성인 콘텐츠를 모두 포함하는 `ALL` 스냅샷을 조회한다.
|
|
||||||
- 스냅샷 기준: KST 매일 00:00 실행, 전날 23:59:59 KST까지의 데이터를 UTC 변환 없이 KST-local `LocalDateTime`으로 반영.
|
|
||||||
- 스냅샷 저장 방식: 기존 `recommendation_snapshot` 테이블을 재사용하고 `RecommendedSectionType` enum에 `NEW_AND_HOT_AUDIO_SAFE`, `NEW_AND_HOT_AUDIO_ALL`, `MOST_COMMENTED_AUDIO_SAFE`, `MOST_COMMENTED_AUDIO_ALL`, `RECOMMENDED_AUDIO_SAFE`, `RECOMMENDED_AUDIO_ALL`을 추가한다. 신규 테이블 DDL은 작성하지 않는다.
|
|
||||||
- New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는 `creator_content_view_history`의 `content_id`별 count를 사용한다.
|
|
||||||
- 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%.
|
|
||||||
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
|
|
||||||
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
|
||||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
|
|
||||||
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
|
|
||||||
- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### API 공통 DTO
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.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/dto/recommendation/HomeRecommendationResponseTest.kt`
|
|
||||||
|
|
||||||
### 신규 API 조립 계층
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
|
||||||
|
|
||||||
### 신규 도메인 조회 계층
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
|
|
||||||
|
|
||||||
### 기존 재사용 파일 확인
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.content.recommendation.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
|
|
||||||
|
|
||||||
data class AudioRecommendationsResponse(
|
|
||||||
val banners: List<RecommendationBannerResponse>,
|
|
||||||
val originalSeries: List<OriginalSeriesResponse>,
|
|
||||||
val latestAudios: List<AudioCardResponse>,
|
|
||||||
val newAndHotAudios: List<AudioCardResponse>,
|
|
||||||
val freeAudios: List<AudioCardResponse>,
|
|
||||||
val pointAudios: List<AudioCardResponse>,
|
|
||||||
val mostCommentedAudios: List<CommentedAudioResponse>,
|
|
||||||
val recommendedAudios: List<AudioCardResponse>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse {
|
|
||||||
return AudioRecommendationsResponse(
|
|
||||||
banners = recommendations.banners.map(RecommendationBannerResponse::from),
|
|
||||||
originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from),
|
|
||||||
latestAudios = recommendations.latestAudios.map(AudioCardResponse::from),
|
|
||||||
newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from),
|
|
||||||
freeAudios = recommendations.freeAudios.map(AudioCardResponse::from),
|
|
||||||
pointAudios = recommendations.pointAudios.map(AudioCardResponse::from),
|
|
||||||
mostCommentedAudios = recommendations.mostCommentedAudios.map(CommentedAudioResponse::from),
|
|
||||||
recommendedAudios = recommendations.recommendedAudios.map(AudioCardResponse::from)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class OriginalSeriesResponse(
|
|
||||||
val seriesId: Long,
|
|
||||||
val coverImageUrl: String?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(series: OriginalSeries): OriginalSeriesResponse {
|
|
||||||
return OriginalSeriesResponse(series.seriesId, series.coverImageUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AudioCardResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean,
|
|
||||||
@JsonProperty("isPointAvailable")
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
@JsonProperty("isFirstContent")
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
@JsonProperty("isOriginalSeries")
|
|
||||||
val isOriginalSeries: Boolean,
|
|
||||||
val creatorNickname: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(audio: AudioCard): AudioCardResponse {
|
|
||||||
return AudioCardResponse(
|
|
||||||
audioContentId = audio.audioContentId,
|
|
||||||
title = audio.title,
|
|
||||||
duration = audio.duration,
|
|
||||||
imageUrl = audio.imageUrl,
|
|
||||||
price = audio.price,
|
|
||||||
isAdult = audio.isAdult,
|
|
||||||
isPointAvailable = audio.isPointAvailable,
|
|
||||||
isFirstContent = audio.isFirstContent,
|
|
||||||
isOriginalSeries = audio.isOriginalSeries,
|
|
||||||
creatorNickname = audio.creatorNickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CommentedAudioResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val latestComment: String,
|
|
||||||
val latestCommentWriterProfileImageUrl: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(audio: CommentedAudio): CommentedAudioResponse {
|
|
||||||
return CommentedAudioResponse(
|
|
||||||
audioContentId = audio.audioContentId,
|
|
||||||
title = audio.title,
|
|
||||||
imageUrl = audio.imageUrl,
|
|
||||||
latestComment = audio.latestComment,
|
|
||||||
latestCommentWriterProfileImageUrl = audio.latestCommentWriterProfileImageUrl
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
|
||||||
|
|
||||||
data class AudioRecommendations(
|
|
||||||
val banners: List<RecommendationBanner>,
|
|
||||||
val originalSeries: List<OriginalSeries>,
|
|
||||||
val latestAudios: List<AudioCard>,
|
|
||||||
val newAndHotAudios: List<AudioCard>,
|
|
||||||
val freeAudios: List<AudioCard>,
|
|
||||||
val pointAudios: List<AudioCard>,
|
|
||||||
val mostCommentedAudios: List<CommentedAudio>,
|
|
||||||
val recommendedAudios: List<AudioCard>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class OriginalSeries(
|
|
||||||
val seriesId: Long,
|
|
||||||
val coverImageUrl: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AudioCard(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val isOriginalSeries: Boolean,
|
|
||||||
val creatorNickname: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CommentedAudio(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val latestComment: String,
|
|
||||||
val latestCommentWriterProfileImageUrl: String
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class AudioRecommendationVisibility {
|
|
||||||
SAFE,
|
|
||||||
ALL
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.content.recommendation.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface AudioRecommendationQueryPort {
|
|
||||||
fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner>
|
|
||||||
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
|
||||||
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
|
||||||
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
|
||||||
fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
|
||||||
fun findAudioCardsByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<AudioCard>
|
|
||||||
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
|
|
||||||
fun findNewAndHotSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
|
||||||
fun findMostCommentedSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
|
||||||
fun findRecommendedAudioSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: 공통 DTO와 API 계약
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 배너 응답 DTO를 공통 패키지로 분리**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.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/dto/recommendation/HomeRecommendationResponseTest.kt`
|
|
||||||
- RED: `HomeRecommendationResponse`의 `banners`가 공통 `RecommendationBannerResponse` 타입을 사용하고 기존 JSON 필드 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`를 유지하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
|
|
||||||
- GREEN: `HomeBannerItem` 필드 구조를 `RecommendationBanner` domain model과 `RecommendationBannerResponse` DTO로 분리하고 홈 추천 DTO/facade import를 갱신한다.
|
|
||||||
- REFACTOR: 홈 탭 전용 controller/facade 로직은 이동하지 않고 DTO 타입만 공통화한다.
|
|
||||||
- 기대 결과: 기존 홈 추천 배너 JSON 계약은 유지되고 신규 오디오 추천 API가 같은 DTO를 재사용할 수 있다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: 오디오 추천 응답 DTO와 facade 변환 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt`
|
|
||||||
- RED: facade가 도메인 `AudioRecommendations`를 `AudioRecommendationsResponse`로 변환하고 `originalSeries`, `latestAudios`, `newAndHotAudios`, `freeAudios`, `pointAudios`, `mostCommentedAudios`, `recommendedAudios` 필드를 모두 채우는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`
|
|
||||||
- GREEN: facade는 `AudioRecommendationQueryService.getRecommendations(member)`만 호출하고 공개 DTO 변환만 담당한다.
|
|
||||||
- REFACTOR: `isOriginalSeries`는 `Boolean`으로 유지하고 nullable 변환을 만들지 않는다.
|
|
||||||
- 기대 결과: API 조립 계층은 도메인 조회 계층에만 의존하고, 도메인 조회 계층은 API DTO에 의존하지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 1.3: 비회원 허용 controller 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
|
|
||||||
- RED: `GET /api/v2/audio/recommendations`가 비회원과 인증 회원 모두 `200 OK`를 반환하고 `ApiResponse.ok` wrapper를 사용하는 MockMvc 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest`
|
|
||||||
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴으로 member nullable을 facade에 전달한다.
|
|
||||||
- REFACTOR: request parameter는 추가하지 않고 controller에는 인증/응답 경계만 남긴다.
|
|
||||||
- 기대 결과: 비회원 조회 가능 계약과 endpoint 경로가 controller 테스트로 고정된다.
|
|
||||||
|
|
||||||
### Phase 2: 도메인 모델과 점수 정책
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 도메인 모델과 visibility enum 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: `AudioRecommendationVisibility.SAFE`는 `NEW_AND_HOT_AUDIO_SAFE`, `ALL`은 `NEW_AND_HOT_AUDIO_ALL`처럼 section type을 선택해야 한다는 service 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
|
||||||
- GREEN: `AudioRecommendations`, `OriginalSeries`, `AudioCard`, `CommentedAudio`, `AudioRecommendationVisibility`를 추가한다.
|
|
||||||
- REFACTOR: domain model에는 API DTO import를 두지 않는다. `AudioRecommendations.banners`는 `v2.common.domain.RecommendationBanner`만 사용한다.
|
|
||||||
- 기대 결과: SAFE/ALL 선택이 문자열이 아니라 enum으로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: 오디오 추천 점수 정책 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
|
|
||||||
- RED: New & Hot 최신성 배수 3/7/14일/그 외, 추천 오디오 최신성 배수 3/7/30일/그 외, 최근 댓글 최신성 배수 3/7/14일/그 이상을 검증하는 테스트를 작성한다. 원본 count 가중합이 정규화 없이 계산되는 테스트도 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest`
|
|
||||||
- GREEN: `calculateNewAndHotScore`, `calculateRecommendedAudioScore`, `calculateCommentScore`와 각 recency multiplier 함수를 구현한다.
|
|
||||||
- REFACTOR: 가중치와 일수 경계는 `companion object` 상수로 모아 테스트 기대값과 용어를 맞춘다.
|
|
||||||
- 기대 결과: PRD 산식과 최신성 경계가 순수 단위 테스트로 고정된다.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: 스냅샷 section enum 확장**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: visibility와 섹션 조합이 `NEW_AND_HOT_AUDIO_SAFE`, `NEW_AND_HOT_AUDIO_ALL`, `MOST_COMMENTED_AUDIO_SAFE`, `MOST_COMMENTED_AUDIO_ALL`, `RECOMMENDED_AUDIO_SAFE`, `RECOMMENDED_AUDIO_ALL`로 매핑되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
|
||||||
- GREEN: 기존 `RecommendedSectionType`에 오디오 추천 섹션 enum 값을 추가하고 service 내부 매핑 함수를 구현한다.
|
|
||||||
- REFACTOR: `recommendation_snapshot.section_type` 길이 50 안에 모든 enum 이름이 들어가는지 확인한다.
|
|
||||||
- 기대 결과: 신규 테이블 없이 기존 스냅샷 저장 구조를 재사용한다.
|
|
||||||
|
|
||||||
### Phase 3: 실시간 조회 섹션 repository
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 배너/오리지널 시리즈/최신 오디오 조회 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 배너는 기존 홈 추천 배너와 동일 필드/활성/차단 정책을 적용하고, 오리지널 시리즈는 `isOriginal = true` 최신순 12개, 최신 오디오는 `releaseDate desc`, `audioContentId desc` 12개를 반환하는 repository 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: QueryDSL로 `findBanners`, `findOriginalSeries`, `findLatestAudios`를 구현한다. 이미지 경로는 `toCdnUrl(cloudFrontHost)`를 사용한다.
|
|
||||||
- REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
|
|
||||||
- 기대 결과: 비회원은 성인 콘텐츠를 제외하고, 인증 회원은 성인 노출 가능 여부에 따라 결과가 달라진다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 무료/포인트 랜덤 오디오 조회 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 10개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 10개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다.
|
|
||||||
- REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다.
|
|
||||||
- 기대 결과: 랜덤 섹션도 공개/성인/차단 조건을 동일하게 적용한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: 공통 오디오 카드 enrichment 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: `AudioCard`가 `audioContentId`, `title`, `duration`, `imageUrl`, `price`, `isAdult`, `isPointAvailable`, `isFirstContent`, `isOriginalSeries`, `creatorNickname`을 채우고, 시리즈 미소속이면 `isOriginalSeries = false`인 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: first content 판정은 기존 크리에이터 채널 오디오 조회 repository의 첫 콘텐츠 계산 패턴을 참고해 구현한다. 원본 시리즈 연결이 없으면 `false`, 연결 시리즈가 있으면 `series.isOriginal`을 사용한다.
|
|
||||||
- REFACTOR: latest/free/point/snapshot 상세 조회 모두 같은 `toAudioCard` 변환을 사용한다.
|
|
||||||
- 기대 결과: 섹션별 오디오 카드 필드 의미가 동일하게 유지된다.
|
|
||||||
|
|
||||||
### Phase 4: 스냅샷 산정과 일 배치
|
|
||||||
|
|
||||||
- [x] **Task 4.1: New & Hot 스냅샷 후보 산정 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 최근 3일 `creator_content_view_history` count, `content_like` active count, `audio_content_comment` active count, 최신성 배수를 원본 count 가중합으로 계산하고 `SAFE`는 비성인만, `ALL`은 성인/비성인을 모두 포함하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: native SQL CTE 또는 QueryDSL aggregate로 `findNewAndHotSnapshots(windowStart, snapshotAt, visibility, limit)`를 구현한다. 정렬은 `score desc`, `randomTieBreaker asc`로 한다.
|
|
||||||
- REFACTOR: Kotlin `AudioRecommendationScorePolicy` 기대값과 DB score가 일치하는 parity 테스트 데이터를 유지한다.
|
|
||||||
- 기대 결과: `NEW_AND_HOT_AUDIO_SAFE/ALL`에 저장할 top 12 후보가 정확한 점수순으로 산출된다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 최근 댓글 많은 오디오 스냅샷 후보 산정 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 최근 7일 댓글 데이터 기반으로 댓글 수 80%, 댓글 최신성 20% 점수를 계산하고 데이터가 없으면 빈 후보를 반환하는 테스트를 작성한다. 가장 최신 댓글 1개의 본문과 작성자 프로필 이미지가 상세 조회에서 내려가는 테스트도 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: `findMostCommentedSnapshots(...)`와 `findCommentedAudiosByIds(...)`를 구현한다. 상세 조회 결과에는 가장 최신 댓글 본문과 작성자 프로필 이미지를 포함한다. 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자는 제외한다.
|
|
||||||
- REFACTOR: 댓글 최신성 배수 계산은 repository SQL과 `AudioRecommendationScorePolicy`가 같은 경계값을 사용하도록 테스트로 고정한다.
|
|
||||||
- 기대 결과: 스냅샷이 없거나 후보가 없으면 `mostCommentedAudios`는 빈 배열이다.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: 추천 오디오 스냅샷 후보 산정 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10% 점수를 계산하고 `SAFE/ALL` visibility별 최대 10개 후보를 반환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: `findRecommendedAudioSnapshots(...)`를 구현한다. 상세 조회수는 `creator_content_view_history` count를 사용하고 `AudioContent.playCount`를 사용하지 않는다.
|
|
||||||
- REFACTOR: New & Hot과 공유 가능한 조회수/좋아요/댓글 aggregate CTE를 private SQL fragment 또는 QueryDSL helper로 정리한다.
|
|
||||||
- 기대 결과: `RECOMMENDED_AUDIO_SAFE/ALL`에 저장할 top 10 후보가 정확한 점수순으로 산출된다.
|
|
||||||
|
|
||||||
- [x] **Task 4.4: 스냅샷 refresh service와 lazy 보강 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: `refreshDailySnapshots(now)`가 KST 전날 23:59:59 기준으로 여섯 section type(`NEW_AND_HOT_AUDIO_SAFE/ALL`, `MOST_COMMENTED_AUDIO_SAFE/ALL`, `RECOMMENDED_AUDIO_SAFE/ALL`)을 replace하고, New & Hot 조회 시 최신 스냅샷이 없으면 lazy refresh를 1회 호출하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
|
|
||||||
- GREEN: 기존 `RecommendationSnapshotPort.replaceSnapshots(...)`와 `findLatestSnapshots(...)`를 재사용한다. lazy 보강은 New & Hot에만 적용하고, 최근 댓글 많은 오디오는 스냅샷이 없으면 빈 배열로 유지한다.
|
|
||||||
- REFACTOR: 기준 시각 계산은 private 함수로 분리하고 KST-local `LocalDateTime` 경계 테스트를 유지한다. 보강 후에도 New & Hot 후보가 0개이면 Redis marker 기준 같은 KST 날짜에는 lazy refresh를 반복하지 않는다.
|
|
||||||
- 기대 결과: 일 배치와 lazy 보강 모두 같은 산정 함수를 사용한다.
|
|
||||||
|
|
||||||
- [x] **Task 4.5: 00:00 KST 스케줄러와 Redisson lock 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
|
|
||||||
- RED: cron이 `0 0 0 * * *`, zone이 `Asia/Seoul`, lock key가 `lock:audio-recommendation-snapshot-refresh`이고 lock 획득 성공 시에만 refresh service를 호출하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest`
|
|
||||||
- GREEN: `RedissonClient`를 주입하고 기존 추천 스냅샷 scheduler 패턴처럼 `tryLock` 성공 시 `refreshDailySnapshots()`를 호출한다.
|
|
||||||
- REFACTOR: 스케줄러에는 lock과 service 호출만 남기고 집계 로직을 두지 않는다.
|
|
||||||
- 기대 결과: 다중 서버에서 하루 한 번만 오디오 추천 스냅샷을 갱신한다.
|
|
||||||
|
|
||||||
### Phase 5: 통합 조회 service와 API 연결
|
|
||||||
|
|
||||||
- [x] **Task 5.1: AudioRecommendationQueryService 통합 조립**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
|
||||||
- RED: 비회원은 `SAFE` visibility와 19금 제외 조건을 사용하고, 19금 노출 가능 회원은 `ALL` visibility를 사용하며, 각 섹션 limit이 PRD와 일치하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
|
||||||
- GREEN: query service가 real-time 섹션과 snapshot 섹션을 조립해 `AudioRecommendations`를 반환한다. `MemberContentPreferenceService`는 facade가 아니라 query service 또는 별도 resolver에서 사용해 도메인 조회 조건을 만든다.
|
|
||||||
- REFACTOR: 섹션 limit은 companion object 상수로 고정하고 테스트에서 같은 값을 검증한다.
|
|
||||||
- 기대 결과: 특정 섹션이 빈 배열이어도 전체 응답은 성공 가능한 domain model로 조립된다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: Facade 성인 정책/이미지 URL 변환 연결**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt`
|
|
||||||
- RED: 회원/비회원별 성인 노출 정책이 query service에 전달되고, CDN URL이 포함된 domain 응답이 공개 DTO로 변환되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`
|
|
||||||
- GREEN: facade는 member를 그대로 query service에 전달하고 `AudioRecommendationsResponse.from(...)`만 수행한다.
|
|
||||||
- REFACTOR: Home 탭 전용 `HomeRecommendationFacade`를 주입하거나 호출하지 않는지 import를 확인한다.
|
|
||||||
- 기대 결과: API 조립 계층은 신규 audio recommendation use case만 호출한다.
|
|
||||||
|
|
||||||
- [x] **Task 5.3: Controller/E2E 통합 검증**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
|
|
||||||
- RED: MockMvc controller 테스트와 최소 E2E 테스트를 작성해 JSON path `$.data.originalSeries`, `$.data.latestAudios`, `$.data.recommendedAudios`, `$.data.latestAudios[0].isOriginalSeries`, `$.data.mostCommentedAudios[0].latestComment`, `$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl`가 존재하는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`
|
|
||||||
- GREEN: controller와 Spring bean wiring을 완성한다.
|
|
||||||
- REFACTOR: 응답 필드명이 PRD와 plan-task의 DTO 초안과 같은지 검색으로 확인한다.
|
|
||||||
- 기대 결과: 비회원과 인증 회원 모두 endpoint 호출이 성공한다.
|
|
||||||
|
|
||||||
### Phase 6: 패키지 구조 content.recommendation 이동
|
|
||||||
|
|
||||||
- [x] **Task 6.1: 공개 API 조립 계층 패키지 이동**
|
|
||||||
- Files:
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
|
||||||
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.*`
|
|
||||||
- Run: `rg -n "v2\\.api\\.audio\\.recommendation" src/main/kotlin src/test/kotlin`
|
|
||||||
- GREEN: `package` 선언과 import를 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 기준으로 갱신한다.
|
|
||||||
- REFACTOR: endpoint `GET /api/v2/audio/recommendations`, response DTO class/field 이름은 공개 API 계약이므로 변경하지 않는다.
|
|
||||||
- 기대 결과: 공개 API 조립 계층은 `v2.api.content.recommendation` 아래에만 존재한다.
|
|
||||||
|
|
||||||
- [x] **Task 6.2: 도메인 조회 계층 패키지 이동**
|
|
||||||
- Files:
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
|
|
||||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/**` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/**`
|
|
||||||
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.*`
|
|
||||||
- Run: `rg -n "v2\\.audio\\.recommendation" src/main/kotlin src/test/kotlin`
|
|
||||||
- GREEN: 도메인 조회 계층의 `package` 선언과 import를 `kr.co.vividnext.sodalive.v2.content.recommendation` 기준으로 갱신한다.
|
|
||||||
- REFACTOR: class 이름(`AudioRecommendation*`)은 현재 API/섹션 의미가 오디오 중심이므로 유지하고, 패키지명만 콘텐츠 범주로 확장한다.
|
|
||||||
- 기대 결과: 도메인 조회 계층은 `v2.content.recommendation` 아래에만 존재하고 `v2.api.*`에 의존하지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 6.3: 패키지 잔여 참조와 문서 동기화 확인**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
|
||||||
- Verify: `src/main/kotlin`
|
|
||||||
- Verify: `src/test/kotlin`
|
|
||||||
- TDD 예외 사유: 문서와 package/import 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`
|
|
||||||
- Run: `rg -n "api\\.content\\.recommendation|v2\\.content\\.recommendation|api/content/recommendation|v2/content/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`
|
|
||||||
- 기대 결과: 잔여 `audio.recommendation` 패키지 참조는 과거 검증 기록을 제외하고 남지 않고, PRD/plan-task의 최종 구조가 `content.recommendation` 기준으로 일치한다.
|
|
||||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
|
|
||||||
- 2026-06-23 Phase 6 구현 기록:
|
|
||||||
- 공개 API 조립 계층을 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 패키지로 이동했다. endpoint `GET /api/v2/audio/recommendations`, class 이름, response DTO field 이름은 변경하지 않았다.
|
|
||||||
- 도메인 조회 계층을 `kr.co.vividnext.sodalive.v2.content.recommendation` 패키지로 이동했다. `AudioRecommendation*` class 이름과 repository/query/scheduler 동작은 변경하지 않았다.
|
|
||||||
- `rg -n "kr\.co\.vividnext\.sodalive\.v2\.(api\.)?audio\.recommendation|v2\.api\.audio\.recommendation|v2\.audio\.recommendation" src/main/kotlin src/test/kotlin`: 결과 없음.
|
|
||||||
- `rg --files src/main/kotlin src/test/kotlin | rg "/v2/(api/)?audio/recommendation/"`: 결과 없음.
|
|
||||||
- `rg -n "/api/v2/audio/recommendations" src/main/kotlin src/test/kotlin`: controller, controller test, E2E test, `SecurityConfig`에서 기존 endpoint 유지 확인.
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*'`: `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*'`: 병렬 Gradle 실행 중 XML test result 파일 쓰기 충돌로 1회 실패 후 단독 재실행해 `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-23 Phase 6 코드 리뷰 및 검증 기록:
|
|
||||||
- `rg -n "v2\\.api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|/v2/audio/recommendation" src/main/kotlin src/test/kotlin`: endpoint 문자열을 제외하고 이전 패키지/경로 참조 없음.
|
|
||||||
- `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`: 문서의 Phase 1-6 과거 작업 경로/검증 기록과 endpoint 문자열만 확인됨.
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*'`: `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*'`: `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew ktlintCheck`: 최초 sandbox 실행은 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한으로 실패했고, 승인 후 재실행해 `BUILD SUCCESSFUL`.
|
|
||||||
|
|
||||||
### Phase 7: 성인 콘텐츠 조회 정책 계산 경로 통일
|
|
||||||
|
|
||||||
- [x] **Task 7.1: MemberContentPreferenceService에 성인 콘텐츠 조회 가능 여부 메서드 추가**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
|
|
||||||
- RED: `canViewAdultContent(member)`가 저장된 `isAdultContentVisible` 설정, 국가 정책, 성인 인증 여부를 반영해 `ViewerContentPreference.isAdult`와 같은 값을 반환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`
|
|
||||||
- GREEN: `MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean`을 추가하고 내부 구현은 `getStoredPreference(member).isAdult`를 반환한다.
|
|
||||||
- REFACTOR: 성인 콘텐츠 조회 가능 여부를 계산하는 신규 호출부는 `isAdultVisibleByPolicy(...)`를 직접 호출하지 않고 service 메서드를 사용한다.
|
|
||||||
- 기대 결과: 사용자 설정(`isAdultContentVisible`), 국가 정책, 성인 인증 여부가 하나의 공개 service 메서드로 일관되게 계산된다.
|
|
||||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
|
||||||
- 2026-06-23 Phase 7 구현 기록:
|
|
||||||
- RED: `MemberContentPreferenceServiceTest.shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent`를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests '*shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent'`를 실행해 `Unresolved reference: canViewAdultContent` 실패를 확인했다.
|
|
||||||
- GREEN: `MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean`을 추가해 `getStoredPreference(member).isAdult`를 반환하도록 했고, 동일 테스트 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.*'`: 따옴표 없이 실행한 첫 명령은 zsh glob 해석으로 실행 전 실패했고, 따옴표로 감싸 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 7.2: 추천 탭과 v2 조회 계층의 성인 정책 호출부를 service 메서드로 교체**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
|
||||||
- Test: 기존 v2 조회 service 테스트 중 성인 콘텐츠 노출 정책을 검증하는 테스트 파일
|
|
||||||
- RED: `AudioRecommendationQueryServiceTest`에서 `memberContentPreferenceService.canViewAdultContent(member)`가 호출되고 `getStoredPreference(...)` 또는 `isAdultVisibleByPolicy(...)` 직접 조합을 사용하지 않는 테스트를 작성한다. 기존 v2 조회 service 테스트에는 성인 콘텐츠 노출 가능/불가 회원별 조회 조건이 유지되는 회귀 테스트를 추가한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
|
|
||||||
- Run: 변경한 기존 v2 조회 service 테스트
|
|
||||||
- GREEN: 각 호출부의 `getStoredPreference(...)` + `isAdultVisibleByPolicy(...)` 조합 또는 `getStoredPreference(...).isAdult` 직접 사용을 `memberContentPreferenceService.canViewAdultContent(member)`로 교체한다.
|
|
||||||
- REFACTOR: 더 이상 필요 없는 `isAdultVisibleByPolicy` import와 중간 `preference` 지역 변수를 제거한다.
|
|
||||||
- 기대 결과: v2 조회 계층과 추천 탭 API의 성인 콘텐츠 조회 정책 계산 경로가 `MemberContentPreferenceService.canViewAdultContent(...)`로 통일된다.
|
|
||||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
|
||||||
- 2026-06-23 Phase 7 구현 기록:
|
|
||||||
- `AudioRecommendationQueryService`, `HomeRecommendationFacade`, v2 creator channel audio/community/home/live/series 조회 service의 성인 콘텐츠 조회 가능 여부 계산을 `memberContentPreferenceService.canViewAdultContent(...)` 호출로 통일했다.
|
|
||||||
- `CreatorChannelHomeQueryService`는 기존 `preference.contentType` 전달이 필요하므로 `getStoredPreference(viewer)`는 유지하고, 성인 콘텐츠 조회 가능 여부 계산만 service 메서드로 교체했다.
|
|
||||||
- 변경한 v2 service/controller 테스트 묶음 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
|
|
||||||
|
|
||||||
- [x] **Task 7.3: 성인 정책 직접 호출 잔여 참조 확인**
|
|
||||||
- Files:
|
|
||||||
- Verify: `src/main/kotlin`
|
|
||||||
- Verify: `src/test/kotlin`
|
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 검색 기반 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`
|
|
||||||
- Run: `rg -n "canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive`
|
|
||||||
- 기대 결과: v2 조회 계층에는 성인 콘텐츠 조회 가능 여부 계산을 위한 `isAdultVisibleByPolicy(...)` 직접 호출이나 `getStoredPreference(...).isAdult` 직접 사용이 남지 않고, `canViewAdultContent(...)` 호출로 통일된다.
|
|
||||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
|
|
||||||
- 2026-06-23 Phase 7 구현 기록:
|
|
||||||
- `rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`: 결과 없음.
|
|
||||||
- `rg -n "canViewAdultContent\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive`: `MemberContentPreferenceService`와 Phase 7 변경 호출부에서 canonical 메서드 사용 확인.
|
|
||||||
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
|
||||||
- `git diff --check`: 출력 없음.
|
|
||||||
- Phase 7 리뷰어 검토 결과: `PASS` (차단 이슈 없음).
|
|
||||||
|
|
||||||
- [x] **Task 7.4: 중복 성인 정책 함수 정리**
|
|
||||||
- Files:
|
|
||||||
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt`
|
|
||||||
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
|
||||||
- Verify: `src/main/kotlin`
|
|
||||||
- Verify: `src/test/kotlin`
|
|
||||||
- TDD 예외 사유: 정책 계산 로직의 공개 진입점 정리와 잔여 사용처 확인 task이며, Task 7.1/7.2의 회귀 테스트가 동작 동일성을 검증한다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin`
|
|
||||||
- Run: `rg -n "calculateIsAdultForQuery|canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference`
|
|
||||||
- GREEN: `isAdultVisibleByPolicy(...)`와 `resolveCountryCodeByPolicy(...)`의 production 사용처가 모두 없어졌으면 제거한다. 아직 v2 외부 사용처가 남아 있으면 즉시 제거하지 않고 `@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")`로 표시한 뒤 별도 후속 task를 남긴다.
|
|
||||||
- REFACTOR: 성인 콘텐츠 조회 가능 여부 정책의 canonical 진입점은 `MemberContentPreferenceService.canViewAdultContent(member)`로 문서화하고, 내부 계산은 기존 `calculateIsAdultForQuery(...)`를 재사용한다.
|
|
||||||
- 기대 결과: 동일한 정책을 중복 구현한 `isAdultVisibleByPolicy(...)` 경로가 제거되거나 명확히 deprecated 처리되어, 신규 호출부가 다시 분산되지 않는다.
|
|
||||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 제거하지 못한 사용처가 있으면 사유와 후속 task를 이 task 아래에 한국어로 누적 기록한다.
|
|
||||||
- 2026-06-23 Phase 7 구현 기록:
|
|
||||||
- `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin` 실행 결과 v2 외부 기존 production 사용처(`content/main`, `content/series`, `content/theme`, `content/AudioContentService` 등)가 남아 있어 즉시 제거하지 않았다.
|
|
||||||
- `MemberContentPreferencePolicy.resolveCountryCodeByPolicy(...)`와 `isAdultVisibleByPolicy(...)`에 `@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")`를 추가했다.
|
|
||||||
- 성인 콘텐츠 조회 가능 여부 정책의 신규 canonical 진입점은 `MemberContentPreferenceService.canViewAdultContent(member)`로 정리했다.
|
|
||||||
- 2026-06-23 Phase 7 코드 리뷰 및 추가 검증 기록:
|
|
||||||
- 코드 리뷰: `canViewAdultContent(member)`가 `getStoredPreference(member).isAdult`를 반환해 기본 preference 초기화, 국가 정책, 성인 인증 여부 계산 경로를 그대로 재사용함을 확인했다. v2 추천 탭/홈/creator channel 호출부도 해당 service 메서드로 통일되어 차단 이슈 없음.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
|
|
||||||
- `rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`: 결과 없음.
|
|
||||||
- `rg -n "canViewAdultContent\(" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference`: `MemberContentPreferenceService`와 Phase 7 v2 변경 호출부에서 canonical 메서드 사용 확인.
|
|
||||||
- `git diff --check`: 출력 없음.
|
|
||||||
- `./gradlew ktlintCheck`: sandbox 환경에서는 Gradle wrapper lock 파일 접근 제한으로 실패했으나, 승인 후 sandbox 밖에서 재실행해 `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
### Phase 8: 회귀 검증과 문서 기록
|
|
||||||
|
|
||||||
- [ ] **Task 8.1: 전체 관련 테스트와 ktlint 실행**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.*`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.*`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
|
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`
|
|
||||||
- Run: `./gradlew ktlintCheck`
|
|
||||||
- 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다.
|
|
||||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
|
||||||
|
|
||||||
- [ ] **Task 8.2: 문서/스키마 영향 최종 확인**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
|
||||||
- TDD 예외 사유: 문서와 enum 확장 범위 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- Run: `rg -n "GET /api/v2/audio/recommendations|AudioRecommendationsResponse|NEW_AND_HOT_AUDIO_SAFE|RECOMMENDED_AUDIO_ALL" docs src/main/kotlin src/test/kotlin`
|
|
||||||
- Run: `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" src/main/kotlin src/test/kotlin`
|
|
||||||
- Run: `rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`
|
|
||||||
- Run: `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin`
|
|
||||||
- Run: `./gradlew tasks --all`
|
|
||||||
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가 `content.recommendation` 기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다.
|
|
||||||
- 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 전체 검증 기록
|
|
||||||
|
|
||||||
- 계획 문서 생성 시점에는 구현 코드를 변경하지 않았으므로 테스트 실행 대상은 없다.
|
|
||||||
- 문서 변경 후 명령 유효성 확인은 `./gradlew tasks --all`로 수행한다.
|
|
||||||
- 패키지 구조 변경 계획 문서 수정 후 `./gradlew tasks --all`을 실행했다. 최초 sandbox 실행은 Gradle wrapper lock 파일의 `~/.gradle` 접근 권한 문제로 실패했고, 승인 후 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
|
|
||||||
## Phase 1-3 검증 기록
|
|
||||||
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`: `BUILD SUCCESSFUL` (홈 배너 공통 DTO 직렬화 필드 포함 확인).
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`: 최초 실행 시 점수 정책 테스트 기대값 산식 오산으로 실패 후 기대값 수정.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
|
||||||
- Phase 4 범위로 보일 수 있는 snapshot 후보 조회 stub 제거 후 동일한 6개 타깃 테스트 명령을 재실행했고 `BUILD SUCCESSFUL`.
|
|
||||||
- reviewer 지적 사항 반영: `latestComment` 응답 필드 추가, PRD 기준 최신성 배수 수정, JSON boolean 필드명과 공개 오디오 필터 테스트 보강.
|
|
||||||
- `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
|
||||||
- 추가 code review 지적 사항 반영: production `SecurityConfig`에 `GET /api/v2/audio/recommendations` 비회원 허용 추가, controller 테스트의 테스트 전용 permitAll 보안 체인 제거, 오디오 추천 배너의 성인 배너 필터 제거로 홈 추천 배너와 동일 정책 유지, 오리지널 시리즈 최신순/12개 limit 및 무료/포인트 10개 limit 테스트 보강.
|
|
||||||
- 동일 targeted test 명령과 `./gradlew ktlintCheck`를 재실행했고 모두 `BUILD SUCCESSFUL`.
|
|
||||||
|
|
||||||
## Phase 4-5 검증 기록
|
|
||||||
|
|
||||||
- RED: `DefaultAudioRecommendationQueryRepositoryTest`, `AudioRecommendationSnapshotRefreshServiceTest`, `AudioRecommendationSnapshotSchedulerTest`, `AudioRecommendationQueryServiceTest`에 Phase 4/5 실패 테스트를 먼저 추가했다. 초기 실행에서 query service Mockito matcher 오류와 동시 Gradle 실행으로 인한 XML 결과 파일 쓰기 충돌, ktlint formatting 실패를 확인했다.
|
|
||||||
- GREEN: snapshot 후보 native SQL, 최신 댓글 상세 조회, KST 기준 refresh service, 00:00 KST Redisson lock scheduler, query service snapshot 조립과 New & Hot lazy refresh를 구현하고 실패 원인을 수정했다.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL` (New & Hot/최근 댓글/추천 후보 산정, 댓글 상세, 기존 실시간 섹션 회귀 포함).
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`: `BUILD SUCCESSFUL` (KST 전날 23:59:59 기준과 여섯 section replace 확인).
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest`: `BUILD SUCCESSFUL` (cron/zone, lock 획득/skip/unlock 확인).
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`: `BUILD SUCCESSFUL` (SAFE snapshot 조회, New & Hot lazy refresh, 빈 mostCommented/recommended 허용 확인).
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.audio.recommendation.*'`: `BUILD SUCCESSFUL` (facade DTO 변환과 controller permitAll 응답 계약 확인).
|
|
||||||
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
|
||||||
- 추가 점검: 댓글 상세 조회에서 차단 작성자 제외 후 최신 active 댓글을 선택하도록 보강하고 `DefaultAudioRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`를 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 추가 code review 지적 사항 반영: `findCommentedAudiosByIds`의 최신 댓글 상세 조회가 스냅샷 산정 SQL과 동일하게 크리에이터-댓글 작성자 간 차단 댓글을 제외하도록 보강하고, 해당 작성자의 더 최신 댓글이 이전 정상 댓글 선택을 막지 않도록 `newer` 후보에도 같은 차단 조건을 적용했다.
|
|
||||||
- `DefaultAudioRecommendationQueryRepositoryTest`에 viewer와 무관한 크리에이터-댓글 작성자 차단 회귀 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
|
||||||
- Task 5.3 보강: `AudioRecommendationEndToEndTest`를 추가해 `@SpringBootTest` + `@AutoConfigureMockMvc`로 production SecurityConfig, controller, facade, query service, repository, snapshot 조회 조합을 통과하는 최소 E2E를 검증했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-23 리뷰 보정: 스냅샷 기준/윈도우를 UTC 변환 `LocalDateTime`이 아니라 KST-local `LocalDateTime`으로 저장/조회하도록 보정하고, 최신성 일수는 24시간 경과 기준으로 Kotlin 정책을 맞췄다. New & Hot lazy refresh는 보강 후에도 row가 없으면 Redis marker 기준 같은 KST 날짜에 반복 실행하지 않도록 보강했다.
|
|
||||||
- 2026-06-23 리뷰 보정 후 추가 보정: post-implementation review에서 `getRecommendations()`의 read-only transaction 안에서 lazy refresh 후 재조회하면 MySQL `REPEATABLE_READ` read view 때문에 새 스냅샷이 같은 요청에서 보이지 않을 수 있다고 지적해, query service의 외부 read-only transaction을 제거했다. 또한 인메모리 guard는 프로세스 재시작/다중 서버에서 KST 날짜별 1회를 보장하지 못하므로 `RedissonClient` Redis marker(`audio-recommendation:new-and-hot:lazy-refresh-attempted:{yyyy-MM-dd}`, TTL 2일)로 변경했다.
|
|
||||||
- 2026-06-23 리뷰 보정 검증: `./gradlew --stop && ./gradlew clean test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest`: `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-23 리뷰 보정 검증: repository focused test는 병렬 Gradle 실행 중 `kaptGenerateStubsTestKotlin` 출력 디렉터리 충돌로 1회 실패해 단독 재실행했다. H2 `MODE=MySQL`의 `TIMESTAMPDIFF` 경계 동작이 운영 MySQL 공식 기준과 달라 신규 repository 경계 테스트는 제거하고 Kotlin 정책 테스트로 24시간 경계를 고정했다. 최종 `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-23 리뷰 보정 검증: `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`, `git diff --check`: 출력 없음.
|
|
||||||
- 2026-06-25 후속 보정: `DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility`의 score 비교 실패 원인은 repository native SQL의 `timestampdiff(day, c.release_date, :snapshotAt)` 최신성 계산이 DB 날짜 경계 기준에 의존해 `AudioRecommendationScorePolicy`의 24시간 경과 기준 `ChronoUnit.DAYS` 계산과 어긋날 수 있는 점으로 확인했다. `DefaultAudioRecommendationQueryRepository`의 New & Hot/추천 오디오 공개일 최신성 계산을 `floor(timestampdiff(hour, c.release_date, :snapshotAt) / 24)`로 변경해 Kotlin 정책과 일치시켰고, `SAFE` 성인 콘텐츠 제외 조건은 기존 `(:includeAdult = true or c.is_adult = false)` 구현이 올바른 것으로 확인했다. 검증은 `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility'`, `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicyTest'`, `./gradlew ktlintCheck` 모두 `BUILD SUCCESSFUL`로 완료했다.
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
# PRD: 메인 콘텐츠 추천 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
메인 콘텐츠 탭의 내부 추천 탭에서 사용할 배너, 오리지널 시리즈, 신규/추천/무료/포인트 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회하는 v2 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 기존 `content.main.tab.home` API는 콘텐츠 홈 화면 전체 구성을 조립하지만, 신규 내부 추천 탭의 섹션 구성과 응답 필드가 다르다.
|
|
||||||
- 신규 추천 탭은 실시간 최신순/랜덤 조회와 일 단위 스냅샷 기반 점수 섹션이 섞여 있어, API 조립 계층과 도메인 조회 계층의 책임을 분리해야 한다.
|
|
||||||
- 기존 v2 패키지에 홈 추천 API, 스냅샷, 배너 조회, 오디오 응답 DTO와 유사한 코드가 있으므로 구현 전 재사용 범위를 명확히 해야 한다.
|
|
||||||
- New & Hot, 최근 댓글 많은 오디오처럼 매일 갱신되는 섹션은 데이터가 없을 때 표시/스케줄 보강 정책이 필요하다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 메인 콘텐츠 추천 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
|
||||||
- 기존 패턴과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
|
|
||||||
- 메인 배너는 메인 홈 추천 배너와 동일한 데이터를 응답한다.
|
|
||||||
- 오리지널 시리즈, 최신 오디오, 무료 오디오, 포인트 오디오는 요청 시점 기준으로 조회한다.
|
|
||||||
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영한 스냅샷을 사용한다.
|
|
||||||
- New & Hot 스냅샷 데이터가 없으면 조회 시점에 lazy로 스케줄/집계 보강을 요청할 수 있어야 한다.
|
|
||||||
- 최근 댓글 많은 오디오는 스냅샷 데이터가 없으면 섹션을 빈 배열로 내려주어 앱에서 표시하지 않게 한다.
|
|
||||||
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 기존 `content.main.tab.home` 공개 API 스키마를 변경하지 않는다.
|
|
||||||
- 기존 메인 홈 추천 API의 공개 스키마를 변경하지 않는다.
|
|
||||||
- 관리자 화면, 수동 편집 기능, 추천 결과 강제 고정 기능은 포함하지 않는다.
|
|
||||||
- 개인화 추천 모델, A/B 테스트, 머신러닝 기반 추천은 포함하지 않는다.
|
|
||||||
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 콘텐츠 메인 탭에서 추천 오디오와 오리지널 시리즈를 탐색하는 사용자
|
|
||||||
- 비회원: 인증 없이 조회 가능한 추천 콘텐츠를 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 추천 탭 첫 화면 섹션을 한 API 응답으로 구성하는 클라이언트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 추천 탭 진입 시 메인 홈 추천 배너와 동일한 배너를 보고 싶다.
|
|
||||||
- 사용자는 오직 보이스 온에서만 볼 수 있는 오리지널 시리즈를 최신순으로 보고 싶다.
|
|
||||||
- 사용자는 새로 올라온 오디오를 최신순으로 확인하고 싶다.
|
|
||||||
- 사용자는 최근 반응이 좋은 New & Hot 오디오를 보고 싶다.
|
|
||||||
- 사용자는 무료 오디오와 포인트 사용 가능 오디오를 빠르게 탐색하고 싶다.
|
|
||||||
- 사용자는 최근 댓글이 많은 오디오와 해당 오디오의 최신 댓글, 최신 댓글 작성자 프로필 이미지를 보고 싶다.
|
|
||||||
- 사용자는 서버 추천 점수 기반의 추천 오디오를 보고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 메인 콘텐츠 추천 탭 통합 조회
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API endpoint는 `GET /api/v2/audio/recommendations`로 정의한다.
|
|
||||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
|
||||||
- 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
|
|
||||||
- 비회원이면 19금 콘텐츠를 노출하지 않는다.
|
|
||||||
- 회원이 차단했거나 회원을 차단한 크리에이터의 시리즈/오디오는 노출하지 않는다.
|
|
||||||
- 섹션별 기본 노출 수는 아래와 같다.
|
|
||||||
- `banners`: 메인 홈 추천 배너와 동일
|
|
||||||
- `originalSeries`: 최신순 12개
|
|
||||||
- `latestAudios`: 최신순 12개
|
|
||||||
- `newAndHotAudios`: 최대 12개
|
|
||||||
- `freeAudios`: 최대 10개 랜덤
|
|
||||||
- `pointAudios`: 최대 10개 랜덤
|
|
||||||
- `mostCommentedAudios`: 최대 5개
|
|
||||||
- `recommendedAudios`: 최대 10개
|
|
||||||
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
|
|
||||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
|
|
||||||
- 예약 공개 콘텐츠는 공개 전에는 노출하지 않는다.
|
|
||||||
- 비활성 콘텐츠, duration이 없는 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.
|
|
||||||
|
|
||||||
### Feature B. 메인 배너
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 메인 홈 추천 배너와 동일한 데이터를 사용한다.
|
|
||||||
- 기존 v2 홈 추천 API의 배너 응답 구조를 공통 DTO로 분리해 재사용한다.
|
|
||||||
- 배너 응답 필드는 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`를 유지한다.
|
|
||||||
- 배너 대상 엔티티가 비활성 처리되었거나 차단 관계에 있으면 기존 홈 추천 배너 정책과 동일하게 제외한다.
|
|
||||||
|
|
||||||
### Feature C. 오직 보이스 온에서만
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 오리지널 시리즈를 최신순으로 12개 조회한다.
|
|
||||||
- `series.isOriginal = true`인 시리즈만 대상으로 한다.
|
|
||||||
- 활성 시리즈와 활성 크리에이터만 노출한다.
|
|
||||||
- Response 필드는 `seriesId`, `coverImageUrl`만 포함하고, 최상위 응답 필드명은 `originalSeries`로 한다.
|
|
||||||
|
|
||||||
### Feature D. 새로 올라온 오디오
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 공개된 오디오 콘텐츠를 최신순으로 12개 조회한다.
|
|
||||||
- 최신순 기준은 `releaseDate desc`, 동률이면 `audioContentId desc`로 한다.
|
|
||||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
|
||||||
|
|
||||||
### Feature E. New & Hot
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 최대 12개를 표시한다.
|
|
||||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
|
||||||
- 최근 3일 데이터를 기반으로 최종 점수를 산출한다.
|
|
||||||
- 최종 점수는 `최신성 35% + 조회수 35% + 좋아요 15% + 댓글 수 15%`로 계산한다.
|
|
||||||
- 조회수는 `creator_content_view_history`의 상세 페이지 조회 이력을 기준으로 최근 3일 `content_id`별 count를 사용한다.
|
|
||||||
- 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
|
||||||
- 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 외 0.8을 적용한다.
|
|
||||||
- 19금 노출 정책은 스냅샷 variant로 분리한다.
|
|
||||||
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
|
|
||||||
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
|
|
||||||
- 19금 노출이 불가능한 사용자와 비회원은 `SAFE` 스냅샷을 조회한다.
|
|
||||||
- 19금 노출이 가능한 회원은 `ALL` 스냅샷을 조회한다.
|
|
||||||
- 산출된 스냅샷 데이터가 없으면 lazy로 스케줄/집계 보강을 추가한다.
|
|
||||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- lazy 보강 중에도 즉시 산출 가능한 결과가 없으면 빈 배열로 내려준다.
|
|
||||||
|
|
||||||
### Feature F. 무료 오디오
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 무료 오디오 중 랜덤으로 최대 10개 조회한다.
|
|
||||||
- 무료 오디오는 `price = 0`인 공개 오디오로 정의한다.
|
|
||||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
|
||||||
|
|
||||||
### Feature G. 포인트 오디오
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 포인트 사용 가능 오디오 중 랜덤으로 최대 10개 조회한다.
|
|
||||||
- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다.
|
|
||||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
|
||||||
|
|
||||||
### Feature H. 최근 댓글이 많은 오디오
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 댓글 점수는 `댓글 수 80% + 댓글 최신성 20%`로 계산한다.
|
|
||||||
- 댓글 최신성 점수는 댓글 작성 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 이상 0을 적용한다.
|
|
||||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
|
||||||
- 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다.
|
|
||||||
- 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다.
|
|
||||||
- 최대 5개를 표시한다.
|
|
||||||
- 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자 프로필은 노출하지 않는다.
|
|
||||||
- 최신 댓글 작성자 프로필 이미지가 없으면 기본 프로필 이미지 URL 정책을 적용한다.
|
|
||||||
|
|
||||||
### Feature I. 추천 오디오
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 최대 10개를 표시한다.
|
|
||||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
|
||||||
- 추천 점수는 `조회수 45% + 좋아요 25% + 댓글 수 20% + 최신성 10%`로 계산한다.
|
|
||||||
- 조회수는 `creator_content_view_history`의 상세 페이지 조회 이력을 기준으로 스냅샷 집계 기간 내 `content_id`별 count를 사용한다.
|
|
||||||
- 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
|
||||||
- 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 30일 이내 1.1, 그 외 1.0을 적용한다.
|
|
||||||
- 19금 노출 정책은 New & Hot과 동일하게 `SAFE`, `ALL` 스냅샷 variant로 분리한다.
|
|
||||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. API Endpoint
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/v2/audio/recommendations
|
|
||||||
Authorization: Bearer {accessToken} (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
- 비회원 조회를 허용한다.
|
|
||||||
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
|
|
||||||
- 별도 request query parameter는 정의하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Response Data Class
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class AudioRecommendationsResponse(
|
|
||||||
val banners: List<AudioBannerResponse>,
|
|
||||||
val originalSeries: List<OriginalSeriesResponse>,
|
|
||||||
val latestAudios: List<AudioCardResponse>,
|
|
||||||
val newAndHotAudios: List<AudioCardResponse>,
|
|
||||||
val freeAudios: List<AudioCardResponse>,
|
|
||||||
val pointAudios: List<AudioCardResponse>,
|
|
||||||
val mostCommentedAudios: List<CommentedAudioResponse>,
|
|
||||||
val recommendedAudios: List<AudioCardResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AudioBannerResponse(
|
|
||||||
val imageUrl: String,
|
|
||||||
val eventItem: EventItem?,
|
|
||||||
val creatorId: Long?,
|
|
||||||
val seriesId: Long?,
|
|
||||||
val link: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class OriginalSeriesResponse(
|
|
||||||
val seriesId: Long,
|
|
||||||
val coverImageUrl: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AudioCardResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val duration: String?,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean,
|
|
||||||
@JsonProperty("isPointAvailable")
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
@JsonProperty("isFirstContent")
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
@JsonProperty("isOriginalSeries")
|
|
||||||
val isOriginalSeries: Boolean,
|
|
||||||
val creatorNickname: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CommentedAudioResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val latestComment: String,
|
|
||||||
val latestCommentWriterProfileImageUrl: String
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Technical Constraints
|
|
||||||
|
|
||||||
### 패키지 구조
|
|
||||||
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 하위에 둔다.
|
|
||||||
- Controller: `...adapter.in.web`
|
|
||||||
- Facade: `...application`
|
|
||||||
- Response DTO: `...dto`
|
|
||||||
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.recommendation` 하위에 둔다.
|
|
||||||
- Query service: `...application`
|
|
||||||
- 점수 정책/domain model: `...domain`
|
|
||||||
- 조회 port: `...port.out`
|
|
||||||
- QueryDSL/JPA 구현: `...adapter.out.persistence`
|
|
||||||
- scheduler: `...adapter.out.scheduler`
|
|
||||||
- `content` 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다.
|
|
||||||
- 의존 방향은 `v2.api.content.recommendation -> v2.content.recommendation`만 허용한다.
|
|
||||||
|
|
||||||
### V2 공통화/재사용 대상
|
|
||||||
- `HomeBannerItem`은 메인 홈 전용 DTO가 아니라 여러 추천 화면에서 사용할 배너 응답 구조이므로 `v2.api.common.dto` 계열 공통 DTO로 분리한다.
|
|
||||||
- `v2.recommendation.adapter.out.persistence.RecommendationSnapshot`: 일 단위 추천 스냅샷 저장 구조
|
|
||||||
- `v2.recommendation.adapter.out.scheduler.RecommendationSnapshotScheduler`: Redisson 분산 lock이 적용된 스케줄러 패턴
|
|
||||||
- `v2.recommendation.adapter.out.persistence.CreatorContentViewHistory`: 오디오 상세 페이지 조회 이력 저장 구조
|
|
||||||
- `v2.recommendation.application.CreatorContentViewHistoryService`: `AudioContentService.getDetail(...)`에서 상세 조회 이력을 기록하는 서비스
|
|
||||||
- `v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴
|
|
||||||
- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수
|
|
||||||
|
|
||||||
### 참고할 기존 패턴
|
|
||||||
- `v2.api.home.adapter.in.web.HomeRecommendationController`와 `v2.api.home.application.HomeRecommendationFacade`는 메인 페이지 Home 탭 전용이므로 직접 재사용하지 않는다.
|
|
||||||
- 신규 API도 controller가 인증/요청 경계를 담당하고 facade가 도메인 조회 결과를 공개 응답 DTO로 변환하는 계층 분리 방식만 참고한다.
|
|
||||||
|
|
||||||
### 스냅샷/스케줄
|
|
||||||
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 스냅샷 기반 조회를 우선한다.
|
|
||||||
- 스케줄러는 `@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")` 기준으로 설계한다.
|
|
||||||
- 다중 서버 환경에서 중복 실행을 막기 위해 기존 Redisson lock 패턴을 따른다.
|
|
||||||
- 스냅샷 기준 시각은 KST 전날 `23:59:59`를 UTC 변환 없이 KST-local `LocalDateTime`으로 저장한다.
|
|
||||||
- 스냅샷 집계 window도 KST-local `00:00:00`부터 KST-local `23:59:59`까지를 기준으로 계산한다.
|
|
||||||
- 19금 노출 영향을 받는 스냅샷 섹션은 visibility variant를 저장한다.
|
|
||||||
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
|
|
||||||
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
|
|
||||||
- `SAFE`와 `ALL`을 분리하는 이유는 스냅샷 조회 후 19금 콘텐츠를 필터링할 경우 비회원/19금 노출 불가 회원에게 최대 노출 개수를 안정적으로 채우기 어렵기 때문이다.
|
|
||||||
- 기존 `recommendation_snapshot`을 확장 재사용할지, 콘텐츠 추천 전용 스냅샷 테이블을 만들지는 구현 계획에서 DDL 영향과 enum 확장 범위를 비교해 결정한다.
|
|
||||||
|
|
||||||
### 조회 정책
|
|
||||||
- 모든 오디오 섹션은 활성 콘텐츠, 활성 크리에이터, `duration is not null`, `releaseDate <= now` 조건을 기본으로 한다.
|
|
||||||
- 성인 콘텐츠는 회원의 `MemberContentPreference`와 본인인증 정책을 반영한다.
|
|
||||||
- 비회원은 성인 콘텐츠를 제외한다.
|
|
||||||
- 차단 관계 필터는 기존 v2 홈/크리에이터 채널 조회 패턴을 따른다.
|
|
||||||
- 조회수 점수의 조회수는 `AudioContent.playCount`가 아니라 `creator_content_view_history`의 상세 페이지 조회 이력 count를 사용한다.
|
|
||||||
- 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
|
|
||||||
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
|
|
||||||
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
|
|
||||||
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Metrics
|
|
||||||
- API 성공/실패 로그
|
|
||||||
- 섹션별 응답 개수
|
|
||||||
- 스냅샷 갱신 성공/실패 로그
|
|
||||||
- 스냅샷 갱신 대상 개수
|
|
||||||
- lazy 보강 발생 횟수
|
|
||||||
- 빈 섹션 목록
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Open Questions
|
|
||||||
- 없음
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
# 메인 콘텐츠 전체 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** `GET /api/v2/audio/contents`로 메인 콘텐츠 전체 탭의 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 정렬/요일/페이징 조건에 맞춰 조회한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.all` 조립 계층에 둔다. 전체 탭 조회 service, 요청 보정 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.content.all` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 `ContentSort`, `SeriesPublishedDaysOfWeek`, 콘텐츠 추천/채널 오디오/채널 시리즈 조회 패턴을 재사용하되 공개 응답 DTO는 전체 탭 전용으로 최소 필드만 둔다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 구현 전 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/audio/contents`
|
|
||||||
- 인증 정책: 비회원 조회 가능. 인증 회원이면 `MemberContentPreferenceService`의 성인 콘텐츠 노출 가능 여부를 반영한다.
|
|
||||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
|
||||||
- 요청 query parameter:
|
|
||||||
- `type`: `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`; 기본값 `AUDIO`
|
|
||||||
- `sort`: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`; 기본값 `LATEST`
|
|
||||||
- `dayOfWeek`: `type=SERIES`에서만 적용. `SeriesPublishedDaysOfWeek` 값 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`
|
|
||||||
- `page`: 0부터 시작. 기본값 `0`
|
|
||||||
- `size`: 기본값 `20`, 최소 `20`, 최대 `50`
|
|
||||||
- `sort`가 invalid이거나 `OWNED`이면 `LATEST`로 fallback한다.
|
|
||||||
- `dayOfWeek`가 invalid이면 요일 조건을 적용하지 않고 `dayOfWeek = null`로 fallback한다.
|
|
||||||
- `type != SERIES`이면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서 `null`로 내려준다.
|
|
||||||
- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다.
|
|
||||||
- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다.
|
|
||||||
- `AUDIO`, `FREE`, `POINT`는 `audios`만 채우고 `series`는 빈 배열로 내려준다.
|
|
||||||
- `SERIES`, `ORIGINAL`은 `series`만 채우고 `audios`는 빈 배열로 내려준다.
|
|
||||||
- 공개 오디오 조건: `audioContent.isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터.
|
|
||||||
- 공개 시리즈 조건: `series.isActive == true`, 활성 크리에이터. 성인 콘텐츠 노출 불가이면 `series.isAdult == false`.
|
|
||||||
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠/시리즈는 제외한다.
|
|
||||||
- 신규 Entity와 DDL은 작성하지 않는다.
|
|
||||||
- `SecurityConfig`에는 `GET /api/v2/audio/contents` permitAll 설정을 추가한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
|
|
||||||
|
|
||||||
### 신규 도메인 조회 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
|
||||||
|
|
||||||
### 기존 설정/회귀
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.content.all.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
|
|
||||||
|
|
||||||
data class MainContentAllTabResponse(
|
|
||||||
val type: MainContentAllType,
|
|
||||||
val totalCount: Int,
|
|
||||||
val audios: List<MainContentAudioResponse>,
|
|
||||||
val series: List<MainContentSeriesResponse>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(tab: MainContentAll): MainContentAllTabResponse {
|
|
||||||
return MainContentAllTabResponse(
|
|
||||||
type = tab.type,
|
|
||||||
totalCount = tab.totalCount,
|
|
||||||
audios = tab.audios.map(MainContentAudioResponse::from),
|
|
||||||
series = tab.series.map(MainContentSeriesResponse::from),
|
|
||||||
sort = tab.sort,
|
|
||||||
dayOfWeek = tab.dayOfWeek,
|
|
||||||
page = tab.page.page,
|
|
||||||
size = tab.page.size,
|
|
||||||
hasNext = tab.hasNext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class MainContentAudioResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean,
|
|
||||||
@JsonProperty("isPointAvailable")
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
@JsonProperty("isFirstContent")
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
@JsonProperty("isOriginalSeries")
|
|
||||||
val isOriginalSeries: Boolean,
|
|
||||||
val creatorNickname: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(audio: MainContentAllAudio): MainContentAudioResponse {
|
|
||||||
return MainContentAudioResponse(
|
|
||||||
audioContentId = audio.audioContentId,
|
|
||||||
title = audio.title,
|
|
||||||
imageUrl = audio.imageUrl,
|
|
||||||
price = audio.price,
|
|
||||||
isAdult = audio.isAdult,
|
|
||||||
isPointAvailable = audio.isPointAvailable,
|
|
||||||
isFirstContent = audio.isFirstContent,
|
|
||||||
isOriginalSeries = audio.isOriginalSeries,
|
|
||||||
creatorNickname = audio.creatorNickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class MainContentSeriesResponse(
|
|
||||||
val seriesId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val creatorNickname: String,
|
|
||||||
@JsonProperty("isOriginal")
|
|
||||||
val isOriginal: Boolean,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(series: MainContentAllSeries): MainContentSeriesResponse {
|
|
||||||
return MainContentSeriesResponse(
|
|
||||||
seriesId = series.seriesId,
|
|
||||||
title = series.title,
|
|
||||||
coverImageUrl = series.coverImageUrl,
|
|
||||||
creatorNickname = series.creatorNickname,
|
|
||||||
isOriginal = series.isOriginal,
|
|
||||||
isAdult = series.isAdult
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.content.all.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
|
|
||||||
enum class MainContentAllType {
|
|
||||||
AUDIO,
|
|
||||||
SERIES,
|
|
||||||
ORIGINAL,
|
|
||||||
FREE,
|
|
||||||
POINT
|
|
||||||
}
|
|
||||||
|
|
||||||
data class MainContentAll(
|
|
||||||
val type: MainContentAllType,
|
|
||||||
val totalCount: Int,
|
|
||||||
val audios: List<MainContentAllAudio>,
|
|
||||||
val series: List<MainContentAllSeries>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
|
||||||
val page: MainContentPage,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MainContentAllAudio(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
val isOriginalSeries: Boolean,
|
|
||||||
val creatorNickname: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MainContentAllSeries(
|
|
||||||
val seriesId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val isOriginal: Boolean,
|
|
||||||
val isAdult: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MainContentPage(
|
|
||||||
val page: Int,
|
|
||||||
val size: Int
|
|
||||||
) {
|
|
||||||
val offset: Long = page.toLong() * size
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.content.all.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
|
||||||
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface MainContentAllQueryPort {
|
|
||||||
fun countAudios(
|
|
||||||
memberId: Long?,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
now: LocalDateTime,
|
|
||||||
onlyFree: Boolean = false,
|
|
||||||
onlyPointAvailable: Boolean = false
|
|
||||||
): Int
|
|
||||||
|
|
||||||
fun findAudios(
|
|
||||||
memberId: Long?,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
now: LocalDateTime,
|
|
||||||
sort: ContentSort,
|
|
||||||
offset: Long,
|
|
||||||
limit: Int,
|
|
||||||
onlyFree: Boolean = false,
|
|
||||||
onlyPointAvailable: Boolean = false
|
|
||||||
): List<MainContentAllAudio>
|
|
||||||
|
|
||||||
fun countSeries(
|
|
||||||
memberId: Long?,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
now: LocalDateTime,
|
|
||||||
onlyOriginal: Boolean = false,
|
|
||||||
dayOfWeek: SeriesPublishedDaysOfWeek? = null
|
|
||||||
): Int
|
|
||||||
|
|
||||||
fun findSeries(
|
|
||||||
memberId: Long?,
|
|
||||||
canViewAdultContent: Boolean,
|
|
||||||
now: LocalDateTime,
|
|
||||||
sort: ContentSort,
|
|
||||||
offset: Long,
|
|
||||||
limit: Int,
|
|
||||||
onlyOriginal: Boolean = false,
|
|
||||||
dayOfWeek: SeriesPublishedDaysOfWeek? = null,
|
|
||||||
locale: String
|
|
||||||
): List<MainContentAllSeries>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: 요청 보정 정책과 도메인 모델
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 전체 탭 타입, page, 요청 보정 policy 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt`
|
|
||||||
- RED: 다음 테스트를 먼저 작성한다.
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldResolveDefaultsAndFallbacks() {
|
|
||||||
val policy = MainContentAllQueryPolicy()
|
|
||||||
|
|
||||||
assertEquals(MainContentAllType.AUDIO, policy.resolveType(null))
|
|
||||||
assertEquals(MainContentAllType.AUDIO, policy.resolveType("UNKNOWN"))
|
|
||||||
assertEquals(ContentSort.LATEST, policy.resolveSort(null))
|
|
||||||
assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN"))
|
|
||||||
assertEquals(ContentSort.LATEST, policy.resolveSort("OWNED"))
|
|
||||||
assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR"))
|
|
||||||
assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = null, size = null))
|
|
||||||
assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = -1, size = 1))
|
|
||||||
assertEquals(MainContentPage(page = 2, size = 50), policy.createPage(page = 2, size = 100))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- RED: `type=SERIES`일 때만 요일이 적용되는 테스트를 작성한다.
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldResolveDayOfWeekOnlyForSeriesType() {
|
|
||||||
val policy = MainContentAllQueryPolicy()
|
|
||||||
|
|
||||||
assertEquals(SeriesPublishedDaysOfWeek.MON, policy.resolveDayOfWeek(MainContentAllType.SERIES, "MON"))
|
|
||||||
assertEquals(SeriesPublishedDaysOfWeek.RANDOM, policy.resolveDayOfWeek(MainContentAllType.SERIES, "RANDOM"))
|
|
||||||
assertNull(policy.resolveDayOfWeek(MainContentAllType.SERIES, "INVALID"))
|
|
||||||
assertNull(policy.resolveDayOfWeek(MainContentAllType.ORIGINAL, "MON"))
|
|
||||||
assertNull(policy.resolveDayOfWeek(MainContentAllType.AUDIO, "MON"))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest`
|
|
||||||
- GREEN: `resolveType(sort: String?)`, `resolveSort(sort: String?)`, `resolveDayOfWeek(type, dayOfWeek)`, `createPage(page, size)`, `limitItems`, `hasNext`를 최소 구현한다.
|
|
||||||
- REFACTOR: `OWNED` fallback과 invalid `dayOfWeek` fallback이 400으로 흐르지 않도록 controller에서 enum 직접 binding을 사용하지 않는 설계를 확인한다.
|
|
||||||
- 기대 결과: 요청 보정 정책이 순수 단위 테스트로 고정된다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 실행 시 `MainContentAllQueryPolicy`, `MainContentAllType`, `MainContentPage` 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 성공으로 기본값/fallback/page/hasNext 정책을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: 전체 탭 domain model 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
|
|
||||||
- RED: `MainContentAllTabResponse.from(...)`이 최소 필드만 변환하는 테스트를 작성한다.
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldMapDomainToResponseWithMinimalFields() {
|
|
||||||
val response = MainContentAllTabResponse.from(
|
|
||||||
MainContentAll(
|
|
||||||
type = MainContentAllType.SERIES,
|
|
||||||
totalCount = 1,
|
|
||||||
audios = emptyList(),
|
|
||||||
series = listOf(
|
|
||||||
MainContentAllSeries(
|
|
||||||
seriesId = 10L,
|
|
||||||
title = "시리즈",
|
|
||||||
coverImageUrl = "https://cdn/series.jpg",
|
|
||||||
creatorNickname = "creator",
|
|
||||||
isOriginal = true,
|
|
||||||
isAdult = false
|
|
||||||
)
|
|
||||||
),
|
|
||||||
sort = ContentSort.LATEST,
|
|
||||||
dayOfWeek = SeriesPublishedDaysOfWeek.MON,
|
|
||||||
page = MainContentPage(0, 20),
|
|
||||||
hasNext = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(MainContentAllType.SERIES, response.type)
|
|
||||||
assertEquals(1, response.totalCount)
|
|
||||||
assertTrue(response.audios.isEmpty())
|
|
||||||
assertEquals("creator", response.series.first().creatorNickname)
|
|
||||||
assertEquals(SeriesPublishedDaysOfWeek.MON, response.dayOfWeek)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest`
|
|
||||||
- GREEN: `MainContentAll`, `MainContentAllAudio`, `MainContentAllSeries`, response DTO를 최소 구현한다.
|
|
||||||
- REFACTOR: `MainContentAudioResponse`에 `duration`, `MainContentSeriesResponse`에 `publishedDaysOfWeek`, `isProceeding`, `contentCount`, `paidContentCount`가 없는지 소스와 테스트에서 확인한다.
|
|
||||||
- 기대 결과: 공개 응답 계약이 PRD와 일치한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 실행 시 `MainContentAllTabResponse`, `MainContentAll` 계열 도메인 모델 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 성공으로 도메인→응답 DTO 변환과 boolean `is*` JSON 필드명을 확인했다.
|
|
||||||
|
|
||||||
### Phase 2: API 조립 계층
|
|
||||||
|
|
||||||
- [x] **Task 2.1: facade 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt`
|
|
||||||
- RED: facade가 문자열 query parameter를 그대로 query service에 넘기고 응답 DTO로 변환하는 테스트를 작성한다.
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldDelegateToQueryServiceAndMapResponse() {
|
|
||||||
val service = FakeMainContentAllQueryService()
|
|
||||||
val facade = MainContentAllFacade(service)
|
|
||||||
|
|
||||||
val response = facade.getContents(
|
|
||||||
type = "FREE",
|
|
||||||
sort = "PRICE_LOW",
|
|
||||||
dayOfWeek = "MON",
|
|
||||||
page = 1,
|
|
||||||
size = 30,
|
|
||||||
member = null
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals("FREE", service.requestedType)
|
|
||||||
assertEquals("PRICE_LOW", service.requestedSort)
|
|
||||||
assertEquals("MON", service.requestedDayOfWeek)
|
|
||||||
assertEquals(MainContentAllType.FREE, response.type)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest`
|
|
||||||
- GREEN: facade는 query service 호출과 `MainContentAllTabResponse.from(...)` 변환만 담당한다.
|
|
||||||
- REFACTOR: facade에 정렬, 요일, DB 조회 정책이 들어가지 않도록 확인한다.
|
|
||||||
- 기대 결과: API 조립 계층과 도메인 조회 계층의 책임이 분리된다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllFacade`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 성공으로 facade가 문자열 query parameter와 `Member?`를 query service에 그대로 전달하고 응답 DTO로 변환함을 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: controller와 보안 설정 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt`
|
|
||||||
- RED: `GET /api/v2/audio/contents`가 비회원에게 `200 OK`를 반환하고 `type` 기본값을 service까지 전달하는 MockMvc 테스트를 작성한다.
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldAllowAnonymousAndUseDefaultType() {
|
|
||||||
mockMvc.get("/api/v2/audio/contents")
|
|
||||||
.andExpect {
|
|
||||||
status { isOk() }
|
|
||||||
jsonPath("$.data.type") { value("AUDIO") }
|
|
||||||
jsonPath("$.data.sort") { value("LATEST") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- RED: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=1&size=30`이 query parameter를 facade로 전달하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest`
|
|
||||||
- GREEN: `@RequestMapping("/api/v2/audio/contents")`, `@RequestParam type: String?`, `sort: String?`, `dayOfWeek: String?`, `page: Int?`, `size: Int?`, optional `member: Member?`로 controller를 구현한다.
|
|
||||||
- GREEN: `SecurityConfig`에 `antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()`을 추가한다.
|
|
||||||
- REFACTOR: `ContentSort`와 `SeriesPublishedDaysOfWeek`를 controller parameter에 직접 binding하지 않는지 확인한다.
|
|
||||||
- 기대 결과: 공개 endpoint, 비회원 허용, invalid parameter fallback을 위한 controller 계약이 고정된다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllController` 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 성공으로 비회원 `GET /api/v2/audio/contents` 200 OK, query parameter/member 전달, `SecurityConfig` permitAll 설정을 확인했다.
|
|
||||||
|
|
||||||
### Phase 3: 조회 service와 port
|
|
||||||
|
|
||||||
- [x] **Task 3.1: query port와 service 분기 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
|
|
||||||
- RED: `AUDIO`, `FREE`, `POINT` type이 audio count/list port를 올바른 필터로 호출하는 fake port 테스트를 작성한다.
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun shouldQueryAudiosByType() {
|
|
||||||
val port = FakeMainContentAllQueryPort()
|
|
||||||
val service = createService(port)
|
|
||||||
|
|
||||||
service.getContents(type = "FREE", sort = "LATEST", dayOfWeek = null, page = 0, size = 20, member = null)
|
|
||||||
|
|
||||||
assertEquals("audio", port.lastListKind)
|
|
||||||
assertTrue(port.lastOnlyFree)
|
|
||||||
assertFalse(port.lastOnlyPointAvailable)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- RED: `SERIES` type이 `dayOfWeek=MON`을 series count/list port에 전달하고 `ORIGINAL` type은 `onlyOriginal=true`, `dayOfWeek=null`로 호출하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
|
|
||||||
- GREEN: service는 policy로 type/sort/day/page를 보정하고, `type`에 따라 port 메서드를 호출한다.
|
|
||||||
- GREEN: `limit = page.size + 1`로 조회한 뒤 `policy.limitItems(...)`와 `policy.hasNext(...)`를 적용한다.
|
|
||||||
- REFACTOR: service에는 QueryDSL 조건식이나 response DTO 변환을 두지 않는다.
|
|
||||||
- 기대 결과: type별 조회 분기, 전체 개수, `hasNext`, fallback 정책이 service 단위 테스트로 고정된다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllQueryPort`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 성공으로 `AUDIO/FREE/POINT` audio 분기, `SERIES/ORIGINAL` series 분기, `limit = size + 1`, `hasNext` 처리를 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 성인 콘텐츠 노출 정책 연결**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
|
|
||||||
- RED: 비회원이면 `canViewAdultContent=false`, 회원이면 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 port에 전달하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
|
|
||||||
- GREEN: 기존 `AudioRecommendationQueryService`와 같은 방식으로 성인 콘텐츠 노출 가능 여부를 계산한다.
|
|
||||||
- REFACTOR: 회원 id는 `member?.id`만 port에 전달하고, port/repository에서 차단 관계 제외 조건을 처리하게 둔다.
|
|
||||||
- 기대 결과: 비회원/회원 성인 콘텐츠 정책이 기존 v2 추천 탭과 일치한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: service 테스트 추가 후 `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 포함 Phase 2-3 테스트 명령 성공으로 비회원 `canViewAdultContent=false`, 회원 `MemberContentPreferenceService.canViewAdultContent(member)` 결과 전달을 확인했다.
|
|
||||||
|
|
||||||
### Phase 4: QueryDSL repository
|
|
||||||
|
|
||||||
- [x] **Task 4.1: audio count/list repository 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
|
||||||
- RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다.
|
|
||||||
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다.
|
|
||||||
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
|
||||||
- GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다.
|
|
||||||
- GREEN: 인기순은 `orders.isActive == true`인 주문의 `orders.can.sum().coalesce(0)`만 사용하고 `orders.point`는 더하지 않는다.
|
|
||||||
- GREEN: `isFirstContent`는 크리에이터별 전체 공개 오디오 중 가장 먼저 공개된 콘텐츠인지로 계산한다.
|
|
||||||
- GREEN: `isOriginalSeries`는 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 계산하고 시리즈 미소속이면 `false`로 내려준다.
|
|
||||||
- REFACTOR: CDN URL 변환은 `toCdnUrl(cloudFrontHost)` 패턴을 사용한다.
|
|
||||||
- 기대 결과: 오디오/무료/포인트 조회의 필터, count, 정렬, 카드 필드가 repository 테스트로 고정된다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 `DefaultMainContentAllQueryRepository` 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 성공으로 공개 오디오 조건, 성인/차단 제외, 무료/포인트 필터, 가격/인기 정렬, CDN URL, 첫 콘텐츠, 오리지널 시리즈 여부를 확인했다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: series count/list repository 구현**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
|
||||||
- RED: `SERIES` 조회가 활성 시리즈와 활성 크리에이터만 반환하고, 비회원은 성인 시리즈를 제외하며, 차단 관계 크리에이터의 시리즈를 제외하는 테스트를 작성한다.
|
|
||||||
- RED: `dayOfWeek=MON`이면 `series.publishedDaysOfWeek`에 `MON`이 포함된 시리즈만 반환하고 `dayOfWeek=RANDOM`이면 `RANDOM` 포함 시리즈만 반환하는 테스트를 작성한다.
|
|
||||||
- RED: `ORIGINAL` 조회가 `series.isOriginal == true`만 반환하고 `dayOfWeek`는 적용하지 않는 테스트를 작성한다.
|
|
||||||
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 시리즈 정렬 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
|
||||||
- GREEN: `DefaultCreatorChannelSeriesQueryRepository.findSeriesIds(...)` 패턴을 참고해 시리즈 id 선조회 후 row를 원래 정렬 순서대로 조립한다.
|
|
||||||
- GREEN: 시리즈 정렬 대표값은 공개 오디오 기준 `max(releaseDate)`, `max(price)`, `min(price)`, `orders.can.sum()`을 사용한다.
|
|
||||||
- GREEN: 시리즈 응답 필드는 `seriesId`, `title`, `coverImageUrl`, `creatorNickname`, `isOriginal`, `isAdult`만 조립한다.
|
|
||||||
- REFACTOR: `MainContentSeriesResponse`에서 제외된 연재 요일/연재 상태/콘텐츠 통계 필드를 조회 응답 조립용으로 불필요하게 projection하지 않는다.
|
|
||||||
- 기대 결과: 시리즈/오리지널 조회의 요일 필터, count, 정렬, 최소 응답 필드가 repository 테스트로 고정된다.
|
|
||||||
- 검증 기록:
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 repository 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 성공으로 활성 시리즈/크리에이터 조건, 성인/차단 제외, 요일 필터, ORIGINAL 필터, 대표 공개 오디오 기준 정렬, 최소 시리즈 응답 필드를 확인했다.
|
|
||||||
|
|
||||||
### Phase 5: 공개 API 통합 검증
|
|
||||||
|
|
||||||
- [x] **Task 5.1: controller-to-repository 통합 테스트 작성**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
|
|
||||||
- RED: Spring context 기반으로 `GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20`가 `audios`, `totalCount`, `sort`, `page`, `size`, `hasNext`를 반환하고 `series`는 빈 배열인 테스트를 작성한다.
|
|
||||||
- RED: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=0&size=20`가 `series`, `dayOfWeek=MON`, `audios=[]`를 반환하는 테스트를 작성한다.
|
|
||||||
- RED: `GET /api/v2/audio/contents?type=ORIGINAL&dayOfWeek=MON`이 `dayOfWeek=null`로 응답하고 오리지널 시리즈만 반환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
|
|
||||||
- GREEN: 테스트 fixture에 공개/비공개/성인/무료/포인트/요일별 시리즈/오리지널 시리즈/차단 관계 데이터를 구성하고 end-to-end 응답을 통과시킨다.
|
|
||||||
- REFACTOR: controller, facade, service, repository 경계가 단방향 의존을 유지하는지 import를 확인한다.
|
|
||||||
- 기대 결과: 실제 HTTP 경로에서 PRD의 주요 응답 계약이 검증된다.
|
|
||||||
- 검증 기록:
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 `AUDIO`, `SERIES dayOfWeek=MON`, `ORIGINAL dayOfWeek 무시` HTTP 통합 경로를 확인했다.
|
|
||||||
- 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으므로, 별도 production 수정은 없었다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: 회귀 테스트와 포맷 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/**`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/**`
|
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
|
||||||
- Modify: `docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md`
|
|
||||||
- RED: 이 task는 신규 동작 추가가 아니라 전체 회귀 검증 task이므로 별도 실패 테스트를 만들지 않는다.
|
|
||||||
- TDD 예외 사유: 앞선 task에서 기능별 실패 테스트를 작성했고, 이 task는 전체 suite와 문서 검증 기록 누적이 목적이다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'`
|
|
||||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`
|
|
||||||
- `./gradlew ktlintCheck`
|
|
||||||
- `git diff --check`
|
|
||||||
- `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all`
|
|
||||||
- GREEN: 위 명령이 모두 성공하고, 응답 DTO에 제거 대상 필드가 남아 있지 않음을 확인한다.
|
|
||||||
- REFACTOR: 검증 결과를 이 문서 하단 `검증 기록`에 누적한다.
|
|
||||||
- 기대 결과: 신규 API 패키지 테스트와 포맷 검증이 완료된다.
|
|
||||||
- 검증 기록:
|
|
||||||
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` 성공.
|
|
||||||
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
|
|
||||||
- GREEN: `./gradlew ktlintCheck` 성공.
|
|
||||||
- GREEN: `git diff --check` 성공.
|
|
||||||
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 실행 명령
|
|
||||||
|
|
||||||
- 정책 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest`
|
|
||||||
- DTO 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest`
|
|
||||||
- Facade 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest`
|
|
||||||
- Controller 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest`
|
|
||||||
- Service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
|
|
||||||
- Repository 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
|
||||||
- End-to-end 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
|
|
||||||
- 전체 신규 패키지 테스트: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`
|
|
||||||
- 포맷 검증: `./gradlew ktlintCheck`
|
|
||||||
- 문서 변경 후 명령 유효성 확인: `./gradlew tasks --all`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 검증 기록
|
|
||||||
|
|
||||||
- 2026-06-25 Phase 1-3 RED/GREEN 검증
|
|
||||||
- RED: Phase 1 정책/DTO 테스트 추가 후 `MainContentAllQueryPolicy`, `MainContentAllType`, `MainContentPage`, `MainContentAllTabResponse`, `MainContentAll` 계열 모델 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 성공.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 성공.
|
|
||||||
- RED: Phase 2-3 facade/controller/service 테스트 추가 후 `MainContentAllFacade`, `MainContentAllController`, `MainContentAllQueryPort`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 성공.
|
|
||||||
- 보강: `MainContentAllQueryServiceTest`에서 `AUDIO`, `FREE`, `POINT` audio 분기를 각각 독립 테스트로 검증하도록 분리했다.
|
|
||||||
- 참고: Phase 4 repository 구현 전이므로 Spring 전체 context에서 `MainContentAllQueryPort` 실제 bean 연결은 아직 범위 밖이다.
|
|
||||||
- 참고: 실제 머지/배포 전에는 Phase 4 repository adapter bean과 Phase 5 end-to-end 테스트를 구현한 뒤 Spring 전체 context 검증을 다시 수행해야 한다.
|
|
||||||
- 2026-06-25 Phase 4 RED/GREEN 검증
|
|
||||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 `DefaultMainContentAllQueryRepository` 미구현 컴파일 실패를 확인했다.
|
|
||||||
- GREEN: 동일 명령 성공으로 audio/series count/list repository의 공개 조건, 성인/차단 제외, FREE/POINT/ORIGINAL/dayOfWeek 필터, 정렬, CDN URL, 최소 응답 필드를 확인했다.
|
|
||||||
- 2026-06-25 Phase 4 코드 리뷰 및 검증
|
|
||||||
- 리뷰: `DefaultMainContentAllQueryRepository.findSeries(...)`가 `locale` 파라미터를 받지만 `SeriesTranslation`을 조회하지 않아, PRD의 언어코드 기반 시리즈 제목 fallback 요구사항을 충족하지 못하는 것을 확인했다.
|
|
||||||
- 리뷰: `ContentSort.LATEST`의 오디오/시리즈 정렬에 `price` 대표값이 보조 정렬로 포함되어 있어, PRD의 `releaseDate desc, id desc` 기준과 다른 순서가 나올 수 있음을 확인했다.
|
|
||||||
- RED: `shouldSortAudiosByLatestReleaseDateAndIdOnly` 추가 후 `expected: <[2, 1]> but was: <[1, 2]>` 실패로 audio `LATEST`가 같은 공개일에서 price desc를 우선하는 문제를 재현했다.
|
|
||||||
- RED: `shouldFindSeriesWithTranslatedTitleFallback` 추가 후 `expected: <Translated Series> but was: <origin-translated-series>` 실패로 series locale 번역 미적용 문제를 재현했다.
|
|
||||||
- RED: `shouldSortSeriesByPublicAudioRepresentatives` 보강 후 `expected: <[6, 5, 4]> but was: <[5, 4, 6]>` 실패로 series `LATEST`가 같은 대표 공개일에서 highestPrice desc를 우선하는 문제를 재현했다.
|
|
||||||
- GREEN: `findSeries(...)`에 `SeriesTranslation` left join과 blank fallback을 추가하고, audio/series `LATEST` 보조 정렬에서 price 대표값을 제거했다.
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 성공.
|
|
||||||
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
|
|
||||||
- GREEN: `./gradlew ktlintCheck` 성공.
|
|
||||||
- GREEN: `git diff --check` 성공.
|
|
||||||
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, DTO 테스트의 부재 검증만 검색되었다.
|
|
||||||
- 확인: 위 리뷰 항목 2건은 보강 테스트와 구현 수정으로 해결했다.
|
|
||||||
- 2026-06-25 Phase 5 공개 API 통합 검증
|
|
||||||
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 실제 HTTP 경로에서 `AUDIO`는 `audios`와 빈 `series`, `SERIES dayOfWeek=MON`은 `series`와 빈 `audios`, `ORIGINAL dayOfWeek=MON`은 `dayOfWeek=null`과 오리지널 시리즈만 반환함을 확인했다.
|
|
||||||
- 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으며, Phase 5에서 production 코드는 변경하지 않았다.
|
|
||||||
- 참고: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'`와 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`를 동시에 실행했을 때 test result XML 파일 쓰기 충돌이 한 번 발생했다. 동일 명령을 순차 재실행해 두 테스트 모두 성공함을 확인했다.
|
|
||||||
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` 성공.
|
|
||||||
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
|
|
||||||
- GREEN: `./gradlew ktlintCheck` 성공.
|
|
||||||
- GREEN: `git diff --check` 성공.
|
|
||||||
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
# PRD: 메인 콘텐츠 전체 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
메인 콘텐츠 탭의 내부 전체 탭에서 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 기존 메인 콘텐츠 추천 탭 API는 여러 추천 섹션을 한 번에 조립하지만, 전체 탭은 사용자가 선택한 구분별 전체 콘텐츠 목록과 전체 개수, 정렬 상태, 페이징 상태를 제공해야 한다.
|
|
||||||
- 기존 크리에이터 채널 오디오/시리즈 탭 API는 특정 크리에이터 기준 조회라서, 전체 탭처럼 차단 관계가 아닌 모든 크리에이터의 공개 콘텐츠를 대상으로 하기 어렵다.
|
|
||||||
- 정렬 기준은 기존 공용 `ContentSort` enum과 의미를 공유해야 하며, 인기순 매출 산식은 포인트 사용액을 제외한 `orders.can` 합계로 명확히 고정해야 한다.
|
|
||||||
- V2 패키지에는 API 조립 계층과 도메인 조회 계층 분리, 공통 오디오 카드 DTO, 차단/성인 콘텐츠/공개 콘텐츠 필터, 시리즈 정렬 패턴이 이미 있으므로 재사용 범위를 먼저 명시해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
|
||||||
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
|
||||||
- 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다.
|
|
||||||
- 공개된 콘텐츠만 조회한다.
|
|
||||||
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다.
|
|
||||||
- 비회원은 19금 콘텐츠를 노출하지 않는다.
|
|
||||||
- 인증 회원은 기존 콘텐츠 조회 설정에 따라 19금 콘텐츠 노출 가능 여부를 반영한다.
|
|
||||||
- 전체 콘텐츠 개수와 페이징 목록을 함께 응답한다.
|
|
||||||
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
|
|
||||||
- `SERIES` 구분은 legacy 시리즈 메인 요일별 조회와 동일하게 요일 선택을 지원한다.
|
|
||||||
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 기존 `content.main.tab.*` legacy API 스키마를 변경하지 않는다.
|
|
||||||
- 기존 메인 콘텐츠 추천 탭 API와 랭킹 탭 API의 공개 스키마를 변경하지 않는다.
|
|
||||||
- 기존 크리에이터 채널 오디오/시리즈 탭 API의 endpoint, 응답 필드, 인증 정책을 변경하지 않는다.
|
|
||||||
- 신규 스냅샷 테이블이나 배치 집계는 이번 범위에 포함하지 않는다.
|
|
||||||
- 개인화 추천, 랜덤 노출, 운영자 고정/제외 기능은 포함하지 않는다.
|
|
||||||
- 구매, 대여, 소장, 포인트 결제 API는 포함하지 않는다.
|
|
||||||
- `ContentSort` enum에 신규 값을 추가하지 않는다.
|
|
||||||
- `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 메인 콘텐츠 전체 탭에서 원하는 구분의 공개 콘텐츠를 정렬해 탐색하는 사용자
|
|
||||||
- 비회원: 인증 없이 조회 가능한 공개 콘텐츠를 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 전체 탭의 구분, 전체 개수, 정렬 상태, 페이징 목록을 단일 계약으로 구성하려는 클라이언트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다.
|
|
||||||
- 사용자는 선택한 요일의 시리즈 목록을 보고 싶다.
|
|
||||||
- 사용자는 오리지널 시리즈만 따로 보고 싶다.
|
|
||||||
- 사용자는 무료 오디오만 따로 보고 싶다.
|
|
||||||
- 사용자는 포인트를 사용할 수 있는 오디오만 따로 보고 싶다.
|
|
||||||
- 앱 클라이언트는 현재 적용된 구분, 정렬, page, size, hasNext를 응답에서 확인해 화면 상태와 서버 결과를 맞추고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 메인 콘텐츠 전체 탭 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API endpoint는 `GET /api/v2/audio/contents`를 기본안으로 한다.
|
|
||||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
|
||||||
- 비회원 조회를 허용한다.
|
|
||||||
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
|
|
||||||
- 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다.
|
|
||||||
- `type` 값은 아래 enum으로 정의한다.
|
|
||||||
- `AUDIO`: 오디오
|
|
||||||
- `SERIES`: 시리즈
|
|
||||||
- `ORIGINAL`: 오리지널
|
|
||||||
- `FREE`: 무료
|
|
||||||
- `POINT`: 포인트
|
|
||||||
- `type`을 보내지 않으면 `AUDIO`를 기본값으로 사용한다.
|
|
||||||
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
|
|
||||||
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
|
|
||||||
- 전체 탭에서 지원하는 정렬 값은 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`다.
|
|
||||||
- `OWNED`가 들어오면 전체 탭 요구사항에 없는 정렬이므로 `LATEST`로 fallback한다.
|
|
||||||
- `dayOfWeek`는 `type=SERIES`일 때만 적용한다.
|
|
||||||
- `dayOfWeek` 값은 legacy `SeriesMainController.getDayOfWeekSeriesList(...)`와 동일하게 `SeriesPublishedDaysOfWeek` enum 값을 사용한다.
|
|
||||||
- `dayOfWeek` 지원 값은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`이다.
|
|
||||||
- `dayOfWeek`를 보내지 않으면 전체 요일의 시리즈를 조회한다.
|
|
||||||
- `type`이 `SERIES`가 아니면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서는 `null`로 내려준다.
|
|
||||||
- `page`는 0부터 시작하는 page index로 처리한다.
|
|
||||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
|
||||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
|
||||||
- `page`가 0보다 작으면 `0`으로 fallback한다.
|
|
||||||
- `size`가 20보다 작으면 `20`으로 fallback한다.
|
|
||||||
- `size`가 50보다 크면 `50`으로 fallback한다.
|
|
||||||
- 응답에는 같은 필터 조건의 전체 콘텐츠 개수와 현재 page 목록을 포함한다.
|
|
||||||
- 다음 page 존재 여부는 `size + 1`개 조회 또는 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 공개된 콘텐츠가 없으면 `totalCount`는 `0`, 목록은 빈 배열, `hasNext`는 `false`로 내려준다.
|
|
||||||
- 요청한 page 범위에 콘텐츠가 없으면 목록은 빈 배열, `hasNext`는 `false`로 내려주되 `totalCount`는 전체 개수를 유지한다.
|
|
||||||
- 특정 구분에서 지원하지 않는 응답 목록 필드는 빈 배열로 내려준다.
|
|
||||||
|
|
||||||
### Feature B. 공통 공개/차단/성인 콘텐츠 정책
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 모든 구분은 공개 가능한 콘텐츠만 조회한다.
|
|
||||||
- 오디오 콘텐츠는 `isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터 조건을 만족해야 한다.
|
|
||||||
- 시리즈는 `isActive == true`, 활성 크리에이터 조건을 만족해야 한다.
|
|
||||||
- 시리즈의 콘텐츠 통계와 정렬 대표값은 공개 가능한 오디오 콘텐츠만 기준으로 계산한다.
|
|
||||||
- 회원이 차단했거나 회원을 차단한 크리에이터의 오디오/시리즈는 제외한다.
|
|
||||||
- 비회원은 19금 오디오/시리즈를 제외한다.
|
|
||||||
- 인증 회원은 `MemberContentPreferenceService`의 기존 성인 콘텐츠 노출 가능 여부를 반영한다.
|
|
||||||
- 이미지 경로는 기존 `v2.common.domain.CdnUrlExtensions`의 CDN URL 변환 패턴을 따른다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 차단 관계가 있는 크리에이터의 시리즈에 속한 오디오도 조회 대상에서 제외한다.
|
|
||||||
- 예약 공개 전 오디오는 모든 구분의 목록, 개수, 정렬 대표값, 매출 집계에서 제외한다.
|
|
||||||
- 비활성 크리에이터의 콘텐츠는 모든 구분에서 제외한다.
|
|
||||||
|
|
||||||
### Feature C. 오디오 구분
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 공개 오디오 콘텐츠를 조회한다.
|
|
||||||
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 오디오 콘텐츠 개수다.
|
|
||||||
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
|
|
||||||
- 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 시리즈에 속하지 않은 오디오도 목록에 포함한다.
|
|
||||||
- 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다.
|
|
||||||
|
|
||||||
### Feature D. 시리즈 구분
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `type=SERIES`는 차단 관계가 아닌 모든 크리에이터의 요일별 시리즈 콘텐츠를 조회한다.
|
|
||||||
- 활성 시리즈를 조회 대상으로 한다.
|
|
||||||
- `dayOfWeek`가 있으면 `series.publishedDaysOfWeek`에 해당 값이 포함된 시리즈만 조회한다.
|
|
||||||
- 요일 필터는 legacy `GET /audio-content/series/main/day-of-week`와 동일하게 query parameter 이름 `dayOfWeek`와 `SeriesPublishedDaysOfWeek` enum 값을 사용한다.
|
|
||||||
- `dayOfWeek`가 없으면 요일 조건 없이 전체 시리즈를 조회한다.
|
|
||||||
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 시리즈 개수다.
|
|
||||||
- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다.
|
|
||||||
- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
|
|
||||||
- 응답 최상위 `dayOfWeek`에는 실제 적용된 요일 값을 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함할 수 있다.
|
|
||||||
- `dayOfWeek=RANDOM` 요청은 legacy와 동일하게 `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 시리즈만 조회한다.
|
|
||||||
- `dayOfWeek`가 지원 enum 값이 아니면 400 오류 대신 요일 조건을 적용하지 않는 fallback을 기본안으로 한다.
|
|
||||||
|
|
||||||
### Feature E. 오리지널 구분
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `type=ORIGINAL`은 차단 관계가 아닌 모든 크리에이터의 `isOriginal == true`인 시리즈를 조회한다.
|
|
||||||
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `SERIES`와 동일하다.
|
|
||||||
- 단, `dayOfWeek` 요일 필터는 `type=ORIGINAL`에 적용하지 않는다.
|
|
||||||
- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 오리지널 시리즈에 공개 가능한 오디오 콘텐츠가 없어도 활성 시리즈이면 목록에 포함한다.
|
|
||||||
- 19금 오리지널 시리즈는 조회자의 성인 콘텐츠 노출 가능 여부를 따른다.
|
|
||||||
|
|
||||||
### Feature F. 무료 구분
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다.
|
|
||||||
- 무료 오디오는 `price == 0`인 공개 오디오로 정의한다.
|
|
||||||
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
|
|
||||||
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 무료 콘텐츠의 `PRICE_HIGH`와 `PRICE_LOW`는 가격이 모두 0일 수 있으므로 2차/3차 정렬인 `releaseDate desc`, `id desc`가 실제 순서를 결정할 수 있다.
|
|
||||||
|
|
||||||
### Feature G. 포인트 구분
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
|
|
||||||
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
|
|
||||||
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
|
|
||||||
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 포인트 사용 가능 여부는 결제 가능 여부 필터일 뿐이며, 인기순 매출 산식에는 포인트 사용액을 포함하지 않는다.
|
|
||||||
|
|
||||||
### Feature H. 콘텐츠 정렬
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
|
|
||||||
- 공개 요청/응답 값은 다음을 사용한다.
|
|
||||||
- `LATEST`: 최신순, 기본값
|
|
||||||
- `POPULAR`: 인기순
|
|
||||||
- `PRICE_HIGH`: 높은 가격순
|
|
||||||
- `PRICE_LOW`: 낮은 가격순
|
|
||||||
- `LATEST`는 `releaseDate desc`, `id desc` 순으로 정렬한다.
|
|
||||||
- `POPULAR`은 인기순 매출 내림차순, `releaseDate desc`, `id desc` 순으로 정렬한다.
|
|
||||||
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 한다.
|
|
||||||
- 인기순 매출에는 포인트 사용액(`orders.point`)을 포함하지 않는다.
|
|
||||||
- 인기순 매출에는 `orders.isActive == true`인 주문만 포함한다.
|
|
||||||
- `PRICE_HIGH`는 `price desc`, `releaseDate desc`, `id desc` 순으로 정렬한다.
|
|
||||||
- `PRICE_LOW`는 `price asc`, `releaseDate desc`, `id desc` 순으로 정렬한다.
|
|
||||||
- 시리즈 정렬에서 `releaseDate`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다.
|
|
||||||
- 시리즈 정렬에서 `price desc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 높은 가격을 대표값으로 사용한다.
|
|
||||||
- 시리즈 정렬에서 `price asc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 낮은 가격을 대표값으로 사용한다.
|
|
||||||
- 시리즈 인기순 매출은 시리즈에 속한 공개 오디오 콘텐츠의 `orders.can` 합계를 사용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 매출이 없는 오디오 또는 시리즈의 인기순 매출값은 0으로 처리한다.
|
|
||||||
- 콘텐츠가 없는 시리즈는 정렬 대표값이 없는 항목으로 처리해 같은 정렬 내 마지막에 노출한다.
|
|
||||||
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. API Endpoint
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20
|
|
||||||
Authorization: Bearer {accessToken} (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
- 비회원 조회를 허용한다.
|
|
||||||
- `SecurityConfig`에 `GET /api/v2/audio/contents` permitAll 설정을 추가한다.
|
|
||||||
- `type` 미지정 시 `AUDIO`를 기본값으로 사용한다.
|
|
||||||
- `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다.
|
|
||||||
- `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다.
|
|
||||||
- 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20`
|
|
||||||
- `page`, `size`는 기존 크리에이터 채널 오디오/시리즈 탭과 같은 보정 정책을 따른다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Response Data Class
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class MainContentAllTabResponse(
|
|
||||||
val type: MainContentAllType,
|
|
||||||
val totalCount: Int,
|
|
||||||
val audios: List<MainContentAudioResponse>,
|
|
||||||
val series: List<MainContentSeriesResponse>,
|
|
||||||
val sort: ContentSort,
|
|
||||||
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
@JsonProperty("hasNext")
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class MainContentAllType {
|
|
||||||
AUDIO,
|
|
||||||
SERIES,
|
|
||||||
ORIGINAL,
|
|
||||||
FREE,
|
|
||||||
POINT
|
|
||||||
}
|
|
||||||
|
|
||||||
data class MainContentAudioResponse(
|
|
||||||
val audioContentId: Long,
|
|
||||||
val title: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val price: Int,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean,
|
|
||||||
@JsonProperty("isPointAvailable")
|
|
||||||
val isPointAvailable: Boolean,
|
|
||||||
@JsonProperty("isFirstContent")
|
|
||||||
val isFirstContent: Boolean,
|
|
||||||
@JsonProperty("isOriginalSeries")
|
|
||||||
val isOriginalSeries: Boolean,
|
|
||||||
val creatorNickname: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MainContentSeriesResponse(
|
|
||||||
val seriesId: Long,
|
|
||||||
val title: String,
|
|
||||||
val coverImageUrl: String?,
|
|
||||||
val creatorNickname: String,
|
|
||||||
@JsonProperty("isOriginal")
|
|
||||||
val isOriginal: Boolean,
|
|
||||||
@JsonProperty("isAdult")
|
|
||||||
val isAdult: Boolean
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Technical Constraints
|
|
||||||
|
|
||||||
### 패키지 구조
|
|
||||||
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.all` 하위에 둔다.
|
|
||||||
- Controller: `...adapter.in.web`
|
|
||||||
- Facade: `...application`
|
|
||||||
- Response DTO: `...dto`
|
|
||||||
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.all` 하위에 둔다.
|
|
||||||
- Query service: `...application`
|
|
||||||
- 조회 정책/domain model: `...domain`
|
|
||||||
- 조회 port: `...port.out`
|
|
||||||
- QueryDSL/JPA 구현: `...adapter.out.persistence`
|
|
||||||
- 의존 방향은 `v2.api.content.all -> v2.content.all`만 허용한다.
|
|
||||||
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
|
|
||||||
|
|
||||||
### V2 공통화/재사용 대상
|
|
||||||
- `v2.common.domain.ContentSort`: 정렬 enum 재사용
|
|
||||||
- `creator.admin.content.series.SeriesPublishedDaysOfWeek`: legacy와 같은 요일 query parameter enum 재사용
|
|
||||||
- `content.series.main.SeriesMainController.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 API 계약 참고
|
|
||||||
- `content.series.ContentSeriesService.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 service 흐름 참고
|
|
||||||
- `v2.api.content.recommendation.adapter.in.web.AudioRecommendationController`: 비회원 허용 controller와 `ApiResponse.ok(...)` 패턴
|
|
||||||
- `v2.api.content.recommendation.application.AudioRecommendationFacade`: API 조립 계층에서 domain 결과를 response DTO로 변환하는 패턴
|
|
||||||
- `v2.content.recommendation.application.AudioRecommendationQueryService`: 회원 성인 콘텐츠 노출 가능 여부 계산과 전체 추천 조회 service 흐름
|
|
||||||
- `v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepository`: 전체 공개 오디오 조회, 차단 크리에이터 제외, CDN URL 변환, `AudioCard` 조립 패턴
|
|
||||||
- `v2.api.content.recommendation.dto.AudioCardResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴
|
|
||||||
- `v2.api.creator.channel.series.dto.CreatorChannelSeriesResponse`: 시리즈 응답 필드와 `JsonProperty` 네이밍 패턴 참고
|
|
||||||
- `v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy`: `sort`, `page`, `size` fallback 정책 참고
|
|
||||||
- `v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepository`: 시리즈 정렬 대표값, 시리즈 콘텐츠 통계, `orders.can` 매출 합산 패턴 참고
|
|
||||||
- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수
|
|
||||||
- `MemberContentPreferenceService`: 성인 콘텐츠 노출 가능 여부 판단
|
|
||||||
- `LangContext`: 시리즈 제목 다국어 처리
|
|
||||||
|
|
||||||
### 구현 주의사항
|
|
||||||
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
|
|
||||||
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
|
|
||||||
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.
|
|
||||||
- 기존 채널 오디오/시리즈 탭의 `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않으므로 전체 탭 policy에서 제외하거나 `LATEST`로 fallback한다.
|
|
||||||
- `POPULAR` 정렬은 기존 채널 탭 코드와 유사하되, 명시적으로 `orders.point`를 더하지 않고 `orders.can`만 집계한다.
|
|
||||||
- 오디오와 시리즈가 다른 응답 item 구조를 가지므로 최상위 응답은 `audios`와 `series`를 분리한다.
|
|
||||||
- 신규 Entity나 DDL은 필요하지 않다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Metrics
|
|
||||||
- 전체 탭 API 성공/실패 건수
|
|
||||||
- 전체 탭 API 응답 시간
|
|
||||||
- `type`별 조회 건수
|
|
||||||
- `sort`별 조회 건수
|
|
||||||
- 추가 로딩 요청 건수
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Open Questions
|
|
||||||
- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
-- MySQL 메인 홈 팔로잉 탭 최근 소식 inbox 테이블
|
|
||||||
-- 날짜/시간 표시 컬럼은 TIMESTAMP를 사용한다.
|
|
||||||
|
|
||||||
create table home_following_news_inbox (
|
|
||||||
id bigint not null auto_increment comment '팔로잉 최근 소식 inbox ID',
|
|
||||||
member_id bigint not null comment '수신 회원 ID(member.id)',
|
|
||||||
creator_id bigint not null comment '소식 발신 크리에이터 회원 ID(member.id)',
|
|
||||||
news_type varchar(30) not null comment '소식 타입(CREATOR_RANKING, CONTENT_RANKING, COMMUNITY_POST, AUDIO_CONTENT, PHOTO_CONTENT)',
|
|
||||||
source_key varchar(200) not null comment '중복 방지용 원천 소식 식별자',
|
|
||||||
target_id bigint not null comment '터치 액션 대상 ID',
|
|
||||||
occurred_at_utc timestamp not null comment '소식 발생 시각(UTC)',
|
|
||||||
visible_from_at_utc timestamp not null comment '소식 노출 시작 시각(UTC)',
|
|
||||||
creator_nickname varchar(100) not null comment '소식 생성 시점 크리에이터 닉네임',
|
|
||||||
creator_profile_image_path varchar(500) null comment '소식 생성 시점 크리에이터 프로필 이미지 path',
|
|
||||||
title varchar(255) not null comment '소식 제목',
|
|
||||||
body varchar(1000) not null comment '소식 본문',
|
|
||||||
thumbnail_image_path varchar(500) null comment '소식 썸네일 이미지 path',
|
|
||||||
rank_no int null comment '랭킹 소식 순위',
|
|
||||||
is_adult tinyint(1) not null default 0 comment '성인 콘텐츠 또는 성인 소식 여부',
|
|
||||||
is_active tinyint(1) not null default 1 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)
|
|
||||||
) engine=InnoDB default charset=utf8mb4 comment='메인 홈 팔로잉 탭 사용자별 최근 소식 inbox';
|
|
||||||
|
|
||||||
create unique index uk_home_following_news_inbox_member_type_source
|
|
||||||
on home_following_news_inbox (member_id, news_type, source_key);
|
|
||||||
|
|
||||||
create index idx_home_following_news_inbox_member_visible
|
|
||||||
on home_following_news_inbox (member_id, is_active, visible_from_at_utc desc, id desc);
|
|
||||||
|
|
||||||
create index idx_home_following_news_inbox_member_creator_active
|
|
||||||
on home_following_news_inbox (member_id, creator_id, is_active);
|
|
||||||
|
|
||||||
create index idx_home_following_news_inbox_creator_type_source
|
|
||||||
on home_following_news_inbox (creator_id, news_type, source_key);
|
|
||||||
@@ -1,652 +0,0 @@
|
|||||||
# 메인 홈 팔로잉 탭 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** `GET /api/v2/home/following`으로 메인 홈 팔로잉 탭의 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식을 조회한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.home.following` 조립 계층에 둔다. 팔로잉 탭 조회 service, 최근 소식 publish service, domain model, port, QueryDSL/JPA repository는 `kr.co.vividnext.sodalive.v2.home.following` 하위에 두고 `v2.api.*`에 의존하지 않는다. 최근 소식은 별도 inbox table에 사용자별 row를 저장하고, 이번 범위에서는 외부 MQ/outbox/worker 없이 내부 publish service에서 follower 조회와 bulk insert를 수행한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, MySQL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/home/following`
|
|
||||||
- 인증 정책: 비로그인 조회 허용. 비로그인 응답은 `isLoginRequired = true`와 빈 섹션 배열을 내려준다.
|
|
||||||
- 로그인 회원 응답은 `isLoginRequired = false`와 팔로잉 탭 데이터를 내려준다.
|
|
||||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
|
||||||
- `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다.
|
|
||||||
- 섹션별 기본 노출 수:
|
|
||||||
- `followingCreators`: 최신 팔로우순 20개
|
|
||||||
- `onAirLives`: 팔로잉 크리에이터의 현재 진행 중인 라이브 최신순 10개
|
|
||||||
- `recentChats`: DM/AI 채팅 최신순 10개
|
|
||||||
- `monthlySchedules`: 이번 달 오늘 이후 일정 중 오늘과 가까운 순 3개
|
|
||||||
- `recentNews`: `visibleFromAtUtc desc`, `newsId desc` 기준 30개
|
|
||||||
- 최근 대화는 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`와 `ChatRoomListItemResponse`를 재사용한다.
|
|
||||||
- 최근 소식 타입은 `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`를 정의한다.
|
|
||||||
- 이번 범위에서 생성하는 랭킹 소식은 `CREATOR_RANKING`만이다. `CONTENT_RANKING`은 향후 확장용으로 enum/table 값만 예약한다.
|
|
||||||
- 최근 소식 응답에는 별도 `creatorId`를 내려주지 않는다. 크리에이터 채널 이동이 필요한 `CREATOR_RANKING`은 `targetId`가 크리에이터 회원 id다.
|
|
||||||
- 최근 소식 랭킹 값은 `rank: Int?`만 사용한다. `rankChange`, `isNew`, nested `ranking` object는 사용하지 않는다.
|
|
||||||
- 최근 소식 inbox table DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`을 기준으로 한다.
|
|
||||||
- inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다.
|
|
||||||
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다.
|
|
||||||
- 이미지 URL은 기존 `v2.common.domain.CdnUrlExtensions.toCdnUrl` 패턴을 따른다.
|
|
||||||
- UTC 문자열 변환은 기존 `toUtcIso` 패턴을 따른다.
|
|
||||||
- 성인 콘텐츠 노출 가능 여부는 `MemberContentPreferenceService.canViewAdultContent(member)`를 사용한다.
|
|
||||||
- 차단 관계가 있는 크리에이터의 팔로잉 크리에이터, On Air, 스케줄, 최근 소식은 노출하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`
|
|
||||||
|
|
||||||
### 신규 도메인 조회 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKey.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKeyTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
|
|
||||||
|
|
||||||
### 기존 파일 수정
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTaskTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`
|
|
||||||
|
|
||||||
### 문서/DDL
|
|
||||||
- Keep: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
|
|
||||||
- Keep: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
|
|
||||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`에는 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 필드 계약을 바꾸는 작업은 먼저 PRD와 이 문서를 갱신한 뒤 별도 변경으로 처리한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.home.following.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
|
|
||||||
|
|
||||||
data class HomeFollowingTabResponse(
|
|
||||||
@JsonProperty("isLoginRequired")
|
|
||||||
val isLoginRequired: Boolean,
|
|
||||||
val followingCreators: List<FollowingCreatorResponse>,
|
|
||||||
val onAirLives: List<FollowingLiveResponse>,
|
|
||||||
val recentChats: List<ChatRoomListItemResponse>,
|
|
||||||
val monthlySchedules: List<FollowingScheduleResponse>,
|
|
||||||
val recentNews: List<FollowingNewsResponse>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun loginRequired(): HomeFollowingTabResponse {
|
|
||||||
return HomeFollowingTabResponse(
|
|
||||||
isLoginRequired = true,
|
|
||||||
followingCreators = emptyList(),
|
|
||||||
onAirLives = emptyList(),
|
|
||||||
recentChats = emptyList(),
|
|
||||||
monthlySchedules = emptyList(),
|
|
||||||
recentNews = emptyList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun from(home: HomeFollowing): HomeFollowingTabResponse {
|
|
||||||
return HomeFollowingTabResponse(
|
|
||||||
isLoginRequired = false,
|
|
||||||
followingCreators = home.followingCreators.map(FollowingCreatorResponse::from),
|
|
||||||
onAirLives = home.onAirLives.map(FollowingLiveResponse::from),
|
|
||||||
recentChats = home.recentChats,
|
|
||||||
monthlySchedules = home.monthlySchedules.map(FollowingScheduleResponse::from),
|
|
||||||
recentNews = home.recentNews.map(FollowingNewsResponse::from)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class FollowingCreatorResponse(
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileImageUrl: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(creator: HomeFollowingCreator): FollowingCreatorResponse {
|
|
||||||
return FollowingCreatorResponse(
|
|
||||||
creatorId = creator.creatorId,
|
|
||||||
creatorNickname = creator.creatorNickname,
|
|
||||||
creatorProfileImageUrl = creator.creatorProfileImageUrl
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class FollowingLiveResponse(
|
|
||||||
val liveId: Long,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val startedAtUtc: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(live: HomeFollowingLive): FollowingLiveResponse {
|
|
||||||
return FollowingLiveResponse(
|
|
||||||
liveId = live.liveId,
|
|
||||||
creatorProfileImageUrl = live.creatorProfileImageUrl,
|
|
||||||
creatorNickname = live.creatorNickname,
|
|
||||||
title = live.title,
|
|
||||||
startedAtUtc = live.startedAtUtc
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class FollowingScheduleResponse(
|
|
||||||
val scheduleId: String,
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val type: CreatorActivityType,
|
|
||||||
val targetId: Long,
|
|
||||||
val scheduledAtUtc: String,
|
|
||||||
@JsonProperty("isOnAir")
|
|
||||||
val isOnAir: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(schedule: HomeFollowingSchedule): FollowingScheduleResponse {
|
|
||||||
return FollowingScheduleResponse(
|
|
||||||
scheduleId = schedule.scheduleId,
|
|
||||||
creatorId = schedule.creatorId,
|
|
||||||
creatorProfileImageUrl = schedule.creatorProfileImageUrl,
|
|
||||||
creatorNickname = schedule.creatorNickname,
|
|
||||||
title = schedule.title,
|
|
||||||
type = schedule.type,
|
|
||||||
targetId = schedule.targetId,
|
|
||||||
scheduledAtUtc = schedule.scheduledAtUtc,
|
|
||||||
isOnAir = schedule.isOnAir
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class FollowingNewsResponse(
|
|
||||||
val newsId: String,
|
|
||||||
val type: FollowingNewsType,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val body: String,
|
|
||||||
val thumbnailImageUrl: String?,
|
|
||||||
val targetId: Long,
|
|
||||||
val occurredAtUtc: String,
|
|
||||||
val visibleFromAtUtc: String,
|
|
||||||
val rank: Int?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(news: HomeFollowingNews): FollowingNewsResponse {
|
|
||||||
return FollowingNewsResponse(
|
|
||||||
newsId = news.newsId,
|
|
||||||
type = news.type,
|
|
||||||
creatorProfileImageUrl = news.creatorProfileImageUrl,
|
|
||||||
creatorNickname = news.creatorNickname,
|
|
||||||
title = news.title,
|
|
||||||
body = news.body,
|
|
||||||
thumbnailImageUrl = news.thumbnailImageUrl,
|
|
||||||
targetId = news.targetId,
|
|
||||||
occurredAtUtc = news.occurredAtUtc,
|
|
||||||
visibleFromAtUtc = news.visibleFromAtUtc,
|
|
||||||
rank = news.rank
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Domain / Port 초안
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.home.following.domain
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
|
||||||
|
|
||||||
data class HomeFollowing(
|
|
||||||
val followingCreators: List<HomeFollowingCreator>,
|
|
||||||
val onAirLives: List<HomeFollowingLive>,
|
|
||||||
val recentChats: List<ChatRoomListItemResponse>,
|
|
||||||
val monthlySchedules: List<HomeFollowingSchedule>,
|
|
||||||
val recentNews: List<HomeFollowingNews>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HomeFollowingCreator(
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileImageUrl: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HomeFollowingLive(
|
|
||||||
val liveId: Long,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val startedAtUtc: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HomeFollowingSchedule(
|
|
||||||
val scheduleId: String,
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val type: CreatorActivityType,
|
|
||||||
val targetId: Long,
|
|
||||||
val scheduledAtUtc: String,
|
|
||||||
val isOnAir: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HomeFollowingNews(
|
|
||||||
val newsId: String,
|
|
||||||
val type: FollowingNewsType,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val body: String,
|
|
||||||
val thumbnailImageUrl: String?,
|
|
||||||
val targetId: Long,
|
|
||||||
val occurredAtUtc: String,
|
|
||||||
val visibleFromAtUtc: String,
|
|
||||||
val rank: Int?
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class FollowingNewsType {
|
|
||||||
CREATOR_RANKING,
|
|
||||||
CONTENT_RANKING,
|
|
||||||
COMMUNITY_POST,
|
|
||||||
AUDIO_CONTENT,
|
|
||||||
PHOTO_CONTENT
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.home.following.port.out
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
interface HomeFollowingQueryPort {
|
|
||||||
fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator>
|
|
||||||
fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive>
|
|
||||||
fun findMonthlySchedules(memberId: Long, canViewAdultContent: Boolean, now: LocalDateTime, limit: Int): List<HomeFollowingSchedule>
|
|
||||||
fun findRecentNews(memberId: Long, canViewAdultContent: Boolean, nowUtc: LocalDateTime, limit: Int): List<HomeFollowingNews>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HomeFollowingNewsInboxPort {
|
|
||||||
fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int
|
|
||||||
fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long
|
|
||||||
fun findActiveFollowerIds(creatorId: Long): List<Long>
|
|
||||||
}
|
|
||||||
|
|
||||||
data class HomeFollowingNewsInboxRecord(
|
|
||||||
val memberId: Long,
|
|
||||||
val creatorId: Long,
|
|
||||||
val newsType: String,
|
|
||||||
val sourceKey: String,
|
|
||||||
val targetId: Long,
|
|
||||||
val occurredAtUtc: LocalDateTime,
|
|
||||||
val visibleFromAtUtc: LocalDateTime,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileImagePath: String?,
|
|
||||||
val title: String,
|
|
||||||
val body: String,
|
|
||||||
val thumbnailImagePath: String?,
|
|
||||||
val rank: Int?,
|
|
||||||
val isAdult: Boolean
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Phase / Task 계획
|
|
||||||
|
|
||||||
### Phase 1: 응답 DTO, 도메인 모델, Security 기본 골격
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 팔로잉 탭 응답 DTO와 domain model 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`
|
|
||||||
- RED: `HomeFollowingTabResponse.loginRequired()`가 `isLoginRequired=true`와 빈 배열을 반환하는 테스트를 작성한다.
|
|
||||||
- RED: `FollowingNewsResponse` 변환 결과가 `creatorId` 없이 `rank: Int?`만 포함하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, DTO 미구현으로 실패 확인.
|
|
||||||
- GREEN: DTO/domain enum/model을 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: import, `JsonProperty`, nullable 필드 정리 후 `./gradlew --no-daemon ktlintCheck` 실행.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: Controller와 Security permitAll 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt`
|
|
||||||
- RED: 비로그인 `GET /api/v2/home/following`이 200과 `isLoginRequired=true`를 반환하는 MockMvc 테스트를 작성한다.
|
|
||||||
- RED: 로그인 회원 요청이 facade를 호출하고 `isLoginRequired=false` 응답을 반환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingControllerTest"` 실행, endpoint 미구현 또는 security 미설정 실패 확인.
|
|
||||||
- GREEN: controller, facade 빈 골격, `SecurityConfig` permitAll을 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 기존 `HomeRecommendationController`의 `@AuthenticationPrincipal` 패턴과 응답 wrapper 스타일에 맞춘다.
|
|
||||||
|
|
||||||
### Phase 2: 최근 소식 Inbox 저장소
|
|
||||||
|
|
||||||
- [x] **Task 2.1: Inbox Entity/JPA repository/DDL 정합성 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt`
|
|
||||||
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
|
|
||||||
- RED: 같은 `memberId/newsType/sourceKey` 중복 저장이 1건만 유지되어야 하는 테스트를 작성한다.
|
|
||||||
- RED: `memberId/creatorId` 기준 활성 row 비활성화가 동작하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행, entity/repository 미구현 실패 확인.
|
|
||||||
- GREEN: Entity와 JPA repository를 DDL 컬럼명에 맞춰 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 컬럼명, enum 저장 방식, timestamp nullable 정책이 DDL과 맞는지 비교한다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: Inbox persistence adapter 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
|
|
||||||
- RED: `insertIgnoreAll(records)`가 중복 source key를 예외 없이 무시하고 신규 row만 저장하는 테스트를 작성한다.
|
|
||||||
- RED: `findActiveFollowerIds(creatorId)`가 활성 팔로워만 반환하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: Task 2.1과 같은 단일 테스트 명령 실행, port/adapter 미구현 실패 확인.
|
|
||||||
- GREEN: JPA `saveAndFlush`와 unique 제약 기반 `DataIntegrityViolationException` 처리로 중복 source key를 예외 없이 무시하는 idempotent 저장을 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: H2/MySQL dialect 분기 없이 단일 JPA 경로를 유지하고, 동시 적재 시 inserted count는 best-effort임을 검증 기록에 남긴다.
|
|
||||||
|
|
||||||
### Phase 3: 팔로잉 탭 조회 Repository/Service
|
|
||||||
|
|
||||||
- [x] **Task 3.1: 팔로잉 크리에이터 조회**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
|
||||||
- RED: 활성 팔로우/활성 크리에이터만 최신 팔로우순 20개 조회하는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 테스트를 작성한다.
|
|
||||||
- RED: 차단 관계 크리에이터가 제외되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, repository 미구현 실패 확인.
|
|
||||||
- GREEN: `creator_following`, `member`, `block_member` 조건을 QueryDSL로 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 기본 프로필 이미지와 CDN 변환 책임은 service/facade 중 기존 패턴과 맞는 위치로 정리한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: On Air 조회**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
|
||||||
- RED: 팔로우한 크리에이터의 `live_room.is_active=true`, `channel_name` 존재 라이브만 `beginDateTime desc, id desc`로 10개 조회하는 테스트를 작성한다.
|
|
||||||
- RED: 성인 콘텐츠 노출 불가이면 19금 라이브가 제외되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: Task 3.1의 repository 단일 테스트 명령 실행, On Air 미구현 실패 확인.
|
|
||||||
- GREEN: 기존 `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)` 조건을 팔로잉 필터로 확장해 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 라이브 진행 중 판단 조건이 스케줄 `isOnAir`와 중복되면 private helper로 추출한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: 이달의 스케줄 조회**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
|
||||||
- RED: KST 오늘 00:00 이상 다음 달 00:00 미만의 라이브/오디오 일정을 `scheduledAt asc`로 3개 조회하는 테스트를 작성한다.
|
|
||||||
- RED: 오늘 이전 일정과 차단 크리에이터 일정이 제외되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: repository 단일 테스트 명령 실행, schedule 미구현 실패 확인.
|
|
||||||
- GREEN: 기존 `CreatorChannelHomeQueryRepository.findSchedules(...)`의 live/audio 조건을 팔로잉 전체 조회로 확장한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: `scheduleId`는 `{TYPE}:{targetId}` 형식으로 안정적으로 생성한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.4: 최근 소식 조회**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
|
||||||
- RED: `memberId`, `isActive=true`, `visibleFromAtUtc <= nowUtc` 조건으로 `visibleFromAtUtc desc, id desc` 30개를 조회하는 테스트를 작성한다.
|
|
||||||
- RED: `creatorId`가 응답 domain에 노출되지 않고 `rank`만 nullable로 내려가는 테스트를 작성한다.
|
|
||||||
- 실패 확인: repository 단일 테스트 명령 실행, recent news 미구현 실패 확인.
|
|
||||||
- GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 조회 시 차단/성인/target 활성 조건을 과도하게 조인하지 않도록 필요한 조건만 유지한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.5: HomeFollowingQueryService 조립**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt`
|
|
||||||
- RED: query service가 팔로잉 크리에이터 20, On Air 10, 스케줄 3, 최근 소식 30 limit로 port를 호출하는 테스트를 작성한다.
|
|
||||||
- RED: `MemberContentPreferenceService.canViewAdultContent(member)` 결과가 조회 port에 전달되는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"` 실행, service 미구현 실패 확인.
|
|
||||||
- GREEN: service에서 now/limit/성인 노출 정책을 조립한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: `nowProvider: () -> LocalDateTime`을 주입해 테스트 시간을 고정한다.
|
|
||||||
|
|
||||||
- [x] **Task 3.6: Inbox 중복 insert 충돌 통합 테스트 보강**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
|
|
||||||
- RED: 실제 `HomeFollowingNewsInboxJpaRepository`로 동일 `memberId/newsType/sourceKey` unique 충돌을 발생시킨 뒤, 같은 테스트 흐름에서 `insertIgnoreAll(records)` 또는 repository 조회가 예외 없이 동작하는 통합 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행, 실제 DB 충돌 후 persistence context/transaction 상태 검증 실패를 확인한다.
|
|
||||||
- GREEN: 필요 시 adapter의 중복 충돌 처리에서 persistence context 정리 또는 트랜잭션 경계를 최소 보강한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: mock 기반 race 테스트와 통합 테스트의 책임을 분리해, mock은 분기 검증만 하고 통합 테스트는 실제 Hibernate 세션/트랜잭션 유효성을 검증하도록 정리한다.
|
|
||||||
|
|
||||||
### Phase 4: 최근 소식 Publish Service와 기존 이벤트 연결
|
|
||||||
|
|
||||||
- [x] **Task 4.1: sourceKey 생성 정책 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKey.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKeyTest.kt`
|
|
||||||
- RED: `CREATOR_RANKING:{creatorId}:{aggregationStartAtUtc}` 형식 source key 생성 테스트를 작성한다.
|
|
||||||
- RED: `AUDIO_CONTENT:{contentId}`와 `COMMUNITY_POST:{postId}` source key 생성 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행, source key 미구현 실패 확인.
|
|
||||||
- GREEN: source key 생성 object를 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 문자열 상수는 `FollowingNewsType` enum 이름과 불일치하지 않게 정리한다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: HomeFollowingNewsPublishService 구현**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt`
|
|
||||||
- RED: `publishCommunityPostCreated(...)`가 현재 active follower에게만 inbox record를 생성하는 테스트를 작성한다.
|
|
||||||
- RED: `publishContentUploaded(...)`가 `visibleFromAtUtc`를 콘텐츠 공개 시각으로 저장하는 테스트를 작성한다.
|
|
||||||
- RED: `publishCreatorRankingVisible(...)`이 `rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, service 미구현 실패 확인.
|
|
||||||
- GREEN: publish service에서 follower 조회, record 변환, `insertIgnoreAll` 호출을 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 외부 MQ/outbox 없이 동작하되 호출부가 service 메서드에만 의존하도록 public API를 작게 유지한다.
|
|
||||||
|
|
||||||
- [x] **Task 4.3: 언팔로우 시 inbox 비활성화 연동**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
|
||||||
- RED: 언팔로우 시 해당 `memberId/creatorId`의 active inbox row가 `isActive=false`가 되는 테스트를 작성한다.
|
|
||||||
- RED: 재팔로우 시 기존 비활성 row가 복구되지 않는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행, inbox 비활성화 미연동 실패 확인.
|
|
||||||
- GREEN: 기존 언팔로우 처리 성공 후 `HomeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 팔로잉 공개 API 스키마는 변경하지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 4.4: 크리에이터 랭킹 소식 발행 연결**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
|
||||||
- RED: `refreshLastCompletedWeek(...)`가 스냅샷 저장 성공 후 `publishCreatorRankingVisible(...)`을 `visibleFromAtUtc`, `rank`, `creatorId`로 호출하는 테스트를 작성한다.
|
|
||||||
- RED: `snapshotPort.replaceSnapshots(...)` 실패 시 `publishCreatorRankingVisible(...)`이 호출되지 않는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행, publish 미연동 실패 확인.
|
|
||||||
- GREEN: `snapshotPort.replaceSnapshots(...)` 성공 직후 `snapshots.mapIndexed { index, snapshot -> rank = index + 1 }`로 publish service를 호출한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 월요일 01:00 생성, 09:00 노출 정책은 inbox `visibleFromAtUtc`로만 처리한다.
|
|
||||||
|
|
||||||
- [x] **Task 4.5: 콘텐츠/커뮤니티 업로드 소식 발행 연결**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`
|
|
||||||
- RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 `publishCommunityPostCreated(...)`가 post id, creator id, 본문 요약, 생성 시각으로 호출되는 테스트를 작성한다.
|
|
||||||
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate <= now`인 즉시 공개 콘텐츠 저장 성공 후 `publishContentUploaded(...)`가 호출되는 테스트를 작성한다.
|
|
||||||
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다.
|
|
||||||
- RED: `AudioContentService.releaseContent()`가 예약 콘텐츠를 active로 바꾸는 시점에 `publishContentUploaded(...)`를 호출하는 테스트를 작성한다.
|
|
||||||
- 실패 확인:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`
|
|
||||||
- 기대 결과: publish 미연동으로 FAIL
|
|
||||||
- GREEN: `AudioContentService.createAudioContent(...)`, `AudioContentService.releaseContent()`, `CreatorCommunityService.createCommunityPost(...)`의 트랜잭션 성공 경로에서 publish service를 호출한다.
|
|
||||||
- 통과 확인: 위 두 단일 테스트 명령 재실행, PASS 확인.
|
|
||||||
- REFACTOR: 결제/수정/관리자 저장 중 실제 공개 이벤트가 아닌 경로에서 중복 발행하지 않도록 sourceKey unique와 호출 지점을 함께 점검한다.
|
|
||||||
|
|
||||||
### Phase 5: Facade 통합, 최근 대화 재사용, API End-to-End
|
|
||||||
|
|
||||||
- [x] **Task 5.1: HomeFollowingFacade 통합**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt`
|
|
||||||
- RED: `member == null`이면 query/chat 서비스를 호출하지 않고 `HomeFollowingTabResponse.loginRequired()`를 반환하는 테스트를 작성한다.
|
|
||||||
- RED: 로그인 회원이면 query service와 `ChatRoomListService.getRooms(member, "ALL", null, 10)`를 호출해 응답을 조립하는 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행, facade 미구현 실패 확인.
|
|
||||||
- GREEN: facade 조립 로직을 최소 구현한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 한 섹션 데이터 부족은 빈 배열/가능한 개수로 성공 처리한다.
|
|
||||||
|
|
||||||
- [x] **Task 5.2: End-to-End API 통합 테스트**
|
|
||||||
- Files:
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
|
||||||
- RED: 비로그인 호출이 200, `isLoginRequired=true`, 모든 배열 빈 값인지 검증하는 통합 테스트를 작성한다.
|
|
||||||
- RED: 로그인 회원 호출이 팔로잉 크리에이터/On Air/최근 대화/스케줄/최근 소식을 모두 조립하는 통합 테스트를 작성한다.
|
|
||||||
- RED: `FollowingNewsResponse`에 `creatorId`와 nested `ranking`이 없고 `rank`만 있는지 JSON path 테스트를 작성한다.
|
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 통합 미구현 실패 확인.
|
|
||||||
- GREEN: 누락된 wiring, bean 등록, security 설정을 최소 수정한다.
|
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
|
||||||
- REFACTOR: 테스트 데이터 builder가 과하게 커지면 테스트 내부 private helper로만 분리한다.
|
|
||||||
|
|
||||||
### Phase 6: 문서/회귀 검증
|
|
||||||
|
|
||||||
- [x] **Task 6.1: 문서 동기화 확인**
|
|
||||||
- Files:
|
|
||||||
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
|
|
||||||
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
|
|
||||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
|
|
||||||
- TDD 예외 사유: 문서 검증 작업이며 실행 코드가 없다.
|
|
||||||
- 대체 검증 방법: `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`로 삭제된 공개 응답 필드가 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄의 `creatorId`와 DDL 내부 컬럼 `creator_id`는 허용한다.
|
|
||||||
- 실행 명령: `./gradlew tasks --all`
|
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
|
||||||
|
|
||||||
- [x] **Task 6.2: 전체 회귀 검증**
|
|
||||||
- Files:
|
|
||||||
- Verify: 전체 Kotlin source/test
|
|
||||||
- TDD 예외 사유: 전체 회귀 검증 task이며 신규 테스트 작성 대상이 아니다.
|
|
||||||
- 대체 검증 방법:
|
|
||||||
- `./gradlew --no-daemon test`
|
|
||||||
- `./gradlew --no-daemon ktlintCheck`
|
|
||||||
- 기대 결과: 두 명령 모두 `BUILD SUCCESSFUL`
|
|
||||||
- 검증 결과 기록: 각 task 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 해당 task 아래에 한국어로 누적 기록한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 구현 순서 요약
|
|
||||||
|
|
||||||
1. DTO/domain/controller/security 기본 응답을 먼저 만든다.
|
|
||||||
2. inbox entity/repository/adapter와 unique 정책을 만든다.
|
|
||||||
3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다.
|
|
||||||
4. query service와 facade에서 섹션을 조립한다.
|
|
||||||
5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다.
|
|
||||||
6. End-to-End 테스트와 전체 회귀 검증을 수행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 검증 기록
|
|
||||||
|
|
||||||
- 2026-06-25 Phase 1-2 구현 검증:
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingControllerTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- 병렬 Gradle 실행 중 `build/snapshot/kotlin/kaptGenerateStubsKotlin` 삭제 충돌이 1회 발생해 동일 명령을 순차 재실행했다.
|
|
||||||
- 2026-06-25 Phase 3 구현 검증:
|
|
||||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 repository/service 미구현 컴파일 오류로 `BUILD FAILED`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-25 Phase 4 구현 검증:
|
|
||||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행 결과 `HomeFollowingNewsSourceKey`, `HomeFollowingNewsPublishService` 미구현 및 생성자 의존성 미연동 컴파일 오류로 `BUILD FAILED`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `insertIgnoreAll`은 H2/MySQL dialect 분기 없이 JPA `saveAndFlush`와 unique 제약 기반 중복 예외 재확인 단일 경로로 검증했다.
|
|
||||||
- 2026-06-25 Phase 5 구현 검증:
|
|
||||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 facade 생성자 미구현으로 `BUILD FAILED`.
|
|
||||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 facade 생성자 미구현으로 `BUILD FAILED`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-26 Phase 3-5 리뷰 보완 검증:
|
|
||||||
- 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 유료 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다.
|
|
||||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 reviewer 보완 전 7개 regression 테스트 실패를 확인했다.
|
|
||||||
- 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-26 Phase 3-5 2차 리뷰 보완 검증:
|
|
||||||
- 2차 리뷰 지적 사항에 따라 inbox insert 정상 경로를 row별 `saveAndFlush`에서 기존 memberId 일괄 조회 + `saveAll` + 단일 `flush`로 완화하고, 중복 충돌 fallback은 유지했다.
|
|
||||||
- 유료 오디오 콘텐츠의 `isFullDetailVisible=false` 상세 설명은 기존 상세 API 정책과 동일하게 미리보기만 최근 소식에 저장하도록 보강했다.
|
|
||||||
- 오디오/커뮤니티/랭킹 최근 소식 발행 실패가 원 업로드/게시글 생성/랭킹 스냅샷 갱신 성공을 실패로 전파하지 않도록 after-commit 발행 예외를 로그로 격리했다.
|
|
||||||
- 보완 직후 regression 테스트에서 adapter race 테스트와 Mockito matcher stubbing 불일치 실패를 확인한 뒤 테스트를 새 구현 경로에 맞게 정리했다.
|
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-26 Phase 3-5 3차 리뷰 보완 검증:
|
|
||||||
- 최근 소식 조회가 `AUDIO_CONTENT`, `COMMUNITY_POST` 원천 target의 `isActive=false` 상태를 최종 제외하도록 보강했다. `CREATOR_RANKING`은 creator 활성/role 필터를 유지하고, 아직 원천 테이블이 없는 예약 타입은 조회에서 노출하지 않는다.
|
|
||||||
- 이달의 스케줄 정렬을 `scheduledAtUtc asc`, `type.sortOrder asc`, `targetId asc`로 안정화했다.
|
|
||||||
- inbox insert를 H2/MySQL 공통 JPA portable path로 변경했다. 구현은 `newsType/sourceKey`별 기존 수신 member id를 일괄 조회한 뒤 신규 row만 `saveAll` + `flush`하고, unique 충돌 시 persistence context를 정리한 뒤 한 번 재조회/재시도한다.
|
|
||||||
- 추후 운영에서 follower 수가 큰 크리에이터 이벤트로 `member_id in (...)` 또는 `saveAll` 배치 크기가 병목이 되면, follower id chunking, outbox table, 비동기 worker, 재시도/모니터링 대시보드 도입을 별도 후속 작업으로 진행한다.
|
|
||||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterRetryTest"` 실행 결과 target 비활성 필터와 insert retry 미구현으로 `BUILD FAILED`.
|
|
||||||
- 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test` 전체 테스트 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- 2026-06-26 Phase 6 문서/회귀 검증:
|
|
||||||
- 문서 동기화 확인을 위해 `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`를 실행했다. 검색 결과의 `creatorId`는 팔로잉 크리에이터/스케줄 공개 필드, 최근 소식의 `creatorId` 부재 검증 설명, 내부 `creator_id`/port 인자/테스트 설명 맥락으로 확인했으며 삭제된 공개 응답 필드 잔존은 확인되지 않았다.
|
|
||||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
# PRD: 메인 홈 팔로잉 탭 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
메인 홈의 내부 팔로잉 탭에서 사용할 팔로잉 크리에이터, 진행 중인 라이브, 최근 대화, 이달의 스케줄, 최근 소식을 한 번에 조회하는 v2 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 팔로잉 탭 화면은 로그인 사용자가 팔로우한 크리에이터 기준으로 여러 섹션을 조립해야 한다.
|
|
||||||
- 기존 v2 홈 추천 API는 추천/랭킹 중심이며, 팔로잉 관계를 기준으로 섹션 전체를 구성하지 않는다.
|
|
||||||
- 기존 채팅 목록 API, 크리에이터 채널 홈 API, 크리에이터 랭킹 스냅샷 패턴에는 재사용 가능한 코드가 있지만, 팔로잉 탭의 공개 응답 필드는 화면 요구사항과 다르다.
|
|
||||||
- 최근 소식은 랭킹, 커뮤니티 게시글 업로드, 콘텐츠 업로드가 섞인 피드라 매 요청마다 팔로잉한 모든 크리에이터의 모든 원천 데이터를 크게 조인하면 응답 지연과 DB 부하가 커질 수 있다.
|
|
||||||
- 최근 소식은 전체 후보를 매번 조회하는 모델보다, 팔로우 중인 크리에이터의 이벤트가 발생할 때 각 follower의 우체통에 소식 row를 넣는 사용자별 Inbox Feed 모델이 요구사항에 더 맞다.
|
|
||||||
- 따라서 공개 API 조립 계층과 도메인 조회 계층을 분리하고, 최근 소식은 사용자별 inbox row를 최신순으로 읽는 구조가 필요하다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 메인 홈 팔로잉 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
|
||||||
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
|
||||||
- 비로그인 사용자도 API 호출은 허용하되, 로그인 유도 화면을 그릴 수 있는 응답을 제공한다.
|
|
||||||
- 사용자가 팔로우한 크리에이터 목록을 최신 팔로우순 20개 응답한다.
|
|
||||||
- 사용자가 팔로우한 크리에이터의 현재 진행 중인 라이브를 최신순 10개 응답한다.
|
|
||||||
- DM/AI 채팅방 중 최신 대화순 10개를 응답한다.
|
|
||||||
- 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다.
|
|
||||||
- 사용자가 팔로우한 크리에이터들의 최근 소식을 최신 노출 가능 시각순 최대 30개 응답한다.
|
|
||||||
- 최근 소식은 팔로우 중인 크리에이터의 이벤트 발생 시점에 사용자별 inbox row를 생성하고, 조회 시 열람 가능 시각/활성 여부/차단/성인 노출 조건을 적용한다.
|
|
||||||
- 새로 팔로우한 사용자는 과거 소식을 받지 않는다.
|
|
||||||
- 언팔로우하면 해당 크리에이터가 보낸 기존 inbox row를 비활성화한다.
|
|
||||||
- 재팔로우해도 기존에 비활성화된 inbox row는 복구하지 않고, 재팔로우 이후 새 이벤트부터 새 inbox row를 생성한다.
|
|
||||||
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 기존 `GET /api/v2/home/recommendations` 공개 API 스키마를 변경하지 않는다.
|
|
||||||
- 기존 `GET /api/v2/chat/rooms` 공개 API 스키마를 변경하지 않는다.
|
|
||||||
- 기존 크리에이터 채널 홈/라이브/커뮤니티/콘텐츠 API 공개 스키마를 변경하지 않는다.
|
|
||||||
- 팔로잉 추가/해제 공개 API 스키마 변경은 이번 범위에 포함하지 않는다.
|
|
||||||
- 단, 최근 소식 정책을 위해 기존 팔로잉/언팔로잉 처리에 inbox 적재/비활성화 연동이 필요하면 내부 동작 보강 범위에 포함한다.
|
|
||||||
- 채팅방 생성, 메시지 전송, 읽음 처리 정책 변경은 포함하지 않는다.
|
|
||||||
- 최근 소식의 운영자 수동 고정/숨김 기능은 포함하지 않는다.
|
|
||||||
- 최근 소식 발송용 외부 MQ, outbox table, 별도 worker, cursor/retry dashboard는 이번 범위에 포함하지 않는다.
|
|
||||||
- 화보 업로드 기능 자체 구현은 포함하지 않는다. 단, 향후 콘텐츠 타입 확장을 고려한 응답 타입은 정의한다.
|
|
||||||
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 홈 팔로잉 탭에서 자신이 팔로우한 크리에이터의 활동을 빠르게 확인하는 사용자
|
|
||||||
- 비회원: 홈 팔로잉 탭에 진입했을 때 로그인 필요 상태를 확인하고 로그인 화면으로 이동하는 사용자
|
|
||||||
- 앱 클라이언트: 팔로잉 탭 첫 화면의 여러 섹션을 하나의 API 응답으로 구성하려는 클라이언트
|
|
||||||
- 운영자: 최근 소식 inbox 적재와 노출 정책이 안정적으로 동작하기를 기대하는 내부 사용자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 내가 팔로우한 크리에이터 목록을 최근 팔로우한 순서로 보고 싶다.
|
|
||||||
- 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다.
|
|
||||||
- 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다.
|
|
||||||
- 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다.
|
|
||||||
- 사용자는 팔로우한 크리에이터의 이번 주 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
|
|
||||||
- 앱 클라이언트는 소식 item의 타입별 터치 액션을 명확한 target id로 처리하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 메인 홈 팔로잉 탭 통합 조회 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API endpoint는 `GET /api/v2/home/following`으로 정의한다.
|
|
||||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
|
||||||
- 비로그인 요청도 성공 응답으로 처리한다.
|
|
||||||
- 비로그인 요청은 `isLoginRequired = true`와 빈 섹션 배열을 내려주고, 앱 클라이언트가 로그인 유도 화면을 표시한다.
|
|
||||||
- 로그인 회원 요청은 `isLoginRequired = false`와 팔로잉 탭 데이터를 내려준다.
|
|
||||||
- 인증 회원 조회는 기존 v2 컨트롤러 패턴과 동일하게 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 사용한다.
|
|
||||||
- 별도 query parameter는 정의하지 않는다.
|
|
||||||
- API 조립 계층은 섹션별 도메인 조회 결과를 받아 공개 응답 DTO로 변환한다.
|
|
||||||
- 한 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
|
|
||||||
- 섹션별 데이터가 없으면 빈 배열을 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 비로그인 요청에서는 팔로잉 크리에이터, On Air, 최근 대화, 스케줄, 최근 소식을 모두 빈 배열로 내려준다.
|
|
||||||
- 비로그인 요청에서는 팔로잉/채팅/스케줄/최근 소식 도메인 조회를 수행하지 않는다.
|
|
||||||
- 사용자가 팔로우한 크리에이터가 없으면 팔로잉 크리에이터, On Air, 스케줄, 최근 소식은 빈 배열로 내려준다.
|
|
||||||
- 최근 대화는 팔로잉 여부와 무관하게 해당 회원의 DM/AI 채팅 최신순 10개를 내려준다.
|
|
||||||
- 조회 중 차단 관계가 있는 크리에이터의 라이브, 스케줄, 최근 소식은 노출하지 않는다.
|
|
||||||
|
|
||||||
### Feature B. 팔로잉 크리에이터
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 사용자가 팔로우한 활성 크리에이터를 최신 팔로우순으로 최대 20개 조회한다.
|
|
||||||
- 팔로잉 기준은 `creator_following.member_id = 요청 회원 id`, `creator_following.is_active = true`다.
|
|
||||||
- 크리에이터는 `member.role = CREATOR`, `member.is_active = true`인 대상만 노출한다.
|
|
||||||
- 응답 필드는 `creatorId`, `creatorNickname`, `creatorProfileImageUrl`을 포함한다.
|
|
||||||
- 프로필 이미지는 `v2.common.domain.CdnUrlExtensions.toCdnUrl(...)` 패턴으로 CDN URL 변환한다.
|
|
||||||
- 프로필 이미지가 없으면 기존 채팅/홈 추천과 동일한 기본 프로필 이미지 정책을 따른다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 팔로잉 row는 활성 상태지만 크리에이터가 비활성 상태이면 제외한다.
|
|
||||||
- 차단 관계가 있는 크리에이터는 제외한다.
|
|
||||||
|
|
||||||
### Feature C. On Air
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 사용자가 팔로우한 활성 크리에이터의 현재 진행 중인 라이브를 최신순으로 최대 10개 조회한다.
|
|
||||||
- 현재 진행 중인 라이브는 기존 홈 추천 라이브와 동일하게 `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''` 조건을 기본으로 한다.
|
|
||||||
- 정렬은 `live_room.begin_date_time desc`, `live_room.id desc`로 한다.
|
|
||||||
- 응답 필드는 `liveId`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `startedAtUtc`를 포함한다.
|
|
||||||
- 19금 라이브 노출 여부는 기존 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 반영한다.
|
|
||||||
- 성별 제한, 크리에이터 입장 제한처럼 기존 라이브 조회에서 필요한 접근 조건이 있으면 구현 계획 단계에서 기존 라이브/크리에이터 채널 라이브 조회 정책과 맞춘다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 라이브 제목이 비어 있으면 기존 라이브 조회 API의 제목 fallback 정책을 확인해 따른다.
|
|
||||||
- 차단 관계가 있는 크리에이터의 라이브는 제외한다.
|
|
||||||
|
|
||||||
### Feature D. 최근 대화
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- DM/AI 채팅방 중 최신 대화순으로 최대 10개 조회한다.
|
|
||||||
- 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)` 재사용을 우선한다.
|
|
||||||
- 터치 시 해당 채팅방으로 이동할 수 있도록 `roomId`와 `chatType`을 응답에 포함한다.
|
|
||||||
- 기존 채팅 목록 응답 `ChatRoomListItemResponse`는 필드가 팔로잉 탭 요구와 맞으므로 직접 재사용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 채팅방이 없으면 빈 배열을 내려준다.
|
|
||||||
- AI/DM 메시지 preview 규칙은 기존 `ChatRoomListService`의 `previewMessage()` 정책을 그대로 따른다.
|
|
||||||
|
|
||||||
### Feature E. 이달의 스케줄
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 사용자가 팔로우한 크리에이터들의 이번 달 스케줄을 최대 3개 조회한다.
|
|
||||||
- 조회 범위는 KST 기준 오늘 00:00:00 이상, 다음 달 00:00:00 미만으로 한다.
|
|
||||||
- 오늘 이전의 데이터는 노출하지 않는다.
|
|
||||||
- 정렬은 `scheduledAt asc`, 같은 시각이면 기존 `CreatorActivityType` 정렬 정책과 target id 순으로 안정화한다.
|
|
||||||
- 스케줄 원천은 기존 크리에이터 채널 홈 스케줄 정책을 팔로잉 전체로 확장한다.
|
|
||||||
- 라이브 예약: `live_room.begin_date_time`
|
|
||||||
- 오디오 콘텐츠 예약: `content.release_date`
|
|
||||||
- 응답 필드는 `scheduleId`, `creatorId`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `type`, `targetId`, `scheduledAtUtc`, `isOnAir`를 포함한다.
|
|
||||||
- `type`은 기존 `CreatorActivityType`을 우선 재사용한다.
|
|
||||||
- 화면의 `On Air` 표시를 위해 예약 라이브가 이미 진행 중이면 `isOnAir = true`로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 오늘 이전 일정은 제외하되, 오늘 시작해서 현재 진행 중인 라이브는 스케줄에 포함할 수 있다.
|
|
||||||
- 이번 달 남은 일정이 3개 미만이면 가능한 개수만 내려준다.
|
|
||||||
- 19금 스케줄은 회원의 성인 콘텐츠 노출 가능 여부를 따른다.
|
|
||||||
- 차단 관계가 있는 크리에이터의 스케줄은 제외한다.
|
|
||||||
|
|
||||||
### Feature F. 최근 소식
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 사용자가 팔로우한 크리에이터들의 소식을 최신 노출 가능 시각순으로 최대 30개 조회한다.
|
|
||||||
- 최근 소식은 사용자별 Inbox Feed로 저장한다.
|
|
||||||
- 크리에이터 이벤트 발생 시점에 해당 크리에이터를 현재 팔로우 중인 회원별 inbox row를 생성한다.
|
|
||||||
- 이번 범위에서는 별도 비동기 이벤트 발송 시스템을 도입하지 않는다.
|
|
||||||
- 이벤트 발생 처리 흐름에서 내부 publish service를 호출해 follower 조회와 inbox bulk insert를 수행한다.
|
|
||||||
- publish service는 콘텐츠/커뮤니티/랭킹 도메인 코드에 직접 흩어지지 않고, 향후 outbox/worker로 전환할 수 있는 단일 경계로 둔다.
|
|
||||||
- follower가 많아져 동기 bulk insert가 운영 부하를 만들면 publish service 내부 구현을 outbox/worker 방식으로 교체할 수 있어야 한다.
|
|
||||||
- 현재 구현은 H2/MySQL 공통 검증이 가능한 JPA portable path를 우선 사용한다. follower 수가 큰 크리에이터 이벤트에서 `member_id in (...)` 또는 `saveAll` 배치 크기가 운영 부하를 만들면, 후속 작업에서 follower id chunking, outbox table, 비동기 worker, 재시도/모니터링 대시보드로 전환한다.
|
|
||||||
- 새로 팔로우한 사용자는 팔로우 이전에 발생한 과거 소식을 받지 않는다.
|
|
||||||
- 언팔로우 시 해당 크리에이터가 보낸 기존 inbox row를 `isActive = false`로 비활성화한다.
|
|
||||||
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
|
|
||||||
- 재팔로우 이후 새로 발생한 이벤트부터 새 inbox row를 생성한다.
|
|
||||||
- 최근 소식 item 타입은 최소 아래를 지원한다.
|
|
||||||
- `CREATOR_RANKING`: 크리에이터 순위 소식
|
|
||||||
- `CONTENT_RANKING`: 향후 콘텐츠 순위 소식
|
|
||||||
- `COMMUNITY_POST`: 커뮤니티 게시글 업로드
|
|
||||||
- `AUDIO_CONTENT`: 오디오 콘텐츠 업로드
|
|
||||||
- `PHOTO_CONTENT`: 향후 화보 콘텐츠 업로드
|
|
||||||
- 이번 범위에서 `CONTENT_RANKING`은 생성하지 않는다.
|
|
||||||
- `PHOTO_CONTENT`는 화보 기능 구현 전에는 생성되지 않지만, 클라이언트 계약 확장을 위해 enum에 포함한다.
|
|
||||||
- 최근 소식은 매 요청마다 모든 팔로잉 크리에이터 원천 데이터를 직접 집계하지 않는다.
|
|
||||||
- inbox row에는 소식 타입, 발생 시각, 열람 가능 시각, 수신 회원 id, 크리에이터 id, target id, 표시용 제목/본문/이미지 path, 랭킹 순위 값 등 응답 생성에 필요한 최소 정보를 저장한다.
|
|
||||||
- API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다.
|
|
||||||
- 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다.
|
|
||||||
- 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다.
|
|
||||||
- 응답 필드는 `newsId`, `type`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `visibleFromAtUtc`, `rank`를 포함한다.
|
|
||||||
- 응답에는 `creatorId`를 별도 필드로 내려주지 않는다.
|
|
||||||
- `CREATOR_RANKING` 터치 액션은 해당 크리에이터 채널 이동이므로 `targetId`는 크리에이터 회원 id다.
|
|
||||||
- `CONTENT_RANKING` 터치 액션은 향후 콘텐츠 상세 이동이므로 `targetId`는 콘텐츠 id로 정의한다.
|
|
||||||
- `COMMUNITY_POST` 터치 액션은 게시글 상세 이동이므로 `targetId`는 커뮤니티 게시글 id다.
|
|
||||||
- `AUDIO_CONTENT` 터치 액션은 오디오 상세 이동이므로 `targetId`는 오디오 콘텐츠 id다.
|
|
||||||
- `PHOTO_CONTENT` 터치 액션은 향후 화보 상세 이동이므로 `targetId`는 화보 콘텐츠 id로 정의한다.
|
|
||||||
- 화면의 상대 시간 표시는 `visibleFromAtUtc` 기준을 기본으로 한다.
|
|
||||||
- 커뮤니티 게시글 업로드 소식의 `occurredAtUtc`와 `visibleFromAtUtc`는 게시글 생성 시각을 기본값으로 한다.
|
|
||||||
- 오디오 콘텐츠 업로드 소식의 `occurredAtUtc`는 콘텐츠 업로드 또는 공개 예약 생성 시각, `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다.
|
|
||||||
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = occurredAtUtc`로 저장할 수 있다.
|
|
||||||
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다.
|
|
||||||
- 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다.
|
|
||||||
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다.
|
|
||||||
- 랭킹 소식은 이번에 몇 위에 올랐는지를 나타내는 `rank`를 내려준다.
|
|
||||||
- `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`의 `rank`는 `null`로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
|
|
||||||
- inbox 적재 실패 시 API 조회에서 실시간 fallback 집계를 무조건 수행하지 않는다.
|
|
||||||
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
|
|
||||||
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
|
|
||||||
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
|
|
||||||
- 콘텐츠 썸네일이 없으면 `thumbnailImageUrl`은 `null`로 내려준다.
|
|
||||||
|
|
||||||
### Feature G. Response 재사용 정책
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 공개 응답 DTO는 화면 계약이 명확해야 하므로 팔로잉 탭 전용 최상위 응답 `HomeFollowingTabResponse`를 신규로 만든다.
|
|
||||||
- 기존 응답 DTO를 무조건 새로 만들지는 않는다.
|
|
||||||
- `recentChats`는 기존 `ChatRoomListItemResponse`를 직접 재사용한다.
|
|
||||||
- `followingCreators`는 기존 `HomeCreatorItem`과 필드 의미가 유사하지만 `v2.api.home.dto.recommendation` 패키지의 추천 탭 전용 DTO이므로, API 결합을 줄이기 위해 팔로잉 탭 전용 `FollowingCreatorResponse`를 만든다.
|
|
||||||
- `onAirLives`는 기존 `HomeLiveItem`에 title/start time이 없고, `CreatorChannelLiveResponse`에는 creator profile/nickname이 없어 그대로 재사용하지 않는다.
|
|
||||||
- `monthlySchedules`는 기존 `CreatorChannelScheduleResponse`에 creator 정보와 `isOnAir`가 없어 그대로 재사용하지 않는다.
|
|
||||||
- `recentNews`는 타입별 target/action이 필요한 신규 피드이므로 전용 DTO를 만든다.
|
|
||||||
- DTO를 새로 만들더라도 CDN URL 변환, UTC ISO 변환, 채팅 목록 조회, 성인 콘텐츠 노출 판단, 차단 관계 필터, 크리에이터 랭킹 스냅샷 visible 시각 정책은 기존 코드를 재사용한다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 기존 `ChatRoomListItemResponse` 변경이 팔로잉 탭 공개 스키마에도 영향을 줄 수 있으므로, 채팅 목록 API 변경 시 팔로잉 탭 회귀 테스트를 함께 수행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. API Endpoint
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/v2/home/following
|
|
||||||
Authorization: Bearer {accessToken} (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
- 비로그인 조회를 허용한다.
|
|
||||||
- 별도 query parameter는 정의하지 않는다.
|
|
||||||
- `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다.
|
|
||||||
- 컨트롤러에서 `member == null`이면 `isLoginRequired = true`와 빈 섹션 배열을 담은 응답을 반환한다.
|
|
||||||
- 앱 클라이언트는 `isLoginRequired = true`일 때 팔로잉 탭 본문 대신 로그인 유도 화면을 표시한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Response Data Class
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class HomeFollowingTabResponse(
|
|
||||||
@JsonProperty("isLoginRequired")
|
|
||||||
val isLoginRequired: Boolean,
|
|
||||||
val followingCreators: List<FollowingCreatorResponse>,
|
|
||||||
val onAirLives: List<FollowingLiveResponse>,
|
|
||||||
val recentChats: List<ChatRoomListItemResponse>,
|
|
||||||
val monthlySchedules: List<FollowingScheduleResponse>,
|
|
||||||
val recentNews: List<FollowingNewsResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class FollowingCreatorResponse(
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileImageUrl: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class FollowingLiveResponse(
|
|
||||||
val liveId: Long,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val startedAtUtc: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class FollowingScheduleResponse(
|
|
||||||
val scheduleId: String,
|
|
||||||
val creatorId: Long,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val type: CreatorActivityType,
|
|
||||||
val targetId: Long,
|
|
||||||
val scheduledAtUtc: String,
|
|
||||||
@JsonProperty("isOnAir")
|
|
||||||
val isOnAir: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class FollowingNewsResponse(
|
|
||||||
val newsId: String,
|
|
||||||
val type: FollowingNewsType,
|
|
||||||
val creatorProfileImageUrl: String,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val title: String,
|
|
||||||
val body: String,
|
|
||||||
val thumbnailImageUrl: String?,
|
|
||||||
val targetId: Long,
|
|
||||||
val occurredAtUtc: String,
|
|
||||||
val visibleFromAtUtc: String,
|
|
||||||
val rank: Int?
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class FollowingNewsType {
|
|
||||||
CREATOR_RANKING,
|
|
||||||
CONTENT_RANKING,
|
|
||||||
COMMUNITY_POST,
|
|
||||||
AUDIO_CONTENT,
|
|
||||||
PHOTO_CONTENT
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다.
|
|
||||||
- `scheduleId`와 `newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Technical Constraints
|
|
||||||
|
|
||||||
### 패키지 구조
|
|
||||||
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home.following` 하위에 둔다.
|
|
||||||
- Controller: `...adapter.in.web`
|
|
||||||
- Facade: `...application`
|
|
||||||
- Response DTO: `...dto`
|
|
||||||
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.home.following` 하위에 둔다.
|
|
||||||
- Query service: `...application`
|
|
||||||
- 최근 소식 publish service: `...application`
|
|
||||||
- 도메인 모델/정책: `...domain`
|
|
||||||
- 조회 port: `...port.out`
|
|
||||||
- QueryDSL/JPA 구현: `...adapter.out.persistence`
|
|
||||||
- 의존 방향은 `v2.api.home.following -> v2.home.following`만 허용한다.
|
|
||||||
|
|
||||||
### V2 공통화/재사용 대상
|
|
||||||
- `v2.chat.service.ChatRoomListService`: 최근 대화 조회
|
|
||||||
- `v2.chat.dto.ChatRoomListItemResponse`: 최근 대화 공개 응답 직접 재사용
|
|
||||||
- `v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepository.findSchedules(...)`: 스케줄 조회 조건 참고
|
|
||||||
- `v2.creator.channel.home.domain.CreatorChannelSchedule`: 스케줄 도메인 의미 참고
|
|
||||||
- `v2.common.domain.CreatorActivityType`: 스케줄/소식 타입 중 활동 타입 재사용
|
|
||||||
- `v2.common.domain.CdnUrlExtensions.toCdnUrl`: 이미지 URL 변환
|
|
||||||
- `v2.api.home.dto.recommendation.toUtcIso`: UTC ISO 문자열 변환 패턴
|
|
||||||
- `MemberContentPreferenceService.canViewAdultContent(...)`: 성인 콘텐츠 노출 가능 여부 판단
|
|
||||||
- `v2.ranking`: 크리에이터 랭킹 스냅샷, `visibleFromAtUtc`, `rank` 의미 참고
|
|
||||||
|
|
||||||
### 최근 소식 Inbox
|
|
||||||
- 신규 Entity와 DB table을 생성한다.
|
|
||||||
- MySQL DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`에 기록한다.
|
|
||||||
- inbox는 사용자별 소식 저장소다.
|
|
||||||
- inbox table의 `creator_id`는 언팔로우 비활성화, 차단 관계 확인, 운영 조회를 위한 내부 컬럼이며 공개 응답의 별도 `creatorId` 필드로 내려주지 않는다.
|
|
||||||
- 커뮤니티/콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다.
|
|
||||||
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시점에 현재 follower 회원별로 적재하되, `visibleFromAtUtc`는 랭킹 스냅샷의 공개 시각을 사용한다.
|
|
||||||
- 이번 구현은 외부 MQ, outbox table, 별도 worker 없이 내부 publish service에서 follower 조회와 inbox bulk insert를 수행하는 최소 구조로 한다.
|
|
||||||
- 콘텐츠/커뮤니티/랭킹 생성 로직은 inbox 저장소를 직접 호출하지 않고 publish service만 호출한다.
|
|
||||||
- publish service는 `publishContentUploaded(...)`, `publishCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다.
|
|
||||||
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
|
|
||||||
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
|
|
||||||
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.
|
|
||||||
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다.
|
|
||||||
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
|
|
||||||
- 현재 `creator_following`에는 재팔로우 시점이 명확히 남지 않으므로, 조회 조건으로 재팔로우 시점을 추론하지 않는다.
|
|
||||||
- 조회 시 차단 관계, 성인 노출 여부, 원천 target 활성 여부는 최종 확인한다.
|
|
||||||
- 중복 방지를 위해 `memberId`, `newsType`, `sourceKey` 기준의 유니크 정책을 필수로 둔다.
|
|
||||||
- `sourceKey`는 `{TYPE}:{targetId}:{periodKey}`처럼 같은 소식을 안정적으로 식별할 수 있는 값으로 정의한다.
|
|
||||||
- 언팔로우 비활성화와 사용자별 조회 성능을 위해 `memberId`, `creatorId`, `isActive` 축의 인덱스를 고려한다.
|
|
||||||
- 최신 30개 조회 성능을 위해 `memberId`, `isActive`, `visibleFromAtUtc` 축의 인덱스를 고려한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Metrics
|
|
||||||
- `GET /api/v2/home/following` 응답 시간
|
|
||||||
- 섹션별 item count
|
|
||||||
- 최근 소식 inbox 적재 성공/실패 횟수
|
|
||||||
- 최근 소식 inbox 적재 지연 시간
|
|
||||||
- 최근 소식 조회 시 필터링 후 노출 수
|
|
||||||
- 빈 섹션 비율
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Open Questions
|
|
||||||
- 현재 PRD 기준의 미결정 요구사항은 없다.
|
|
||||||
- 구현 계획 단계에서는 기존 라이브 조회 코드의 진행 중 판단 조건과 스케줄 `isOnAir` 판단 조건을 같은 조건으로 추출할지 검토한다.
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
# 현재 진행 중인 라이브 조회 API Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
|
||||||
|
|
||||||
**Goal:** 인증 회원이 `GET /api/v2/home/on-air-lives`로 현재 진행 중인 라이브를 20개씩 페이징 조회한다.
|
|
||||||
|
|
||||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.home.live` 조립 계층에 둔다. 도메인 조회는 기존 `kr.co.vividnext.sodalive.v2.recommendation`의 `HomeRecommendationQueryService`와 `HomeRecommendationQueryPort.findLiveRecommendations(...)`를 확장 재사용한다. 기존 추천 탭 공개 응답 DTO는 변경하지 않고, 신규 endpoint에서만 `title`, `price`, `beginDateTimeUtc`를 포함한 응답 DTO로 조립한다.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 확정 사항
|
|
||||||
|
|
||||||
- API endpoint: `GET /api/v2/home/on-air-lives`
|
|
||||||
- 인증 정책: 인증 회원만 조회 가능
|
|
||||||
- 비회원/anonymous 요청: 기존 인증 필요 API와 동일하게 인증 오류 반환
|
|
||||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
|
||||||
- query parameter: `page`만 받음, 기본값 `0`
|
|
||||||
- page size: 항상 20개 고정, 클라이언트가 `size`를 지정하지 않음
|
|
||||||
- page 응답: `items`, `page`, `size`, `hasNext`
|
|
||||||
- `hasNext` 판정: 내부에서 `PAGE_SIZE + 1`개 조회 후 응답에는 최대 20개만 노출
|
|
||||||
- 현재 진행 중인 라이브 조건: `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''`
|
|
||||||
- 정렬: `live_room.begin_date_time desc`, `live_room.id desc`
|
|
||||||
- 방송자 조건: `member.is_active = true`
|
|
||||||
- 차단 정책: 요청 회원과 크리에이터의 양방향 활성 차단 관계 제외
|
|
||||||
- 성인 라이브 정책: `MemberContentPreferenceService.canViewAdultContent(member)` 결과 반영
|
|
||||||
- 시작 시간 응답: `LiveRoom.beginDateTime`을 기존 UTC ISO 문자열 변환 패턴으로 변환해 `beginDateTimeUtc`로 응답
|
|
||||||
- 프로필 이미지: 기존 홈 추천 패턴과 동일하게 CDN URL 변환, 없으면 기본 프로필 이미지 URL
|
|
||||||
- 기존 공개 API 스키마 유지:
|
|
||||||
- `GET /api/v2/home/recommendations`
|
|
||||||
- `GET /api/v2/home/recommendations/lives`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 파일 구조 계획
|
|
||||||
|
|
||||||
### 신규 API 조립 계층
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponseTest.kt`
|
|
||||||
|
|
||||||
### 기존 도메인 조회 계층 확장
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
|
|
||||||
### 기존 설정 수정
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
|
|
||||||
|
|
||||||
### 문서
|
|
||||||
- Keep: `docs/20260626_현재진행중인라이브조회_API/prd.md`
|
|
||||||
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Response data class 초안
|
|
||||||
|
|
||||||
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`에 아래 DTO를 추가한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.api.home.live.dto
|
|
||||||
|
|
||||||
data class HomeOnAirLivePageResponse(
|
|
||||||
val items: List<HomeOnAirLiveResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HomeOnAirLiveResponse(
|
|
||||||
val roomId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileImage: String,
|
|
||||||
val title: String,
|
|
||||||
val price: Int,
|
|
||||||
val beginDateTimeUtc: String
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`의 기존 record는 아래처럼 확장한다.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package kr.co.vividnext.sodalive.v2.recommendation.port.out
|
|
||||||
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
data class HomeLiveRecommendationRecord(
|
|
||||||
val liveRoomId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileImage: String?,
|
|
||||||
val title: String,
|
|
||||||
val price: Int,
|
|
||||||
val beginDateTime: LocalDateTime
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
기존 `HomeRecommendationFacade.toItem()`은 `title`, `price`를 무시하고 기존 `HomeLiveItem` 필드만 매핑해 기존 API 응답 스키마를 유지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: 도메인 조회 record 확장
|
|
||||||
|
|
||||||
- [x] **Task 1.1: 라이브 추천 record에 title/price/beginDateTime 포함**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- RED: `DefaultHomeRecommendationQueryRepositoryTest`에 `shouldReturnLiveTitlePriceAndBeginDateTimeForOnAirLiveQuery` 테스트를 추가한다. fixture는 `LiveRoom(title = "paid live", price = 30, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30), channelName = "channel")`를 저장하고, `findLiveRecommendations(offset = 0, limit = 1, memberId = viewer.id, includeAdultLives = true)` 결과의 `title == "paid live"`, `price == 30`, `beginDateTime == LocalDateTime.of(2026, 6, 26, 12, 30)`을 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: `HomeLiveRecommendationRecord`에 `title`, `price`, `beginDateTime`을 추가하고, QueryDSL projection에 `liveRoom.title`, `liveRoom.price`, `liveRoom.beginDateTime`을 추가한다.
|
|
||||||
- REFACTOR: 기존 `HomeRecommendationFacade.toItem()`과 기존 테스트 컴파일 오류를 수정하되 `HomeLiveItem` 공개 필드는 추가하지 않는다.
|
|
||||||
- 기대 결과: repository 테스트가 PASS이고 기존 추천 탭 응답 DTO에는 `title`, `price`, `beginDateTimeUtc`가 추가되지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 1.2: 기존 라이브 조회 조건 회귀 테스트 보강**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- RED: 기존 `shouldFindPagedLiveRecommendationsWithAdultFilter` 테스트를 확장하거나 별도 `shouldApplyOnAirLiveVisibilityPolicy` 테스트를 추가한다. 활성 방송자/비활성 방송자, `channelName = null`, 빈 `channelName`, `isActive = false`, 성인 라이브, 양방향 차단 라이브를 fixture로 만들고 조건에 맞는 라이브만 최신순으로 반환되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
|
||||||
- GREEN: 기존 조회 조건이 부족하면 `member.isActive.isTrue`, `liveRoom.channelName.isNotNull`, `liveRoom.channelName.isNotEmpty`, `includeAdultLiveCondition(...)`, `notBlockedCreatorCondition(...)`을 보강한다.
|
|
||||||
- REFACTOR: 중복 조건은 기존 private condition 함수로 유지하고 신규 abstraction은 추가하지 않는다.
|
|
||||||
- 기대 결과: 진행 중 라이브 조회 정책이 PRD의 노출 조건과 일치한다.
|
|
||||||
|
|
||||||
- [x] **Task 1.3: HomeRecommendationQueryService 위임 계약 유지**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
|
||||||
- RED: `shouldDelegateLiveRecommendationQueryWithPagingAndAdultFlag` 테스트를 추가한다. mock `HomeRecommendationQueryPort`가 `HomeLiveRecommendationRecord(liveRoomId = 1L, creatorNickname = "creator", creatorProfileImage = "profile.png", title = "live", price = 10, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30))`을 반환하도록 하고, service가 `offset`, `limit`, `memberId`, `includeAdultLives`를 그대로 port에 전달하는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
|
||||||
- GREEN: 컴파일 오류가 있으면 record 생성부와 import를 갱신한다. service 메서드 시그니처는 기존 `findLiveRecommendations(offset, limit, memberId, includeAdultLives)`를 유지한다.
|
|
||||||
- REFACTOR: service에는 신규 API 전용 page 조립 로직을 넣지 않는다.
|
|
||||||
- 기대 결과: 도메인 조회 계층은 API DTO에 의존하지 않고 기존 port record만 반환한다.
|
|
||||||
|
|
||||||
### Phase 2: 신규 API 조립 계층
|
|
||||||
|
|
||||||
- [x] **Task 2.1: 신규 응답 DTO와 직렬화 테스트 추가**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponseTest.kt`
|
|
||||||
- RED: `shouldSerializeOnAirLivePageResponse` 테스트를 작성한다. `HomeOnAirLivePageResponse(items = listOf(HomeOnAirLiveResponse(...)), page = 0, size = 20, hasNext = true)`를 Jackson으로 직렬화하고 `items[0].roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`, `page`, `size`, `hasNext` 필드가 존재하는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
|
|
||||||
- GREEN: PRD의 Response data class와 동일한 DTO를 추가한다.
|
|
||||||
- REFACTOR: DTO에는 도메인 조회나 CDN 변환 로직을 넣지 않는다.
|
|
||||||
- 기대 결과: 공개 응답 필드명이 PRD와 일치한다.
|
|
||||||
|
|
||||||
- [x] **Task 2.2: HomeOnAirLiveFacade 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`
|
|
||||||
- RED: `shouldReturnFixedSizePageAndHasNext` 테스트를 작성한다. mock `HomeRecommendationQueryService`가 21개 record를 반환하게 하고, facade가 `page = 0`, `size = 20`, `hasNext = true`, `items.size = 20`을 반환하는지 검증한다. `offset = 0`, `limit = 21`, `memberId = member.id`, `includeAdultLives = true` 호출도 검증한다.
|
|
||||||
- RED: `shouldUseDefaultProfileImageWhenCreatorProfileImageIsBlank` 테스트를 작성한다. `creatorProfileImage = null`인 record가 `https://cdn.test/profile/default-profile.png`로 매핑되는지 검증한다.
|
|
||||||
- RED: `shouldMapBeginDateTimeToUtcIsoString` 테스트를 작성한다. record의 `beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)`가 응답 `beginDateTimeUtc = "2026-06-26T12:30:00Z"`로 변환되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
|
|
||||||
- GREEN: `HomeOnAirLiveFacade`를 `@Component`로 추가한다. 생성자에는 `HomeRecommendationQueryService`, `MemberContentPreferenceService`, `@Value("\${cloud.aws.cloud-front.host}") cloudFrontHost`를 주입한다.
|
|
||||||
- GREEN: `getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse`를 구현하고, 내부 상수는 `PAGE_SIZE = 20`, `MAX_PAGE = 10_000`으로 둔다.
|
|
||||||
- GREEN: `page.coerceIn(0, MAX_PAGE)`로 page를 보정하고, `offset = normalizedPage * PAGE_SIZE`, `limit = PAGE_SIZE + 1`로 조회한다.
|
|
||||||
- REFACTOR: CDN URL 변환은 기존 홈 추천의 `profileImageUrl(cloudFrontHost, path)` 의미와 동일하게 유지한다. 시작 시간 UTC 문자열 변환은 기존 `toUtcIso` 의미와 동일하게 유지한다. 해당 helper들이 package-private이라 재사용이 어렵다면 facade 내부 private 함수로 최소 복제한다.
|
|
||||||
- 기대 결과: facade가 page 조립, 성인 노출 플래그 계산, DTO 매핑만 담당한다.
|
|
||||||
|
|
||||||
- [x] **Task 2.3: HomeOnAirLiveController 작성**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
|
|
||||||
- RED: `shouldRejectAnonymousRequest` 테스트를 작성한다. `GET /api/v2/home/on-air-lives`를 인증 없이 호출하면 401 Unauthorized가 반환되는지 검증한다.
|
|
||||||
- RED: `shouldPassAuthenticatedMemberAndPageToFacade` 테스트를 작성한다. `with(user(MemberAdapter(member)))`로 `GET /api/v2/home/on-air-lives?page=2`를 호출하고 facade가 member와 page 2를 받으며 `$.data.size == 20` 응답을 반환하는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
|
|
||||||
- GREEN: `@RestController`, `@RequestMapping("/api/v2/home/on-air-lives")` controller를 추가한다. `@GetMapping` 메서드는 `@RequestParam(defaultValue = "0") page: Int`와 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 받는다.
|
|
||||||
- GREEN: `member ?: throw SodaException(messageKey = "common.error.bad_credentials")`로 인증 회원을 요구하고, `ApiResponse.ok(homeOnAirLiveFacade.getOnAirLives(member, page))`를 반환한다.
|
|
||||||
- REFACTOR: controller에는 조회 조건/응답 매핑 로직을 넣지 않는다.
|
|
||||||
- 기대 결과: 신규 endpoint는 인증 회원만 접근 가능하고 기존 `ApiResponse.ok(...)` wrapper를 따른다.
|
|
||||||
|
|
||||||
### Phase 3: 보안 설정과 회귀 검증
|
|
||||||
|
|
||||||
- [x] **Task 3.1: SecurityConfig에 인증 필요 endpoint 등록**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
|
|
||||||
- RED: `HomeOnAirLiveControllerTest.shouldRejectAnonymousRequest`가 `SecurityConfig` 적용 상태에서 401을 기대하도록 유지한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
|
|
||||||
- GREEN: `SecurityConfig`에 `GET /api/v2/home/on-air-lives`를 `authenticated()` 경로로 추가한다. `permitAll`에는 추가하지 않는다.
|
|
||||||
- REFACTOR: 기존 `/api/v2/home/recommendations` permitAll과 `/api/v2/home/recommendations/**` authenticated 정책을 변경하지 않는다.
|
|
||||||
- 기대 결과: 현재 진행 중인 라이브 신규 API는 인증 필수이고, 기존 추천 탭 통합 조회와 전체보기 API의 기존 보안 정책은 변경되지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 3.2: 기존 추천 탭 응답 스키마 회귀 테스트**
|
|
||||||
- Files:
|
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
|
||||||
- RED: `shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc` 테스트를 추가한다. `HomeLiveItem(roomId = 1L, creatorNickname = "creator", creatorProfileImage = "https://cdn.test/profile.png")`를 직렬화하고 `title`, `price`, `beginDateTimeUtc` 필드가 없음을 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
|
|
||||||
- GREEN: `HomeRecommendationFacade`의 기존 `HomeLiveRecommendationRecord.toItem()` 매핑은 `roomId`, `creatorNickname`, `creatorProfileImage`만 사용하도록 유지한다.
|
|
||||||
- REFACTOR: 신규 API DTO와 기존 추천 탭 DTO import가 섞이지 않도록 패키지를 명확히 유지한다.
|
|
||||||
- 기대 결과: 기존 `GET /api/v2/home/recommendations/lives` 응답 스키마는 변경되지 않는다.
|
|
||||||
|
|
||||||
- [x] **Task 3.3: End-to-end 조회 검증**
|
|
||||||
- Files:
|
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
|
||||||
- RED: `shouldReturnAuthenticatedOnAirLivesWithTitlePriceAndBeginDateTimeUtc` 통합 테스트를 작성한다. 인증 회원, 활성 크리에이터, 진행 중 라이브 2개를 저장하고 `GET /api/v2/home/on-air-lives?page=0` 호출 결과에서 최신순, `roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`, `page = 0`, `size = 20`, `hasNext = false`를 검증한다.
|
|
||||||
- RED: `shouldExcludeAdultLiveWhenViewerCannotViewAdultContent` 통합 테스트를 작성한다. 성인 콘텐츠 노출 불가 회원 기준으로 성인 라이브가 제외되는지 검증한다.
|
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`
|
|
||||||
- GREEN: controller, facade, query repository 연결을 보강해 통합 테스트를 통과시킨다.
|
|
||||||
- REFACTOR: 테스트 fixture helper는 해당 테스트 클래스 내부 private 함수로 두고, 공용 테스트 유틸은 만들지 않는다.
|
|
||||||
- 기대 결과: 실제 Spring MVC, Security, JPA/QueryDSL 경로로 신규 API 요구사항이 검증된다.
|
|
||||||
|
|
||||||
### Phase 4: 최종 검증과 문서 기록
|
|
||||||
|
|
||||||
- [x] **Task 4.1: 단일/회귀 테스트 실행 및 기록**
|
|
||||||
- Files:
|
|
||||||
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
|
|
||||||
- RED: 신규/수정 테스트가 모두 구현된 상태에서 아래 명령을 실행한다.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
|
||||||
- 실패 확인: 실패가 있으면 해당 task로 돌아가 원인을 수정한다.
|
|
||||||
- GREEN: 신규 API 관련 단일 테스트가 모두 PASS인지 확인한다.
|
|
||||||
- REFACTOR: `./gradlew ktlintCheck`를 실행해 포맷 위반을 확인한다.
|
|
||||||
- 회귀 확인: `./gradlew test`를 실행해 전체 테스트 회귀를 확인한다.
|
|
||||||
- 기대 결과: 단일 테스트, ktlint, 전체 테스트 결과를 이 task 아래에 한국어로 누적 기록한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇을: 신규 API 관련 controller/facade/DTO/repository/query service 단일 테스트, 신규 API E2E 테스트, ktlint, 전체 회귀 테스트를 실행했다.
|
|
||||||
- 왜: Phase 1~3 구현 결과가 신규 endpoint 계약과 기존 추천 도메인 회귀 범위를 유지하는지 최종 확인하기 위해서다.
|
|
||||||
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했다.
|
|
||||||
- 결과: 단일 테스트 6개 명령과 `ktlintCheck`는 모두 `BUILD SUCCESSFUL`로 통과했다. `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했고, 실패 테스트는 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 검증은 모두 통과했으므로 전체 회귀 실패는 기존 하단 검증 기록과 같은 범위 외 잔여 실패로 기록한다.
|
|
||||||
|
|
||||||
- [x] **Task 4.2: 문서 동기화 확인**
|
|
||||||
- Files:
|
|
||||||
- Keep: `docs/20260626_현재진행중인라이브조회_API/prd.md`
|
|
||||||
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
|
|
||||||
- RED: 구현 중 endpoint, response field, 인증 정책, page size가 바뀌었는지 확인한다.
|
|
||||||
- 실패 확인: PRD와 구현이 다르면 구현 전에 PRD와 plan-task를 먼저 갱신한다.
|
|
||||||
- GREEN: 변경 사항이 없으면 문서 경로와 검증 결과만 유지한다.
|
|
||||||
- REFACTOR: `./gradlew tasks --all`을 실행해 문서 유지보수 규칙의 명령 유효성을 확인한다.
|
|
||||||
- 기대 결과: PRD와 plan-task가 같은 endpoint, response data class, 인증 정책, 페이징 정책을 설명한다.
|
|
||||||
- 검증 기록:
|
|
||||||
- 무엇을: PRD와 plan-task의 endpoint, response field, 인증 정책, page size 설명이 구현/테스트 대상과 같은지 확인했다.
|
|
||||||
- 왜: Phase 4에서 최종 문서 계약이 실제 신규 API 구현과 어긋나지 않도록 하기 위해서다.
|
|
||||||
- 어떻게: `docs/20260626_현재진행중인라이브조회_API/prd.md`, 이 문서의 확정 사항/실행 명령, `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `HomeOnAirLiveEndToEndTest`의 검증 범위를 대조하고 `./gradlew tasks --all`을 실행했다.
|
|
||||||
- 결과: PRD와 plan-task 모두 `GET /api/v2/home/on-air-lives`, 인증 회원 전용, page size 20, `items/page/size/hasNext`, `roomId/creatorNickname/creatorProfileImage/title/price/beginDateTimeUtc` 응답 필드를 동일하게 설명한다. `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 실행 명령
|
|
||||||
|
|
||||||
- 컨트롤러 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
|
|
||||||
- facade 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
|
|
||||||
- DTO 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
|
|
||||||
- repository 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
|
||||||
- query service 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
|
||||||
- 신규 API E2E 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`
|
|
||||||
- 포맷 검증: `./gradlew ktlintCheck`
|
|
||||||
- 전체 회귀 테스트: `./gradlew test`
|
|
||||||
- Gradle 명령 유효성 확인: `./gradlew tasks --all`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 검증 기록
|
|
||||||
|
|
||||||
- 문서 작성 시점에는 구현을 진행하지 않았으므로 테스트 실행 기록은 없다.
|
|
||||||
- 2026-06-26 문서 작성 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-26 `beginDateTimeUtc` 응답 필드 문서 보강 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-26 Phase 1/2 RED 확인: 신규 테스트 추가 후 `HomeLiveRecommendationRecord.title/price/beginDateTime`, `HomeOnAirLiveResponse`, `HomeOnAirLiveFacade`, `HomeOnAirLiveController` 미구현으로 `:compileTestKotlin FAILED`를 확인했다.
|
|
||||||
- 2026-06-26 Phase 1/2 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-26 Phase 1/2 포맷 검증: `./gradlew ktlintCheck`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-26 전체 회귀 확인: `./gradlew test`는 1026개 테스트 중 1개 실패로 종료했다. 실패 테스트는 `kr.co.vividnext.sodalive.content.AudioContentServiceTest.shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive`이며, 동일 테스트 단독 재실행도 같은 `HomeFollowingNewsPublishService` mock interaction 검증 실패를 재현했다. 이번 Phase 1/2 변경 파일은 `v2/recommendation`, `v2/api/home/live`, 문서에 한정되어 해당 실패는 범위 외 잔여 실패로 기록한다.
|
|
||||||
- 2026-06-27 Phase 3 RED 확인: `HomeOnAirLiveEndToEndTest` 신규 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`를 실행했고, `$.data.items.length()`가 기대값 2가 아닌 3으로 실패하는 것을 확인했다. 실패 원인은 신규 E2E 테스트 메서드 간 H2 fixture 공유로 확인했다.
|
|
||||||
- 2026-06-27 Phase 3 GREEN 확인: `SecurityConfig`에 `GET /api/v2/home/on-air-lives` 인증 matcher를 명시하고 E2E 테스트 격리를 보강한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`를 각각 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-27 Phase 3 포맷 검증: `./gradlew ktlintCheck`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-27 Phase 3 회귀 묶음 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-27 Phase 3 코드 리뷰 보강: `HomeRecommendationResponseTest.shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc`에 테스트 스타일 규칙에 맞는 `@DisplayName`을 추가했다. 이후 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`와 `./gradlew --no-daemon ktlintCheck`를 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-27 Phase 4 단일/E2E/포맷 검증: `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`, `HomeOnAirLiveEndToEndTest`를 각각 `./gradlew test --tests ...`로 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다. 이어서 `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-27 Phase 4 전체 회귀 확인: `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했다. 실패 테스트는 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 테스트는 모두 통과했고, 실패 위치가 `content.AudioContentServiceTest`로 이번 Phase 4 문서 기록 범위 및 신규 `v2/api/home/live`, `v2/recommendation` 변경 범위 밖이므로 잔여 실패로 기록한다.
|
|
||||||
- 2026-06-27 Phase 4 문서 동기화 확인: PRD와 plan-task가 `GET /api/v2/home/on-air-lives`, 인증 회원 전용, page size 20, `items/page/size/hasNext`, `roomId/creatorNickname/creatorProfileImage/title/price/beginDateTimeUtc` 응답 필드를 동일하게 설명하는지 확인했다. 문서 유지보수 규칙 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-27 Phase 4 코드 리뷰 보강: `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`에 테스트 스타일 규칙에 맞는 `@DisplayName`을 추가했다. 이후 `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`, `HomeOnAirLiveEndToEndTest`를 각각 `./gradlew test --tests ...`로 재실행했고 모두 `BUILD SUCCESSFUL`을 확인했다. `./gradlew ktlintCheck`와 `./gradlew tasks --all`도 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 2026-06-27 Phase 4 전체 회귀 재확인: `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했다. 실패 테스트는 기존 기록과 동일하게 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 테스트는 모두 통과했으므로 범위 외 잔여 실패로 유지한다.
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
# PRD: 현재 진행 중인 라이브 조회 API
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
메인 홈에서 현재 진행 중인 라이브 목록을 20개씩 페이징 조회하는 v2 API를 제공한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problem
|
|
||||||
- 메인 홈 추천 탭 통합 API는 상단에 현재 진행 중인 라이브를 일부 내려주지만, 별도 목록 조회에 필요한 응답 필드가 부족하다.
|
|
||||||
- 기존 `GET /api/v2/home/recommendations/lives`는 `roomId`, `creatorNickname`, `creatorProfileImage`만 내려주며 이번 요구사항의 `title`, `price`, `beginDateTimeUtc`를 포함하지 않는다.
|
|
||||||
- 기존 공개 API 스키마를 변경하면 클라이언트 회귀 영향이 생길 수 있으므로, 신규 API 계약을 별도로 명시해야 한다.
|
|
||||||
- 기존 v2 홈 추천/팔로잉 탭에는 현재 진행 중인 라이브 조회 조건과 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재활용해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
- 현재 진행 중인 라이브 목록 조회 API를 `kr.co.vividnext.sodalive.v2` 하위에 제공한다.
|
|
||||||
- 한 page당 20개씩 조회한다.
|
|
||||||
- 응답 item에는 `roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`를 포함한다.
|
|
||||||
- 기존 패턴과 동일하게 클라이언트 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
|
|
||||||
- 기존 메인 홈 추천 탭의 라이브 조회 조건을 최대한 재사용한다.
|
|
||||||
- 인증 회원만 조회할 수 있게 하고, 회원별 차단/성인 콘텐츠 노출 조건을 반영한다.
|
|
||||||
- 기존 공개 API 응답 스키마는 변경하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Non-Goals
|
|
||||||
- 기존 `GET /api/v2/home/recommendations` 응답 스키마를 변경하지 않는다.
|
|
||||||
- 기존 `GET /api/v2/home/recommendations/lives` 응답 스키마를 변경하지 않는다.
|
|
||||||
- 라이브 생성, 예약, 입장, 종료 API는 포함하지 않는다.
|
|
||||||
- 라이브 추천 산식, 스냅샷, 랭킹, 배너 정책은 변경하지 않는다.
|
|
||||||
- 앱 표시용 가격 단위, 다국어 문구, 날짜 포맷은 서버에서 처리하지 않는다.
|
|
||||||
- 20개 외 page size를 클라이언트가 지정하는 기능은 이번 범위에 포함하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Target Users
|
|
||||||
- 회원: 메인 홈에서 현재 진행 중인 라이브 목록을 더 탐색하는 사용자
|
|
||||||
- 앱 클라이언트: 현재 라이브 목록 화면 또는 추천 탭의 추가 로딩 화면을 구성하는 클라이언트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. User Stories
|
|
||||||
- 사용자는 메인 홈에서 현재 진행 중인 라이브를 20개씩 추가로 보고 싶다.
|
|
||||||
- 사용자는 라이브 제목과 가격을 목록에서 바로 확인하고 싶다.
|
|
||||||
- 앱 클라이언트는 다음 page 존재 여부를 응답에서 확인해 무한 스크롤 또는 더보기 UI를 구성하고 싶다.
|
|
||||||
- 앱 클라이언트는 기존 추천 탭 상단 라이브와 동일한 노출 정책으로 별도 목록을 조회하고 싶다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Core Features
|
|
||||||
|
|
||||||
### Feature A. 현재 진행 중인 라이브 목록 API
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 신규 API endpoint는 `GET /api/v2/home/on-air-lives`로 정의한다.
|
|
||||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
|
||||||
- `page` query parameter를 받는다.
|
|
||||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
|
||||||
- `page`는 0부터 시작하는 page index로 처리한다.
|
|
||||||
- `size` query parameter는 받지 않고, page size는 항상 20으로 고정한다.
|
|
||||||
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단한다.
|
|
||||||
- 응답 목록에는 최대 20개만 내려준다.
|
|
||||||
- 인증 회원만 조회할 수 있다.
|
|
||||||
- 인증 회원 조회는 기존 v2 컨트롤러 패턴과 동일하게 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 사용한다.
|
|
||||||
- `member == null`이면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
|
||||||
- 현재 진행 중인 라이브는 기존 홈 추천 라이브와 동일하게 `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''` 조건을 기본으로 한다.
|
|
||||||
- 방송자는 `member.is_active = true`인 대상만 노출한다.
|
|
||||||
- 정렬은 기존 홈 추천 라이브와 동일하게 `live_room.begin_date_time desc`, `live_room.id desc`로 한다.
|
|
||||||
- 양방향 차단 관계가 있는 크리에이터의 라이브는 제외한다.
|
|
||||||
- 성인 라이브 노출 여부는 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 따른다.
|
|
||||||
- 프로필 이미지는 기존 홈 추천/팔로잉 탭과 동일하게 CDN URL로 변환하고, 값이 없으면 기본 프로필 이미지 URL을 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- 조회 결과가 없으면 `items = emptyList()`, `hasNext = false`를 내려준다.
|
|
||||||
- 비회원이 조회하면 목록을 내려주지 않고 인증 오류를 반환한다.
|
|
||||||
- `page`가 0보다 작으면 기존 홈 추천 컨트롤러의 `normalizePage` 패턴과 동일하게 0으로 보정한다.
|
|
||||||
- 매우 큰 `page` 값은 기존 홈 추천 컨트롤러의 `MAX_PAGE = 10_000` 패턴과 동일하게 상한 보정한다.
|
|
||||||
- 20개보다 적게 조회되면 가능한 개수만 내려주고 성공 처리한다.
|
|
||||||
- 라이브 제목이 빈 문자열이면 별도 fallback을 만들지 않고 저장된 `LiveRoom.title` 값을 그대로 내려준다.
|
|
||||||
- 라이브 가격은 `LiveRoom.price` 값을 그대로 내려준다.
|
|
||||||
- 라이브 시작 시간은 `LiveRoom.beginDateTime`을 기존 UTC ISO 문자열 변환 패턴으로 변환해 `beginDateTimeUtc`로 내려준다.
|
|
||||||
|
|
||||||
### Feature B. 계층 분리와 재사용 정책
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 클라이언트 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home.live` 하위에 둔다.
|
|
||||||
- API 조립 계층 후보 파일은 다음과 같다.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
|
|
||||||
- 도메인 조회 계층은 기존 `kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService`와 `HomeRecommendationQueryPort.findLiveRecommendations(...)` 확장 재사용을 기본안으로 한다.
|
|
||||||
- 기존 `HomeLiveRecommendationRecord`에 `title`, `price`, `beginDateTime`을 추가해 신규 API DTO로 조립할 수 있게 한다.
|
|
||||||
- 기존 `HomeLiveItem`은 기존 필드만 매핑해 기존 추천 탭 공개 응답 스키마를 유지한다.
|
|
||||||
- 기존 `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)`의 조회 조건과 정렬을 유지하되 `liveRoom.title`, `liveRoom.price`, `liveRoom.beginDateTime` select를 추가한다.
|
|
||||||
- API 조립 계층은 도메인 조회 결과를 공개 응답 DTO로 변환하고, CDN URL 변환/기본 프로필 이미지 정책은 기존 홈 추천 패턴을 따른다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `HomeRecommendationQueryService` 확장으로 추천 도메인 결합이 과도하다고 판단되면 구현 계획 단계에서 `kr.co.vividnext.sodalive.v2.home.live` 하위 전용 query service/port/repository를 만들 수 있다. 이 경우에도 기존 조회 조건, 정렬, 테스트 케이스는 동일하게 유지한다.
|
|
||||||
- 기존 record 확장 시 생성자 projection 순서와 모든 매핑 호출부를 함께 수정해야 한다.
|
|
||||||
|
|
||||||
### Feature C. Response 스키마
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
- 응답 최상위 DTO 이름은 `HomeOnAirLivePageResponse`를 기본안으로 한다.
|
|
||||||
- 응답 item DTO 이름은 `HomeOnAirLiveResponse`를 기본안으로 한다.
|
|
||||||
- 응답 item은 다음 값을 포함한다.
|
|
||||||
- `roomId`: 라이브 방 id
|
|
||||||
- `creatorNickname`: 방송자 닉네임
|
|
||||||
- `creatorProfileImage`: 방송자 프로필 이미지 CDN URL
|
|
||||||
- `title`: 라이브 제목
|
|
||||||
- `price`: 라이브 입장 가격
|
|
||||||
- `beginDateTimeUtc`: 라이브 시작 시간 UTC ISO 문자열
|
|
||||||
- page metadata는 기존 `HomeRecommendationPageResponse`와 동일한 의미로 `page`, `size`, `hasNext`를 포함한다.
|
|
||||||
- `size`는 항상 `20`으로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
- `creatorProfileImage` 원본 값이 없으면 기본 프로필 이미지 CDN URL을 내려준다.
|
|
||||||
- `price`가 무료이면 `0`을 내려준다.
|
|
||||||
- `beginDateTimeUtc`는 `LiveRoom.beginDateTime`을 UTC ISO 문자열로 변환한 값으로 내려준다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. API Endpoint
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/v2/home/on-air-lives?page=0
|
|
||||||
Authorization: Bearer {accessToken}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `page`: 선택값, 기본값 `0`, 0부터 시작하는 page index
|
|
||||||
- `size`: 받지 않음, 서버에서 20으로 고정
|
|
||||||
- `SecurityConfig`에 `GET /api/v2/home/on-air-lives` authenticated 설정을 추가한다.
|
|
||||||
- 회원 token이 없거나 anonymous이면 기존 인증 필요 API와 동일하게 인증 오류를 반환한다.
|
|
||||||
- 인증 회원 기준으로 차단/성인 콘텐츠 노출 조건을 반영한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Response Data Class
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class HomeOnAirLivePageResponse(
|
|
||||||
val items: List<HomeOnAirLiveResponse>,
|
|
||||||
val page: Int,
|
|
||||||
val size: Int,
|
|
||||||
val hasNext: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HomeOnAirLiveResponse(
|
|
||||||
val roomId: Long,
|
|
||||||
val creatorNickname: String,
|
|
||||||
val creatorProfileImage: String,
|
|
||||||
val title: String,
|
|
||||||
val price: Int,
|
|
||||||
val beginDateTimeUtc: String
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Technical Constraints
|
|
||||||
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
|
|
||||||
- 신규 코드는 기존 v2 패키지 구조와 네이밍을 따른다.
|
|
||||||
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.*` 하위에 두고, 재사용 가능한 조회 책임은 API 패키지 밖 도메인 조회 계층에 둔다.
|
|
||||||
- 기존 `ApiResponse.ok(...)` 응답 wrapper를 사용한다.
|
|
||||||
- QueryDSL 기반 조회 패턴을 유지한다.
|
|
||||||
- 공개 API 스키마 변경은 신규 endpoint에만 한정한다.
|
|
||||||
- 구현 계획 단계에서는 TDD 기준으로 controller/facade/query repository 테스트를 작성한 뒤 최소 구현한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Reuse Candidates
|
|
||||||
- `HomeRecommendationController`: page 정규화, `ApiResponse.ok(...)`, `requireMember(...)` 인증 필수 패턴 참고
|
|
||||||
- `HomeRecommendationFacade`: `size + 1` 조회 후 `hasNext`를 판단하는 page 응답 조립 패턴 참고
|
|
||||||
- `HomeRecommendationPageResponse`: page metadata 의미 참고
|
|
||||||
- `HomeRecommendationQueryService.findLiveRecommendations(...)`: 현재 진행 중인 라이브 도메인 조회 진입점으로 확장 재사용
|
|
||||||
- `HomeRecommendationQueryPort.HomeLiveRecommendationRecord`: `title`, `price`, `beginDateTime`을 추가해 신규 API 응답 조립에 재사용
|
|
||||||
- `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)`: 진행 중 라이브 조건, 정렬, 차단 필터, 성인 라이브 필터 재사용
|
|
||||||
- `HomeFollowingLive`/`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`: `title` 포함 라이브 응답 모델링과 CDN URL 변환 패턴 참고
|
|
||||||
- `LiveRoom`: `title`, `price`, `beginDateTime`, `channelName`, `isAdult`, `isActive` 필드 사용
|
|
||||||
- `MemberContentPreferenceService.canViewAdultContent(member)`: 성인 라이브 노출 가능 여부 판단
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Open Questions
|
|
||||||
- 없음. 현재 PRD는 인증 회원만 조회 가능, page size 20 고정, 기존 추천 라이브 조건 재사용을 기본 가정으로 작성한다.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 문서 유지보수
|
|
||||||
|
|
||||||
## 문서 유지보수 규칙
|
|
||||||
- PRD 문서와 구현 계획/TASK 문서는 `docs/[날짜]_구현할내용한글/` 아래에 함께 둔다.
|
|
||||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
|
||||||
- PRD 문서 파일명은 `prd.md`, 구현 계획/TASK 문서 파일명은 `plan-task.md`를 사용한다.
|
|
||||||
- PRD 문서는 `sample-prd.md`에서 필요한 섹션만 발췌해 작성하고, 불필요한 빈 섹션을 기계적으로 복사하지 않는다.
|
|
||||||
- `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다.
|
|
||||||
- 구현 계획/TASK 문서는 의미 단위 phase로 나누고 `### Phase 1: ...`, `### Phase 2: ...` 형식의 heading을 사용한다.
|
|
||||||
- 각 phase 아래에는 단계별 task를 체크박스(`- [ ] **Task N.N: ...**`) 형태로 작성한다.
|
|
||||||
- 각 task에는 구현 시 생성/수정/확인할 파일 경로를 명시한다.
|
|
||||||
- 각 task에는 TDD 절차를 명시한다. 기본 형식은 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다.
|
|
||||||
- 테스트 작성이 현실적으로 불가능한 task는 `TDD 예외 사유`와 `대체 검증 방법`을 task에 명시한다.
|
|
||||||
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
|
|
||||||
- 각 task의 검증 기준에는 단일 테스트 실행 명령과 필요한 경우 전체 회귀 명령을 포함한다.
|
|
||||||
- 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
|
|
||||||
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
|
|
||||||
- 결과 보고 시 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다.
|
|
||||||
- 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증 기록은 문서 하단에 한국어로 남긴다.
|
|
||||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
|
|
||||||
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
|
|
||||||
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
|
|
||||||
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
|
|
||||||
- 연속된 하나의 작업에 대해 PRD 또는 구현 계획/TASK 문서가 여러 개 생기지 않도록 기존 문서 재사용 여부를 먼저 확인한다.
|
|
||||||
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
|
|
||||||
- 운영 DB 반영용 DDL 문서는 MySQL 기준으로 작성한다.
|
|
||||||
- DDL 작성 시 날짜/시간 표시 컬럼은 `TIMESTAMP` 타입을 사용하고, `created_at`은 `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각'`, `updated_at`은 `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각'` 형식을 기본으로 한다.
|
|
||||||
- DDL의 모든 컬럼에는 MySQL `COMMENT`를 추가하고, 테이블에도 가능한 경우 `COMMENT`를 남긴다.
|
|
||||||
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
|
|
||||||
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
|
|
||||||
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# 설정 보안
|
|
||||||
|
|
||||||
## 설정/보안 유의사항
|
|
||||||
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.
|
|
||||||
- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다.
|
|
||||||
- 환경변수/시크릿 파일은 커밋 대상에서 제외한다.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 실행 명령어
|
|
||||||
|
|
||||||
## 실행 기준
|
|
||||||
- 아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다.
|
|
||||||
- 변경 범위에 맞는 최소 명령으로 검증하고, 결과는 계획 문서 하단 검증 기록에 남긴다.
|
|
||||||
|
|
||||||
## Build/Lint/Test
|
|
||||||
```bash
|
|
||||||
./gradlew tasks --all
|
|
||||||
./gradlew bootRun
|
|
||||||
./gradlew build
|
|
||||||
./gradlew clean build
|
|
||||||
./gradlew test
|
|
||||||
./gradlew check
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
./gradlew ktlintFormat
|
|
||||||
```
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 작업 절차
|
|
||||||
|
|
||||||
## 작업 절차 체크리스트
|
|
||||||
- 변경 전: 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다.
|
|
||||||
- 변경 전: 사용자 프롬프트를 받으면 먼저 PRD 문서를 작성한다.
|
|
||||||
- 변경 전: PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰하고 PRD를 보강한다.
|
|
||||||
- 변경 전: PRD는 `sample-prd.md`에서 작업에 필요한 부분만 발췌해 작성한다. `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다.
|
|
||||||
- 변경 전: 문서는 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 작성한다.
|
|
||||||
- 변경 전: 보강된 PRD를 바탕으로 구현 계획/TASK 문서를 작성한 뒤, 해당 문서를 기준으로 필요한 내용만 최소 구현한다.
|
|
||||||
- 변경 전: 구현 계획/TASK 문서의 각 task에는 TDD 기준의 실패 테스트 작성, 실패 확인, 최소 구현, 통과 확인, 리팩터링/회귀 확인 단계를 포함한다.
|
|
||||||
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
|
|
||||||
- 변경 전: 신규 API나 하위 코드 작성 시 `docs/agent-guides/코드스타일.md`의 패키지/코드 배치 규칙을 확인한다.
|
|
||||||
- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 PRD 또는 구현 계획/TASK 문서를 만들지 말고 기존 문서를 갱신한다.
|
|
||||||
- 변경 중: 신규 기능, 버그 수정, 리팩터링, 동작 변경은 테스트 작성이 불가능한 작업이 아닌 한 실패하는 테스트를 먼저 작성하고 실패를 확인한 뒤 구현한다.
|
|
||||||
- 변경 중: 범위가 변경되면 구현 전에 계획 문서 체크리스트를 먼저 업데이트한다.
|
|
||||||
- 변경 중: Todo를 사용할 때는 사용자에게 보이는 Todo 내용을 한국어로 작성한다. 경로, 클래스명, 명령어, 코드 식별자는 원문을 유지한다.
|
|
||||||
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
|
||||||
- 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
|
|
||||||
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
|
|
||||||
- 변경 후: 각 task의 검증 결과는 해당 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
|
|
||||||
- 변경 후: 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 커밋 메시지
|
|
||||||
|
|
||||||
## 커밋 메시지 규칙 (표준 Conventional Commits)
|
|
||||||
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
|
|
||||||
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
|
|
||||||
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
|
|
||||||
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
|
|
||||||
|
|
||||||
## 커밋 메시지 검증 절차
|
|
||||||
- `git commit` 직전/직후 항상 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
|
|
||||||
- 스크립트 결과가 `[FAIL]`이면 메시지를 수정한 뒤 다시 검증한다.
|
|
||||||
- 커밋 메시지 본문에 에이전트 홍보/서명 footer를 추가하지 않는다.
|
|
||||||
- `Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)` 또는 `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>`가 있으면 커밋 완료 전 제거한다.
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# 코드 스타일
|
|
||||||
|
|
||||||
## 코드 스타일 규칙
|
|
||||||
|
|
||||||
### 1) 포맷/기본 규칙
|
|
||||||
- `.editorconfig` 기준을 준수한다.
|
|
||||||
- 인덴트: 공백 4칸.
|
|
||||||
- 줄바꿈: LF.
|
|
||||||
- 최대 라인 길이: 130.
|
|
||||||
- 파일 끝 개행 유지, trailing whitespace 제거.
|
|
||||||
|
|
||||||
### 2) import 규칙
|
|
||||||
- 와일드카드 import(`*`)를 사용하지 않는다.
|
|
||||||
- 사용하지 않는 import를 남기지 않는다.
|
|
||||||
- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다.
|
|
||||||
- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다.
|
|
||||||
|
|
||||||
### 3) 네이밍 규칙
|
|
||||||
- 클래스/인터페이스/enum: PascalCase.
|
|
||||||
- 함수/변수/파라미터: camelCase.
|
|
||||||
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
|
|
||||||
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
|
|
||||||
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
|
|
||||||
- 인터페이스의 기본 구현체는 접미사 `Impl`을 사용하지 않고 접두사 `Default`를 사용한다. 예: `HomeRecommendationQueryRepository`의 기본 구현체는 `DefaultHomeRecommendationQueryRepository`로 명명한다.
|
|
||||||
|
|
||||||
### 4) 패키지/코드 배치 규칙
|
|
||||||
- 기존 로직을 수정하는 경우에는 기존 패키지 구조를 따른다.
|
|
||||||
- 기존 로직 수정이 아닌 신규 API나 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
|
|
||||||
- 신규 도메인 또는 신규 기능 패키지 생성 시 `kr.co.vividnext.sodalive.v2` 바로 아래에 도메인 패키지를 먼저 만들고, 그 아래에 경량 헥사고날 아키텍처를 적용한다.
|
|
||||||
- 클라이언트 공개 API의 화면/클라이언트 맞춤 조립 계층은 `kr.co.vividnext.sodalive.v2.api.{기능}` 하위에 둘 수 있다.
|
|
||||||
- 여러 API나 내부 기능에서 재사용될 수 있는 도메인 기능은 `kr.co.vividnext.sodalive.v2.{도메인}` 하위에 별도 패키지로 둔다.
|
|
||||||
- 공개 API 조립 패키지가 재사용 도메인 패키지를 호출하는 방향은 허용하지만, 재사용 도메인 패키지가 공개 API 조립 패키지에 의존하지 않는다.
|
|
||||||
- 신규 도메인 또는 신규 기능의 기본 패키지 구조는 `application`, `domain`, `port`, `adapter`, `dto`를 사용한다.
|
|
||||||
- `application`에는 use case, orchestration service, 트랜잭션 경계를 둔다.
|
|
||||||
- `domain`에는 순수 정책, 점수 계산, 값 객체, 도메인 모델, 스냅샷 모델처럼 인프라 의존이 없는 코드를 둔다.
|
|
||||||
- `port`에는 application이 필요로 하는 입력/출력 인터페이스를 둔다. 단순 내부 호출까지 억지로 port로 만들지 않는다.
|
|
||||||
- `adapter`에는 web controller, JPA/QueryDSL persistence, cache, scheduler 등 외부 입출력 구현을 둔다.
|
|
||||||
- `dto`에는 API 요청/응답 DTO와 adapter 경계에서 사용하는 DTO를 둔다.
|
|
||||||
- 기존 패키지를 수정하는 작업에는 헥사고날 패키지 구조를 강제로 적용하지 않는다.
|
|
||||||
- 기존 `kr.co.vividnext.sodalive.v2` 하위 코드가 이미 다른 구조로 작성되어 있으면 해당 작업의 범위 안에서만 기존 구조를 유지하고, 신규 도메인부터 헥사고날 구조를 적용한다.
|
|
||||||
|
|
||||||
### 5) 타입/널 처리
|
|
||||||
- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다.
|
|
||||||
- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다.
|
|
||||||
- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다.
|
|
||||||
|
|
||||||
### 6) API/응답 규칙
|
|
||||||
- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다.
|
|
||||||
- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다.
|
|
||||||
- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다.
|
|
||||||
|
|
||||||
### 7) 예외 처리 규칙
|
|
||||||
- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용.
|
|
||||||
- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다.
|
|
||||||
- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다.
|
|
||||||
- 예외를 삼키는 빈 `catch` 블록을 금지한다.
|
|
||||||
|
|
||||||
### 8) 트랜잭션 규칙
|
|
||||||
- 서비스 계층에서 `@Transactional`을 사용한다.
|
|
||||||
- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다.
|
|
||||||
- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다.
|
|
||||||
|
|
||||||
### 9) 비동기/동시성 규칙
|
|
||||||
- 비동기 처리는 Kotlin Coroutines 패턴을 따른다.
|
|
||||||
- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다.
|
|
||||||
- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다.
|
|
||||||
- 다중 서버 인스턴스에서 같은 `@Scheduled` 작업이 동시에 실행될 수 있는 스케줄러는 Redisson 기반 분산 lock을 적용해 클러스터 전체에서 한 인스턴스만 작업을 실행하도록 한다.
|
|
||||||
- 스케줄러 분산 lock은 기존 `RedissonClient` bean을 재사용하고, lock key는 작업 목적이 드러나도록 `lock:{job-name}` 형식으로 고정한다.
|
|
||||||
- lock 획득 실패는 다른 인스턴스가 처리 중인 정상 skip으로 보고, 작업 본문은 lock을 획득한 경우에만 실행한다.
|
|
||||||
- lock 해제는 `finally`에서 `lock.isHeldByCurrentThread` 확인 후 `unlock()`한다.
|
|
||||||
- 스케줄러 작업 시간이 예측 가능하면 무기한 watchdog 의존보다 최악 실행 시간에 여유를 더한 명시적 `leaseTime`을 우선 검토한다.
|
|
||||||
|
|
||||||
### 10) 의존성 주입
|
|
||||||
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
|
|
||||||
- 필드 주입보다 명시적 생성자 주입을 우선한다.
|
|
||||||
|
|
||||||
### 11) 주석
|
|
||||||
- 의미 단위별로 주석을 작성한다.
|
|
||||||
- 주석은 한 문장으로 간결하게 작성한다.
|
|
||||||
- 주석은 코드의 의도와 구조를 설명한다.
|
|
||||||
- 주석은 코드 변경 시 업데이트를 잊지 않는다.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 테스트 스타일
|
|
||||||
|
|
||||||
## 테스트 스타일 규칙
|
|
||||||
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
|
|
||||||
- 신규 기능, 버그 수정, 리팩터링, 동작 변경은 TDD를 기본 프로세스로 따른다.
|
|
||||||
- TDD 순서는 RED(실패 테스트 작성) → 실패 확인 → GREEN(최소 구현) → 통과 확인 → REFACTOR(정리) → 회귀 확인 순서로 진행한다.
|
|
||||||
- 실패 테스트는 실제 구현 결함 또는 미구현 동작 때문에 실패해야 하며, 오타/설정 오류/테스트 데이터 오류 때문에 실패한 상태로 RED를 통과한 것으로 보지 않는다.
|
|
||||||
- 테스트 작성이 현실적으로 불가능한 작업은 계획 문서에 이유와 대체 검증 방법을 명시한다.
|
|
||||||
- 도메인 모델과 엔티티는 유닛 테스트로 작성한다.
|
|
||||||
- 서비스와 컨트롤러는 통합 테스트(`@SpringBootTest`)로 작성한다.
|
|
||||||
- 목킹은 정말 필요한 경우가 아니면 사용하지 않는다.
|
|
||||||
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
|
|
||||||
- 테스트 이름은 의도가 드러나는 영어 문장형(`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"])`로 캐시를 끈다.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 20260220 LSP 설정 추가
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] oh-my-opencode 설정 파일에서 현재 LSP 매핑을 확인한다.
|
|
||||||
- [x] `.md` 확장자에 `remark-language-server` 매핑을 추가하고, `.sh`는 기존 `bash` 서버 설정이 정상 동작하는지 확인한다.
|
|
||||||
- [x] 수정 후 `lsp_diagnostics`로 Bash/Markdown 파일 진단이 가능한지 검증한다.
|
|
||||||
- [x] 저장소 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `/Users/klaus/.config/opencode/oh-my-opencode.json`에 `remark-language-server --stdio` 기반 `.md` 매핑을 추가했다.
|
|
||||||
- 왜: Bash는 설치 후 즉시 진단 가능했지만 Markdown은 LSP 매핑이 없어 `lsp_diagnostics`가 실패했기 때문이다.
|
|
||||||
- 어떻게 검증했는지: `work/scripts/check-commit-message-rules.sh`와 `docs/20260220_lsp설정추가.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all`, `./gradlew build`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
ALTER TABLE member
|
|
||||||
ADD fancimm_url VARCHAR(255) DEFAULT NULL COMMENT '팬심M url' AFTER instagram_url,
|
|
||||||
ADD x_url VARCHAR(255) DEFAULT NULL COMMENT 'X url' AFTER fancimm_url
|
|
||||||
;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 20260220 삭제 닉네임 접두사 표시 정리
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] 콘텐츠 댓글, 팬톡 응원, 커뮤니티 댓글의 닉네임 표시 흐름(조회/매핑/응답 DTO)을 각각 식별한다.
|
|
||||||
- [x] 닉네임이 `deleted_`로 시작하는지 판별하고 표시 시 접두사만 제거하는 공통 처리 지점을 설계한다.
|
|
||||||
- [x] 콘텐츠 댓글 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
|
|
||||||
- [x] 팬톡 응원 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
|
|
||||||
- [x] 커뮤니티 댓글 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
|
|
||||||
- [x] `deleted_` 미포함 닉네임, `deleted_` 포함 닉네임, 접두사만 존재하는 경계 케이스를 기준으로 테스트 케이스를 추가/보강한다.
|
|
||||||
|
|
||||||
## 검증 계획
|
|
||||||
- [x] 닉네임 표시에 영향이 있는 테스트를 우선 실행하고 실패 시 원인을 보정한다.
|
|
||||||
- [x] `./gradlew test`를 실행해 회귀 여부를 확인한다.
|
|
||||||
- [x] 필요 시 `./gradlew ktlintCheck`로 스타일 규칙 위반 여부를 확인한다.
|
|
||||||
- [x] `./gradlew build`를 실행해 전체 빌드 성공을 확인한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `String.removeDeletedNicknamePrefix()` 공통 확장 함수를 추가하고, 콘텐츠 댓글(`AudioContentCommentRepository`), 팬톡 응원(`ExplorerQueryRepository#getCheersList`), 커뮤니티 댓글(`CreatorCommunityCommentRepository`) 응답 닉네임에 동일 규칙을 적용했다.
|
|
||||||
- 왜: 탈퇴/비활성 사용자 닉네임 저장 정책(`deleted_` 접두사 유지)과 화면 표시 정책(접두사 제거)을 분리해, 사용자에게는 일관된 표시값을 제공하기 위해서다.
|
|
||||||
- 어떻게 검증했는지: `./gradlew test --tests "kr.co.vividnext.sodalive.extensions.StringExtensionsTest"`, `./gradlew test`, `./gradlew ktlintCheck`, `./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. 또한 경계 케이스(`deleted_testUser`, `testUser`, `deleted_`) 단위 테스트를 추가해 기대 출력이 각각 `testUser`, `testUser`, `""`인지 검증했다.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 20260220 커밋 규칙 스킬 분리
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] 커밋 메시지 정책의 최소 필수 항목을 `AGENTS.md`에 유지한다.
|
|
||||||
- [x] 커밋 상세 절차와 실행 가이드를 `.opencode/skills/commit-policy/SKILL.md`로 분리한다.
|
|
||||||
- [x] `/commit` 커맨드가 커밋 작업 시작 시 `commit-policy` 스킬을 우선 로드하도록 갱신한다.
|
|
||||||
- [x] 커밋 검증 강제 수단(`work/scripts/check-commit-message-rules.sh`)이 유지되는지 확인한다.
|
|
||||||
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `AGENTS.md`의 커밋 섹션을 최소 정책(형식, 한글 description, 검증 절차, 스킬 로드 지침) 중심으로 정리하고, 상세 절차를 `.opencode/skills/commit-policy/SKILL.md`로 분리했다. `/commit` 커맨드(`.opencode/commands/commit.md`)는 실행 시 `commit-policy` 스킬을 먼저 로드하도록 변경했다.
|
|
||||||
- 왜: 커밋 상세 규칙을 상시 컨텍스트에서 분리해 토큰 사용량을 줄이면서도, 커밋 시점에는 스킬 로드로 동일한 절차를 강제하기 위해서다.
|
|
||||||
- 어떻게 검증했는지: `AGENTS.md`, `.opencode/commands/commit.md`, `.opencode/skills/commit-policy/SKILL.md`, `docs/20260220_커밋규칙스킬분리.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all`과 `./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 20260220 커밋 메시지 검증 규칙 추가
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] AGENTS.md의 커밋 메시지 규칙 섹션에 커밋 전/후 검증 절차를 추가한다.
|
|
||||||
- [x] AGENTS.md의 작업 절차 체크리스트에 커밋 전/후 스크립트 실행 규칙을 추가한다.
|
|
||||||
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
|
|
||||||
- [x] AGENTS.md 커밋 메시지 규칙과 불일치하는 `work/scripts/check-commit-message-rules.sh` 검증 로직을 정합성 있게 수정한다.
|
|
||||||
- [x] 수정한 스크립트에 대해 문법 및 실행 검증을 수행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 검증 결과를 작업 완료 후 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `AGENTS.md`에 커밋 전/후 검증 절차를 추가했고, `work/scripts/check-commit-message-rules.sh`를 AGENTS.md 기준(Conventional Commit 형식, 소문자 type, 한글 description, `Refs:` footer 형식)으로 정합성 있게 수정했다.
|
|
||||||
- 왜: 문서 규칙과 실제 검증 로직이 어긋나면 커밋 메시지 정책이 일관되게 강제되지 않기 때문이다.
|
|
||||||
- 어떻게 검증했는지: `bash -n ./work/scripts/check-commit-message-rules.sh`, 유효/무효 메시지 실행 검증(`--message`), `Refs` footer 유효/무효 케이스 검증을 수행했다. 추가로 `./gradlew tasks --all`과 `./gradlew build`를 실행해 저장소 명령 유효성과 전체 빌드 성공(`BUILD SUCCESSFUL`)을 확인했다.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 20260220 커스텀 커맨드 /commit 추가
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] `.opencode/commands/` 디렉터리에 `/commit` 커맨드 파일을 추가한다.
|
|
||||||
- [x] `/commit` 커맨드가 AGENTS.md 커밋 메시지 규칙(`type(scope): description`, 소문자 type, 한글 description)을 따르도록 지시한다.
|
|
||||||
- [x] `/commit` 커맨드가 커밋 직전 `./work/scripts/check-commit-message-rules.sh --message` 검증을 수행하도록 지시한다.
|
|
||||||
- [x] `/commit` 커맨드가 커밋 직후 `./work/scripts/check-commit-message-rules.sh` 재검증을 수행하도록 지시한다.
|
|
||||||
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `.opencode/commands/commit.md`에 `/commit` 커스텀 커맨드를 추가해 변경사항 분석, AGENTS.md 규칙 기반 커밋 메시지 생성, 커밋 전/후 검증 스크립트 실행 절차를 일관되게 지시하도록 구성했다.
|
|
||||||
- 왜: 저장소의 커밋 메시지 컨벤션(Conventional Commit + 한글 description + Refs footer 규칙)과 검증 절차를 반복 작업마다 동일하게 강제하기 위해서다.
|
|
||||||
- 어떻게 검증했는지: `.opencode/commands/commit.md`, `docs/20260220_커스텀커맨드커밋추가.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 팬심M/X URL 추가 작업 계획
|
|
||||||
|
|
||||||
- [x] `Member` 엔티티 SNS 필드에 팬심M URL, X URL 속성 추가
|
|
||||||
- [x] 인스타그램 URL 수정 흐름 분석 후 동일한 수정 요청 DTO 반영
|
|
||||||
- [x] 서비스의 프로필 수정 로직에 팬심M URL, X URL 수정 처리 추가
|
|
||||||
- [x] 관련 응답 DTO에 신규 URL 필드 반영 및 매핑 연결
|
|
||||||
- [x] 후속 요청 반영: `fansimMUrl` 필드명을 `fancimmUrl`로 일괄 변경
|
|
||||||
- [x] `ddl-auto: validate` 대응을 위한 DB 컬럼 추가 SQL 파일 생성
|
|
||||||
- [x] 진단/테스트/빌드 검증 실행 후 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- 무엇을: 팬심M/X URL 필드 추가, 인스타그램 URL 수정 흐름과 동일한 수정/응답 매핑 반영, `fansimMUrl` -> `fancimmUrl` 명칭 변경을 검증했다.
|
|
||||||
- 왜: 프로필 수정 API에서 두 URL이 저장되고, 주요 응답 DTO에서 값이 일관되게 내려가야 하기 때문이다.
|
|
||||||
- 어떻게: `./gradlew ktlintCheck test build`를 팬심M/X URL 추가 시점과 `fancimmUrl` 명칭 변경 시점에 각각 실행해 정적 검사, 테스트, 빌드 성공(Exit code 0)을 확인했다. 또한 `docs/20260220_member_fancimm_x_url_ddl.sql`에 운영 DB 반영용 DDL을 추가했다. Kotlin LSP 미구성으로 `lsp_diagnostics`는 수행할 수 없었다.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
CREATE TABLE channel_donation_message
|
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
|
|
||||||
member_id BIGINT NOT NULL COMMENT '후원한 유저',
|
|
||||||
creator_id BIGINT NOT NULL COMMENT '후원 받은 채널 크리에이터',
|
|
||||||
can INT NOT NULL COMMENT '후원한 캔',
|
|
||||||
is_secret TINYINT(1) NOT NULL DEFAULT 0 COMMENT '비밀후원 여부(false=0, true=1)',
|
|
||||||
additional_message TEXT NULL COMMENT '추가 메시지',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY idx_channel_donation_message_creator_created_at (creator_id, created_at),
|
|
||||||
KEY idx_channel_donation_message_member (member_id),
|
|
||||||
CONSTRAINT fk_channel_donation_message_member
|
|
||||||
FOREIGN KEY (member_id) REFERENCES member (id),
|
|
||||||
CONSTRAINT fk_channel_donation_message_creator
|
|
||||||
FOREIGN KEY (creator_id) REFERENCES member (id)
|
|
||||||
) COMMENT ='채널 후원 메시지';
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 차단 유저 댓글 및 크리에이터 노출 차단 구현
|
|
||||||
|
|
||||||
- [x] 차단(`BlockMember`) 데이터 접근 패턴 및 기존 필터 지점 확인
|
|
||||||
- [x] 콘텐츠 댓글 목록에서 차단한 유저 댓글 비노출 적용
|
|
||||||
- [x] 채널 응원 목록에서 차단한 유저 댓글 비노출 적용
|
|
||||||
- [x] 커뮤니티 댓글 목록에서 차단한 유저 댓글 비노출 적용
|
|
||||||
- [x] 차단한 크리에이터의 콘텐츠/라이브 비노출 동작 보강
|
|
||||||
- [x] 변경 파일 진단 및 테스트/빌드 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 무엇을: 리뷰에서 지적된 단방향 차단 누락을 기준으로 콘텐츠/라이브/콘텐츠 댓글/커뮤니티 댓글/채널 응원(cheers) 노출 경로를 재점검해, 한쪽이라도 차단 관계면 조회·검색·상세 접근에서 숨겨지도록 양방향 차단 로직으로 보강했다. `/explorer/profile/{id}/cheers`의 우회 접근도 양방향 차단으로 막았다.
|
|
||||||
- 왜: 사용자 차단 정책을 일관되게 적용해 차단한 유저와 차단한 크리에이터의 활동이 조회 결과에 보이지 않도록 하기 위함이다.
|
|
||||||
- 어떻게 검증했는가:
|
|
||||||
- `lsp_diagnostics`를 수정 Kotlin 파일들에 대해 실행했으나, 현재 환경에 `.kt` LSP 서버가 설정되어 있지 않아 진단 불가를 확인했다.
|
|
||||||
- `./gradlew test` 실행 성공.
|
|
||||||
- `./gradlew build -x test` 실행 성공(ktlint/check 포함).
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 채널 후원 기능 추가 작업 계획
|
|
||||||
|
|
||||||
## 메시지 저장 전략 선택
|
|
||||||
- 선택: 기본 메시지는 DB에 저장하지 않고, 후원 이력에는 `can`, `isSecret`, `additionalMessage`를 저장한 뒤 리스트 조회 시 메시지를 생성한다.
|
|
||||||
- 이유: 일반/비밀 구분과 캔 수 노출 요구를 구조화 필드로 충족할 수 있고, 문구 변경/다국어 확장 시 DB 마이그레이션 없이 대응 가능하다.
|
|
||||||
- 메시지 생성 규칙:
|
|
||||||
- 일반 후원: `OO캔을 후원하셨습니다.`
|
|
||||||
- 비밀 후원: `OO캔을 비밀후원하셨습니다.`
|
|
||||||
- 추가 메시지 입력 시: 기본 메시지 + `\n` + `"사용자 추가 메시지"`
|
|
||||||
|
|
||||||
- [x] 채널 후원 도메인 모델/저장소 설계 (`ChannelDonationMessage` 성격의 별도 엔티티, creator/sponsor/can/isSecret/additionalMessage/createdAt)
|
|
||||||
- [x] `CanUsage`에 채널 후원 전용 값 1종 추가 및 영향 범위 정의 (`CanPaymentService`, 사용내역 타이틀 매핑)
|
|
||||||
- [x] 채널 후원 API 요청/응답 스펙 확정 (필드: `creatorId`, `can`, `isSecret`, `message`, `container`)
|
|
||||||
- [x] 채널 후원 API 서비스 플로우 설계 (인증/크리에이터 검증 -> 캔 차감 -> 후원 메시지 DB 저장)
|
|
||||||
- [x] 채널 후원 리스트 API 스펙 확정 (최근 1개월, `createdAt` 내림차순, 페이징)
|
|
||||||
- [x] 채널 후원 리스트 조회 권한 규칙 반영
|
|
||||||
- 크리에이터: 모든 후원 내역 조회
|
|
||||||
- 유저: 일반 후원 + 본인이 한 비밀 후원 내역 조회
|
|
||||||
- [x] 리스트 응답 메시지 조합 규칙 반영 (일반/비밀 기본 메시지 + 추가 메시지 쌍따옴표 처리)
|
|
||||||
- [x] `explorer/profile/{id}` 응답 확장 설계 (최근 1개월 채널 후원 내역 최대 5건 포함)
|
|
||||||
- [x] QueryDSL 조회 조건 확정 (`createdAt >= now().minusMonths(1)`, `orderBy(createdAt.desc(), id.desc())`, `limit 5`)
|
|
||||||
- [x] 테스트 계획 수립 (서비스 단위 테스트 + 리포지토리 날짜 필터/정렬 테스트 + 컨트롤러 통합 테스트)
|
|
||||||
- [x] 정산 로직 제외 범위 명시 (정산 비율 변경 작업은 미포함, 채널 후원 기능만 구현)
|
|
||||||
- [x] 구현 후 검증 계획 확정 (`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`)
|
|
||||||
- [x] 운영 반영용 DDL 파일 추가 (`docs/20260223_channel_donation_message_ddl.sql`)
|
|
||||||
- [x] 채널 후원 회귀 테스트 구현
|
|
||||||
- 서비스: `ChannelDonationServiceTest`
|
|
||||||
- 리포지토리: `ChannelDonationMessageRepositoryTest`
|
|
||||||
- 컨트롤러: `ChannelDonationControllerTest`
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- 무엇을:
|
|
||||||
- 1차 계획 수립: 채널 후원 기능의 API/도메인/조회 범위를 정의하고, 메시지 저장 전략을 선택해 계획 문서로 고정했다.
|
|
||||||
- 2차 수정: 채널 후원 리스트 API의 조회 권한 규칙(크리에이터 전체 조회, 유저는 일반 후원+본인 비밀 후원 조회)을 계획 항목에 추가했다.
|
|
||||||
- 3차 구현: 채널 후원 API/리스트 API/Explorer 프로필 확장, `CanUsage.CHANNEL_DONATION`, 메시지 엔티티 저장, 권한별 노출 필터를 구현했다.
|
|
||||||
- 왜:
|
|
||||||
- 기존 코드 패턴(Explorer/CanUsage/후원 조회)을 따르는 구현 범위를 먼저 고정해 불필요한 확장과 API 불일치를 방지하기 위해.
|
|
||||||
- 리스트 조회 시 요청자 역할에 따라 비밀 후원 노출 범위가 달라지므로, 구현 전 권한 규칙을 계획 단계에서 명확히 고정하기 위해.
|
|
||||||
- 채널 후원은 기존 라이브/콘텐츠 후원과 정산 분리를 위해 별도 `CanUsage`와 별도 메시지 저장소가 필요하고, 프로필 화면에 최근 내역 노출 요구가 있어 Explorer 응답 확장이 필요하기 때문에.
|
|
||||||
- 어떻게:
|
|
||||||
- 내부 탐색: `ExplorerController`, `ExplorerService`, `ExplorerQueryRepository`, `CanUsage`, `CanPaymentService`, `LiveRoomService`, `LiveRoomRepository`를 확인했다.
|
|
||||||
- 병렬 조사: `explore` 2건(`bg_07537536`, `bg_5be8611b`)과 `librarian` 1건(`bg_bfe81033`) 결과를 수집해 근거를 보강했다.
|
|
||||||
- 추가 확인: `AudioContentCommentRepository`, `CreatorCommunityCommentRepository`, `LiveRoomRepository`의 비밀/본인 공개 조건 패턴(`isSecret.isFalse.or(writerId.eq(memberId))`)을 확인해 문서 규칙에 반영했다.
|
|
||||||
- 구현 파일: `explorer/profile/channelDonation/*`, `CanUsage.kt`, `CanPaymentService.kt`, `CanService.kt`, `ExplorerService.kt`, `GetCreatorProfileResponse.kt`를 수정/추가했다.
|
|
||||||
- 검증 명령:
|
|
||||||
- `./gradlew test` -> 성공
|
|
||||||
- `./gradlew build` -> 최초 1회 `GetCreatorProfileResponse.kt` import 정렬 실패(ktlint), 정렬 수정 후 재실행 성공
|
|
||||||
- `./gradlew ktlintCheck` -> 성공
|
|
||||||
|
|
||||||
### 4차 보완(리뷰 지적사항 반영)
|
|
||||||
- 무엇을:
|
|
||||||
- 누락됐던 운영 반영용 DDL 파일 `docs/20260223_channel_donation_message_ddl.sql`을 추가했다.
|
|
||||||
- 채널 후원 회귀 테스트 3종(서비스/리포지토리/컨트롤러)을 신규 추가했다.
|
|
||||||
- 왜:
|
|
||||||
- `ddl-auto: validate` 환경에서 신규 엔티티 스키마 누락 시 부팅 실패 위험이 있어 적용 스크립트를 분리 관리해야 했기 때문이다.
|
|
||||||
- 권한별 비밀후원 노출, 1개월 필터, 정렬/페이징 규칙을 자동 검증해 회귀를 방지하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 추가 파일:
|
|
||||||
- `docs/20260223_channel_donation_message_ddl.sql`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt`
|
|
||||||
- 검증 명령:
|
|
||||||
- `lsp_diagnostics` -> Kotlin LSP 미설정으로 실행 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "*ChannelDonation*"` -> 성공
|
|
||||||
- `./gradlew test` -> 성공
|
|
||||||
- `./gradlew build` -> 성공
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 크리에이터 상세정보 조회 API 추가 작업 계획
|
|
||||||
|
|
||||||
- [x] `ExplorerController`에 크리에이터 상세정보 조회 엔드포인트 추가
|
|
||||||
- [x] `ExplorerService`에 상세정보 조회 비즈니스 로직 추가
|
|
||||||
- [x] `ExplorerQueryRepository`에 데뷔일/활동요약 조회 쿼리 추가
|
|
||||||
- [x] 응답 DTO 추가 및 `Member` SNS URL 매핑 연결
|
|
||||||
- [x] 3차 수정: 미래 라이브만 있는 크리에이터의 음수 `D+` 노출 방지
|
|
||||||
- [x] 정적 진단/테스트/빌드 검증 및 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- 무엇을:
|
|
||||||
- 1차 구현: 크리에이터 상세정보 조회 API(`/explorer/profile/{id}/detail`)와 응답 DTO를 추가하고, 데뷔일(라이브 `beginDateTime`/콘텐츠 `releaseDate` 최솟값), `D+N`, 활동요약, SNS URL 반환을 구현했다.
|
|
||||||
- 2차 수정: 상세 조회에 차단 관계 검사를 추가하고, 활동요약의 `contentCount`를 오픈된 콘텐츠(`releaseDate <= now`) 기준으로 집계하도록 기존 쿼리를 보정했다.
|
|
||||||
- 3차 수정: 라이브 데뷔 후보 조회에서 미래 `beginDateTime`을 제외하고, `D+` 계산 결과가 음수인 경우 `""`을 반환하도록 상세 조회 로직을 보정했다.
|
|
||||||
- 왜:
|
|
||||||
- 1차 구현: 탐색 화면에서 크리에이터 기본 정보·활동 통계·데뷔 정보·SNS를 한 번에 조회할 수 있어야 했다.
|
|
||||||
- 2차 수정: 차단 관계에서도 상세정보가 노출되는 우회가 있었고, 예약 공개 콘텐츠가 포함되어 요구사항의 “오픈한 콘텐츠 수”와 불일치할 수 있었다.
|
|
||||||
- 3차 수정: 오픈된 콘텐츠 없이 미래 예약 라이브만 있을 때 `D+-N`이 내려가 요구사항의 “오늘 기준 데뷔일로부터 며칠째(D+N)” 표현과 불일치했다.
|
|
||||||
- 어떻게:
|
|
||||||
- 1차 구현/2차 수정 모두 Kotlin LSP 부재로 `lsp_diagnostics`는 불가를 확인했다.
|
|
||||||
- 1차 구현 시점과 2차 수정 시점에 각각 `./gradlew ktlintCheck test build`를 실행해 정적검사/테스트/빌드 성공(Exit code 0)을 확인했다.
|
|
||||||
- 3차 수정 시점에도 Kotlin LSP 부재로 `lsp_diagnostics`는 불가를 확인했고, `./gradlew ktlintCheck test build`를 실행해 정적검사/테스트/빌드 성공(Exit code 0)을 확인했다.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 회원 차단 동일 본인인증 확장 구현
|
|
||||||
|
|
||||||
- [x] `memberBlock` 기존 단일 유저 차단 동작 확인
|
|
||||||
- [x] 차단 대상 유저가 본인인증(`Auth`)된 유저인지 확인
|
|
||||||
- [x] 본인인증 유저일 경우 동일 `di`를 가진 유저 id 목록 조회
|
|
||||||
- [x] 요청 유저(`memberId`)가 목록에 포함된 경우 제외
|
|
||||||
- [x] 대상 유저 + 동일 본인인증 유저 전체에 대해 차단 활성화 처리
|
|
||||||
- [x] 변경 파일 LSP 진단 및 관련 테스트 실행
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 무엇을: `MemberService.memberBlock`을 확장해 차단 대상 1명 + 동일 `Auth.di`를 가진 모든 계정을 일괄 차단하도록 수정했다.
|
|
||||||
- 왜: 본인인증 기반 다중 계정 우회 차단을 방지하고, 요청된 정책(동일 본인인증 정보 보유 계정 전체 차단)을 반영하기 위함이다.
|
|
||||||
- 어떻게 검증했는가:
|
|
||||||
- `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가를 확인했다.
|
|
||||||
- `./gradlew test` 실행 성공.
|
|
||||||
- `./gradlew build -x test` 실행 성공(ktlint/check 포함).
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
## 구현 항목
|
|
||||||
|
|
||||||
- [x] SNS 응답/요청 DTO 전수 점검 후 `blogUrl` 제거
|
|
||||||
- [x] SNS 응답/요청 DTO에 `kakaoOpenChatUrl` 추가
|
|
||||||
- [x] 기존 `websiteUrl` 입력/반환 값을 `kakaoOpenChatUrl`로 동일 매핑
|
|
||||||
- [x] 회원 정보 수정 API(`ProfileUpdateRequest`, `MemberService.profileUpdate`) 반영
|
|
||||||
- [x] SNS 정보를 반환하는 API 응답(`ProfileResponse`, `MyPageResponse`, `CreatorResponse`, `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`, `GetRoomDetailManager`) 반영
|
|
||||||
- [x] LSP 진단/테스트/빌드 검증 및 결과 기록
|
|
||||||
- [x] 2차 수정: non-null Response 호환성을 위해 `GetCreatorDetailResponse`의 `websiteUrl`, `blogUrl` 복구
|
|
||||||
- [x] 2차 수정: non-null Response 호환성을 위해 `GetLiveRoomUserProfileResponse`의 `websiteUrl`, `blogUrl` 복구
|
|
||||||
- [x] 2차 수정 검증: 테스트/빌드 재실행 및 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 1차 구현
|
|
||||||
- 무엇을: SNS 필드를 `instagramUrl`, `fancimmUrl`, `xUrl`, `youtubeUrl`, `kakaoOpenChatUrl` 구조로 통일하고 `blogUrl`을 API 요청/응답 계층에서 제거했다. `kakaoOpenChatUrl`은 기존 `member.websiteUrl` 컬럼 값을 그대로 사용하도록 매핑했다.
|
|
||||||
- 왜: DB/Entity 변경 없이 기존 `websiteUrl` 저장 데이터를 카카오 오픈채팅 링크로 재해석해 노출하고, 더 이상 사용하지 않는 `blogUrl`을 API 스펙에서 제거하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 반영: `ProfileUpdateRequest`, `ProfileResponse`, `MyPageResponse`, `CreatorResponse`, `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`, `GetRoomDetailResponse`, `MemberService`, `ExplorerService`, `LiveRoomService`
|
|
||||||
- 정적 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 미구성으로 불가(환경 제약 확인)
|
|
||||||
- 동작 검증: `./gradlew test && ./gradlew build` 실행
|
|
||||||
- 결과: `BUILD SUCCESSFUL` (test 성공 후 build 성공)
|
|
||||||
|
|
||||||
- 2차 수정
|
|
||||||
- 무엇을: non-null Response에서 제거되었던 `websiteUrl`, `blogUrl` 필드를 `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`에 복구했다. 동시에 각 서비스 매핑에서 해당 필드를 다시 응답에 포함했다.
|
|
||||||
- 왜: 필수 응답 키 제거로 인한 하위 호환성 이슈를 해소하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 반영: `GetCreatorDetailResponse`, `ExplorerService`, `GetLiveRoomUserProfileResponse`, `LiveRoomService`
|
|
||||||
- 동작 검증: `./gradlew test && ./gradlew build` 실행
|
|
||||||
- 결과: `BUILD SUCCESSFUL` (test 성공 후 build 성공)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
- [x] 홈 인기 크리에이터 조회 경로 확인 및 차단 필터 적용 지점 확정
|
|
||||||
- [x] 인기 크리에이터 목록에서 내가 차단한 크리에이터 제외 로직 적용
|
|
||||||
- [x] 변경 파일 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 결과 기록
|
|
||||||
|
|
||||||
## 1차 구현 검증 기록
|
|
||||||
- 무엇: 홈 인기 크리에이터 조회 시 차단 관계 조건을 양방향으로 반영해 내가 차단한 크리에이터가 노출되지 않도록 수정했다.
|
|
||||||
- 왜: 일반 유저가 차단한 크리에이터가 인기 크리에이터 목록에 계속 노출되는 문제를 해결하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`: Kotlin LSP 서버가 환경에 구성되지 않아 해당 도구 기반 진단은 수행 불가.
|
|
||||||
- `./gradlew ktlintCheck`: 성공.
|
|
||||||
- `./gradlew test`: 성공.
|
|
||||||
- `./gradlew build -x test`: 성공.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 20260225_채널후원메시지_캔_천단위콤마추가
|
|
||||||
|
|
||||||
## 구현 항목
|
|
||||||
- [x] `ChannelDonationService.kt`의 `buildMessage` 함수 수정 (캔 수량 천단위 콤마 추가)
|
|
||||||
- [x] 관련 테스트 코드를 통한 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
### 1차 구현
|
|
||||||
- **무엇을**: `buildMessage` 함수 내에서 `can` 변수를 `String.format("%,d", can)`으로 포맷팅하도록 수정
|
|
||||||
- **왜**: 후원 메시지 표시 시 캔 수량에 천단위 콤마를 추가하여 가독성을 높이기 위함
|
|
||||||
- **어떻게**:
|
|
||||||
- `ChannelDonationService.kt` 수정
|
|
||||||
- `./gradlew test` 실행 후 결과 확인
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- **무엇을**: `ChannelDonationServiceTest`에 `can = 1000`일 때 메시지가 `1,000캔` 형식으로 생성되는지 검증하는 테스트(`shouldFormatCanWithCommaInDonationMessage`)를 추가하고 문서 체크박스를 완료 처리
|
|
||||||
- **왜**: 기존 테스트는 천단위 콤마 포맷을 직접 검증하지 않아 문서의 "관련 테스트 코드를 통한 검증" 항목을 충족하기 어려웠기 때문
|
|
||||||
- **어떻게**:
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt`에 메시지 포맷 검증 테스트 추가
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
- [x] 기존 `memberBlock` 동일인 판별 로직(`di` 단일 조건)과 연관 Repository 조회 경로 확인
|
|
||||||
- [x] `AuthRepository`에 `name + birth + di + gender` AND 조건 조회 메서드 추가
|
|
||||||
- [x] `MemberService.memberBlock`에서 다중 조건 조회 메서드 사용으로 변경
|
|
||||||
- [x] 변경 파일 정적 진단 및 테스트 실행
|
|
||||||
- [x] 구현 결과/검증 기록 문서 반영
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `memberBlock`의 동일인 확장 조회를 `di` 단일 조건에서 `name + birth + di + gender` AND 조건으로 변경했다.
|
|
||||||
- 왜: 동일인 판단 정밀도를 높여, `di`만 일치하는 케이스로 과차단되는 가능성을 줄이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt`에 `getMemberIdsByNameAndBirthAndDiAndGender(...)` QueryDSL 조회를 추가했다.
|
|
||||||
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`의 `memberBlock`에서 `blockedMember.auth`의 `name/birth/di/gender`를 사용해 신규 조회 메서드를 호출하도록 바꿨다.
|
|
||||||
- 검증: `lsp_diagnostics`는 `.kt` LSP 서버 미구성으로 실행 불가(도구 에러 확인). 대신 `./gradlew test` 성공, `./gradlew build -x test` 성공으로 테스트/빌드 및 `ktlint` 체크 통과를 확인했다.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
SET @schema_name := DATABASE();
|
|
||||||
|
|
||||||
SET @use_can_index_exists := (
|
|
||||||
SELECT COUNT(1)
|
|
||||||
FROM information_schema.statistics
|
|
||||||
WHERE table_schema = @schema_name
|
|
||||||
AND table_name = 'use_can'
|
|
||||||
AND index_name = 'idx_use_can_channel_donation_filter'
|
|
||||||
);
|
|
||||||
SET @use_can_index_sql := IF(
|
|
||||||
@use_can_index_exists = 0,
|
|
||||||
'ALTER TABLE use_can ADD INDEX idx_use_can_channel_donation_filter (can_usage, is_refund, created_at, id)',
|
|
||||||
'SELECT "idx_use_can_channel_donation_filter already exists"'
|
|
||||||
);
|
|
||||||
PREPARE use_can_index_stmt FROM @use_can_index_sql;
|
|
||||||
EXECUTE use_can_index_stmt;
|
|
||||||
DEALLOCATE PREPARE use_can_index_stmt;
|
|
||||||
|
|
||||||
SET @use_can_calculate_join_index_exists := (
|
|
||||||
SELECT COUNT(1)
|
|
||||||
FROM information_schema.statistics
|
|
||||||
WHERE table_schema = @schema_name
|
|
||||||
AND table_name = 'use_can_calculate'
|
|
||||||
AND index_name = 'idx_use_can_calculate_settlement_join'
|
|
||||||
);
|
|
||||||
SET @use_can_calculate_join_index_sql := IF(
|
|
||||||
@use_can_calculate_join_index_exists = 0,
|
|
||||||
'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_settlement_join (use_can_id, status, recipient_creator_id)',
|
|
||||||
'SELECT "idx_use_can_calculate_settlement_join already exists"'
|
|
||||||
);
|
|
||||||
PREPARE use_can_calculate_join_index_stmt FROM @use_can_calculate_join_index_sql;
|
|
||||||
EXECUTE use_can_calculate_join_index_stmt;
|
|
||||||
DEALLOCATE PREPARE use_can_calculate_join_index_stmt;
|
|
||||||
|
|
||||||
SET @use_can_calculate_creator_index_exists := (
|
|
||||||
SELECT COUNT(1)
|
|
||||||
FROM information_schema.statistics
|
|
||||||
WHERE table_schema = @schema_name
|
|
||||||
AND table_name = 'use_can_calculate'
|
|
||||||
AND index_name = 'idx_use_can_calculate_creator_settlement'
|
|
||||||
);
|
|
||||||
SET @use_can_calculate_creator_index_sql := IF(
|
|
||||||
@use_can_calculate_creator_index_exists = 0,
|
|
||||||
'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_creator_settlement (recipient_creator_id, status, use_can_id)',
|
|
||||||
'SELECT "idx_use_can_calculate_creator_settlement already exists"'
|
|
||||||
);
|
|
||||||
PREPARE use_can_calculate_creator_index_stmt FROM @use_can_calculate_creator_index_sql;
|
|
||||||
EXECUTE use_can_calculate_creator_index_stmt;
|
|
||||||
DEALLOCATE PREPARE use_can_calculate_creator_index_stmt;
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
# 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 작업 계획
|
|
||||||
|
|
||||||
- [x] 기존 정산 API 패턴(`admin.calculate`, `creator.admin.calculate`)과 채널 후원 데이터 소스(`ChannelDonationMessage`, `CanUsage.CHANNEL_DONATION`)를 확인한다.
|
|
||||||
- [x] 기존 패키지에 직접 누적하지 않도록 신규 하위 패키지를 설계한다.
|
|
||||||
- 관리자: `kr.co.vividnext.sodalive.admin.calculate.channelDonation`
|
|
||||||
- 크리에이터 관리자: `kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation`
|
|
||||||
- [x] 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)로 전체 데이터를 조회한 뒤 응답을 크리에이터별로 그룹화해 반환하도록 설계한다.
|
|
||||||
- [x] 크리에이터 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)만 입력받아 인증 사용자 본인 데이터만 조회한다.
|
|
||||||
- [x] 서비스 계층에서 날짜 문자열을 `convertLocalDateTime()`으로 변환하고 종료일은 `23:59:59`로 보정해 조회 구간을 통일한다.
|
|
||||||
- [x] 저장소(QueryRepository) 계층에 날짜 범위 조건(`createdAt >= startDate`, `createdAt <= endDate`)과 크리에이터 기준 그룹화(`groupBy(member.id)` 등)를 반영한 집계 조회를 추가한다.
|
|
||||||
- [x] API URL을 기존 정산 URL 규칙에 맞춰 확정하고 문서화한다.
|
|
||||||
- 관리자: `GET /admin/calculate/channel-donation-by-creator`
|
|
||||||
- 크리에이터 관리자: `GET /creator-admin/calculate/channel-donation`
|
|
||||||
- [x] 정산 계산 공식을 공통 로직으로 구현하고, 사람이 이해하기 쉬운 한글 주석을 추가한다.
|
|
||||||
- 원화 = 캔 * 100
|
|
||||||
- 수수료 = 원화 * 6.6%
|
|
||||||
- 정산금액 = (원화 - 수수료) * 85%
|
|
||||||
- 원천세 = 정산금액 * 3.3%
|
|
||||||
- 입금액 = 정산금액 - 원천세
|
|
||||||
- [x] 계산 정밀도 정책을 정의한다(`BigDecimal`, `RoundingMode.HALF_UP`, 반올림 시점 고정).
|
|
||||||
- [x] 성능/효율 개선 항목을 반영한다(집계 쿼리 중심 처리, 불필요한 애플리케이션 후처리 최소화, count 조회 최적화 검토).
|
|
||||||
- [x] 응답 DTO 스펙을 아래 필드로 고정하고 권한 정책(관리자=전체, 크리에이터 관리자=본인)을 함께 검증한다.
|
|
||||||
- 날짜(`yyyy-MM-dd`)
|
|
||||||
- 크리에이터
|
|
||||||
- 건수(`count`)
|
|
||||||
- 총 받은 캔 수(`totalCan`)
|
|
||||||
- 원화
|
|
||||||
- 수수료
|
|
||||||
- 정산금액
|
|
||||||
- 원천세
|
|
||||||
- 입금액
|
|
||||||
- [x] 테스트를 추가한다(관리자 날짜 필터 + 크리에이터별 그룹화 응답, 크리에이터 관리자 날짜 필터/본인 범위, 계산식 정확성, 경계값).
|
|
||||||
- [x] 검증을 수행한다(`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`).
|
|
||||||
|
|
||||||
## API URL 선정 근거
|
|
||||||
|
|
||||||
- 기본 경로는 권한 범위별 정산 컨트롤러 관례를 따른다.
|
|
||||||
- 관리자: `@RequestMapping("/admin/calculate")`
|
|
||||||
- 크리에이터 관리자: `@RequestMapping("/creator-admin/calculate")`
|
|
||||||
- 하위 경로는 기존 정산 API와 동일하게 소문자 하이픈(`kebab-case`) 명사 조합을 사용한다.
|
|
||||||
- 예: `content-donation-list`, `cumulative-sales-by-content`, `community-by-creator`
|
|
||||||
- `channel-donation` 토큰은 기존 채널 후원 API 경로(`@RequestMapping("/explorer/profile/channel-donation")`)와 용어를 맞춰 도메인 표현을 통일한다.
|
|
||||||
- 관리자 정산은 조회 결과가 크리에이터별 그룹화 응답이므로 기존 `*-by-creator` 패턴을 적용해 `channel-donation-by-creator`로 정한다.
|
|
||||||
- 크리에이터 관리자 정산은 인증 사용자 본인 범위로 고정되므로 `-by-creator` 접미사를 제외하고 `channel-donation`으로 정한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 계획 수립
|
|
||||||
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 구현을 위한 작업 계획 문서를 작성했다.
|
|
||||||
- 왜: 구현 전에 패키지 구조, 날짜 범위 조회, 정산 계산식, 성능 검증 기준을 명확히 하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `docs`의 기존 작업 계획 문서 형식(체크박스 + 검증 기록)을 기준으로 템플릿을 맞췄다.
|
|
||||||
- `admin.calculate`, `creator.admin.calculate`, `explorer.profile.channelDonation` 경로를 탐색해 반영했다.
|
|
||||||
- 사용자 요청에 따라 실제 코드 구현/테스트는 수행하지 않고 계획 문서만 작성했다.
|
|
||||||
|
|
||||||
### 2차 계획 수정
|
|
||||||
- 무엇을: 조회 조건을 `관리자=날짜+크리에이터 구분`, `크리에이터 관리자=날짜만`으로 명확히 분리했고, 응답 필드를 `날짜(yyyy-MM-dd), 크리에이터, 원화, 수수료, 정산금액, 원천세, 입금액`으로 고정했다.
|
|
||||||
- 왜: 추가 요구사항(조회 조건 분리, Response 필드 고정)을 계획 단계에서 누락 없이 반영하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `admin.calculate`/`creator.admin.calculate`의 기존 날짜 파라미터 및 인증 기반 필터링 패턴을 재탐색해 계획 항목을 수정했다.
|
|
||||||
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
|
||||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 문서만 작성해야 하는 요청 범위를 유지하기 위해 코드 구현/테스트 변경은 수행하지 않았다.
|
|
||||||
|
|
||||||
### 3차 계획 수정
|
|
||||||
- 무엇을: 관리자 조회 요구사항을 `크리에이터 식별값으로 필터`가 아닌 `조회 결과를 크리에이터별로 그룹화하여 반환`으로 정정했다.
|
|
||||||
- 왜: 사용자 의도가 “조회 조건 추가”가 아니라 “응답 결과 구성 방식(크리에이터별 그룹화)”이었기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminCalculateController`의 `*-by-creator` 엔드포인트가 날짜/페이지 파라미터만 받고(`creatorId/memberId` 미입력), 서비스/리포지토리에서 `GetCalculateByCreatorResponse`와 `groupBy(member.id)` 기반으로 결과를 구성하는 패턴을 확인했다.
|
|
||||||
- 위 근거를 바탕으로 체크리스트를 `관리자=날짜 필터 + 크리에이터별 그룹화 응답` 기준으로 수정했다.
|
|
||||||
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
|
||||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
### 4차 계획 수정
|
|
||||||
- 무엇을: 작업 계획 문서에 API URL을 어떤 기준으로 정했는지(경로 규칙, 용어 선택, 최종 URL) 근거를 추가했다.
|
|
||||||
- 왜: 구현 전에 URL 명명 기준을 명확히 남겨, 이후 개발 시 경로 해석 차이와 재작업을 방지하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminCalculateController`, `CreatorAdminCalculateController`, `ChannelDonationController`의 `@RequestMapping`/`@GetMapping` 패턴을 비교해 기준 경로와 하위 경로 규칙을 도출했다.
|
|
||||||
- 관리자 URL은 `*-by-creator` 관례를 적용해 `/admin/calculate/channel-donation-by-creator`, 크리에이터 관리자 URL은 본인 범위 고정 특성에 맞춰 `/creator-admin/calculate/channel-donation`으로 문서화했다.
|
|
||||||
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
|
||||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
### 5차 구현
|
|
||||||
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 API를 신규 하위 패키지로 구현하고, 날짜 범위 조회/크리에이터별 그룹화/정산 공식 공통 계산 로직을 적용했다.
|
|
||||||
- 왜: 기존 정산 코드에 얽히지 않고 유지보수 가능한 구조로 요구사항(관리자=크리에이터별 그룹 응답, 크리에이터 관리자=본인 범위 조회)을 정확히 반영하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 신규 패키지 생성: `admin.calculate.channelDonation`, `creator.admin.calculate.channelDonation`, 공통 계산기 `calculate.channelDonation`.
|
|
||||||
- API 구현: `GET /admin/calculate/channel-donation-by-creator`, `GET /creator-admin/calculate/channel-donation`.
|
|
||||||
- QueryDSL 집계: `UseCan` + `UseCanCalculate`를 사용해 `CanUsage.CHANNEL_DONATION`, 날짜 범위, 환불 제외 조건을 적용하고 관리자 응답은 날짜+크리에이터 기준 그룹화, 크리에이터 관리자 응답은 날짜 기준 그룹화로 구현.
|
|
||||||
- 정산 계산식 공통화: `ChannelDonationSettlementCalculator`에서 `BigDecimal("0.066")`, `BigDecimal("0.85")`, `BigDecimal("0.033")`, `RoundingMode.HALF_UP` 정책으로 계산하고 공식 설명 한글 주석을 추가.
|
|
||||||
- 테스트 추가: 계산식/반올림 단위 테스트 및 관리자·크리에이터 관리자 컨트롤러/서비스 경로 테스트를 추가.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculatorTest"` → 성공
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
- 참고: Kotlin LSP 서버 미설정 환경이라 `.kt` 파일에 대한 `lsp_diagnostics`는 실행 시 서버 미설정 오류를 반환했다.
|
|
||||||
|
|
||||||
### 6차 수정
|
|
||||||
- 무엇을: 정산 계산식을 단계별 반올림 후 다음 단계 계산하는 방식으로 수정하고, 크리에이터 관리자 조회 쿼리/카운트에서 불필요한 `member` 조인을 제거했다.
|
|
||||||
- 왜: 정산 항목 간 관계(`입금액 = 정산금액 - 원천세`)를 정수 기준으로 일관되게 맞추고, 조회 성능 최적화를 위해 불필요 조인을 줄이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `ChannelDonationSettlementCalculator`를 단계별 반올림 파이프라인으로 변경했다.
|
|
||||||
- `수수료 = round(원화 * 6.6%)`
|
|
||||||
- `정산금액 = round((원화 - 수수료) * 85%)`
|
|
||||||
- `원천세 = round(정산금액 * 3.3%)`
|
|
||||||
- `입금액 = 정산금액 - 원천세`
|
|
||||||
- 크리에이터 관리자 경로는 인증 사용자 닉네임을 서비스 인자로 전달해 응답 `creator`를 구성하고, QueryRepository의 `member` 조인/닉네임 select를 제거했다.
|
|
||||||
- 관리자 totalCount는 `member` 조인 없이 `recipientCreatorId` 기반 distinct 키로 계산하도록 변경했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
|
|
||||||
### 7차 수정
|
|
||||||
- 무엇을: 요청한 2번/3번 최적화를 반영해 QueryDSL `@QueryProjection` 기반 매핑으로 전환하고, 날짜 그룹 조회 경로 인덱스 전략 DDL을 추가했다. 또한 테스트 가독성을 위해 `@DisplayName`을 추가했다.
|
|
||||||
- 왜: `Projections.constructor` 대비 타입 안전성과 유지보수성을 높이고, 채널 후원 정산 조회의 날짜 범위/조인 필터 성능 개선 근거를 DDL로 명확히 남기기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- Query DTO 전환:
|
|
||||||
- `GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`에 `@QueryProjection`을 적용했다.
|
|
||||||
- 각 QueryRepository의 `Projections.constructor`를 `QGet*QueryData(...)` 호출로 교체했다.
|
|
||||||
- 인덱스 전략 반영:
|
|
||||||
- `docs/20260226_channel_donation_settlement_index_ddl.sql` 파일을 추가해 `use_can`, `use_can_calculate` 인덱스 DDL을 정의했다.
|
|
||||||
- 테스트 가독성 개선:
|
|
||||||
- 채널 후원 정산 관련 신규 테스트에 `@DisplayName`(한글)을 추가해 테스트 의도를 명확히 했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
- 참고: `./gradlew test`와 `./gradlew build`를 병렬 실행하면 테스트 결과 XML 파일 쓰기 충돌이 재발할 수 있어, 순차 실행 기준으로 최종 검증했다.
|
|
||||||
|
|
||||||
### 8차 수정
|
|
||||||
- 무엇을: 채널 후원 정산 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `count` 필드를 추가하고, QueryData/Repository/Test를 함께 갱신했다.
|
|
||||||
- 왜: 사용자 요청에 따라 정산 응답에서 그룹별 건수를 직접 확인할 수 있도록 하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- Item DTO에 `@JsonProperty("count") val count: Int`를 추가했다.
|
|
||||||
- QueryDTO(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)에 `count: Long`을 추가하고 `toResponseItem()`에서 `count.toInt()`로 매핑했다.
|
|
||||||
- Repository projection에 `useCan.id.count()`를 추가해 count 값을 조회하도록 반영했다.
|
|
||||||
- 컨트롤러/서비스 테스트 fixture 및 assertion에 `count` 검증을 추가했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
|
|
||||||
### 9차 수정
|
|
||||||
- 무엇을: 채널 후원 정산 `count`가 분할 정산 레코드 수로 과집계되던 문제를 수정하고, 동일 후원(`UseCan` 1건) + 분할 정산(`UseCanCalculate` 2건) 회귀 테스트를 관리자/크리에이터 관리자 경로에 추가했다.
|
|
||||||
- 왜: 결제 게이트웨이별 분할 정산이 발생하면 기존 `useCan.id.count()`가 실제 후원 건수보다 크게 집계되어 정산 화면 `count`가 잘못 표시되기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminChannelDonationCalculateQueryRepository`, `CreatorAdminChannelDonationCalculateQueryRepository`의 집계 `count`를 `useCan.id.countDistinct()`로 변경했다.
|
|
||||||
- QueryRepository 통합 테스트(`AdminChannelDonationCalculateQueryRepositoryTest`, `CreatorAdminChannelDonationCalculateQueryRepositoryTest`)를 추가해 분할 정산 시 `count=1`, `totalCan` 합산(50) 동작을 검증했다.
|
|
||||||
- H2 환경에서 MySQL 함수(`DATE_FORMAT`, `CONVERT_TZ`)를 테스트 가능하게 하기 위해 `H2MySqlFunctionDialect`, `H2MysqlDateFunctions` 테스트 지원 코드를 추가하고 각 테스트에서 alias를 등록했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
- 참고: Kotlin LSP 미설정 환경이라 `.kt` 대상 `lsp_diagnostics`는 실행 시 서버 미설정 오류가 발생했다.
|
|
||||||
|
|
||||||
### 10차 수정
|
|
||||||
- 무엇을: 관리자 채널 후원 정산의 `totalCount` 쿼리에 `member` `innerJoin`을 추가해 목록 조회와 동일한 조인 조건으로 집계하도록 정렬했다.
|
|
||||||
- 왜: 기존에는 `totalCount`는 `member` 조인 없이 계산하고 목록은 `member` `innerJoin`을 사용해, 데이터 정합성 이슈(고아 `recipientCreatorId`)가 있을 때 `totalCount`와 `items`가 불일치할 수 있었다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotalCount(...)`에 `member` 조인(`member.id = useCanCalculate.recipientCreatorId`)을 추가했다.
|
|
||||||
- distinct 그룹 키를 `recipientCreatorId` 문자열 대신 `member.id` 문자열 기준으로 변경해 목록 쿼리의 그룹 축(날짜+멤버)과 맞췄다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
- 참고: `./gradlew test`와 `./gradlew build`를 병렬 실행했을 때 test result XML 쓰기 충돌이 1회 발생해, 이후 순차 실행으로 재검증했다.
|
|
||||||
|
|
||||||
### 10차 수정
|
|
||||||
- 무엇을: 정산 페이지 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `totalCan` 필드를 추가했다.
|
|
||||||
- 왜: 사용자 요청대로 화면에서 건수 다음에 총 받은 캔 수를 함께 노출하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- Item DTO에 `@JsonProperty("totalCan") val totalCan: Int`를 `count` 다음 위치로 추가했다.
|
|
||||||
- QueryData(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)의 `toResponseItem()`에서 `totalCan ?: 0`을 응답 Item의 `totalCan`으로 매핑했다.
|
|
||||||
- 컨트롤러/서비스 테스트 fixture와 assertion에 `totalCan` 검증을 추가했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
|
|
||||||
### 11차 수정
|
|
||||||
- 무엇을: 채널 후원 정산 인덱스 DDL(`docs/20260226_channel_donation_settlement_index_ddl.sql`)을 재실행 가능한 멱등 스크립트로 수정했다.
|
|
||||||
- 왜: 동일 DB에 DDL을 재적용할 때 기존 `ADD INDEX`가 `Duplicate key name`으로 실패할 수 있어, 운영 재적용/롤백 후 재적용 시 안정성을 확보해야 했기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `information_schema.statistics`에서 `table_schema = DATABASE()` 기준으로 인덱스 존재 여부를 조회하도록 변경했다.
|
|
||||||
- 인덱스가 없을 때만 `ALTER TABLE ... ADD INDEX`를 실행하고, 이미 존재하면 안내 `SELECT`를 실행하는 동적 SQL(`PREPARE`/`EXECUTE`) 패턴을 적용했다.
|
|
||||||
- 대상 인덱스 3개(`idx_use_can_channel_donation_filter`, `idx_use_can_calculate_settlement_join`, `idx_use_can_calculate_creator_settlement`) 모두 동일 규칙으로 반영했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `lsp_diagnostics`(대상: `docs/20260226_channel_donation_settlement_index_ddl.sql`) → `.sql` LSP 서버 미설정으로 진단 불가(환경 제약)
|
|
||||||
- `lsp_diagnostics`(대상: 본 문서) → `No diagnostics found`
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 라이브 추천 차단 JOIN 및 캐시 무효화
|
|
||||||
|
|
||||||
- [x] `LiveRecommendService.getRecommendLive`의 차단 필터 처리 구조 점검
|
|
||||||
- [x] `LiveRecommendRepository.getRecommendLive`를 DB 조회 시 차단 관계를 JOIN/조건으로 제외하도록 변경
|
|
||||||
- [x] 차단(`memberBlock`) 및 차단 해제(`memberUnBlock`) 시 추천 라이브 캐시가 즉시 반영되도록 무효화 처리
|
|
||||||
- [x] 변경 코드 정적 진단 및 테스트/빌드 검증
|
|
||||||
- [x] 검증 기록 작성
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `getRecommendLive`의 차단 제외 로직을 서비스 단 필터링에서 QueryDSL `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 이동했고, 차단/차단해제 시 `CacheManager`로 `getRecommendLive:{memberId}` 키를 직접 evict 하도록 적용했다.
|
|
||||||
- 왜: 기존 방식은 추천 결과 조회 후 creator마다 `isBlocked`를 반복 호출해 후처리하고, 캐시 만료 전까지 차단/해제 결과가 반영되지 않는 문제가 있어 DB 레벨 필터링과 이벤트성 캐시 무효화가 필요했다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` (대상: `LiveRecommendRepository.kt`, `LiveRecommendService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
|
|
||||||
- `./gradlew test` 실행 결과: **성공 (BUILD SUCCESSFUL)**
|
|
||||||
- `./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL, ktlint/check 포함)**
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 라이브 추천 차단 JOIN/캐시 무효화 검증 테스트
|
|
||||||
|
|
||||||
- [x] `LiveRecommendRepository.getRecommendLive`가 차단 관계(`member -> creator`, `creator -> member`)를 DB 조회 단계에서 제외하는지 테스트 추가
|
|
||||||
- [x] `LiveRecommendService.getRecommendLive`가 서비스 단 후처리 없이 저장소 결과를 그대로 위임하는지 테스트 추가
|
|
||||||
- [x] `MemberService.memberBlock`/`memberUnBlock` 호출 시 추천 라이브 캐시 키(`getRecommendLive:{memberId}`)가 즉시 무효화되는지 테스트 추가
|
|
||||||
- [x] 테스트 및 빌드 검증 수행
|
|
||||||
- [x] 검증 기록 작성
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 검증 테스트 구현
|
|
||||||
- 무엇을: 문서 요구사항(추천 라이브 차단 JOIN, 서비스 위임 구조, 차단/해제 시 캐시 무효화)을 검증하는 테스트 3종을 추가했다.
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt`
|
|
||||||
- 왜: `docs/20260226_라이브추천차단조인및캐시무효화.md`에 기재된 구현이 실제 코드에서 회귀 없이 유지되는지 자동 검증이 필요하다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` (대상: 위 3개 Kotlin 테스트 파일) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` 실행 결과: **성공 (BUILD SUCCESSFUL)**
|
|
||||||
- `./gradlew build` 1차 실행 결과: **실패 (`MemberServiceCacheEvictionTest.kt` 라인 길이/인자 줄바꿈 ktlint 위반)**
|
|
||||||
- `MemberServiceCacheEvictionTest.kt` 포맷 수정 후 `./gradlew build` 재실행 결과: **성공 (BUILD SUCCESSFUL, test/check/ktlint 통과)**
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 오리지널 시리즈 차단 필터 적용
|
|
||||||
|
|
||||||
## 구현 체크리스트
|
|
||||||
- [x] `HomeService.fetchData` 경로에서 오리지널 시리즈 조회 시 `memberId` 전달
|
|
||||||
- [x] `ContentSeriesService.getOriginalAudioDramaList` 시그니처에 `memberId` 반영
|
|
||||||
- [x] `ContentSeriesRepository.getOriginalAudioDramaList` 인터페이스/구현에 `memberId` 반영
|
|
||||||
- [x] 오리지널 시리즈 QueryDSL 조회에 양방향 차단(`내가 차단`/`나를 차단`) 서브쿼리 필터 적용
|
|
||||||
- [x] 오리지널 탭 API 경로(`AudioContentMainTabSeries*`)에도 `memberId` 전달
|
|
||||||
- [x] 빌드/테스트/진단 실행 후 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- 1차 구현
|
|
||||||
- 무엇을: 홈/시리즈탭의 오리지널 시리즈 조회 경로에 `memberId`를 전달하고, `ContentSeriesRepository.getOriginalAudioDramaList` 및 `getOriginalAudioDramaTotalCount`에 양방향 차단 서브쿼리(`blockedSubquery.exists().not()`)를 추가해 차단된 크리에이터 시리즈가 제외되도록 반영했다.
|
|
||||||
- 왜: 기존에는 오리지널 시리즈 조회 쿼리에 차단 조건이 없어, 내가 차단했거나 나를 차단한 크리에이터의 시리즈가 노출될 수 있었다.
|
|
||||||
- 어떻게: `./gradlew test` 실행 성공, `./gradlew build` 실행 성공으로 컴파일/테스트/정적검사(ktlint 포함 check 단계) 통과를 확인했다. Kotlin LSP는 환경에 서버가 없어(`.kt` 미지원) 진단 도구로는 확인할 수 없어 Gradle 빌드 기반으로 검증했다.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
- [x] Admin 채널 후원 정산 조회 흐름(Controller/Service/Repository/DTO) 확인
|
|
||||||
- [x] Creator 정산 조회 흐름(Controller/Service/Repository/DTO) 확인
|
|
||||||
- [x] 날짜 기준 비페이징 합계 조회 방식 결정 및 반영
|
|
||||||
- [x] `GetAdminChannelDonationSettlementResponse`에 합계 필드 추가
|
|
||||||
- [x] `GetCreatorChannelDonationSettlementResponse`에 합계 필드 추가
|
|
||||||
- [x] 관련 테스트/빌드/진단 실행 및 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 응답에 날짜 범위 전체(비페이징) 합계(`total`)를 추가하고, QueryRepository에 합계 전용 집계 쿼리를 추가했다.
|
|
||||||
- 왜: 기존 응답이 페이지 내 `items`와 `totalCount`만 제공해 날짜 범위 전체 정산 합계를 확인할 수 없었기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- 응답 DTO 확장
|
|
||||||
- `GetAdminChannelDonationSettlementResponse`에 `total` 필드 추가
|
|
||||||
- `GetCreatorChannelDonationSettlementResponse`에 `total` 필드 추가
|
|
||||||
- 합계 DTO/QueryData 추가: `GetAdminChannelDonationSettlementTotal`, `GetCreatorChannelDonationSettlementTotal`, 각 `*TotalQueryData`
|
|
||||||
- 서비스/리포지토리 반영
|
|
||||||
- 관리자: `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotal(...)` 추가 후 서비스에서 `total` 매핑
|
|
||||||
- 크리에이터 관리자: `CreatorAdminChannelDonationCalculateQueryRepository.getChannelDonationSettlementTotal(...)` 추가 후 서비스에서 `total` 매핑
|
|
||||||
- 테스트 반영
|
|
||||||
- 컨트롤러/서비스/리포지토리 테스트에서 `total` 필드와 합계 집계 검증 추가
|
|
||||||
- 검증 명령 및 결과
|
|
||||||
- `lsp_diagnostics`(Kotlin 대상): `.kt` LSP 서버 미설정으로 진단 불가(환경 제약)
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 1차 실패(ktlint max line length), 코드 포맷 수정 후 재실행 성공
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 2026-02-26 콘텐츠/시리즈 상세 차단 오류메시지 수정
|
|
||||||
|
|
||||||
## 구현 체크리스트
|
|
||||||
- [x] 콘텐츠 상세(`getDetail`) 차단 예외 메시지 키를 전용 차단 키로 변경
|
|
||||||
- [x] 시리즈 상세(`getSeriesDetail`) 차단 예외 메시지 키를 전용 차단 키로 변경
|
|
||||||
- [x] `SodaMessageSource`에 콘텐츠/시리즈 차단 전용 메시지 키 추가
|
|
||||||
- [x] 정적 진단 및 테스트로 변경 영향 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇: `AudioContentService.getDetail`의 차단 예외 키를 `content.error.blocked_access`로 변경하고, `ContentSeriesService.getSeriesDetail`의 차단 예외 키를 `series.error.blocked_access`로 변경했다. `SodaMessageSource`에 두 키를 추가해 한국어 기준으로 각각 "콘텐츠 접근이 차단되었습니다.", "시리즈 접근이 차단되었습니다."를 반환하도록 반영했다.
|
|
||||||
- 왜: 기존에는 차단 상황에서도 `invalid_content_retry`/`invalid_series_retry`를 사용해 오류 의미가 모호했고, 요청 사항대로 차단 상황을 명확한 문구로 안내해야 했기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` (`AudioContentService.kt`, `ContentSeriesService.kt`, `SodaMessageSource.kt`) 실행: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가)
|
|
||||||
- `./gradlew test` 실행: 성공
|
|
||||||
- `./gradlew ktlintCheck` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
- [x] 홈 `fetchData` 콘텐츠 랭킹 조회 경로 및 차단 적용 패턴 확인
|
|
||||||
- [x] `RankingRepository.getAudioContentRanking`에 양방향 차단(내가 차단/나를 차단) 조건 적용
|
|
||||||
- [x] 변경 파일 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 결과 기록
|
|
||||||
|
|
||||||
## 1차 구현 검증 기록
|
|
||||||
- 무엇: 홈 `fetchData`의 `contentRanking`에서 내가 차단한 크리에이터와 나를 차단한 크리에이터의 콘텐츠를
|
|
||||||
모두 제외하도록 서비스 레벨 필터를 추가했다.
|
|
||||||
- 왜: 기존 랭킹 조회 쿼리에는 한 방향 차단만 반영되어 양방향 차단 관계를 완전히 차단하지 못할 수 있기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`: Kotlin(`.kt`)용 LSP 서버가 현재 환경에 없어 도구 기반 진단은 수행 불가.
|
|
||||||
- `./gradlew ktlintCheck`: 성공.
|
|
||||||
- `./gradlew test`: 성공.
|
|
||||||
- `./gradlew build -x test`: 성공.
|
|
||||||
|
|
||||||
## 2차 수정 검증 기록
|
|
||||||
- 무엇: 서비스(`HomeService`)에서 처리하던 `contentRanking` 차단 필터를 제거하고, `RankingRepository.getAudioContentRanking`
|
|
||||||
쿼리의 `blockMemberCondition`을 양방향 차단 조건으로 수정했다.
|
|
||||||
- 왜: 홈 서비스가 아닌 랭킹 데이터 조회 계층에서 차단 정책을 일관되게 보장하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`: Kotlin(`.kt`)용 LSP 서버가 현재 환경에 없어 도구 기반 진단은 수행 불가.
|
|
||||||
- `./gradlew ktlintCheck`: 성공.
|
|
||||||
- `./gradlew test`: 성공.
|
|
||||||
- `./gradlew build -x test`: 성공.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
- [x] Explorer 후원랭킹 집계 경로에서 후원 타입 필터 조건을 확인한다.
|
|
||||||
- [x] 크리에이터 프로필 후원랭킹 집계에 `CanUsage.CHANNEL_DONATION`을 반영하도록 쿼리를 수정한다.
|
|
||||||
- [x] 변경 범위와 연관된 테스트/검증(컴파일/테스트)을 실행한다.
|
|
||||||
- [x] 구현 완료 후 체크박스를 갱신하고 검증 기록(무엇을/왜/어떻게)을 남긴다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 1차 구현
|
|
||||||
- 무엇을: `CreatorDonationRankingQueryRepository`의 후원랭킹 조회/총원 집계 조건에 `CanUsage.CHANNEL_DONATION`을 추가했다.
|
|
||||||
- 왜: `ExplorerService.getCreatorProfile`의 후원랭킹이 기존 `DONATION`, `SPIN_ROULETTE`, `LIVE`만 포함해 채널 후원이 누락되고 있었기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`로 Kotlin 파일 진단을 시도했지만, 현재 환경에 `.kt` LSP 서버가 없어 도구 기반 진단은 불가했다.
|
|
||||||
- `./gradlew test` 실행 결과: 성공
|
|
||||||
- `./gradlew build -x test` 실행 결과: 성공
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 최근 종료 라이브(getLatestFinishedLive) 최적화
|
|
||||||
|
|
||||||
- [x] `getLatestFinishedLive` 조회를 DB 단계에서 차단 관계(`left join`)로 필터링하도록 변경
|
|
||||||
- [x] 조회 결과를 `GetLatestFinishedLiveResponse`로 QueryProjection 하여 서비스 단 추가 `map` 제거
|
|
||||||
- [x] 회원 차단(`memberBlock`) / 차단해제(`memberUnBlock`) 시 최근 종료 라이브 캐시 무효화 적용
|
|
||||||
- [x] 정적 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 기록 작성
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `getLatestFinishedLive`를 서비스 후처리(`filter`/`map`)에서 제거하고, `LiveRoomRepository`에서 `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 차단 관계를 DB 단계에서 제외하도록 변경했다. 또한 `GetLatestFinishedLiveResponse`에 `@QueryProjection` 생성자를 추가해 쿼리 결과를 응답 DTO로 바로 생성했다. 마지막으로 `memberBlock`/`memberUnBlock`에서 `getLatestFinishedLive:{memberId}` 캐시를 즉시 evict 하도록 반영했다.
|
|
||||||
- 왜: 기존 로직은 조회 후 애플리케이션 레벨에서 차단 여부를 반복 조회하고 별도 `map`을 수행해 비용이 컸고, 차단/차단해제 직후 최근 종료 라이브 캐시가 TTL 만료 전까지 stale 상태가 될 수 있어 DB 레벨 필터링 및 이벤트성 캐시 무효화가 필요했다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` (대상: `GetLatestFinishedLiveResponse.kt`, `LiveRoomRepository.kt`, `LiveRoomService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
|
|
||||||
- `./gradlew test && ./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL)**
|
|
||||||
- `./gradlew tasks --all` 실행 결과: **성공 (BUILD SUCCESSFUL)**
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
- [x] `getCreatorProfile`의 채널 후원 리스트 조회 경로 식별 (`ExplorerService` -> `ChannelDonationService` -> `ChannelDonationMessageRepository`)
|
|
||||||
- [x] 프로필 채널 후원 조회 시 조회 월의 1일~말일 범위만 조회되도록 기간 조건 반영
|
|
||||||
- [x] 기존 일반 채널 후원 목록 API 동작 영향 없는지 확인
|
|
||||||
- [x] 수정 파일 기준 정적 진단/테스트/빌드 검증 수행
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 크리에이터 프로필의 채널 후원 리스트 조회 기간을 월 단위로 제한
|
|
||||||
- 왜: 기존 기간 계산(`now - 1 month`)은 월 경계 기준 요구사항(해당 월 1일~말일)과 다름
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 파일 2개) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 프로필 집계 응답뿐 아니라 전체 채널 후원 리스트 API도 월 단위(1일~말일) 조회로 통일
|
|
||||||
- 왜: 요구사항이 프로필 전용이 아닌 전체 채널 후원 리스트 대상까지 확장됨
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: `endDateTime` nullable 분기와 중복 메서드를 제거하고 기존 조회 메서드 시그니처에 `endDateTime`을 포함해 단일 로직으로 정리
|
|
||||||
- 왜: `endDateTime`이 항상 존재하는 현재 요구사항에서 null 분기 로직은 불필요하며 유지보수 복잡도만 증가시킴
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
|
|
||||||
|
|
||||||
### 4차 수정
|
|
||||||
- 무엇을: `endDateTime` 도입 이후 테스트 의미를 월 경계 의도에 맞게 보강 (`Service`는 월 시작/종료 전달 검증, `Repository`는 월 범위 기반 필터 검증)
|
|
||||||
- 왜: 기존 테스트 일부는 단순 파라미터 통과 확인 수준이어서 월 경계 요구사항을 직접 담지 못함
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 테스트 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
|
|
||||||
|
|
||||||
### 5차 수정
|
|
||||||
- 무엇을: 채널 후원 테스트 2개 파일의 가독성 개선을 위해 `@DisplayName`(한글)과 BDD(`given/when/then`) 단락 설명을 추가
|
|
||||||
- 왜: 테스트 코드 길이가 길어지며 의도 파악이 어려워져, 시나리오/준비/실행/검증 흐름을 빠르게 읽을 수 있도록 개선 필요
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 테스트 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*" && ./gradlew build` 실행: 성공
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 관리자 채널후원 정산 리뷰 지적사항 반영 작업 계획
|
|
||||||
|
|
||||||
- [x] 하위 호환성 유지 이슈는 요구사항 재확인 결과, 기존 이름을 신규 목적 경로로 사용하기로 확정되어 작업 범위에서 제외한다.
|
|
||||||
- [x] 엑셀 다운로드 API 테스트에서 `Content-Disposition` 헤더를 실질적으로 검증하도록 보강한다.
|
|
||||||
- [x] 관련 테스트와 빌드를 실행해 회귀 여부를 확인한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 반영
|
|
||||||
- 무엇을: 엑셀 다운로드 컨트롤러 테스트에서 `Content-Disposition` 헤더를 `getFirst(HttpHeaders.CONTENT_DISPOSITION)`로 조회하고, `attachment; filename*=` 포함 여부를 검증하도록 수정했다.
|
|
||||||
- 왜: 기존 `response.headers.contentDisposition` null 체크만으로는 헤더 누락/형식 회귀를 충분히 잡지 못해 테스트 신뢰도를 높이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 변경: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt`
|
|
||||||
- 범위 조정: 하위 호환성 유지 이슈는 요구사항 재확인 결과 작업 제외로 확정
|
|
||||||
- 실행 결과:
|
|
||||||
- `lsp_diagnostics (AdminChannelDonationCalculateControllerTest.kt)` → Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test --tests "*AdminChannelDonationCalculateControllerTest"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 관리자 채널후원 크리에이터별 정산 조회 및 엑셀 API 작업 계획
|
|
||||||
|
|
||||||
- [x] 기존 관리자 채널후원 정산 API의 날짜별 조회 경로를 식별하고 URL 변경 범위를 확정한다.
|
|
||||||
- [x] 관리자 채널후원 정산 날짜별 조회 API URL을 목적에 맞게 변경한다.
|
|
||||||
- [x] 관리자 크리에이터별 채널후원 정산 조회 API(`GET /admin/calculate/channel-donation-by-creator`)를 구현한다.
|
|
||||||
- [x] 관리자 크리에이터별 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-creator/excel`)를 구현한다.
|
|
||||||
- [x] 크리에이터별 집계/카운트/합계 Query를 추가하고, 정산 계산 비율은 기존 채널후원 정산과 동일하게 적용한다.
|
|
||||||
- [x] 관련 테스트를 수정/추가하고 `./gradlew test`, `./gradlew build`로 검증한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 채널후원 정산 API를 날짜별/크리에이터별로 분리하고, 크리에이터별 정산 엑셀 다운로드 API를 추가했다.
|
|
||||||
- 왜: 기존 `/admin/calculate/channel-donation-by-creator`가 날짜별 조회 성격이어서 URL 의미를 분리하고, 요청한 크리에이터별 목록/엑셀 기능을 제공하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 컨트롤러에서 기존 날짜별 조회 경로를 `GET /admin/calculate/channel-donation-by-date`로 변경했다.
|
|
||||||
- 신규 크리에이터별 조회 `GET /admin/calculate/channel-donation-by-creator`와 엑셀 다운로드 `GET /admin/calculate/channel-donation-by-creator/excel`를 추가했다.
|
|
||||||
- QueryRepository에 날짜별/크리에이터별 집계 메서드를 분리하고, 크리에이터별 총건수(distinct creator) 및 엑셀용 전체 조회를 추가했다.
|
|
||||||
- 서비스에서 크리에이터별 조회 응답 DTO와 엑셀(XSSFWorkbook) 생성 로직을 구현했다.
|
|
||||||
- 정산 비율/공식은 기존 `ChannelDonationSettlementCalculator`를 그대로 사용해 동일 정책을 유지했다.
|
|
||||||
- 테스트를 수정/추가해 날짜별 라우팅, 크리에이터별 조회, 엑셀 다운로드, Query 집계를 검증했다.
|
|
||||||
- 실행 결과:
|
|
||||||
- `lsp_diagnostics` (수정된 `.kt` 파일들) → Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 크리에이터별 정산 엑셀 다운로드 파일의 시트명과 헤더를 한글로 변경했다.
|
|
||||||
- 왜: 관리자 화면에서 다운로드한 엑셀의 컬럼 의미를 즉시 식별할 수 있도록 가독성을 높이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 시트명 `channel-donation-by-creator`를 `크리에이터별 채널후원 정산`으로 변경했다.
|
|
||||||
- 헤더를 `크리에이터`, `건수`, `총 받은 캔 수`, `원화`, `수수료`, `정산금액`, `원천세`, `입금액`으로 변경했다.
|
|
||||||
- 실행 결과:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 20260303_기부목록조회월범위한국시간수정
|
|
||||||
|
|
||||||
## 구현 항목
|
|
||||||
- [x] `ChannelDonationService.kt`의 `getChannelDonationList` 내 조회 범위 수정
|
|
||||||
- UTC 현재 시각을 기준으로 한국 시간(KST) 월 경계를 계산
|
|
||||||
- KST 월 경계(해당월 1일 00:00:00 ~ 다음달 1일 00:00:00)를 UTC 조회 구간으로 변환
|
|
||||||
- [x] 채널 후원 조회 UTC 전달값 검증 테스트 보강
|
|
||||||
- `ChannelDonationServiceTest`에서 전달된 UTC 범위를 KST로 역변환했을 때 월 경계가 유지되는지 검증
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 기부 목록 조회 시 사용되는 시간 범위를 한국 시간 기준으로 변경
|
|
||||||
- 왜: 현재 UTC 기준으로 1일~말일이 설정되어 있어 한국 사용자의 기대와 다름
|
|
||||||
- 어떻게: `ZoneId.of("Asia/Seoul")`을 사용하여 현재 한국 시간을 구하고, 해당 월의 시작일 자정을 계산하도록 수정함.
|
|
||||||
```kotlin
|
|
||||||
val kstZoneId = ZoneId.of("Asia/Seoul")
|
|
||||||
val nowKst = ZonedDateTime.now(kstZoneId)
|
|
||||||
val startDateTime = nowKst
|
|
||||||
.with(TemporalAdjusters.firstDayOfMonth())
|
|
||||||
.toLocalDate()
|
|
||||||
.atStartOfDay()
|
|
||||||
val endDateTime = startDateTime.plusMonths(1)
|
|
||||||
```
|
|
||||||
- 결과: 기존 단위 테스트(`ChannelDonationServiceTest`) 4건 모두 통과 확인.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest` 실행 결과 성공.
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: `getChannelDonationList`에서 월 조회 시작/종료 시각을 KST 기준으로 계산한 뒤 UTC `LocalDateTime`으로 변환해 repository에 전달하도록 수정
|
|
||||||
- 왜: KST 타임존만 적용하고 조회 파라미터를 UTC로 변환하지 않으면 조회 날짜가 기존과 동일하게 남아 월 경계가 의도대로 이동하지 않음
|
|
||||||
- 어떻게:
|
|
||||||
- `ChannelDonationService.kt`
|
|
||||||
- `ZonedDateTime.now(ZoneId.of("UTC"))`로 현재 시각을 얻고 `withZoneSameInstant(ZoneId.of("Asia/Seoul"))`로 KST 변환
|
|
||||||
- KST 월 시작/종료(`startDateTimeKst`, `endDateTimeKst`)를 각각 UTC로 변환해 `startDateTime`, `endDateTime` 생성
|
|
||||||
- `ChannelDonationServiceTest.kt`
|
|
||||||
- 캡처한 UTC 조회 파라미터를 KST로 역변환해 `1일 00:00:00` 및 `+1개월` 월 경계를 검증하도록 수정
|
|
||||||
- 정적 진단: `lsp_diagnostics` 실행 시 `.kt` 확장자 LSP 미구성으로 진단 불가(환경 제약)
|
|
||||||
- 검증 명령:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공
|
|
||||||
- `./gradlew tasks --all` 실행: 성공
|
|
||||||
- 결과: KST 월 경계가 UTC 조회 구간으로 반영되어 예시와 같은 형태(예: 2026-03-01 00:00:00 KST → 2026-02-28 15:00:00 UTC 시작)로 조회 조건이 구성됨
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
- [x] 관리자 차단 신규 API/DTO/서비스 파일 생성
|
|
||||||
- [x] 차단 처리 시 탈퇴 이유 저장 및 회원 비활성화 처리
|
|
||||||
- [x] 차단 처리 시 Redis 로그인 토큰 전체 삭제
|
|
||||||
- [x] 본인인증 회원 BlockAuth 기록 처리
|
|
||||||
- [x] 동일 본인인증 정보 계정 일괄 탈퇴 처리
|
|
||||||
- [x] 활성 계정 조회 조건을 `name + birth + di + uniqueCi`로 강화
|
|
||||||
- [x] 관리자 차단 서비스 테스트 추가
|
|
||||||
- [x] 정적 진단 및 테스트/빌드 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `kr.co.vividnext.sodalive.admin.member` 패키지에 신규 관리자 차단 API(`AdminMemberBlockController`), 요청 DTO(`AdminMemberBlockRequest`), 서비스(`AdminMemberBlockService`)를 추가했다. 서비스에서 탈퇴 이유 저장/회원 비활성화, Redis 로그인 토큰 전체 삭제, 본인인증 정보 `BlockAuth` 기록을 순서대로 처리하고, 서비스 단위 테스트(`AdminMemberBlockServiceTest`)를 추가했다.
|
|
||||||
- 왜: 관리자 페이지에서 사용자 차단 시 계정 비활성화 이력, 세션 무효화, 본인인증 기반 재가입 차단 정보를 한 번의 동작으로 일관되게 처리하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
|
||||||
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 관리자 차단 시 차단 대상 회원의 본인인증 정보(`di`)와 동일한 활성 계정을 모두 조회해 일괄 탈퇴 처리하도록 `AdminMemberBlockService`를 수정했다. 각 대상 계정마다 탈퇴 사유(`SignOut`) 저장, 회원 비활성화, Redis 로그인 토큰 전체 삭제를 수행하고, 기존 `BlockAuth` 저장 로직은 유지했다. 테스트도 동일 본인인증 다계정 탈퇴 시나리오를 포함하도록 확장했다.
|
|
||||||
- 왜: 본인인증 정보를 공유하는 다중 계정을 관리자 차단 시 함께 정리해야 우회 가입 계정이 활성 상태로 남지 않기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
|
||||||
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: 활성 계정 조회 조건을 `di` 단일 조건에서 `name + birth + di + uniqueCi` AND 조건으로 강화했다. 이를 위해 `AuthRepository`의 활성 계정 조회 메서드를 `getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(...)`로 변경하고, 호출부인 `AdminMemberBlockService`, `AuthService.authenticate`를 모두 신규 메서드로 교체했다. `AdminMemberBlockServiceTest`도 신규 시그니처 기준으로 스텁/검증을 수정했다.
|
|
||||||
- 왜: `di`만으로 동일인을 판단하면 과매칭 리스크가 있어, 본인인증 핵심 식별 속성을 함께 사용해 활성 계정 판별 정확도를 높이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
|
||||||
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# 관리자 정산 엑셀 다운로드 추가 작업 계획
|
|
||||||
|
|
||||||
- [x] 기존 정산 API 구조와 엑셀 다운로드 응답 패턴(`ResponseEntity<InputStreamResource>`)을 기준으로 구현 범위를 확정한다.
|
|
||||||
- [x] 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live/excel`)를 추가한다.
|
|
||||||
- [x] 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-list/excel`)를 추가한다.
|
|
||||||
- [x] 콘텐츠 후원 정산 엑셀 다운로드 API(`GET /admin/calculate/content-donation-list/excel`)를 추가한다.
|
|
||||||
- [x] 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-post/excel`)를 추가한다.
|
|
||||||
- [x] 크리에이터별 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live-by-creator/excel`)를 추가한다.
|
|
||||||
- [x] 크리에이터별 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-by-creator/excel`)를 추가한다.
|
|
||||||
- [x] 크리에이터별 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-by-creator/excel`)를 추가한다.
|
|
||||||
- [x] 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-date/excel`)를 추가한다.
|
|
||||||
- [x] 각 엑셀 API가 시작/끝 날짜를 받아 전체 데이터를 내려주도록 서비스/리포지토리를 확장한다.
|
|
||||||
- [x] `lsp_diagnostics`, 테스트, 빌드로 변경사항을 검증하고 결과를 문서 하단에 기록한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 정산 API 8종(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별)에 `/excel` 다운로드 엔드포인트를 추가하고, 전체 데이터 엑셀 생성 서비스를 구현했다.
|
|
||||||
- 왜: 페이지네이션 기반 조회 API와 별도로 시작일/종료일 기준의 전체 정산 데이터를 한 번에 내려받을 수 있어야 한다는 요구사항을 충족하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminCalculateController`에 7개 엔드포인트(`.../excel`)를 추가하고 공통 다운로드 헤더(`Content-Disposition`, xlsx content type)를 적용했다.
|
|
||||||
- `AdminCalculateService`에 7개 엑셀 생성 메서드를 추가해 기간 변환 후 전체 데이터 조회 및 `XSSFWorkbook` 기반 시트/헤더/행 작성을 구현했다.
|
|
||||||
- 페이지네이션 대상(커뮤니티 정산, 크리에이터별 정산 3종)은 `totalCount`를 조회해 `offset=0`, `limit=totalCount`로 전체 행을 조회하도록 처리했다.
|
|
||||||
- `AdminChannelDonationCalculateController`에 `GET /admin/calculate/channel-donation-by-date/excel`를 추가하고 기존 크리에이터별 엑셀 응답 로직과 동일한 규칙을 적용했다.
|
|
||||||
- `AdminChannelDonationCalculateService`에 날짜별 엑셀 다운로드 메서드를 추가해 전체 데이터 기준 시트를 생성했다.
|
|
||||||
- 테스트를 보강했다.
|
|
||||||
- `AdminChannelDonationCalculateControllerTest`: 날짜별 엑셀 다운로드 테스트 추가
|
|
||||||
- `AdminChannelDonationCalculateServiceTest`: 날짜별 엑셀 바이트 생성 테스트 추가
|
|
||||||
- 실행 결과:
|
|
||||||
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 관리자 정산 콘텐츠 크리에이터별 조회 SQL 오류 수정 작업 계획
|
|
||||||
|
|
||||||
- [x] `/admin/calculate/content-by-creator` 호출 경로(Controller/Service/Repository)와 SQL 생성 지점을 확인한다.
|
|
||||||
- [x] `ONLY_FULL_GROUP_BY` 위반 원인(`content_settlement_ratio` 비집계 컬럼)을 제거하는 최소 수정안을 적용한다.
|
|
||||||
- [x] 수정된 쿼리가 기존 응답 스키마/정산 계산 로직과 호환되는지 코드 레벨로 검증한다.
|
|
||||||
- [x] `lsp_diagnostics`, 관련 테스트, 빌드를 실행해 정상 동작을 검증한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 수정
|
|
||||||
- 무엇을: `AdminCalculateQueryRepository#getCalculateContentByCreator`의 `groupBy`를 `member.id`에서 `member.id, creatorSettlementRatio.contentSettlementRatio`로 수정해 SELECT의 비집계 컬럼(`contentSettlementRatio`)이 GROUP BY에 포함되도록 변경했다.
|
|
||||||
- 왜: `/admin/calculate/content-by-creator` 조회 시 `creator_settlement_ratio.content_settlement_ratio`가 SELECT 절에 존재하지만 GROUP BY에 없어 MySQL `ONLY_FULL_GROUP_BY` 모드에서 SQLSyntaxErrorException이 발생했기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- 경로/원인 확인: `AdminCalculateController#getCalculateContentByCreator` -> `AdminCalculateService#getCalculateContentByCreator` -> `AdminCalculateQueryRepository#getCalculateContentByCreator` 호출 체인을 확인했다.
|
|
||||||
- 코드 수정: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`의 콘텐츠 크리에이터별 조회 쿼리 `groupBy`를 보완했다.
|
|
||||||
- 검증 실행 결과:
|
|
||||||
- `lsp_diagnostics` (`AdminCalculateQueryRepository.kt`) -> Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test` -> 성공
|
|
||||||
- `./gradlew build -x test` -> 성공
|
|
||||||
- `./gradlew tasks --all` -> 성공
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
- [x] 페이징 미적용 관리자 정산 API 식별
|
|
||||||
- [x] Controller에 Pageable 파라미터 추가 및 Service 호출에 offset/limit 전달
|
|
||||||
- [x] Service/Repository 쿼리에 offset/limit 반영
|
|
||||||
- [x] 정적 진단 및 테스트/빌드 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 정산 API 중 페이징이 없던 `/admin/calculate/live`, `/admin/calculate/content-list`, `/admin/calculate/content-donation-list`에 `Pageable` 기반 페이징을 추가하고, 응답을 `totalCount + items` 구조로 변경했다. 또한 동일 쿼리를 사용하는 엑셀 다운로드 로직이 기존과 동일하게 전체 데이터를 내려주도록 totalCount 기반 전체 조회 방식으로 맞췄다.
|
|
||||||
- 왜: 조회 건수가 많아질 수 있는 정산 목록 API에서 페이지 단위 조회를 지원해 응답 크기와 조회 성능을 안정적으로 관리하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
|
||||||
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 관리자 충전 상태 상세 응답 필드 수정
|
|
||||||
|
|
||||||
- [x] `GetChargeStatusDetailResponse`에서 `memberId` 제거
|
|
||||||
- [x] `GetChargeStatusDetailResponse`에 `chargeId` 추가
|
|
||||||
- [x] 연관 매핑 코드 반영 및 빌드 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 충전 상세 응답 DTO의 식별자를 `memberId`에서 `chargeId`로 변경하고, Query DTO/서비스 매핑/QueryDSL select 값을 동일하게 정합성 맞춰 수정했다.
|
|
||||||
- 왜: 충전 상세 응답에서 회원 식별자 대신 충전 건 식별자를 내려주도록 요구사항이 변경되었기 때문이다.
|
|
||||||
- 어떻게: `lsp_diagnostics`는 `.kt` 확장자 LSP 미설정으로 도구 검증이 불가해 사유를 확인했고, `./gradlew build`를 실행해 컴파일/테스트/체크를 통합 검증했으며 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 관리자 충전 상세 캔 개수 추가
|
|
||||||
|
|
||||||
- [x] `GetChargeStatusDetailResponse`에 `chargeCan`, `rewardCan` 필드 추가
|
|
||||||
- [x] `AdminChargeStatusQueryRepository.getChargeStatusDetail` QueryProjection 인자에 캔 개수 매핑 추가
|
|
||||||
- [x] 관련 검증 수행 (`lsp_diagnostics`, `./gradlew test`, `./gradlew build`)
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 충전 상세 응답 DTO에 `chargeCan`, `rewardCan` 필드를 추가하고, 상세 조회 QueryProjection(`QGetChargeStatusDetailResponse`) 인자에 `charge.chargeCan`, `charge.rewardCan` 매핑을 추가했다.
|
|
||||||
- 왜: 충전 상세 응답에 유료 캔/보너스 캔 수량 정보를 함께 내려주기 위한 요구사항을 반영하기 위해서다.
|
|
||||||
- 어떻게: `lsp_diagnostics`로 수정 파일 진단을 시도했으나 `.kt` LSP 미설정으로 도구 검증이 불가함을 확인했고, 대신 `./gradlew test`와 `./gradlew build -x test`를 실행해 테스트/빌드 모두 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 관리자 충전 상세 QueryProjection 리팩토링
|
|
||||||
|
|
||||||
- [x] `AdminChargeStatusService.getChargeStatusDetail` 후처리 매핑 제거
|
|
||||||
- [x] `AdminChargeStatusQueryRepository.getChargeStatusDetail` 반환 타입을 응답 DTO QueryProjection으로 변경
|
|
||||||
- [x] 관련 DTO/QueryDSL 생성 타입 정합성 확인
|
|
||||||
- [x] 검증 수행 (`lsp_diagnostics`, `./gradlew test`, `./gradlew build`)
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `GetChargeStatusDetailResponse`에 `@QueryProjection`을 적용하고, `AdminChargeStatusQueryRepository`가 해당 DTO를 직접 select 하도록 변경했으며, 서비스의 후처리 `map`을 제거했다. 또한 불필요해진 `GetChargeStatusDetailQueryDto.kt` 파일을 삭제했다.
|
|
||||||
- 왜: 상세 응답 가공을 서비스에서 한 번 더 수행하지 않고 DB 조회 시점(QueryProjection)에서 완성된 응답 형태를 가져오도록 구조를 단순화하기 위해서다.
|
|
||||||
- 어떻게: `lsp_diagnostics`로 수정 파일 진단을 시도했으나 `.kt` LSP 미설정으로 도구 검증이 불가함을 확인했고, 대신 `./gradlew test`와 `./gradlew build -x test`를 실행해 테스트/빌드 성공(`BUILD SUCCESSFUL`)을 확인했다.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 관리자 정산 엑셀 스트리밍 전환 작업 계획
|
|
||||||
|
|
||||||
- [x] 기존 정산 엑셀 다운로드 API의 요청/응답 계약(엔드포인트, 쿼리 파라미터, 헤더)을 유지한다.
|
|
||||||
- [x] `AdminCalculateController`의 엑셀 응답 타입을 `StreamingResponseBody` 기반으로 전환한다.
|
|
||||||
- [x] `AdminCalculateService`의 엑셀 생성 방식을 `XSSFWorkbook + ByteArrayOutputStream`에서 `SXSSFWorkbook + 스트리밍 write`로 전환한다.
|
|
||||||
- [x] `AdminChannelDonationCalculateController`의 날짜별/크리에이터별 엑셀 응답을 `StreamingResponseBody` 기반으로 전환한다.
|
|
||||||
- [x] `AdminChannelDonationCalculateService`의 날짜별/크리에이터별 엑셀 생성을 `SXSSFWorkbook` 스트리밍 방식으로 전환한다.
|
|
||||||
- [x] 관련 테스트를 스트리밍 응답 기준으로 수정한다.
|
|
||||||
- [x] `lsp_diagnostics`, 테스트, 빌드를 실행하고 결과를 검증 기록에 남긴다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 정산 엑셀 다운로드 API 전체(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별/채널후원 크리에이터별)의 서버 내부 생성/전송 방식을 스트리밍으로 전환했다.
|
|
||||||
- 왜: 기존 `XSSFWorkbook + ByteArrayOutputStream + InputStreamResource` 방식은 전체 워크북과 바이트 배열을 메모리에 유지해 대용량 다운로드 시 피크 메모리 사용량이 커지기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- 컨트롤러 응답 타입을 `ResponseEntity<StreamingResponseBody>`로 변경하고, 기존 파일명 인코딩/`Content-Disposition`/xlsx MIME 타입은 유지했다.
|
|
||||||
- 서비스 반환 타입을 `StreamingResponseBody`로 변경하고 `SXSSFWorkbook(100)`로 row window 기반 생성 후 `outputStream`에 직접 `write`하도록 변경했다.
|
|
||||||
- 스트리밍 완료 시 `workbook.dispose()`와 `workbook.close()`를 호출해 임시 파일/리소스 해제를 보장했다.
|
|
||||||
- 채널후원 컨트롤러/서비스(날짜별, 크리에이터별)에도 동일 패턴을 적용했다.
|
|
||||||
- 테스트를 스트리밍 응답 기준으로 수정했다.
|
|
||||||
- 컨트롤러 테스트: `InputStreamResource` 검증 -> `StreamingResponseBody` 검증
|
|
||||||
- 서비스 테스트: `readAllBytes()` -> `StreamingResponseBody.writeTo(ByteArrayOutputStream)` 검증
|
|
||||||
- 실행 결과:
|
|
||||||
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
- [x] 기존 charge/payment/member 및 admin API 패턴 확인
|
|
||||||
- [x] `kr.co.vividnext.sodalive.admin.charge` 패키지에 캔 환불 API 생성
|
|
||||||
- [x] 환불 조건 검증 구현 (미사용, 7일 이내)
|
|
||||||
- [x] ChargeEntity/PaymentEntity/MemberEntity 환불 반영 로직 구현
|
|
||||||
- [x] 캔 환불 API 테스트 코드 작성
|
|
||||||
- [x] 검증 실행 및 결과 기록
|
|
||||||
|
|
||||||
## 환불 조건 상세
|
|
||||||
- 환불 가능 충전내역 조건: `charge.status == CHARGE` 그리고 `payment.status == COMPLETE`
|
|
||||||
- 이미 사용한 캔 판정 조건: `charge.title`에서 숫자를 추출해 현재 `chargeCan/rewardCan`과 비교
|
|
||||||
- 예시1) `100 캔 + 50 캔` -> `chargeCan = 100`, `rewardCan = 50`
|
|
||||||
- 예시2) `5,000 캔 + 500 캔` -> `chargeCan = 5000`, `rewardCan = 500`
|
|
||||||
- 예시3) `500캔` -> `chargeCan = 500`
|
|
||||||
- 예시4) `4,000 캔` -> `chargeCan = 4000`
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 캔 환불 API(`POST /admin/charge/refund`)와 환불 서비스/요청 DTO, i18n 메시지, 단위 테스트를 추가했다.
|
|
||||||
- 왜: 사용하지 않은 캔만 7일 이내 환불 가능하도록 하고, 환불 시 Charge/Payment/Member 상태를 요구사항대로 갱신하기 위해.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
|
|
||||||
- `./gradlew build` 실행 → 성공 (ktlint/check/test/build 포함)
|
|
||||||
- LSP 진단 시도(`lsp_diagnostics`) → Kotlin LSP 미설정으로 불가, 대신 Gradle 컴파일/ktlint/test/build로 검증
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: `AdminChargeRefundServiceTest`에 한글 `@DisplayName`을 추가하고, 각 테스트 문단에 given/when/then 역할 주석을 보강했다.
|
|
||||||
- 왜: 테스트 의도를 한눈에 파악하고, 문단별 책임을 명확히 하기 위해.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
|
|
||||||
- `./gradlew ktlintTestSourceSetCheck` 실행 → 성공
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: 이미 사용한 캔 판정을 `charge.title` 숫자 파싱 비교 방식으로 변경하고, 단일 숫자/콤마 포함 제목 테스트 케이스를 추가했다.
|
|
||||||
- 왜: 환불 조건을 충전 제목 기반 비교 규칙(단일/복수 숫자, 콤마 포함)으로 명확하게 적용하기 위해.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
|
|
||||||
- `./gradlew build` 실행 → 성공
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
- [x] `getCalculateContentDonationList` 호출 경로(Controller → Service → QueryData) 확인
|
|
||||||
- [x] 유료/무료 콘텐츠 후원 정산 비율이 모두 70%로 적용되는지 검증
|
|
||||||
- [x] `GetCalculateContentDonationQueryData` 계산 로직의 불필요 분기/중복 제거 및 가독성 개선
|
|
||||||
- [x] 관련 테스트/빌드/정적 진단 실행 및 결과 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `GetCalculateContentDonationQueryData`에서 유료/무료 공통 정산 비율 70% 적용 상태를 확인하고, 정산 계산 상수(`KRW_PER_CAN`, `PAYMENT_FEE_RATE`, `SETTLEMENT_RATE`, `TAX_RATE`)를 `companion object`로 추출해 계산 로직을 정리했다.
|
|
||||||
- 왜: 유료/무료 분기 제거 후 동일 70% 정책을 명확히 유지하고, `BigDecimal` 상수 재사용으로 계산 의도와 유지보수성을 높이기 위해서다.
|
|
||||||
- 어떻게: 호출 경로(`AdminCalculateController` → `AdminCalculateService` → `AdminCalculateQueryRepository` → `GetCalculateContentDonationQueryData`)를 확인했고, 정적 진단은 `.kt` LSP 미구성으로 대체 검증했다. 실행 명령과 결과는 아래와 같다.
|
|
||||||
- `lsp_diagnostics` (`GetCalculateContentDonationQueryData.kt`): Kotlin LSP 미지원으로 실행 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test`: 성공 (`BUILD SUCCESSFUL`)
|
|
||||||
- `./gradlew build`: 성공 (`BUILD SUCCESSFUL`, `ktlintMainSourceSetCheck` 포함)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
- [x] deep_link 파라미터 추가 여부를 푸시 발송 코드 기준으로 확인한다.
|
|
||||||
- [x] deep_link 값이 `voiceon://community/345` 형태인지 생성 규칙을 확인한다.
|
|
||||||
- [x] 검증 결과를 문서 하단에 기록한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 확인
|
|
||||||
- 무엇을: 푸시 발송 시 FCM payload에 `deep_link` 파라미터가 실제로 추가되는지와 커뮤니티 알림 형식이 `voiceon://community/{id}`인지 확인했다.
|
|
||||||
- 왜: 서버 구현이 문서 설명과 일치하는지, 그리고 앱이 기대하는 딥링크 문자열을 실제로 내려주는지 검증하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` 확인: `createDeepLink(deepLinkValue, deepLinkId)` 결과가 null이 아니면 `multicastMessage.putData("deep_link", deepLink)`로 payload에 추가됨.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` 확인: 생성 규칙은 `server.env == voiceon`일 때 `voiceon://{deepLinkValue.value}/{deepLinkId}`, 그 외 환경은 `voiceon-test://{deepLinkValue.value}/{deepLinkId}`임.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` 확인: 커뮤니티 새 글 알림은 `deepLinkValue = FcmDeepLinkValue.COMMUNITY`, `deepLinkId = member.id!!`를 전달하므로 운영 환경 기준 최종 값은 `voiceon://community/{creatorId}` 형식임.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt` 확인: 커뮤니티 목록 조회 API가 `creatorId`를 받으므로 커뮤니티 딥링크의 식별자도 크리에이터 ID 기준과 일치함.
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
- 코드 수정은 하지 않음(확인 작업만 수행).
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
- [x] FCM 푸시 생성 경로에서 딥링크 파라미터 추가 위치 확정
|
|
||||||
- [x] `server.env` 기반 URI scheme(`voiceon://`, `voiceon-test://`) 분기 로직 구현
|
|
||||||
- [x] `deep_link_value` 매핑 규칙(`live`, `channel`, `content`, `series`, `audition`, `community`) 반영
|
|
||||||
- [x] FCM payload에 최종 딥링크 문자열(`{URISCHEME}://{deep_link_value}/{ID}`) 주입
|
|
||||||
- [x] 관련 테스트/검증 수행 후 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: FCM 이벤트에 딥링크 메타(`deepLinkValue`, `deepLinkId`)를 추가하고, `FcmService`에서 `deep_link` payload(`{URISCHEME}://{deep_link_value}/{ID}`)를 생성하도록 구현했다.
|
|
||||||
- 왜: 푸시 수신 시 앱이 직접 딥링크로 진입하도록 서버에서 일관된 규칙으로 URL을 포함하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(초기 실패: import 정렬 ktlint 위반)
|
|
||||||
- `./gradlew ktlintFormat` 실행(성공)
|
|
||||||
- `./gradlew test && ./gradlew build` 재실행(성공)
|
|
||||||
- LSP 진단은 Kotlin LSP 미구성 환경으로 실행 불가(Gradle 컴파일/테스트/ktlint로 대체 검증)
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 오디션 푸시의 `deepLinkId`를 `-1` 대체값이 아닌 실제 `audition.id` nullable 값으로 조정했다.
|
|
||||||
- 왜: ID가 null일 때 비정상 딥링크(`/audition/-1`)가 생성되는 가능성을 제거하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test && ./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: `server.env` 값 해석 기준을 `voiceon`(프로덕션), `voiceon_test` 및 그 외(개발/기타)로 조정했다.
|
|
||||||
- 왜: 실제 운영 환경 변수 규칙과 딥링크 URI scheme 선택 조건을 일치시키기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test && ./gradlew build` 실행(성공)
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
- [x] 요구사항 확정: 푸시 발송 내용을 알림 리스트에 적재하고, 미수신 상황에서도 조회 가능하도록 범위를 고정한다.
|
|
||||||
- [x] 도메인 모델 설계: 알림 본문/발송자 스냅샷/카테고리/딥링크/언어코드/수신자 청크(JSON 배열) 저장 구조를 JPA 엔티티로 정의한다.
|
|
||||||
- [x] 푸시 적재 로직 구현: 수신자가 없으면 저장하지 않고, 언어별 데이터로 분리 저장하며 수신자 ID를 청크 단위(JSON 배열)로 기록한다.
|
|
||||||
- [x] 조회 기간 제한 구현: 알림 조회는 최근 1개월 데이터만 조회하도록 서비스/리포지토리에 공통 조건을 적용한다.
|
|
||||||
- [x] API 구현: 인증 사용자 기준 알림 목록 조회 API(전체/카테고리별)와 알림 존재 카테고리 조회 API를 구현한다.
|
|
||||||
- [x] 카테고리 다국어 응답 구현: 카테고리 조회 API 응답을 현재 기기 언어(ko/en/ja) 라벨로 반환한다.
|
|
||||||
- [x] 페이징 구현: Pageable 파라미터를 사용해 offset/limit 기반 조회를 적용한다.
|
|
||||||
- [x] 시간 포맷 구현: 발송시간을 UTC 기반 String으로 응답 DTO에 포함한다.
|
|
||||||
- [x] TDD 구현: 스프링 컨테이너 없이 실행 가능한 단위 테스트를 먼저 작성하고, 구현 후 테스트를 통과시킨다.
|
|
||||||
- [x] SQL 문서화: 신규 테이블 생성 SQL 및 추가 인덱스 SQL(MySQL, TIMESTAMP NOT NULL)을 문서 하단에 기록한다.
|
|
||||||
|
|
||||||
## API 상세 작업 계획
|
|
||||||
|
|
||||||
### 1) GET `/push/notification/list`
|
|
||||||
- 목적: 인증 사용자의 알림 리스트를 현재 기기 언어 기준으로 조회한다.
|
|
||||||
- 요청 파라미터:
|
|
||||||
- `page`, `size`, `sort` (Pageable)
|
|
||||||
- `category` (선택, 없으면 전체 조회)
|
|
||||||
- 처리 규칙:
|
|
||||||
- 인증 사용자(`Member?`) null이면 `SodaException(messageKey = "common.error.bad_credentials")`
|
|
||||||
- 현재 요청 언어(`LangContext.lang.code`)와 일치하는 알림만 조회
|
|
||||||
- 조회 범위는 `now(UTC) - 1개월` 이후 데이터만 허용
|
|
||||||
- `category` 미지정 시 전체 카테고리 조회
|
|
||||||
- `category`는 코드(`live`) 또는 다국어 라벨(`라이브`/`Live`/`ライブ`) 입력을 허용한다
|
|
||||||
- `category`가 `전체`/`All`/`すべて`이면 전체 카테고리 조회로 처리한다
|
|
||||||
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 알림만 조회
|
|
||||||
- 응답 항목:
|
|
||||||
- 발송자 스냅샷(닉네임, 프로필 이미지)
|
|
||||||
- 발송 메시지
|
|
||||||
- 카테고리
|
|
||||||
- 딥링크
|
|
||||||
- 발송시간(UTC String)
|
|
||||||
- 구현 작업:
|
|
||||||
- [x] Controller: 인증/파라미터/ApiResponse 처리
|
|
||||||
- [x] Service: 1개월/언어/카테고리/페이지 조건 조합
|
|
||||||
- [x] Repository: 수신자 청크 JSON membership + pageable 조회 + totalCount
|
|
||||||
- [x] DTO: `GetPushNotificationListResponse`, `PushNotificationListItem` 정의
|
|
||||||
|
|
||||||
### 2) GET `/push/notification/categories`
|
|
||||||
- 목적: 인증 사용자 기준으로 알림 데이터가 실제 존재하는 카테고리만 조회한다.
|
|
||||||
- 요청 파라미터: 없음
|
|
||||||
- 처리 규칙:
|
|
||||||
- 인증 필수
|
|
||||||
- 현재 요청 언어 기준 데이터만 대상
|
|
||||||
- 최근 1개월 데이터만 대상
|
|
||||||
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 데이터만 대상
|
|
||||||
- 응답 항목:
|
|
||||||
- 카테고리 목록(현재 기기 언어 라벨)
|
|
||||||
- 구현 작업:
|
|
||||||
- [x] Controller: 인증/ApiResponse 처리
|
|
||||||
- [x] Service: 중복 제거된 카테고리 목록 반환
|
|
||||||
- [x] Repository: 사용자/언어/기간 기반 카테고리 distinct 조회
|
|
||||||
- [x] DTO: `GetPushNotificationCategoryResponse` 정의
|
|
||||||
|
|
||||||
## 비API 작업 계획
|
|
||||||
- [x] FCM 이벤트 모델 확장: 알림 리스트 적재에 필요한 카테고리/발송자 스냅샷 정보를 이벤트에 포함한다.
|
|
||||||
- [x] FCM 전송 리스너 연동: 언어별 푸시 전송 시점에 알림 리스트 저장 서비스를 호출한다.
|
|
||||||
- [x] 발송자 스냅샷 처리: 이벤트 스냅샷 우선 사용, 없으면 발송자 ID 기반 조회로 보완한다.
|
|
||||||
- [x] 딥링크 저장 처리: 현재 푸시 딥링크 규칙과 동일한 값으로 저장한다.
|
|
||||||
- [x] 수신자 청크 저장 처리: 수신자 ID를 고정 크기 청크로 분할해 JSON 배열로 저장한다.
|
|
||||||
- [x] 수신자 미존재 처리: 최종 수신자 ID가 비어 있으면 알림 자체를 저장하지 않는다.
|
|
||||||
|
|
||||||
## 테스트(TDD) 계획
|
|
||||||
- [x] 단위 테스트: 알림 저장 서비스가 수신자 없음/언어별 분리/청크 분할/스냅샷 저장을 정확히 처리하는지 검증한다.
|
|
||||||
- [x] 단위 테스트: 조회 서비스가 1개월 제한/언어 필터/카테고리 옵션/pageable 전달을 정확히 적용하는지 검증한다.
|
|
||||||
- [x] 단위 테스트: 카테고리 조회 서비스가 사용자/언어/기간 기준 distinct 결과를 반환하는지 검증한다.
|
|
||||||
- [x] 단위 테스트: 컨트롤러가 인증 실패 시 에러 응답을 반환하고, 정상 시 서비스 호출 파라미터를 올바르게 전달하는지 검증한다.
|
|
||||||
|
|
||||||
## SQL 초안 (구현 확정)
|
|
||||||
|
|
||||||
### 1) 신규 테이블 생성 SQL (MySQL)
|
|
||||||
```sql
|
|
||||||
CREATE TABLE push_notification_list
|
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
|
|
||||||
sender_nickname_snapshot VARCHAR(255) NOT NULL COMMENT '발송자 닉네임 스냅샷',
|
|
||||||
sender_profile_image_snapshot VARCHAR(500) NULL COMMENT '발송자 프로필 이미지 스냅샷',
|
|
||||||
message TEXT NOT NULL COMMENT '발송 메시지',
|
|
||||||
category VARCHAR(20) NOT NULL COMMENT '발송 카테고리',
|
|
||||||
deep_link VARCHAR(500) NULL COMMENT '딥링크',
|
|
||||||
language_code VARCHAR(8) NOT NULL COMMENT '언어 코드',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)'
|
|
||||||
) COMMENT ='푸시 알림 리스트';
|
|
||||||
|
|
||||||
CREATE TABLE push_notification_recipient_chunk
|
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
|
|
||||||
notification_id BIGINT NOT NULL COMMENT '알림 ID',
|
|
||||||
recipient_member_ids JSON NOT NULL COMMENT '수신자 회원 ID 청크(JSON 배열)',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)',
|
|
||||||
CONSTRAINT fk_push_notification_recipient_chunk_notification
|
|
||||||
FOREIGN KEY (notification_id) REFERENCES push_notification_list (id)
|
|
||||||
) COMMENT ='푸시 알림 수신자 청크';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) 추가 인덱스 SQL (MySQL)
|
|
||||||
```sql
|
|
||||||
ALTER TABLE push_notification_list
|
|
||||||
ADD INDEX idx_push_notification_list_language_created (language_code, created_at, id),
|
|
||||||
ADD INDEX idx_push_notification_list_category_language_created (category, language_code, created_at, id);
|
|
||||||
|
|
||||||
ALTER TABLE push_notification_recipient_chunk
|
|
||||||
ADD INDEX idx_push_notification_recipient_chunk_notification (notification_id);
|
|
||||||
|
|
||||||
-- MySQL 8.0.17+ 환경에서 JSON 배열 membership 최적화가 필요할 때 사용
|
|
||||||
ALTER TABLE push_notification_recipient_chunk
|
|
||||||
ADD INDEX idx_push_notification_recipient_chunk_member_ids_mvi ((CAST(recipient_member_ids AS UNSIGNED ARRAY)));
|
|
||||||
```
|
|
||||||
|
|
||||||
#### MVI 조건부 적용 가이드 (짧게)
|
|
||||||
- MySQL 8.0.17+ 환경이면 인덱스를 먼저 추가해 둔다.
|
|
||||||
- 실제 사용 여부는 옵티마이저가 쿼리 조건과 비용을 보고 결정하므로 `EXPLAIN`으로 확인한다.
|
|
||||||
- 현재 조회 조건처럼 `JSON_CONTAINS(JSON컬럼, JSON_ARRAY(값), '$')` 형태일 때 사용 후보가 된다.
|
|
||||||
- 인덱스가 선택되지 않아도 기능 오동작은 없지만, 쓰기/저장공간 비용은 항상 발생한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 푸시 발송 시 언어별 메시지를 알림 리스트로 적재하는 `PushNotificationService`와 관련 JPA 엔티티/리포지토리/조회 API 2종(`/push/notification/list`, `/push/notification/categories`)을 추가하고, 기존 `FcmEvent` 발행 지점에 카테고리/발송자 스냅샷 소스를 연결했다.
|
|
||||||
- 왜: 푸시를 놓친 사용자도 최근 1개월 내 알림을 현재 기기 언어 기준으로 확인하고, 카테고리별 필터/카테고리 존재 여부를 조회할 수 있어야 하기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew test` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(초기 실패: ktlint import 정렬 위반)
|
|
||||||
- `./gradlew ktlintFormat` 실행(성공)
|
|
||||||
- `./gradlew test` 재실행(성공)
|
|
||||||
- `./gradlew build` 재실행(성공)
|
|
||||||
- Kotlin LSP 미구성으로 `lsp_diagnostics`는 실행 불가, Gradle test/build/ktlint로 대체 검증
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: `PushNotificationRecipientChunk`의 `chunkOrder` 필드를 제거하고, 저장 로직/문서 SQL(컬럼 및 인덱스)을 함께 정리했다.
|
|
||||||
- 왜: 저장 시점에만 값이 할당되고 조회/정렬/필터에서 실제 사용되지 않아 불필요한 데이터였기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: 알림 리스트 조회를 `PushNotificationListRow -> service map` 구조에서 `PushNotificationListItem` 직접 프로젝션 구조로 변경하고, 조회/카운트 쿼리에서 `innerJoin + distinct/countDistinct`를 제거해 `EXISTS` 기반 JSON membership 필터로 최적화했다.
|
|
||||||
- 왜: 중간 변환 객체가 불필요하고, 조인 기반 중복 제거 비용(distinct/countDistinct)이 커질 수 있어 페이지 조회 성능을 개선하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 4차 수정
|
|
||||||
- 무엇을: `sentAt` 포맷을 DB `DATE_FORMAT` 문자열 생성 방식에서 `PushNotificationListItem` QueryProjection 생성자 기반 UTC Instant 문자열(`...Z`) 생성 방식으로 변경했다.
|
|
||||||
- 왜: `GetLatestFinishedLiveResponse.dateUtc`와 동일하게 애플리케이션 레이어에서 명시적 UTC 변환을 적용해 포맷/의미 일관성을 맞추기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 5차 수정
|
|
||||||
- 무엇을: `getAvailableCategories`가 카테고리 코드를 그대로 반환하던 동작을, 현재 기기 언어(`ko/en/ja`)에 맞는 카테고리 라벨을 반환하도록 변경했다.
|
|
||||||
- 왜: 카테고리 조회 응답을 조회 기기 언어에 따라 한글/영어/일본어로 내려주어야 하기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 6차 수정
|
|
||||||
- 무엇을: `getAvailableCategories` 응답 리스트 맨 앞에 `전체` 항목을 고정 추가하고, `ko/en/ja` 다국어 라벨로 반환하도록 변경했다.
|
|
||||||
- 왜: 카테고리 필터 UI에서 전체 조회 옵션이 항상 첫 번째로 필요하기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 7차 수정
|
|
||||||
- 무엇을: `getNotificationList`의 `category` 입력이 한글/영어/일본어 라벨(`라이브`/`Live`/`ライブ` 등)도 파싱되도록 확장하고, `전체`/`All`/`すべて` 입력은 전체 조회로 처리하도록 수정했다.
|
|
||||||
- 왜: 카테고리 조회 API가 다국어 라벨을 반환하므로, 목록 조회 API도 동일 라벨 입력을 처리할 수 있어야 하기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotificationServiceTest"` 실행(성공)
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 8차 수정
|
|
||||||
- 무엇을: 추가 인덱스 SQL 하단에 MVI 인덱스의 조건부 사용 가이드를 짧게 추가했다.
|
|
||||||
- 왜: 인덱스는 선반영 가능하지만 실제 사용은 쿼리/옵티마이저 조건에 따라 달라진다는 점을 문서에 명시하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew tasks --all` 실행(성공)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 푸시 알림 조회 쿼리 오류 수정
|
|
||||||
|
|
||||||
- [x] `PushNotificationController` 연계 조회 API에서 발생한 DB 조회 오류 재현 경로와 실제 실패 쿼리 식별
|
|
||||||
- [x] `QuerySyntaxException` 원인인 JPQL/HQL 함수 사용 구문을 코드베이스 패턴에 맞게 수정
|
|
||||||
- [x] 수정 코드 정적 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 결과를 문서 하단에 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 수정
|
|
||||||
- 무엇을: `PushNotificationListRepository.recipientContainsMember`의 QueryDSL 템플릿을 `JSON_CONTAINS({0}, JSON_ARRAY({1}), '$')`에서 `function('JSON_CONTAINS', {0}, function('JSON_ARRAY', {1}), '$') = 1`로 수정했다.
|
|
||||||
- 왜: Hibernate JPQL/HQL 파서는 MySQL 함수명(`JSON_CONTAINS`, `JSON_ARRAY`) 직접 호출 구문을 인식하지 못해 `QuerySyntaxException`이 발생하므로, JPQL 표준 함수 호출 래퍼(`function`)로 감싸 파싱 가능하도록 변경이 필요했다.
|
|
||||||
- 어떻게:
|
|
||||||
- 검색: `grep`/AST/Explore/Librarian로 `PushNotificationController -> PushNotificationService -> PushNotificationListRepository` 호출 흐름과 문제 쿼리를 확인했다.
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 Kotlin 파일 진단을 시도했으나 현재 환경에 `.kt` LSP 서버 미설정으로 실행 불가를 확인했다.
|
|
||||||
- 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest" --tests "kr.co.vividnext.sodalive.fcm.notification.PushNotificationControllerTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user